Skip to content

Commit 4fcba4d

Browse files
authored
Merge pull request #48 from KaiOnCode/fix/finalize-gate-bypass-visibility
fix(agent-team): mark finalize-gate bypass on the final turn instead of delivering silently
2 parents bbed073 + 6ab6b12 commit 4fcba4d

7 files changed

Lines changed: 312 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ Initial open-source release of FrontierAgent.
6262

6363
### Fixed
6464

65+
- Surface finalize-gate bypasses on the final turn: an answer delivered despite
66+
open task-board items now carries an unfinished-work note and a
67+
`finalize_gate_bypassed` marker instead of reading as a clean success.
6568
- Native mode puts the CLI's own Python environment ahead of the inherited
6669
`PATH`, so `read_file`, `download_file`, and `python3` inside `bash` use the
6770
interpreter the CLI was installed with rather than a system Python.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from collections.abc import Mapping
5+
from typing import Any
6+
7+
import pytest
8+
9+
from frontier_agent.core.loop_types import TurnContext, notify_observers
10+
from plugins.tools import task_board as tb
11+
from workflows._shared.citation_contract import (
12+
finalize_report_with_canonical_references,
13+
)
14+
from workflows.agent_team.nodes import fast_reporter_v1
15+
from workflows.agent_team.nodes.reporter import agent_team_reporter
16+
from workflows.agent_team.observers import bare_text_finalize as btf
17+
from workflows.agent_team.observers.bare_text_finalize import (
18+
BareTextFinalizeObserver,
19+
)
20+
from workflows.agent_team.spec import SWARM_SPEC
21+
from workflows.agent_team.spec_report import AGENT_TEAM_REPORT_SPEC
22+
23+
_TASK = "task"
24+
_ANSWER = "Deployment complete; 100% of attacks blocked [1]."
25+
_REFERENCES = [{"url": "https://example.com/a", "title": "Example"}]
26+
_BYPASS_ERR = (
27+
"Cannot finish: task board has unresolved item(s) ['t1']. "
28+
"For each, call update_task(...)"
29+
)
30+
31+
32+
def _seed_board(resolutions: dict[str, str]) -> None:
33+
tb._BOARDS[_TASK] = {
34+
"seq": len(resolutions),
35+
"tasks": {
36+
tid: {
37+
"description": f"work {tid}",
38+
"resolution": resolution,
39+
"owners": [],
40+
}
41+
for tid, resolution in resolutions.items()
42+
},
43+
}
44+
45+
46+
def _context(turn: int, max_turns: int = 20) -> TurnContext:
47+
return TurnContext(
48+
turn=turn,
49+
max_turns=max_turns,
50+
task_id=_TASK,
51+
role_id="coordinator",
52+
ai_text=_ANSWER,
53+
thinking="",
54+
tool_calls=[],
55+
messages=[],
56+
usage=None,
57+
metadata={},
58+
)
59+
60+
61+
def _dispatch(ctx: TurnContext) -> list:
62+
try:
63+
return asyncio.run(
64+
notify_observers(
65+
[BareTextFinalizeObserver()],
66+
"on_llm_response",
67+
ctx,
68+
),
69+
)
70+
finally:
71+
tb._BOARDS.pop(_TASK, None)
72+
73+
74+
def test_mid_run_unfinished_board_still_blocks() -> None:
75+
"""Away from the last turn the gate wins: rejected, never latched."""
76+
_seed_board({"t1": "open"})
77+
ctx = _context(turn=5)
78+
79+
interventions = _dispatch(ctx)
80+
81+
(intervention,) = interventions
82+
assert intervention.continue_to_next_turn is True
83+
assert intervention.stop_reason is None
84+
assert "final_answer" not in ctx.metadata
85+
86+
87+
def test_final_turn_bypass_stores_marker_and_warning() -> None:
88+
"""The bypass must leave machine- and human-readable traces in metadata.
89+
90+
``finalize_gate`` blocks while board items are open, but the final turn
91+
accepts the answer anyway (never lose it to max_turns). The observer
92+
stores the gate message (``finalize_gate_bypassed``) and a ready-to-append
93+
warning built while the board is still live
94+
(``finalize_gate_warning``); delivery nodes append the warning after any
95+
finalization that would otherwise strip it (issue #19, review on #48).
96+
The latched answer itself stays clean — appending here would be defeated
97+
by the reporter's References cleanup.
98+
"""
99+
_seed_board({"t1": "open", "t2": "open"})
100+
ctx = _context(turn=19)
101+
102+
interventions = _dispatch(ctx)
103+
104+
(intervention,) = interventions
105+
assert intervention.stop_reason == "final_answer"
106+
assert ctx.metadata.get("finalize_gate_bypassed")
107+
warning = str(ctx.metadata.get("finalize_gate_warning") or "")
108+
assert "unfinished" in warning.casefold()
109+
assert "t1" in warning and "t2" in warning
110+
# The answer is latched verbatim; the warning travels separately.
111+
assert ctx.metadata["final_answer"] == _ANSWER
112+
113+
114+
def test_final_turn_clean_board_latches_untouched() -> None:
115+
"""Gate passes → the answer is latched verbatim, no marker, no warning."""
116+
_seed_board({"t1": "resolved", "t2": "cancelled"})
117+
ctx = _context(turn=19)
118+
119+
interventions = _dispatch(ctx)
120+
121+
(intervention,) = interventions
122+
assert intervention.stop_reason == "final_answer"
123+
assert ctx.metadata["final_answer"] == _ANSWER
124+
assert "finalize_gate_bypassed" not in ctx.metadata
125+
assert "finalize_gate_warning" not in ctx.metadata
126+
127+
128+
def test_references_finalizer_strips_a_trailing_warning() -> None:
129+
"""Documents WHY the warning must be re-attached after finalization.
130+
131+
``finalize_report_with_canonical_references`` drops everything from the
132+
``References`` heading to the end of the body — a warning appended after
133+
that section does not survive the reporter's citation cleanup.
134+
135+
``strip_trailing_references`` refuses to cut when the heading starts
136+
before 30% of the body (mid-body sections are legitimate), so the body
137+
must be realistically long — as in a real report, where the reviewer's
138+
reproduction showed the warning being stripped.
139+
"""
140+
body = (
141+
"Attackers probed hidden paths, enumerated backup archives, and "
142+
"fuzzed administrative endpoints across several weeks of access "
143+
"logs before the intrusion was detected. " * 3
144+
+ "[1]\n\n"
145+
"## References\n\n"
146+
"[1] https://example.com/a\n"
147+
)
148+
warning = "\n\n---\n\n> ⚠ Unfinished work at submission: task t1."
149+
finalized = finalize_report_with_canonical_references(
150+
body + warning,
151+
references=_REFERENCES,
152+
language="en",
153+
)
154+
assert "Unfinished" not in finalized
155+
assert "https://example.com/a" in finalized # canonical block re-appended
156+
157+
158+
def test_append_bypass_warning_is_idempotent() -> None:
159+
"""Delivery boundaries append exactly once, even on repeated calls."""
160+
warning = "\n\n---\n\n> ⚠ Unfinished work at submission: task t1."
161+
source: Mapping[str, Any] = {"finalize_gate_warning": warning}
162+
163+
once = btf.append_bypass_warning("Report body.", source)
164+
twice = btf.append_bypass_warning(once, source)
165+
assert once.endswith(warning)
166+
assert twice == once
167+
# No stored warning → text untouched.
168+
assert btf.append_bypass_warning("Report body.", {}) == "Report body."
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_reporter_reappends_warning_after_finalization(
173+
monkeypatch: pytest.MonkeyPatch,
174+
) -> None:
175+
"""The reporter's output must carry the warning exactly once (review #48).
176+
177+
``_run_fast_reporter`` is the post-finalization report: References
178+
cleanup already ran inside the chain, so the node itself is the seam
179+
that re-attaches the stored bypass warning.
180+
"""
181+
finalized_report = (
182+
"Rewritten report with citation [1].\n\n"
183+
"## References\n\n- [1] https://example.com/a\n"
184+
)
185+
186+
async def _stub_reporter(
187+
_state: dict[str, Any], _ctx: Any,
188+
) -> str:
189+
return finalized_report
190+
191+
monkeypatch.setattr(fast_reporter_v1, "_run_fast_reporter", _stub_reporter)
192+
warning = "\n\n---\n\n> ⚠ Unfinished work at submission: task t1."
193+
state: dict[str, Any] = {
194+
"reporter_backend": "fast",
195+
"metadata": {},
196+
"finalize_gate_bypassed": _BYPASS_ERR,
197+
"finalize_gate_warning": warning,
198+
}
199+
200+
out = await agent_team_reporter(state, None) # type: ignore[arg-type]
201+
202+
final_answer = str(out.get("final_answer") or "")
203+
assert final_answer.endswith(warning.rstrip("\n")) or warning in final_answer
204+
assert final_answer.count("Unfinished work at submission") == 1
205+
# The marker must pass through so downstream consumers keep it.
206+
assert out.get("finalize_gate_bypassed") == _BYPASS_ERR
207+
208+
209+
def _node(spec: Any, node_id: str) -> Any:
210+
return next(node for node in spec.nodes if node.node_id == node_id)
211+
212+
213+
@pytest.mark.parametrize("spec", [SWARM_SPEC, AGENT_TEAM_REPORT_SPEC])
214+
def test_specs_publish_and_forward_the_bypass_marker(spec: Any) -> None:
215+
"""main_agent must publish the marker; the reporter must receive it.
216+
217+
Without these list entries the marker dies in loop-local metadata and
218+
consumers only ever see ``answer_status="complete"`` (review on #48).
219+
"""
220+
marker_keys = {"finalize_gate_bypassed", "finalize_gate_warning"}
221+
main_fields = set(_node(spec, "main_agent").output_fields)
222+
reporter = _node(spec, "agent_team_reporter")
223+
assert marker_keys <= main_fields
224+
assert marker_keys <= set(reporter.context_policy.include_fields)
225+
assert marker_keys <= set(reporter.output_fields)

