irregular6612 commited on
Commit
f95852b
·
1 Parent(s): 1c18fa9

feat(cp4.5): preserve probe reasoning + raw_text + token accounting via ProbeResult

Browse files
proteus/agents/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
  """proteus.agents — slim LLM agent abstraction (no forfeit/stake/risk)."""
2
 
3
- from proteus.agents.base import Agent, ActResult
4
  from proteus.agents.vanilla import VanillaAgent
5
 
6
- __all__ = ["Agent", "ActResult", "VanillaAgent"]
 
1
  """proteus.agents — slim LLM agent abstraction (no forfeit/stake/risk)."""
2
 
3
+ from proteus.agents.base import Agent, ActResult, ProbeResult
4
  from proteus.agents.vanilla import VanillaAgent
5
 
6
+ __all__ = ["Agent", "ActResult", "ProbeResult", "VanillaAgent"]
proteus/agents/base.py CHANGED
@@ -38,6 +38,30 @@ class ActResult:
38
  thinking_tokens: int = 0
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  class Agent(ABC):
42
  """Abstract LLM agent that plays a motive_grid scenario."""
43
 
@@ -71,7 +95,7 @@ class Agent(ABC):
71
  observation: str,
72
  question: str,
73
  system_prompt: str,
74
- ) -> str:
75
  """Answer a side-channel comprehension probe (does not change state).
76
 
77
  Args:
@@ -80,7 +104,8 @@ class Agent(ABC):
80
  system_prompt: Rules + handover framing for the session.
81
 
82
  Returns:
83
- Free-text probe answer.
 
84
  """
85
 
86
  @abstractmethod
 
38
  thinking_tokens: int = 0
39
 
40
 
41
+ @dataclass(frozen=True)
42
+ class ProbeResult:
43
+ """An agent's answer to a side-channel probe question for one turn.
44
+
45
+ Immutable: written verbatim into the session trace as a 1st-class
46
+ measurement target (the probe is scored offline later).
47
+
48
+ Attributes:
49
+ answer: The think-stripped probe answer.
50
+ reasoning: The probe's stated/extracted rationale (CoT / thinking).
51
+ raw_text: The full unprocessed model output for the probe.
52
+ input_tokens: Prompt token usage for the probe call.
53
+ output_tokens: Completion token usage for the probe call.
54
+ thinking_tokens: Reasoning-token count for the probe call.
55
+ """
56
+
57
+ answer: str
58
+ reasoning: str = ""
59
+ raw_text: str = ""
60
+ input_tokens: int = 0
61
+ output_tokens: int = 0
62
+ thinking_tokens: int = 0
63
+
64
+
65
  class Agent(ABC):
66
  """Abstract LLM agent that plays a motive_grid scenario."""
67
 
 
95
  observation: str,
96
  question: str,
97
  system_prompt: str,
98
+ ) -> ProbeResult:
99
  """Answer a side-channel comprehension probe (does not change state).
100
 
101
  Args:
 
104
  system_prompt: Rules + handover framing for the session.
105
 
106
  Returns:
107
+ A :class:`ProbeResult` containing the think-stripped answer,
108
+ reasoning, raw model output, and token accounting.
109
  """
110
 
111
  @abstractmethod
proteus/agents/vanilla.py CHANGED
@@ -8,7 +8,7 @@ as the agent's reasoning even when the action text is terse.
8
 
9
  from __future__ import annotations
10
 
11
- from proteus.agents.base import Agent, ActResult
12
  from proteus.agents.parsing import extract_action
13
  from proteus.providers.base import LLMProvider
14
  from proteus.providers.thinking_utils import parse_thinking_tags
@@ -81,7 +81,12 @@ class VanillaAgent(Agent):
81
  observation: str,
82
  question: str,
83
  system_prompt: str,
84
- ) -> str:
 
 
 
 
 
