mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-31 02:02:22 +01:00
feat(harness): authority, turn contract, tools, clean_agent_preview
Chat/agent harness work: request-authority model, turn contract, tool
policy/parsing/execution, clean_agent_preview streaming/thinking, ask_user
persistence, session probes, and scheduler.
Source: vendor/photocraft (ad1cff44) harness files, 3-way merged onto
upstream/dev (a8c147b2). Harness core files were independently developed
in both lineages; resolution policy documented in pr_work/C_conflicts.md
(vendor kept where ahead, upstream kept where vendor is behind; full delta
preserved in pr_work/C_upstream_delta.patch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a8c147b238
commit
364229f2dc
@@ -316,7 +316,12 @@ class RequestAuthority:
|
||||
|
||||
|
||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"})
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info",
|
||||
"batch"})
|
||||
# Interaction classes are admitted on a strict router (spec) surface whenever
|
||||
# the server itself offered private_browser: the router decides the tool set,
|
||||
# so requiring the verb in the text would break "browse X then click Y".
|
||||
_BROWSER_INTERACTION_ACTIONS = frozenset({"click", "fill", "type", "press", "evaluate", "select"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -363,7 +368,8 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None,
|
||||
spec_surface=False):
|
||||
"""Deterministic server policy over semantic facts, never schema inventory."""
|
||||
if not isinstance(request_text, str):
|
||||
raise TypeError("Authority requires trusted request text")
|
||||
@@ -372,10 +378,25 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
families = intent.capabilities
|
||||
if capabilities is not None:
|
||||
families |= frozenset(capabilities)
|
||||
if spec_surface:
|
||||
# Strict router (spec) surface: agent_loop builds the final tool set in
|
||||
# src/spec_router.py AFTER this runs, so turn_contract.offered is still
|
||||
# the compact inventory here. The router already limits schemas to its
|
||||
# picks + sticky prior tools + ask_user/update_plan, and
|
||||
# _resolve_tool_blocks drops calls to unoffered tools, so admit every
|
||||
# family and let the spec surface be the boundary. Selected-tool
|
||||
# narrowing is skipped for the same reason.
|
||||
families |= frozenset(FAMILY_TOOLS)
|
||||
tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families))
|
||||
selected = intent.selected_tools
|
||||
selected = None if spec_surface else intent.selected_tools
|
||||
if selected is not None:
|
||||
tools = tools & set(selected) if families else set(selected)
|
||||
if "web_search" in tools:
|
||||
# Search selection narrows to the query tool, but reading a source
|
||||
# it surfaces is the same bounded read operation. Without the
|
||||
# sibling reader every discovered result is a dead end and the
|
||||
# turn fails closed on the first web_fetch of a found URL.
|
||||
tools.add("web_fetch")
|
||||
if tools & {"web_search", "web_fetch"}:
|
||||
tools.add("private_browser")
|
||||
operation = intent.required_read
|
||||
@@ -389,13 +410,28 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
actions = inputs = None
|
||||
if name == "private_browser":
|
||||
actions = _BROWSER_READ_ACTIONS
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
if spec_surface:
|
||||
# The strict router itself offered a working browser: admit its
|
||||
# interaction classes too, not only when the verb is in the text.
|
||||
actions |= _BROWSER_INTERACTION_ACTIONS
|
||||
elif re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select")
|
||||
if re.search(r"\b" + action + r"\b", request_text, re.I))
|
||||
if operation is not None and name == canonical_tool(operation.tool):
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input})
|
||||
required_args = dict(operation.args)
|
||||
if set(required_args) <= {"action"}:
|
||||
# An action-only read ("list my tasks/memories") pins the action
|
||||
# class, not the exact JSON: a model that adds benign arguments
|
||||
# (limit/offset/filter) still performs the same admitted read.
|
||||
# Reads that name an identity keep the exact-input seal below.
|
||||
required_action = str(required_args.get("action") or "").strip().casefold() or None
|
||||
if required_action:
|
||||
actions = (frozenset({required_action}) if actions is None
|
||||
else actions & frozenset({required_action}))
|
||||
else:
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(required_args)).input})
|
||||
grants.append(OperationGrant(name, actions, inputs))
|
||||
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
||||
str(workspace or ""), tuple(grants))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""One presentation gate between agent execution and externally visible prose.
|
||||
|
||||
Tool, progress and interaction events stay live. Answer deltas are held until
|
||||
the generator unwinds so a later replacement cannot conceal an earlier false
|
||||
Tool, progress and interaction events stay live. Evidence-safe complete
|
||||
sentences from ordinary answers may stream before the terminal check; execution
|
||||
answers remain gated so a later replacement cannot conceal an earlier false
|
||||
claim. This consumes no provider calls. Cancellation closes the inner generator
|
||||
under the same journal/turn authority; it never emits a successful terminal event.
|
||||
"""
|
||||
@@ -123,7 +124,7 @@ def _supported_prose(text: str, ledger: EvidenceLedger, decision: CompletionDeci
|
||||
or not ledger._supports_verifier_claim(identities, paths)):
|
||||
why = 'no current passing executable verification supports the claim'
|
||||
elif claim == 'mutation':
|
||||
if not ledger._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
|
||||
if not ledger._supports_mutation_claim(paths):
|
||||
why = 'no matching artifact mutation supports the execution claim'
|
||||
elif claim == 'execution':
|
||||
# A generic assertion cannot be tied confidently to a receipt.
|
||||
@@ -167,6 +168,16 @@ def _disclosure(answer: str, ledger: EvidenceLedger) -> str:
|
||||
return ('\n\n' + summary) if answer.strip() else summary
|
||||
|
||||
|
||||
def _complete_prose_prefix(text: str) -> str:
|
||||
"""Return complete statement segments, keeping the current sentence pending."""
|
||||
statements = list(_unquoted_statements(text))
|
||||
if not statements:
|
||||
return ''
|
||||
if statements[-1][0].strip():
|
||||
statements.pop()
|
||||
return ''.join(statement for statement, _ in statements)
|
||||
|
||||
|
||||
def _completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
|
||||
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
|
||||
execution_required = _execution_obligation(ledger.requirements)
|
||||
@@ -232,6 +243,10 @@ def with_completion_gate(func):
|
||||
journal.effects = (parent.effects if parent is not None and parent.effects is not None
|
||||
else EffectLog(journal.run_id))
|
||||
answer_events: list[dict] = []
|
||||
streamed_answer_events: set[int] = set()
|
||||
streamed_safe_text = ''
|
||||
stream_conflict = False
|
||||
first_visible_answer_at = None
|
||||
metrics_events: list[dict] = []
|
||||
answer = ''
|
||||
has_final = False
|
||||
@@ -297,18 +312,36 @@ def with_completion_gate(func):
|
||||
first_answer_at = perf_counter()
|
||||
# Boolean thinking=True marks a reasoning-only delta;
|
||||
# a textual thinking companion must not hide an answer
|
||||
# delta. Both shapes remain buffered until the gate.
|
||||
# delta. Thinking remains buffered until terminal review.
|
||||
if isinstance(data.get('thinking'), str):
|
||||
answer_events.append({'delta': data['thinking'], 'thinking': True})
|
||||
thinking_event = {'delta': data['thinking'], 'thinking': True}
|
||||
answer_events.append(thinking_event)
|
||||
data = {key: value for key, value in data.items() if key != 'thinking'}
|
||||
if 'delta' not in data:
|
||||
continue
|
||||
if data.get('thinking') is not True and 'delta' in data:
|
||||
if has_final:
|
||||
stream_conflict |= bool(streamed_safe_text)
|
||||
streamed_safe_text = ''
|
||||
answer = ''
|
||||
has_final = False
|
||||
answer += str(data.get('delta') or '')
|
||||
answer_events.append(data)
|
||||
if data.get('thinking') is not True and 'delta' in data \
|
||||
and not _execution_obligation(requirements):
|
||||
prefix = _complete_prose_prefix(answer)
|
||||
if prefix:
|
||||
ledger_now = _ledger(journal, requirements)
|
||||
decision_now = ledger_now.evaluate(exhausted=exhausted, awaiting_user=awaiting)
|
||||
safe_prefix, _ = _supported_prose(prefix, ledger_now, decision_now)
|
||||
if safe_prefix.startswith(streamed_safe_text):
|
||||
delta = safe_prefix[len(streamed_safe_text):]
|
||||
if delta:
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event({'delta': delta})
|
||||
streamed_safe_text = safe_prefix
|
||||
elif streamed_safe_text:
|
||||
stream_conflict = True
|
||||
continue
|
||||
yield chunk
|
||||
if provider_error and not answer_events and not metrics_events:
|
||||
@@ -361,7 +394,10 @@ def with_completion_gate(func):
|
||||
disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft
|
||||
or filtered_answer != answer)
|
||||
replaced_answer = not disclosure_only and bool(
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer)
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer or stream_conflict
|
||||
or (provider_error and streamed_safe_text))
|
||||
if streamed_safe_text and not answer.startswith(streamed_safe_text):
|
||||
replaced_answer = True
|
||||
if replaced_answer:
|
||||
reasoning = [event for event in answer_events if event.get('thinking') is True]
|
||||
_, unsafe_reasoning = completion_answer(
|
||||
@@ -369,11 +405,31 @@ def with_completion_gate(func):
|
||||
replace(presentation_decision, can_complete=True))
|
||||
if not unsafe_reasoning:
|
||||
for event in reasoning:
|
||||
yield _event(event)
|
||||
yield _event({'type': 'final_response', 'content': safe_answer})
|
||||
if id(event) not in streamed_answer_events:
|
||||
yield _event(event)
|
||||
yield _event({
|
||||
'type': 'final_response',
|
||||
'content': safe_answer,
|
||||
'render_owner': 'streamed',
|
||||
'replacement_scope': 'turn',
|
||||
})
|
||||
else:
|
||||
for event in answer_events:
|
||||
yield _event(event)
|
||||
if event.get('thinking') is True:
|
||||
if id(event) not in streamed_answer_events:
|
||||
streamed_answer_events.add(id(event))
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event(event)
|
||||
elif event.get('type') == 'final_response':
|
||||
if streamed_safe_text:
|
||||
event = {**event, 'replacement_scope': 'turn'}
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event(event)
|
||||
if not has_final:
|
||||
remainder = answer[len(streamed_safe_text):] if answer.startswith(streamed_safe_text) else answer
|
||||
if remainder:
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event({'delta': remainder})
|
||||
if disclosure_only:
|
||||
yield _event({'delta': disclosure})
|
||||
if provider_error:
|
||||
@@ -386,8 +442,11 @@ def with_completion_gate(func):
|
||||
if ledger.effects:
|
||||
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
|
||||
metadata['completion_gate'] = {
|
||||
'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': released_at - started,
|
||||
'buffer_seconds': max(0, (first_visible_answer_at or released_at) - first_answer_at)
|
||||
if first_answer_at is not None else 0,
|
||||
'evaluation_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': first_visible_answer_at - started
|
||||
if first_visible_answer_at is not None else 0,
|
||||
'additional_provider_calls': 0,
|
||||
'answer_replaced': replaced_answer,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user