Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions extropy/simulation/reasoning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)?",
]
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -487,6 +495,7 @@ def build_pass1_schema() -> dict[str, Any]:
"private_thought",
"public_statement",
"reasoning_summary",
"action_intent",
"sentiment",
"conviction",
"will_share",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -664,6 +677,7 @@ def build_merged_schema(outcomes: OutcomeConfig) -> dict[str, Any]:
"reasoning",
"public_statement",
"reasoning_summary",
"action_intent",
"sentiment",
"conviction",
"will_share",
Expand Down Expand Up @@ -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)
# =============================================================================
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -909,14 +932,19 @@ 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,
sentiment=sentiment,
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,
Expand Down Expand Up @@ -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)

Expand All @@ -1027,6 +1056,7 @@ async def _reason_agent_merged_async(
"reasoning",
"public_statement",
"reasoning_summary",
"action_intent",
"sentiment",
"conviction",
"will_share",
Expand All @@ -1037,14 +1067,16 @@ 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,
sentiment=sentiment,
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,
Expand Down Expand Up @@ -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 ===
Expand Down Expand Up @@ -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}, "
Expand All @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions tests/test_reasoning_execution.py
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions tests/test_reasoning_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -545,6 +547,7 @@ def test_has_required_fields(self):
"reasoning",
"public_statement",
"reasoning_summary",
"action_intent",
"sentiment",
"conviction",
"will_share",
Expand Down
Loading