From b29573a12997d6e98cf42cc1eba6ed63c856f18b Mon Sep 17 00:00:00 2001 From: RandomOscillations Date: Tue, 17 Feb 2026 01:05:54 -0500 Subject: [PATCH] fix(reasoning): strengthen intent accountability and capture action intent --- extropy/simulation/reasoning.py | 48 +++++++++-- tests/test_reasoning_execution.py | 135 ++++++++++++++++++++++++++++++ tests/test_reasoning_prompts.py | 3 + 3 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 tests/test_reasoning_execution.py diff --git a/extropy/simulation/reasoning.py b/extropy/simulation/reasoning.py index 3f71c38..f1156b9 100644 --- a/extropy/simulation/reasoning.py +++ b/extropy/simulation/reasoning.py @@ -330,8 +330,12 @@ def build_pass1_prompt( prompt_parts.extend( [ "", - f'Last time I said I intended to: "{context.prior_action_intent}". ' - "Has anything changed?", + "## Follow Through Check", + "", + f'Last time I said I intended to: "{context.prior_action_intent}".', + "Did I actually do it? Be explicit:", + "- If yes: what exactly did I do, and what happened?", + "- If no: what stopped me (time, money, logistics, fear, competing priorities)?", ] ) @@ -444,6 +448,10 @@ def build_pass1_schema() -> dict[str, Any]: "type": "string", "description": "A single sentence capturing your core reaction (for your own memory).", }, + "action_intent": { + "type": "string", + "description": "The concrete action you intend to take next. Use an empty string only if you truly have no plan.", + }, "sentiment": { "type": "number", "minimum": -1.0, @@ -487,6 +495,7 @@ def build_pass1_schema() -> dict[str, Any]: "private_thought", "public_statement", "reasoning_summary", + "action_intent", "sentiment", "conviction", "will_share", @@ -622,6 +631,10 @@ def build_merged_schema(outcomes: OutcomeConfig) -> dict[str, Any]: "type": "string", "description": "A single sentence capturing your core reaction (for your own memory).", }, + "action_intent": { + "type": "string", + "description": "The concrete action you intend to take next. Use an empty string only if you truly have no plan.", + }, "sentiment": { "type": "number", "minimum": -1.0, @@ -664,6 +677,7 @@ def build_merged_schema(outcomes: OutcomeConfig) -> dict[str, Any]: "reasoning", "public_statement", "reasoning_summary", + "action_intent", "sentiment", "conviction", "will_share", @@ -754,6 +768,14 @@ def _sentiment_to_tone(sentiment: float) -> str: return "strongly opposed" +def _normalize_action_intent(value: Any) -> str | None: + """Normalize action intent to None when empty.""" + if value is None: + return None + text = str(value).strip() + return text or None + + # ============================================================================= # Two-pass reasoning (async) # ============================================================================= @@ -844,6 +866,7 @@ async def _reason_agent_two_pass_async( conviction_score = pass1_response.get("conviction") will_share = pass1_response.get("will_share", False) actions = pass1_response.get("actions", []) + pass1_action_intent = _normalize_action_intent(pass1_response.get("action_intent")) # Map conviction score (0-100) to float via bucketing conviction_float = score_to_conviction_float(conviction_score) @@ -909,6 +932,11 @@ async def _reason_agent_two_pass_async( # Merge sentiment into outcomes for backwards compat if sentiment is not None: outcomes["sentiment"] = sentiment + action_intent = pass1_action_intent or _normalize_action_intent( + outcomes.get("action_intent") + ) + if action_intent is not None: + outcomes["action_intent"] = action_intent return ReasoningResponse( position=position, @@ -916,7 +944,7 @@ async def _reason_agent_two_pass_async( conviction=conviction_float, public_statement=public_statement, reasoning_summary=reasoning_summary, - action_intent=outcomes.get("action_intent"), + action_intent=action_intent, will_share=will_share, reasoning=reasoning, outcomes=outcomes, @@ -1014,6 +1042,7 @@ async def _reason_agent_merged_async( conviction_score = response.get("conviction") will_share = response.get("will_share", False) actions = response.get("actions", []) + action_intent = _normalize_action_intent(response.get("action_intent")) conviction_float = score_to_conviction_float(conviction_score) @@ -1027,6 +1056,7 @@ async def _reason_agent_merged_async( "reasoning", "public_statement", "reasoning_summary", + "action_intent", "sentiment", "conviction", "will_share", @@ -1037,6 +1067,8 @@ async def _reason_agent_merged_async( # Merge sentiment into outcomes for backwards compat if sentiment is not None: outcomes["sentiment"] = sentiment + if action_intent is not None: + outcomes["action_intent"] = action_intent return ReasoningResponse( position=position, @@ -1044,7 +1076,7 @@ async def _reason_agent_merged_async( conviction=conviction_float, public_statement=public_statement, reasoning_summary=reasoning_summary, - action_intent=outcomes.get("action_intent"), + action_intent=action_intent, will_share=will_share, reasoning=reasoning, outcomes=outcomes, @@ -1138,6 +1170,7 @@ def reason_agent( sentiment = pass1_response.get("sentiment") conviction_score = pass1_response.get("conviction") will_share = pass1_response.get("will_share", False) + pass1_action_intent = _normalize_action_intent(pass1_response.get("action_intent")) conviction_float = score_to_conviction_float(conviction_score) # === Pass 2: Classification === @@ -1181,6 +1214,11 @@ def reason_agent( if sentiment is not None: outcomes["sentiment"] = sentiment + action_intent = pass1_action_intent or _normalize_action_intent( + outcomes.get("action_intent") + ) + if action_intent is not None: + outcomes["action_intent"] = action_intent logger.info( f"[REASON] Agent {context.agent_id} - SUCCESS: position={position}, " @@ -1193,7 +1231,7 @@ def reason_agent( conviction=conviction_float, public_statement=public_statement, reasoning_summary=reasoning_summary, - action_intent=outcomes.get("action_intent"), + action_intent=action_intent, will_share=will_share, reasoning=reasoning, outcomes=outcomes, diff --git a/tests/test_reasoning_execution.py b/tests/test_reasoning_execution.py new file mode 100644 index 0000000..1c11ea0 --- /dev/null +++ b/tests/test_reasoning_execution.py @@ -0,0 +1,135 @@ +"""Execution tests for reasoning internals that affect memory/accountability.""" + +import asyncio +from datetime import datetime +from unittest.mock import patch + +from extropy.core.llm import TokenUsage +from extropy.core.models import ExposureRecord, ReasoningContext, SimulationRunConfig +from extropy.core.models.scenario import ( + Event, + EventType, + ExposureChannel, + ExposureRule, + InteractionConfig, + InteractionType, + OutcomeConfig, + OutcomeDefinition, + OutcomeType, + ScenarioMeta, + ScenarioSpec, + SeedExposure, + SimulationConfig, + SpreadConfig, + TimestepUnit, +) +from extropy.simulation.reasoning import _reason_agent_two_pass_async + + +def _make_context() -> ReasoningContext: + return ReasoningContext( + agent_id="a1", + persona="I am a parent with limited time.", + event_content="School board policy update", + exposure_history=[ + ExposureRecord( + timestep=0, + channel="broadcast", + content="Policy update", + credibility=0.9, + source_agent_id=None, + ) + ], + agent_name="Taylor", + ) + + +def _make_scenario_no_pass2() -> ScenarioSpec: + return ScenarioSpec( + meta=ScenarioMeta( + name="reasoning_exec", + description="Reasoning execution test", + population_spec="test.yaml", + study_db="study.db", + population_id="default", + network_id="default", + created_at=datetime(2024, 1, 1), + ), + event=Event( + type=EventType.NEWS, + content="Policy update", + source="Board", + credibility=0.8, + ambiguity=0.3, + emotional_valence=0.0, + ), + seed_exposure=SeedExposure( + channels=[ + ExposureChannel( + name="broadcast", + description="Broadcast", + reach="broadcast", + ) + ], + rules=[ + ExposureRule( + channel="broadcast", + timestep=0, + when="true", + probability=1.0, + ) + ], + ), + interaction=InteractionConfig( + primary_model=InteractionType.PASSIVE_OBSERVATION, + description="Observe updates", + ), + spread=SpreadConfig(share_probability=0.4), + outcomes=OutcomeConfig( + suggested_outcomes=[ + OutcomeDefinition( + name="notes", + description="Open notes only", + type=OutcomeType.OPEN_ENDED, + required=False, + ) + ] + ), + simulation=SimulationConfig(max_timesteps=2, timestep_unit=TimestepUnit.DAY), + ) + + +def test_action_intent_is_captured_without_pass2_outcome(): + context = _make_context() + scenario = _make_scenario_no_pass2() + config = SimulationRunConfig( + scenario_path="test.yaml", + output_dir="test/", + max_retries=1, + ) + + async def _mock_simple_call_async(*args, **kwargs): + return ( + { + "reasoning": "I need to show up and speak.", + "private_thought": "If I don't go, nothing changes.", + "public_statement": "I should attend the meeting.", + "reasoning_summary": "I need to attend and speak.", + "action_intent": "Attend the next board meeting", + "sentiment": -0.2, + "conviction": 75, + "will_share": True, + "actions": [], + }, + TokenUsage(), + ) + + with patch( + "extropy.simulation.reasoning.simple_call_async", + new=_mock_simple_call_async, + ): + response = asyncio.run(_reason_agent_two_pass_async(context, scenario, config)) + + assert response is not None + assert response.action_intent == "Attend the next board meeting" + assert response.outcomes.get("action_intent") == "Attend the next board meeting" diff --git a/tests/test_reasoning_prompts.py b/tests/test_reasoning_prompts.py index 630e36d..8150217 100644 --- a/tests/test_reasoning_prompts.py +++ b/tests/test_reasoning_prompts.py @@ -368,6 +368,8 @@ def test_intent_accountability(self): prompt = build_pass1_prompt(context, scenario) assert "cancel my subscription" in prompt assert "intended to" in prompt.lower() + assert "did i actually do it" in prompt.lower() + assert "what stopped me" in prompt.lower() def test_no_intent_on_first_reasoning(self): """No intent accountability section when prior_action_intent is None.""" @@ -545,6 +547,7 @@ def test_has_required_fields(self): "reasoning", "public_statement", "reasoning_summary", + "action_intent", "sentiment", "conviction", "will_share",