fix: close Wave 1.1 completion-gate audit findings

- Headless consumers (task scheduler, background follow-up) now treat a
  completion-gate final_response as the authoritative answer instead of
  collecting deltas only. A gated replacement no longer leaves scheduled
  output empty, which used to trigger an extra, ungated grace-summary
  model call.
- The scheduler closes the agent stream with contextlib.aclosing, so the
  approval-pause break unwinds the gate's journal and teacher-takeover
  context in its own task. Chained runs no longer inherit a stale
  parent_run_id, and later finalization no longer raises ContextVar
  reset errors.
- On provider error, the completion gate applies the live answer's
  statement filter to persisted round_texts. Diagnostics and the failure
  note survive; claims rejected by the gate cannot reappear on reload.
This commit is contained in:
Alexandre Teixeira
2026-10-01 14:49:32 +01:00
parent f4793696f4
commit d49071bbec
5 changed files with 300 additions and 64 deletions
+22 -7
View File
@@ -92,13 +92,8 @@ def _current_run_claims(statement: str, *, execution_required: bool) -> list[tup
return claims
def completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
"""Keep explanatory prose; remove unsupported assertions and attach facts.
Exit status proves neither test counts nor coverage. A bad assertion is
removed at statement boundaries instead of erasing an entire explanation.
The execution outcome remains separate from a discarded model assertion.
"""
def _supported_prose(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
"""Remove unsupported assertions at statement boundaries; add no notice."""
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
execution_required = _execution_obligation(ledger.requirements)
kept = []
@@ -128,6 +123,19 @@ def completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDec
else:
kept.append(statement)
prose = ''.join(kept).strip() if removed else text
return prose, removed
def completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
"""Keep explanatory prose; remove unsupported assertions and attach facts.
Exit status proves neither test counts nor coverage. A bad assertion is
removed at statement boundaries instead of erasing an entire explanation.
The execution outcome remains separate from a discarded model assertion.
"""
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
execution_required = _execution_obligation(ledger.requirements)
prose, removed = _supported_prose(text, ledger, decision)
if incomplete or (removed and execution_required and decision.status in {CompletionStatus.UNVERIFIED, CompletionStatus.AWAITING_USER}):
reason = incomplete or removed
missing = (' Missing artifacts: ' + ', '.join(decision.missing_artifacts) + '.'
@@ -336,6 +344,13 @@ def with_completion_gate(func):
if not provider_error:
metadata['round_texts'] = [safe_answer]
metadata['completion_gate_reason'] = reason or unsafe_draft or 'receipt_summary'
if provider_error and isinstance(metadata.get('round_texts'), list):
# Failed rounds stay as per-round diagnostics, but they are
# rendered again on reload. Apply the same statement filter
# as the live answer so a rejected claim cannot reappear.
metadata['round_texts'] = [
_supported_prose(text, ledger, presentation_decision)[0] if isinstance(text, str) else text
for text in metadata['round_texts']]
if isinstance(metadata.get('thinking'), str):
_, unsafe_thinking = completion_answer(metadata['thinking'], ledger,
replace(presentation_decision, can_complete=True))
+11
View File
@@ -42,6 +42,7 @@ async def _drain_agent(sess, messages):
saves, so the frontend rebuilds them as standard agent-thread tool cards."""
from src.agent_loop import stream_agent_loop
full = ""
final_replaced = False
tool_events = []
round_num = 1
async for chunk in stream_agent_loop(
@@ -68,7 +69,17 @@ async def _drain_agent(sess, messages):
if isinstance(delta, str):
if d.get("thinking"):
continue
if final_replaced:
# A later answer supersedes the replacement, as the
# completion gate treats it.
full = ""
final_replaced = False
full += delta
elif d.get("type") == "final_response":
# The completion gate may present its sanitized answer as one
# replacement instead of deltas.
full = str(d.get("content") or "")
final_replaced = True
elif d.get("type") == "agent_step":
round_num = d.get("round", round_num)
elif d.get("type") == "tool_output":
+70 -56
View File
@@ -1976,6 +1976,7 @@ class TaskScheduler:
except Exception:
pass
full_text = ""
final_text_replaced = False
tool_results = []
approval_pause = None
@@ -1997,62 +1998,75 @@ class TaskScheduler:
)[1:]
except Exception:
_task_fallbacks = []
async for event_str in stream_agent_loop(
endpoint_url=endpoint_url,
model=model,
messages=messages,
max_rounds=_task_max_rounds,
session_id=session_id,
owner=task.owner,
headers=headers,
disabled_tools=disabled_tools,
relevant_tools=relevant_tools,
fallbacks=_task_fallbacks,
workload="background",
):
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
try:
data = json.loads(event_str[6:])
# Capture text from all event types, not just delta
if "delta" in data:
if data.get("thinking"):
continue
full_text += data["delta"]
elif data.get("type") == "tool_output":
# Tool results — capture summary so we have SOMETHING even
# if the model never produces a final text response
tool_summary = data.get("stdout") or data.get("output") or data.get("result") or ""
if isinstance(tool_summary, str) and tool_summary.strip():
tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}")
approval = data.get("ask_user")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
):
approval_pause = {
"tool": data.get("tool") or "tool",
"approval_id": approval.get("approval_id"),
}
# Scheduled tasks have no interactive surface that
# can safely resume a one-use grant. Retire the
# record immediately instead of leaving it pending
# and report an explicit manual-action boundary.
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
approval_pause["approval_id"],
decision="deny",
owner=task.owner,
session_id=session_id,
)
except Exception:
logger.debug(
"Could not retire scheduled-task approval",
exc_info=True,
)
break
except (json.JSONDecodeError, KeyError):
pass
# Close the stream in this task on every exit, including the
# approval-pause break, so the agent run's context state unwinds here.
async with contextlib.aclosing(stream_agent_loop(
endpoint_url=endpoint_url,
model=model,
messages=messages,
max_rounds=_task_max_rounds,
session_id=session_id,
owner=task.owner,
headers=headers,
disabled_tools=disabled_tools,
relevant_tools=relevant_tools,
fallbacks=_task_fallbacks,
workload="background",
)) as agent_stream:
async for event_str in agent_stream:
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
try:
data = json.loads(event_str[6:])
# Capture text from all event types, not just delta
if "delta" in data:
if data.get("thinking"):
continue
if final_text_replaced:
# A later answer supersedes the replacement,
# as the completion gate treats it.
full_text = ""
final_text_replaced = False
full_text += data["delta"]
elif data.get("type") == "final_response":
# The completion gate may present its sanitized
# answer as one replacement instead of deltas.
full_text = str(data.get("content") or "")
final_text_replaced = True
elif data.get("type") == "tool_output":
# Tool results — capture summary so we have SOMETHING even
# if the model never produces a final text response
tool_summary = data.get("stdout") or data.get("output") or data.get("result") or ""
if isinstance(tool_summary, str) and tool_summary.strip():
tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}")
approval = data.get("ask_user")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
):
approval_pause = {
"tool": data.get("tool") or "tool",
"approval_id": approval.get("approval_id"),
}
# Scheduled tasks have no interactive surface that
# can safely resume a one-use grant. Retire the
# record immediately instead of leaving it pending
# and report an explicit manual-action boundary.
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
approval_pause["approval_id"],
decision="deny",
owner=task.owner,
session_id=session_id,
)
except Exception:
logger.debug(
"Could not retire scheduled-task approval",
exc_info=True,
)
break
except (json.JSONDecodeError, KeyError):
pass
if approval_pause is not None:
return (