Skip to content

Commit fad4b01

Browse files
gadievrongadievronclaude
authored
fix(llm): close the truncated-response silent-FN family (adapters + enhancer + verifier) (#207)
Co-authored-by: gadievron <gadi@unpromptedcon.org> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f9f200f commit fad4b01

10 files changed

Lines changed: 340 additions & 21 deletions

libs/openant-core/context/repo_explorer.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,25 @@ def explore_repository(
283283
if not isinstance(block, ToolUseBlock):
284284
continue
285285
if block.name == finish_tool.name:
286+
# R3-A: a finish call on a turn TRUNCATED at the token cap
287+
# (stop_reason=="max_tokens") may carry partial arguments — accepting
288+
# it writes an under-scoped application-context / threat-model doc that
289+
# every later scan trusts (silent coverage loss). Don't accept it.
290+
# R4-1: but the Messages API requires a tool_result for every tool_use,
291+
# so ANSWER this finish's tool_use with a retry nudge (rather than
292+
# skipping it unanswered, which 400s the next turn) — the loop then
293+
# asks for a complete finish and, failing that, exhausts MAX_TURNS and
294+
# raises (a visible failure, not a silent partial). Mirrors the
295+
# verifier + enhancer max_tokens gate.
296+
if getattr(response, "stop_reason", None) == "max_tokens":
297+
results.append(ToolResultBlock(
298+
tool_use_id=block.id, name=block.name,
299+
content=json.dumps({
300+
"error": "Your finish call was cut off at the token limit; "
301+
"reply again with a complete but more concise finish call."
302+
}),
303+
))
304+
continue
286305
return dict(block.input or {}), budget
287306
outcome = explorer.execute(block.name, block.input or {})
288307
results.append(ToolResultBlock(

libs/openant-core/tests/test_agent_degenerate_exit.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,35 @@ def record_call(self, **kw):
113113
assert result.input_tokens == 5000 and result.output_tokens == 200
114114

115115

116+
def _finish_block(classification):
117+
# a COMPLETE, valid finish call (all required fields, valid classification)
118+
return ToolUseBlock(id="t1", name="finish", input={
119+
"include_functions": ["f"], "usage_context": "ctx",
120+
"security_classification": classification,
121+
"classification_reasoning": "r", "confidence": 0.5,
122+
})
123+
124+
125+
def test_truncated_finish_at_max_tokens_is_incomplete_not_a_verdict():
126+
"""R2-B: a VALID finish call on a turn truncated at max_tokens must be
127+
INCOMPLETE, not accepted as a complete classification — a truncated
128+
finish(security_classification="neutral") would silently drop a unit."""
129+
result = _run(_agent([
130+
CompletionResult(content=[_finish_block("neutral")],
131+
input_tokens=1, output_tokens=1, stop_reason="max_tokens"),
132+
]))
133+
assert result.security_classification == "incomplete"
134+
135+
136+
def test_complete_finish_still_accepted():
137+
"""Regression guard: a finish on a NORMAL (tool_use) turn is still accepted."""
138+
result = _run(_agent([
139+
CompletionResult(content=[_finish_block("security_control")],
140+
input_tokens=1, output_tokens=1, stop_reason="tool_use"),
141+
]))
142+
assert result.security_classification == "security_control"
143+
144+
116145
def test_no_tool_calls_is_not_a_neutral_verdict():
117146
"""Sibling path: model responded but made no tool calls."""
118147
result = _run(_agent([

libs/openant-core/tests/test_llm_anthropic_adapter.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,13 @@ def test_tool_use_and_result_blocks_round_trip(self):
180180

181181

182182
class TestResponseTranslation:
183-
def test_unknown_stop_reason_normalised_to_end_turn(self):
184-
# Future SDK adding a new stop reason must not crash the
185-
# pipeline. The adapter falls back to "end_turn" defensively.
183+
def test_unknown_stop_reason_treated_as_max_tokens(self):
184+
# R2-C: a future/unknown/proxy stop_reason must not read as a clean
185+
# end_turn — for a security tool that masks a refusal/abnormal
186+
# termination as a finished completion. The adapter defaults it to
187+
# "max_tokens" (a not-a-clean-finish signal), mirroring the OpenAI
188+
# adapter. Known values (end_turn/max_tokens/tool_use/stop_sequence)
189+
# keep their explicit mapping.
186190
adapter, _ = _stub_adapter(
187191
lambda **kw: _ok_response(stop_reason="future_invention")
188192
)
@@ -192,7 +196,7 @@ def test_unknown_stop_reason_normalised_to_end_turn(self):
192196
messages=[Message(role="user", content=[TextBlock("hi")])],
193197
max_tokens=8,
194198
)
195-
assert result.stop_reason == "end_turn"
199+
assert result.stop_reason == "max_tokens"
196200

197201
def test_tool_use_block_extracted_from_response(self):
198202
def respond(**kw):

libs/openant-core/tests/test_repo_explorer_loop.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616

1717

1818
class _Resp:
19-
def __init__(self, content):
19+
def __init__(self, content, stop_reason=None):
2020
self.content = content
21+
self.stop_reason = stop_reason
2122

2223

2324
class _FakeAdapter:
@@ -32,6 +33,30 @@ def complete(self, *, model, system, messages, max_tokens, tools):
3233
return self._scripted.pop(0)
3334

3435

36+
class _PairingFakeAdapter(_FakeAdapter):
37+
"""Enforces the Messages-API rule real providers enforce: every assistant
38+
tool_use must be answered by a tool_result in the immediately following user
39+
turn. Catches an unanswered/dangling tool_use (which real APIs 400 on)."""
40+
41+
def complete(self, *, model, system, messages, max_tokens, tools):
42+
for i, m in enumerate(messages):
43+
if m.role != "assistant":
44+
continue
45+
tu_ids = [b.id for b in m.content if isinstance(b, ToolUseBlock)]
46+
if not tu_ids:
47+
continue
48+
nxt = messages[i + 1] if i + 1 < len(messages) else None
49+
tr_ids = [b.tool_use_id for b in (nxt.content if nxt else ())
50+
if isinstance(b, ToolResultBlock)]
51+
for tid in tu_ids:
52+
if tid not in tr_ids:
53+
raise RuntimeError(
54+
f"400: assistant tool_use {tid!r} has no matching tool_result "
55+
f"in the next user turn")
56+
return super().complete(model=model, system=system, messages=messages,
57+
max_tokens=max_tokens, tools=tools)
58+
59+
3560
class _FakeBinding:
3661
def __init__(self, adapter):
3762
self.adapter = adapter
@@ -60,6 +85,24 @@ def test_finish_returns_payload_and_counts_turns(tmp_path):
6085
assert budget.turns == 1
6186

6287

88+
def test_truncated_finish_at_max_tokens_is_not_accepted(tmp_path):
89+
# R3-A: a finish call on a turn truncated at max_tokens must NOT be accepted as
90+
# a complete survey (it under-scopes the threat model every later scan trusts).
91+
# R4-1: and the skipped finish's tool_use must be ANSWERED (pairing-validating
92+
# adapter enforces the real Messages-API rule) so the retry turn isn't a 400.
93+
# The loop nudges and uses a later COMPLETE finish instead.
94+
adapter = _PairingFakeAdapter([
95+
_Resp((ToolUseBlock(id="tu1", name="finish", input={"partial": True}),),
96+
stop_reason="max_tokens"),
97+
_Resp((ToolUseBlock(id="tu2", name="finish", input={"complete": True}),),
98+
stop_reason="tool_use"),
99+
])
100+
payload, budget = explore_repository(_repo(tmp_path), _FakeBinding(adapter),
101+
"sys", "task", _FINISH)
102+
assert payload == {"complete": True} # the truncated finish was skipped
103+
assert budget.turns == 2
104+
105+
63106
def test_chatty_turn_is_answered_with_plain_text_not_toolresult(tmp_path):
64107
# Turn 1: model returns prose, calls no tool -> the nudge branch.
65108
# Turn 2: model calls finish.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Truncation silent-FN family — cross-provider adapter invariants (R2-A, R2-C).
2+
3+
Family invariant (adapter side): every adapter must emit stop_reason="max_tokens"
4+
for a TRUNCATED response and must not mask it (as tool_use) or launder an
5+
unknown/abnormal termination into a clean end_turn — otherwise a downstream
6+
consumer (verifier / enhancer) accepts a truncated reply as a complete verdict.
7+
Offline stubs; no network.
8+
"""
9+
from __future__ import annotations
10+
11+
import sys
12+
from pathlib import Path
13+
from types import SimpleNamespace
14+
15+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16+
17+
from utilities.llm.providers.google import _response_to_unified as _google_unify
18+
19+
20+
def _gemini_resp(*, finish_reason, with_tool=False, text=None):
21+
parts = []
22+
if with_tool:
23+
parts.append(SimpleNamespace(
24+
function_call=SimpleNamespace(name="finish", args={"agree": False}, id=None), text=None))
25+
if text is not None:
26+
parts.append(SimpleNamespace(function_call=None, text=text))
27+
return SimpleNamespace(
28+
candidates=[SimpleNamespace(finish_reason=finish_reason,
29+
content=SimpleNamespace(parts=parts))],
30+
usage_metadata=SimpleNamespace(prompt_token_count=1, candidates_token_count=1),
31+
)
32+
33+
34+
def test_gemini_truncation_wins_over_tool_use():
35+
# R2-A: a MAX_TOKENS candidate carrying a function_call must surface as
36+
# max_tokens (truncation), NOT tool_use — else the consumer accepts a
37+
# truncated finish as a complete verdict.
38+
r = _google_unify(_gemini_resp(finish_reason="MAX_TOKENS", with_tool=True))
39+
assert r.stop_reason == "max_tokens"
40+
41+
42+
def test_gemini_normal_tool_call_still_tool_use():
43+
# regression guard: a normal (STOP) tool call is still tool_use.
44+
r = _google_unify(_gemini_resp(finish_reason="STOP", with_tool=True, text=None))
45+
assert r.stop_reason == "tool_use"
46+
47+
48+
def test_gemini_unknown_finish_is_max_tokens_not_end_turn():
49+
# R2-C: an unknown/abnormal finish_reason (SAFETY/RECITATION/proxy) is not a
50+
# clean end_turn.
51+
r = _google_unify(_gemini_resp(finish_reason="ZZ_FUTURE_REASON", text="partial"))
52+
assert r.stop_reason == "max_tokens"
53+
54+
55+
def test_gemini_unknown_finish_with_tool_call_is_max_tokens_not_tool_use():
56+
# round-5: an UNKNOWN/abnormal finish_reason carrying a function_call must surface
57+
# as max_tokens, not tool_use — an abnormal termination wins over the tool-call
58+
# signal (so a consumer's max_tokens gate can fire), consistent with unknown->max_tokens.
59+
r = _google_unify(_gemini_resp(finish_reason="ZZ_FUTURE_REASON", with_tool=True))
60+
assert r.stop_reason == "max_tokens"
61+
62+
63+
def test_gemini_known_stop_unchanged():
64+
r = _google_unify(_gemini_resp(finish_reason="STOP", text="hi"))
65+
assert r.stop_reason == "end_turn"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""C(b): the Stage-2 verifier must not accept a `finish` tool call from a TRUNCATED
2+
turn (stop_reason == "max_tokens") as a completed verdict.
3+
4+
Before this fix the block loop harvested a `finish` ToolUseBlock regardless of
5+
stop_reason, so a `max_tokens`-truncated reply carrying a well-formed
6+
`finish(agree=False, correct_finding="safe")` was parsed as a COMPLETE verdict
7+
(`incomplete=False`) and downgraded a Stage-1 `vulnerable` to `safe` silently — the
8+
verify-stage tail of the same silent-false-negative family the adapter BUG-2/BUG-7
9+
fixes address (the adapter now honestly reports truncation as `max_tokens`; the
10+
verifier must gate on it). Offline stub adapters; no real LLM calls.
11+
"""
12+
from __future__ import annotations
13+
14+
import sys
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
_CORE_ROOT = Path(__file__).resolve().parents[1]
20+
sys.path.insert(0, str(_CORE_ROOT))
21+
22+
from utilities.agentic_enhancer.repository_index import RepositoryIndex
23+
from utilities.finding_verifier import FindingVerifier, VerificationResult
24+
from utilities.llm import PhaseBinding, ToolUseBlock
25+
from utilities.llm.adapter import CompletionResult
26+
from utilities.llm_client import reset_warning_state
27+
28+
STAGE1_FINDING = "vulnerable"
29+
30+
31+
@pytest.fixture(autouse=True)
32+
def _reset():
33+
reset_warning_state()
34+
yield
35+
reset_warning_state()
36+
37+
38+
def _verify(adapter) -> VerificationResult:
39+
binding = PhaseBinding(phase="verify", adapter=adapter, model="claude-x", provider_name="anthropic")
40+
v = FindingVerifier(index=RepositoryIndex({}, repo_path=None), binding=binding)
41+
return v.verify_result(code="x = 1", finding=STAGE1_FINDING, attack_vector="a", reasoning="r")
42+
43+
44+
class _TruncatedFinishAdapter:
45+
"""A finish(agree=False, safe) call on a turn the model truncated at max_tokens."""
46+
name = "anthropic"
47+
supports_tools = True
48+
pricing = {"claude-x": {"input": 1.0, "output": 1.0}}
49+
50+
def complete(self, *, model, system, messages, max_tokens, tools=None):
51+
return CompletionResult(
52+
content=[ToolUseBlock(id="t1", name="finish",
53+
input={"agree": False, "correct_finding": "safe"})],
54+
input_tokens=1, output_tokens=1, stop_reason="max_tokens",
55+
)
56+
57+
58+
class _CompleteFinishAdapter:
59+
"""Regression guard: a finish on a NORMAL turn (tool_use) is still accepted."""
60+
name = "anthropic"
61+
supports_tools = True
62+
pricing = {"claude-x": {"input": 1.0, "output": 1.0}}
63+
64+
def complete(self, *, model, system, messages, max_tokens, tools=None):
65+
return CompletionResult(
66+
content=[ToolUseBlock(id="t1", name="finish",
67+
input={"agree": True, "correct_finding": "vulnerable"})],
68+
input_tokens=1, output_tokens=1, stop_reason="tool_use",
69+
)
70+
71+
72+
def test_truncated_finish_at_max_tokens_is_incomplete_not_safe():
73+
r = _verify(_TruncatedFinishAdapter())
74+
assert r.incomplete is True # must NOT be a completed verdict
75+
assert r.correct_finding == STAGE1_FINDING # Stage-1 verdict preserved, not "safe"
76+
assert r.agree is False
77+
78+
79+
def test_complete_finish_still_accepted():
80+
r = _verify(_CompleteFinishAdapter())
81+
assert r.incomplete is False # a normal finish is still a real verdict
82+
assert r.agree is True

libs/openant-core/utilities/agentic_enhancer/agent.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,35 @@ def analyze_unit(
351351
)
352352
)
353353

354+
# R2-B: a finish call on a turn the model TRUNCATED (stop_reason ==
355+
# "max_tokens") is not a trustworthy complete classification — a
356+
# truncated finish defaulting security_classification to "neutral"
357+
# would silently drop a unit from the analysed set (a coverage/recall
358+
# loss). Treat it as INCOMPLETE, mirroring this agent's degenerate-exit
359+
# handling and the verifier's max_tokens gate.
360+
if finish_result and stop_reason == "max_tokens":
361+
call_record = self.tracker.record_call(
362+
model=self.binding.model,
363+
input_tokens=total_input_tokens,
364+
output_tokens=total_output_tokens,
365+
pricing=lookup_pricing(self.binding),
366+
)
367+
return AgentResult(
368+
include_functions=[],
369+
usage_context="Agent finish call truncated at max_tokens",
370+
security_classification=INCOMPLETE_CLASSIFICATION,
371+
classification_reasoning="Analysis incomplete - finish call truncated",
372+
confidence=0.3,
373+
iterations=iterations,
374+
total_tokens=total_input_tokens + total_output_tokens,
375+
is_entry_point=is_entry_point,
376+
reachable_from_entry=reachable_from_entry,
377+
entry_point_path=entry_point_path,
378+
input_tokens=total_input_tokens,
379+
output_tokens=total_output_tokens,
380+
cost_usd=call_record.get("cost_usd", 0.0),
381+
)
382+
354383
# If finish was called, return result
355384
if finish_result:
356385
# Record token usage

libs/openant-core/utilities/finding_verifier.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,15 @@ def verify_result(
422422
# sets result["finding"] = correct_finding, and the report
423423
# filters on that field — using "inconclusive" here would drop
424424
# a Stage-1 "vulnerable" from the report entirely.
425+
# C(a): record spend on this degenerate exit too — the three sibling
426+
# degenerate paths (finish, no-tool-calls, max-iterations) all record,
427+
# this one alone did not, undercounting the unit's tokens/cost.
428+
self.tracker.record_call(
429+
model=self.binding.model,
430+
input_tokens=total_input_tokens,
431+
output_tokens=total_output_tokens,
432+
pricing=lookup_pricing(self.binding),
433+
)
425434
return VerificationResult(
426435
agree=False,
427436
correct_finding=finding,
@@ -463,6 +472,29 @@ def verify_result(
463472
)
464473
)
465474

475+
# A finish call on a turn the model TRUNCATED (stop_reason == "max_tokens")
476+
# is not a trustworthy completed verdict: a well-formed
477+
# finish(agree=False, "safe") from a cut-off turn would silently downgrade a
478+
# Stage-1 vulnerable. Treat it as verification-incomplete (preserve the
479+
# Stage-1 verdict for triage) — honoring the adapter's truncation signal
480+
# (the responses/chat paths relabel abnormal terminations to "max_tokens")
481+
# rather than reading a truncated reply as a clean verdict.
482+
if finish_result and stop_reason == "max_tokens":
483+
self.tracker.record_call(
484+
model=self.binding.model,
485+
input_tokens=total_input_tokens,
486+
output_tokens=total_output_tokens,
487+
pricing=lookup_pricing(self.binding),
488+
)
489+
return VerificationResult(
490+
agree=False,
491+
correct_finding=finding,
492+
explanation="Verification incomplete (finish call truncated at max_tokens)",
493+
iterations=iterations,
494+
total_tokens=total_input_tokens + total_output_tokens,
495+
incomplete=True,
496+
)
497+
466498
if finish_result:
467499
self.tracker.record_call(
468500
model=self.binding.model,

0 commit comments

Comments
 (0)