mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 11:52:25 +02:00
fix(approvals): let "approve once" run ungranted bash/python, close skill-test streams (#6635) (#6683)
ToolApprovalStore.create() sealed a process identity only from the request
authority's launch scopes, which exist only for granted bash/python. For an
ungranted call the sealing failed silently, so the approval was created
without a process identity and the executor always refused it ("Approved
action has no sealed process/job identity"). When the authority is not
inherited and has no launch scope for the backend, seal one in the
request's single workspace root at approval time, as filesystem approvals
already do. Inherited authorities still cannot widen.
The skill test run and audit arm left their `async for` over
stream_agent_loop early when an approval was needed, without closing the
generator. asyncio closed it later from another task, so its ContextVar
resets raised "Token ... was created in a different Context". Close the
stream with aclosing, like the scheduler and teacher escalation do.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8feb50e894
commit
afecd44ddf
+106
-99
@@ -9,6 +9,7 @@ The on-disk format is SKILL.md (frontmatter + structured body) under
|
||||
|
||||
import logging
|
||||
import re
|
||||
from contextlib import aclosing
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
@@ -558,70 +559,74 @@ async def _run_skill_test_job(
|
||||
|
||||
messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
|
||||
try:
|
||||
async for chunk in stream_agent_loop(
|
||||
# aclosing: pausing for an approval leaves this loop early, and the
|
||||
# stream's ContextVar cleanup must run in this task, not in asyncio's
|
||||
# finalizer (#6635).
|
||||
async with aclosing(stream_agent_loop(
|
||||
url, model, messages, headers=headers,
|
||||
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
||||
exact_approval=exact_approval,
|
||||
request_authority=(request_authority or (
|
||||
exact_approval.pending.request_authority if exact_approval is not None else None
|
||||
) or RequestAuthority.empty(owner=owner)),
|
||||
):
|
||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||
continue
|
||||
try:
|
||||
d = _json.loads(chunk[6:])
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("delta"):
|
||||
say_buf.append(d["delta"]); transcript.append(d["delta"])
|
||||
elif d.get("type") == "tool_start":
|
||||
_flush_say()
|
||||
skill_stats["tool_calls"] += 1
|
||||
cmd = str(d.get("command") or d.get("args") or "")[:300]
|
||||
log.append({"type": "tool_start", "tool": d.get("tool"), "command": cmd})
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {cmd}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
_flush_say()
|
||||
out = str(d.get("output") or "")[:600]
|
||||
tool_log = {"type": "tool_output", "output": out}
|
||||
approval = d.get("ask_user")
|
||||
if isinstance(approval, dict):
|
||||
tool_log["ask_user"] = approval
|
||||
log.append(tool_log)
|
||||
transcript.append(f"[output] {out}\n")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
and approval.get("approval_id")
|
||||
):
|
||||
# Manual skill tests have their own polling UI instead of a
|
||||
# chat session. Pause the run and retain only server-side
|
||||
# continuation state until the same owner approves/denies
|
||||
# this exact sealed action.
|
||||
job["status"] = "awaiting_approval"
|
||||
job["approval"] = approval
|
||||
job["_transcript"] = transcript
|
||||
return
|
||||
elif d.get("type") == "agent_step":
|
||||
_flush_say()
|
||||
)) as stream:
|
||||
async for chunk in stream:
|
||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||
continue
|
||||
try:
|
||||
skill_stats["turns"] = max(skill_stats["turns"], int(d.get("round") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
log.append({"type": "agent_step", "round": d.get("round")})
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
elif d.get("type") == "metrics":
|
||||
data = d.get("data") or {}
|
||||
try:
|
||||
skill_stats["turns"] = max(skill_stats["turns"], int(data.get("agent_rounds") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
skill_stats["tool_calls"] = max(skill_stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if len(log) > 600:
|
||||
del log[0:len(log) - 600]
|
||||
d = _json.loads(chunk[6:])
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("delta"):
|
||||
say_buf.append(d["delta"]); transcript.append(d["delta"])
|
||||
elif d.get("type") == "tool_start":
|
||||
_flush_say()
|
||||
skill_stats["tool_calls"] += 1
|
||||
cmd = str(d.get("command") or d.get("args") or "")[:300]
|
||||
log.append({"type": "tool_start", "tool": d.get("tool"), "command": cmd})
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {cmd}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
_flush_say()
|
||||
out = str(d.get("output") or "")[:600]
|
||||
tool_log = {"type": "tool_output", "output": out}
|
||||
approval = d.get("ask_user")
|
||||
if isinstance(approval, dict):
|
||||
tool_log["ask_user"] = approval
|
||||
log.append(tool_log)
|
||||
transcript.append(f"[output] {out}\n")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
and approval.get("approval_id")
|
||||
):
|
||||
# Manual skill tests have their own polling UI instead of a
|
||||
# chat session. Pause the run and retain only server-side
|
||||
# continuation state until the same owner approves/denies
|
||||
# this exact sealed action.
|
||||
job["status"] = "awaiting_approval"
|
||||
job["approval"] = approval
|
||||
job["_transcript"] = transcript
|
||||
return
|
||||
elif d.get("type") == "agent_step":
|
||||
_flush_say()
|
||||
try:
|
||||
skill_stats["turns"] = max(skill_stats["turns"], int(d.get("round") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
log.append({"type": "agent_step", "round": d.get("round")})
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
elif d.get("type") == "metrics":
|
||||
data = d.get("data") or {}
|
||||
try:
|
||||
skill_stats["turns"] = max(skill_stats["turns"], int(data.get("agent_rounds") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
skill_stats["tool_calls"] = max(skill_stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if len(log) > 600:
|
||||
del log[0:len(log) - 600]
|
||||
_flush_say()
|
||||
except Exception as e:
|
||||
_flush_say()
|
||||
@@ -1054,53 +1059,55 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
|
||||
# OpenAI-compat) generate an empty completion, which manifested as
|
||||
# the skill test returning nothing while chat (which carries its
|
||||
# preset's max_tokens) worked. 4096 matches the chat default.
|
||||
async for chunk in stream_agent_loop(
|
||||
# aclosing: stopping for an approval leaves this loop early (#6635).
|
||||
async with aclosing(stream_agent_loop(
|
||||
url, model, messages, headers=headers,
|
||||
temperature=0.3, max_tokens=4096, max_rounds=8,
|
||||
owner=owner, workload=workload, suppress_skills=True,
|
||||
request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)),
|
||||
):
|
||||
# Streams can include an SSE event line before the data line,
|
||||
# notably `event: error`. Do not silently discard those failures.
|
||||
payload = next((line[6:] for line in chunk.splitlines() if line.startswith("data: ")), None)
|
||||
if payload is None or payload == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
d = _json.loads(payload)
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("error") or d.get("type") == "error":
|
||||
raise SkillAuditUnavailable(str(d.get("error") or d.get("message") or "Audit stream failed"))
|
||||
if d.get("delta"):
|
||||
transcript.append(d["delta"])
|
||||
elif d.get("type") == "tool_start":
|
||||
stats["tool_calls"] += 1
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
||||
approval = d.get("ask_user")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
):
|
||||
approval_required = approval
|
||||
break
|
||||
elif d.get("type") == "agent_step":
|
||||
)) as stream:
|
||||
async for chunk in stream:
|
||||
# Streams can include an SSE event line before the data line,
|
||||
# notably `event: error`. Do not silently discard those failures.
|
||||
payload = next((line[6:] for line in chunk.splitlines() if line.startswith("data: ")), None)
|
||||
if payload is None or payload == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
stats["turns"] = max(stats["turns"], int(d.get("round") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
elif d.get("type") == "metrics":
|
||||
data = d.get("data") or {}
|
||||
try:
|
||||
stats["turns"] = max(stats["turns"], int(data.get("agent_rounds") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
stats["tool_calls"] = max(stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
d = _json.loads(payload)
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("error") or d.get("type") == "error":
|
||||
raise SkillAuditUnavailable(str(d.get("error") or d.get("message") or "Audit stream failed"))
|
||||
if d.get("delta"):
|
||||
transcript.append(d["delta"])
|
||||
elif d.get("type") == "tool_start":
|
||||
stats["tool_calls"] += 1
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
||||
approval = d.get("ask_user")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
):
|
||||
approval_required = approval
|
||||
break
|
||||
elif d.get("type") == "agent_step":
|
||||
try:
|
||||
stats["turns"] = max(stats["turns"], int(d.get("round") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
elif d.get("type") == "metrics":
|
||||
data = d.get("data") or {}
|
||||
try:
|
||||
stats["turns"] = max(stats["turns"], int(data.get("agent_rounds") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
stats["tool_calls"] = max(stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except SkillAuditUnavailable:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user