‎workflows/agent_team/nodes/main_agent.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
from workflows.agent_team.observers.auto_fan_in import AutoFanInObserver
105105
from workflows.agent_team.observers.bare_text_finalize import (
106106
BareTextFinalizeObserver,
107+
append_bypass_warning,
107108
)
108109
from workflows.agent_team.observers.console import RichConsoleObserver
109110
from workflows.agent_team.observers.no_progress_guard import NoProgressGuard
@@ -1667,6 +1668,13 @@ async def _run_main_loop(
16671668
url_repair_stats["unmatched"], url_repair_stats["checked"],
16681669
)
16691670

1671+
# Re-attach the finalize-gate bypass warning (if any) only now: the
1672+
# observer stores it while the board is live, and this is the delivery
1673+
# boundary for the coordinator's own answer. When the reporter runs it
1674+
# re-appends on its own freshly finalized text (References cleanup would
1675+
# strip an earlier append) — see review on #48.
1676+
final_text = append_bypass_warning(final_text, result.metadata)
1677+
16701678
if reporter_enabled and result.metadata.get("report_handoff"):
16711679
logger.info(
16721680
"agent_team: research stopped by %s; advancing to downstream reporter",
@@ -1708,6 +1716,12 @@ async def _run_main_loop(
17081716
result.metadata.get("final_answer_rescue_mode") or "",
17091717
),
17101718
"final_answer_source": answer_source,
1719+
"finalize_gate_bypassed": str(
1720+
result.metadata.get("finalize_gate_bypassed") or "",
1721+
),
1722+
"finalize_gate_warning": str(
1723+
result.metadata.get("finalize_gate_warning") or "",
1724+
),
17111725
# Conditional edge in both agent-team specs consumes this resolved
17121726
# per-request value. False routes directly to END, preserving the
17131727
# coordinator's answer as the protocol final.

‎workflows/agent_team/nodes/reporter.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,16 @@ async def agent_team_reporter(
337337
if not report_md.strip():
338338
return {}
339339

340+
# Re-attach the finalize-gate bypass warning now: References cleanup
341+
# inside the chain has already run, so this append survives it — the
342+
# observer's stored warning is the same one main_agent would have
343+
# appended had the reporter not replaced the answer (review on #48).
344+
from workflows.agent_team.observers.bare_text_finalize import (
345+
append_bypass_warning,
346+
)
347+
348+
report_md = append_bypass_warning(report_md, state)
349+
340350
try:
341351
_refresh_trace_terminal(state.get("metadata") or {}, report_md)
342352
except Exception as exc:
@@ -355,4 +365,6 @@ async def agent_team_reporter(
355365
"final_answer_source": "reporter_llm",
356366
"final_answer_rescued": False,
357367
"final_answer_rescue_mode": "",
368+
"finalize_gate_bypassed": str(state.get("finalize_gate_bypassed") or ""),
369+
"finalize_gate_warning": str(state.get("finalize_gate_warning") or ""),
358370
}

‎workflows/agent_team/observers/bare_text_finalize.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,54 @@
22
from __future__ import annotations
33

44
import logging
5+
from collections.abc import Mapping
6+
from typing import Any
57

68
from frontier_agent.core.execution_context import get_current_execution_scope
79
from frontier_agent.core.loop_types import BaseObserver, Intervention, TurnContext
810
from plugins.tools._bus_scope import resolve_bus_task_id
911
from plugins.tools.finalize_answer import finalize_gate
12+
from plugins.tools.task_board import unresolved_task_ids
1013

1114
logger = logging.getLogger(__name__)
1215

1316

17+
def _build_bypass_warning(task_id: str) -> str:
18+
"""Build the standalone warning for an answer delivered past a blocked gate.
19+
20+
Built once while the task board is still live — the board is cleared
21+
before the workflow output is assembled — and stored in
22+
``finalize_gate_warning`` for the delivery nodes to append after any
23+
finalization that would otherwise strip it (the reporter's References
24+
cleanup drops everything after that heading).
25+
"""
26+
pending = unresolved_task_ids(task_id)
27+
if pending:
28+
what = f"task-board item(s) still unfinished: {', '.join(pending)}"
29+
else:
30+
what = "the finalize gate was still rejecting this submission"
31+
return (
32+
"\n\n---\n\n"
33+
f"> ⚠ Unfinished work at submission: {what}. This answer was "
34+
"delivered on the final turn despite the gate — conclusions that "
35+
"depend on that work are unverified."
36+
)
37+
38+
39+
def append_bypass_warning(text: str, source: Mapping[str, Any] | None) -> str:
40+
"""Re-attach the stored bypass warning at a delivery boundary, once.
41+
42+
The observer stores the ready-made warning; the delivery nodes append
43+
it *after* finalization (reporter References cleanup would strip an
44+
earlier append). Idempotent: text already carrying the warning is
45+
returned unchanged.
46+
"""
47+
warning = str((source or {}).get("finalize_gate_warning") or "")
48+
if not warning or not text or warning in text:
49+
return text
50+
return f"{text.rstrip()}{warning}"
51+
52+
1453
class BareTextFinalizeObserver(BaseObserver):
1554
critical = True
1655

@@ -36,6 +75,17 @@ async def on_llm_response(self, ctx: TurnContext) -> Intervention | None:
3675
return Intervention(continue_to_next_turn=True, inject_messages=[err])
3776

3877
if isinstance(ctx.metadata, dict):
78+
if err:
79+
# Last-turn bypass: the gate says BLOCK, but the answer is
80+
# delivered anyway rather than lost to max_turns. Keep that
81+
# fallback — and make it visible: store the gate message and
82+
# a ready-to-append warning (built while the board is live).
83+
# The visible text is appended by the delivery nodes AFTER
84+
# reporter finalization, which would strip it otherwise.
85+
ctx.metadata["finalize_gate_bypassed"] = err
86+
ctx.metadata["finalize_gate_warning"] = _build_bypass_warning(
87+
ctx.task_id,
88+
)
3989
ctx.metadata["final_answer"] = text
4090
ctx.metadata["final_answer_confidence"] = 1.0
4191
logger.info(

‎workflows/agent_team/spec.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"answer_status", "answer_sentinel",
4848
"final_answer_rescued", "final_answer_rescue_mode",
4949
"final_answer_source", "stopped_by",
50+
"finalize_gate_bypassed", "finalize_gate_warning",
5051
],
5152
),
5253
NodeDefinition(
@@ -64,6 +65,7 @@
6465
"live_followups", "effective_question",
6566
"reporter_backend", "reporter_wall_time_s",
6667
"reporter_deadline_monotonic_s",
68+
"finalize_gate_bypassed", "finalize_gate_warning",
6769
],
6870
),
6971
compression=CompressionConfig(enabled=False),
@@ -76,6 +78,8 @@
7678
"final_answer_source",
7779
"final_answer_rescued",
7880
"final_answer_rescue_mode",
81+
"finalize_gate_bypassed",
82+
"finalize_gate_warning",
7983
],
8084
),
8185
],

0 commit comments

Comments
 (0)