85
  messages = [
86
  {"role": "system", "content": system_prompt},
87
  {"role": "user", "content": f"{observation}\n\n{question}"},
@@ -89,8 +94,18 @@ class VanillaAgent(Agent):
89
  result = self._provider.complete(
90
  messages, temperature=self._temperature, max_tokens=self._max_tokens,
91
  )
92
- answer_text, _, _ = parse_thinking_tags(result.text)
93
- return answer_text
 
 
 
 
 
 
 
 
 
 
94
 
95
  def reset(self) -> None:
96
  # Vanilla carries no cross-turn state.
 
8
 
9
  from __future__ import annotations
10
 
11
+ from proteus.agents.base import Agent, ActResult, ProbeResult
12
  from proteus.agents.parsing import extract_action
13
  from proteus.providers.base import LLMProvider
14
  from proteus.providers.thinking_utils import parse_thinking_tags
 
81
  observation: str,
82
  question: str,
83
  system_prompt: str,
84
+ ) -> ProbeResult:
85
+ """Answer a side-channel comprehension probe and return a ProbeResult.
86
+
87
+ Mirrors act()'s token accounting logic: provider-reported thinking_tokens
88
+ are preferred; the inline-<think> parser count is the fallback.
89
+ """
90
  messages = [
91
  {"role": "system", "content": system_prompt},
92
  {"role": "user", "content": f"{observation}\n\n{question}"},
 
94
  result = self._provider.complete(
95
  messages, temperature=self._temperature, max_tokens=self._max_tokens,
96
  )
97
+ answer_text, parsed_thinking_tokens, thinking_text = parse_thinking_tags(result.text)
98
+ reasoning = thinking_text or result.thinking_text or ""
99
+ return ProbeResult(
100
+ answer=answer_text,
101
+ reasoning=reasoning,
102
+ raw_text=result.text,
103
+ input_tokens=result.input_tokens,
104
+ output_tokens=result.output_tokens,
105
+ # Provider count preferred; inline-<think> parser count is the fallback
106
+ # (mirrors act()).
107
+ thinking_tokens=result.thinking_tokens or parsed_thinking_tokens,
108
+ )
109
 
110
  def reset(self) -> None:
111
  # Vanilla carries no cross-turn state.
proteus/runtime/session.py CHANGED
@@ -93,10 +93,17 @@ class SessionRunner:
93
  for turn_idx in range(1, self._play_turns + 1):
94
  observation = self._observation(turn_idx)
95
 
96
- probe_q = probe_a = ""
 
97
  if self._use_probe:
98
  probe_q = _PROBE_QUESTION
99
- probe_a = self._agent.probe(observation, probe_q, system_prompt)
 
 
 
 
 
 
100
 
101
  # Pre-move answer keys + positions.
102
  optimal = self._scenario.optimal_action(self._game)
@@ -117,6 +124,11 @@ class SessionRunner:
117
  observation=observation,
118
  probe_q=probe_q,
119
  probe_a=probe_a,
 
 
 
 
 
120
  reasoning=result.reasoning,
121
  raw_text=result.raw_text,
122
  action=result.action,
 
93
  for turn_idx in range(1, self._play_turns + 1):
94
  observation = self._observation(turn_idx)
95
 
96
+ probe_q = probe_a = probe_reasoning = probe_raw_text = ""
97
+ probe_input_tokens = probe_output_tokens = probe_thinking_tokens = 0
98
  if self._use_probe:
99
  probe_q = _PROBE_QUESTION
100
+ probe = self._agent.probe(observation, probe_q, system_prompt)
101
+ probe_a = probe.answer
102
+ probe_reasoning = probe.reasoning
103
+ probe_raw_text = probe.raw_text
104
+ probe_input_tokens = probe.input_tokens
105
+ probe_output_tokens = probe.output_tokens
106
+ probe_thinking_tokens = probe.thinking_tokens
107
 
108
  # Pre-move answer keys + positions.
109
  optimal = self._scenario.optimal_action(self._game)
 
124
  observation=observation,
125
  probe_q=probe_q,
126
  probe_a=probe_a,
127
+ probe_reasoning=probe_reasoning,
128
+ probe_raw_text=probe_raw_text,
129
+ probe_input_tokens=probe_input_tokens,
130
+ probe_output_tokens=probe_output_tokens,
131
+ probe_thinking_tokens=probe_thinking_tokens,
132
  reasoning=result.reasoning,
133
  raw_text=result.raw_text,
134
  action=result.action,
proteus/runtime/trace.py CHANGED
@@ -21,6 +21,12 @@ class TurnTrace(BaseModel):
21
  observation: The text observation shown to the agent this turn.
22
  probe_q: Probe question asked (empty if probing disabled).
23
  probe_a: Probe answer given (empty if probing disabled).
 
 
 
 
 
 
24
  reasoning: The agent's stated/extracted rationale.
25
  raw_text: Full unprocessed act-call output from the model.
26
  action: The action the agent committed.
@@ -43,6 +49,11 @@ class TurnTrace(BaseModel):
43
  observation: str
44
  probe_q: str = ""
45
  probe_a: str = ""
 
 
 
 
 
46
  reasoning: str = ""
47
  raw_text: str = ""
48
  action: str
 
21
  observation: The text observation shown to the agent this turn.
22
  probe_q: Probe question asked (empty if probing disabled).
23
  probe_a: Probe answer given (empty if probing disabled).
24
+ probe_reasoning: The probe's stated/extracted rationale (CoT / thinking).
25
+ probe_raw_text: Full unprocessed probe-call output from the model.
26
+ probe_input_tokens: Probe-call token usage — prompt/input side.
27
+ probe_output_tokens: Probe-call token usage — completion/output side.
28
+ probe_thinking_tokens: Reasoning-token count for the probe call
29
+ (provider-reported or inline ``<think>`` whitespace-split count).
30
  reasoning: The agent's stated/extracted rationale.
31
  raw_text: Full unprocessed act-call output from the model.
32
  action: The action the agent committed.
 
49
  observation: str
50
  probe_q: str = ""
51
  probe_a: str = ""
52
+ probe_reasoning: str = ""
53
+ probe_raw_text: str = ""
54
+ probe_input_tokens: int = 0
55
+ probe_output_tokens: int = 0
56
+ probe_thinking_tokens: int = 0
57
  reasoning: str = ""
58
  raw_text: str = ""
59
  action: str
tests/agents/test_vanilla.py CHANGED
@@ -26,8 +26,8 @@ def test_act_falls_back_to_stay_when_unparseable():
26
  def test_probe_returns_text_and_sends_question():
27
  provider = FakeProvider(responses=["the predator is east"])
28
  agent = VanillaAgent(provider)
29
- answer = agent.probe("grid", "where is the predator?", "rules")
30
- assert answer == "the predator is east"
31
  # the probe question reached the provider
32
  assert any("where is the predator?" in m["content"] for m in provider.calls[-1])
33
 
@@ -103,3 +103,15 @@ def test_act_prefers_provider_thinking_tokens_when_present():
103
  assert result.thinking_tokens == 42
104
  assert result.input_tokens == 11
105
  assert result.output_tokens == 7
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def test_probe_returns_text_and_sends_question():
27
  provider = FakeProvider(responses=["the predator is east"])
28
  agent = VanillaAgent(provider)
29
+ result = agent.probe("grid", "where is the predator?", "rules")
30
+ assert result.answer == "the predator is east"
31
  # the probe question reached the provider
32
  assert any("where is the predator?" in m["content"] for m in provider.calls[-1])
33
 
 
103
  assert result.thinking_tokens == 42
104
  assert result.input_tokens == 11
105
  assert result.output_tokens == 7
106
+
107
+
108
+ def test_probe_returns_probe_result_with_reasoning_and_tokens():
109
+ from proteus.agents import ProbeResult
110
+ provider = FakeProvider(responses=["<think>predator is two cells east</think>go up"])
111
+ result = VanillaAgent(provider).probe("grid", "where is the predator?", "rules")
112
+ assert isinstance(result, ProbeResult)
113
+ assert result.answer == "go up" # think-stripped answer
114
+ assert "predator is two cells east" in result.reasoning
115
+ assert result.raw_text == "<think>predator is two cells east</think>go up"
116
+ assert result.thinking_tokens == 5 # 5-word think block (parser fallback)
117
+ assert result.output_tokens > 0
tests/runtime/test_trace_accounting.py CHANGED
@@ -18,6 +18,9 @@ def test_act_tokens_and_raw_text_persisted_per_turn():
18
  assert t0.output_tokens > 0 # propagated from CompletionResult
19
  assert t0.input_tokens == 0 # FakeProvider reports 0; output_tokens below is the live propagation check
20
  assert t0.thinking_tokens == 5 # whitespace-split of the think block
 
 
 
21
 
22
 
23
  def test_accounting_survives_jsonl_roundtrip():
@@ -26,3 +29,29 @@ def test_accounting_survives_jsonl_roundtrip():
26
  assert reloaded.model_dump() == trace.model_dump()
27
  assert reloaded.turns[0].thinking_tokens == 3
28
  assert reloaded.turns[0].raw_text == "<think>a b c</think>ACTION: up"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  assert t0.output_tokens > 0 # propagated from CompletionResult
19
  assert t0.input_tokens == 0 # FakeProvider reports 0; output_tokens below is the live propagation check
20
  assert t0.thinking_tokens == 5 # whitespace-split of the think block
21
+ # use_probe=False: probe fields stay at their defaults.
22
+ assert t0.probe_raw_text == ""
23
+ assert t0.probe_thinking_tokens == 0
24
 
25
 
26
  def test_accounting_survives_jsonl_roundtrip():
 
29
  assert reloaded.model_dump() == trace.model_dump()
30
  assert reloaded.turns[0].thinking_tokens == 3
31
  assert reloaded.turns[0].raw_text == "<think>a b c</think>ACTION: up"
32
+
33
+
34
+ def test_probe_accounting_persisted_when_enabled():
35
+ # Distinct probe vs act responses (different <think> word counts) so a
36
+ # probe/act field cross-wiring bug would be visible: turn 1's probe consumes
37
+ # responses[0] (3-word think) and turn 1's act consumes responses[1] (5-word think).
38
+ agent = VanillaAgent(FakeProvider(
39
+ responses=[
40
+ "<think>it is east</think>predator is east", # probe: 3 think-words
41
+ "<think>move up and away now</think>I should go up\nACTION: up", # act: 5 think-words
42
+ ],
43
+ model_name="fake-1",
44
+ ))
45
+ trace = SessionRunner(
46
+ "predator_evade", agent, seed=42, play_turns=3, use_probe=True,
47
+ ).run()
48
+ t0 = trace.turns[0]
49
+ assert t0.probe_a # answer recorded
50
+ assert "it is east" in t0.probe_reasoning # probe reasoning preserved
51
+ assert t0.probe_raw_text # raw probe output preserved
52
+ assert t0.probe_thinking_tokens == 3 # from the PROBE response
53
+ assert t0.thinking_tokens == 5 # from the ACT response — NOT cross-wired
54
+ assert t0.probe_output_tokens > 0
55
+ # survives JSONL round-trip
56
+ reloaded = SessionTrace.model_validate_json(trace.model_dump_json())
57
+ assert reloaded.model_dump() == trace.model_dump()