ouroboros/ouroboros/delegate_shared.py
Ouroboros c5afa387db feat(delegate): delegate_message places a live message into a running run
The fifth nanny verb, delegate_message(run_id, text, message_id=""), lives
beside _delegate_answer in delegate_interactions.py: custody-gated through
_owned_run, one internal 100 s deadline under its 120 s ToolEntry timeout,
typed end to end. A FRESH message never POSTs to a settled run (not_active,
reason run_settled) or to an incapable route (unsupported): the engine's
/v2/operations catalog must list the operation AND the route's
agent-capabilities row must declare a liveInput other than none; an
unreadable read is unsupported too, never a guess. Outcomes mirror the
engine's LiveMessageOutcome 1:1 with the reason relayed verbatim, plus the
host's own not_found for ANY 404 after both positive reads (custody
untouched; daemon_says_absent is never consulted). The typed problem code
is read first: 409 idempotency_conflict is rejected (an agent fault), the
other 409s, every 5xx, transport death and deadline exhaustion are
delivery_unknown, 400/413/422 bodies a payload rejected.

The host mints message_id (uuid4, the wire Idempotency-Key) and returns it
in every result; a call carrying a returned id skips both short-circuits
and POSTs so the engine replays the stored receipt, which is the recovery
for delivery_unknown and only for it. No retry loop, no stall detector, no
custody row, no harness-name branch. The receipt is the
delegate_message_outcome event (digest and size, never the text).

Registration: ToolEntry after delegate_answer, docstrings say five verbs,
_AGENT_FAULT_REASONS gains message_text_required and idempotency_conflict,
both child tool profiles, nanny_pacing DELEGATE_ACTIVITY_TOOLS (not a
baseline reset), the cybergym disabled lists, and the delegate_answer /
delegate_wait descriptions become capability-based (delegate_wait names the
timeline's message.* rows as the reconciler; delegate_progress keeps their
messageId and outcome). The exact-set pins and the family refusal table are
updated; the fake daemon serves /v2/operations, liveInput on its harness
row and the messages route with Idempotency-Key replay and typed outcomes
at HTTP 200. tests/test_delegate_message.py pins the gateway, every typed
outcome, the message_id custody, the registration surfaces and the fake
daemon contract against the real client.

ouroboros/safety.py is a protected path and is deliberately NOT edited: the
coordinator adds "delegate_message": POLICY_SKIP after line 127, which
tests/test_safety_policy.py::test_tool_policy_covers_all_builtin_tools
requires (the one expected red until then).

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-26 08:10:21 +03:00

325 lines
16 KiB
Python

"""Shared nanny-verb helpers: the typed refusal, the custody-rooted emit, and
run-ownership resolution.
Extracted from ``ouroboros/tools/delegate.py`` to break the import cycle the
delegate split left behind: ``delegate_interactions`` imported these three
helpers back from the facade (``tools/delegate`` → ``delegate_interactions`` →
``tools/delegate``), and the house seam pattern is one-way — an extracted
module never imports the facade back. ``tools.delegate`` re-exports all three,
so every existing reference and monkeypatch target keeps the same objects.
This module also owns the external-executor family's RESULT ENVELOPE. Inside the
family (``delegate_start``/``delegate_wait``/``delegate_cancel``/
``delegate_answer``/``delegate_message``, their producers and their host
consumers) a result is a native ``ToolResult``; only the five registered entries
project it back to the ``str`` handler ABI. The envelope is two additive JSON keys — ``ok`` and
``host_code`` — written beside the domain payload, never instead of it: the
domain ``reason`` keeps its own name and its own vocabulary, and nothing here
renames it into ``ToolResult.code``.
"""
from __future__ import annotations
import json
import logging
import pathlib
from typing import Any, Dict, Mapping, Optional, Tuple
from ouroboros import delegate_custody as custody
from ouroboros.delegate_custody import RunCustody as _RunCustody
from ouroboros.tools.registry import ToolContext
from ouroboros.tools.tool_result import (
TOOL_CODE_SPECS,
ToolResult,
_publish_tool_result,
)
log = logging.getLogger(__name__)
# The two host classes the owner's rule leaves for this family (ARCHITECTURE
# §"Tool capability and execution": a refusal is recorded as a refusal, and a
# refused steer/refusal does not degrade the execution axis). A SUBSTRATE
# refusal — the daemon, the engine, custody or the run itself said no — is
# recorded and never degrading (`_outcome_tool_errors._POLICY_DENIAL_STATUSES`
# holds `tool_reported_failure`). An AGENT fault — the call itself was
# malformed or self-contradictory — degrades and feeds reflection. Neither is
# `TOOL_TIMEOUT` (nothing here timed out) nor `TOOL_ERROR` (which would hide
# which of the two this was).
SUBSTRATE_REFUSAL_CODE = "TOOL_REPORTED_FAILURE"
AGENT_FAULT_CODE = "TOOL_ARG_ERROR"
# The EXACT domain reasons whose refusal is the caller's own defect: a missing or
# empty required argument, two selectors that contradict each other, an answer
# row that is not an answer, a turn bound to one actor asking for another. Every
# other reason — including every engine/daemon code that arrives through
# `ClaudexorUnavailable` and every custody verdict — defaults to the substrate
# class, which is the safe direction: a reason nobody has classified yet is
# recorded honestly instead of being blamed on the agent.
_AGENT_FAULT_REASONS = frozenset({
"answer_row_empty",
"answer_row_invalid",
"answers_required",
"api_actor_requires_schedule_subagent",
"checkpoint_requires_time_and_reason",
"configured_actor_resource_mismatch",
"configured_actor_route_mismatch",
"empty_prompt",
# The engine's typed rejection of a live message whose message_id was
# replayed with DIFFERENT text: the caller reused an invocation identity.
"idempotency_conflict",
"message_text_required",
"missing_interaction_id",
"missing_run_id",
"payload_binding_mismatch",
"payload_selector_incomplete",
"retry_prompt_mismatch",
"retry_selector_conflict",
"selector_on_retry",
"source_response_invalid",
"source_response_not_required",
"subagent_selection_required",
"subagent_selector_conflict",
"unknown_subagent_id",
"unsupported_root",
})
def refusal_host_code(reason: str) -> str:
"""The host class of one domain refusal reason. Exact keys, safe default."""
return AGENT_FAULT_CODE if str(reason or "") in _AGENT_FAULT_REASONS else SUBSTRATE_REFUSAL_CODE
def delegate_result(payload: Mapping[str, Any], *,
meta: Optional[Mapping[str, Any]] = None) -> ToolResult:
"""Render one external-executor payload as its native result.
The payload's own envelope keys ARE the classification: ``ok: false`` marks a
refusal and ``host_code`` names its class. A payload without them is an
ordinary observation and stays ``OK`` — a terminal run that FAILED is still a
successful observation of an unsuccessful leaf, which is the distinction this
whole envelope exists to keep.
"""
code = "OK"
if payload.get("ok") is False:
code = str(payload.get("host_code") or "")
if code not in TOOL_CODE_SPECS:
code = SUBSTRATE_REFUSAL_CODE
return ToolResult(
status=TOOL_CODE_SPECS[code].status,
code=code,
text=json.dumps(dict(payload), ensure_ascii=False, indent=2),
meta=dict(meta or {}),
)
def delegate_payload(result: ToolResult) -> Dict[str, Any]:
"""The JSON object one native family result carries.
Every producer here renders a JSON OBJECT, so this is a READ of the
producer's own payload rather than a parse of untrusted text: a consumer that
silently fell back to ``{}`` would ship an undecorated start receipt or an
unacknowledgeable wake instead of failing where the contract actually broke.
"""
payload = json.loads(result.text)
if not isinstance(payload, dict):
raise TypeError("delegate result text is not a JSON object")
return payload
def publish_delegate_result(ctx: Any, result: ToolResult) -> str:
"""The family's ONE public boundary: publish the native result, return its text."""
return _publish_tool_result(ctx, result)
def _fail(tool: str, code: str, detail: str, **extra: Any) -> ToolResult:
payload = {
"status": "refused", "ok": False, "tool": tool, "reason": code,
"host_code": refusal_host_code(code), "detail": detail, **extra,
}
return delegate_result(payload)
# The typed facts a refused snapshot provision may carry; the same keys ride the
# refusal payload, the $0 terminal, the availability row and the START_FAILED row.
REFUSAL_FACT_KEYS = ("cause", "holder", "waited_sec", "retryable", "retry_hint")
def lock_busy_facts(exc: BaseException) -> Dict[str, Any]:
"""Typed facts when a HELD worktree ops lock refused a snapshot provision (#1241):
who holds it and for what (``subagent_worktrees.WorktreeOpsLockBusy``), so the
nanny can wait for that provision instead of guessing. ``{}`` for any other cause."""
holder = getattr(exc, "holder", None)
if not isinstance(exc, TimeoutError) or holder is None:
return {}
return {"cause": "lock_busy", "holder": dict(holder), "retryable": True,
"waited_sec": round(float(getattr(exc, "waited_sec", 0.0) or 0.0), 1),
"retry_hint": "Another snapshot is being provisioned under the shared worktree "
"lock; wait for it (see holder) and retry delegate_start."}
def _emit(ctx: ToolContext, kind: str, payload: Dict[str, Any]) -> None:
custody.emit(custody.custody_root(ctx), kind, {
"task_id": str(getattr(ctx, "task_id", "") or ""), **payload,
})
def _owned_run(ctx: ToolContext, tool: str, run_id: str) -> Tuple[Optional[ToolResult], Optional[_RunCustody]]:
"""Resolve custody for a run, or return a typed refusal payload.
The daemon bearer token grants the ENTIRE Claudexor API, so a run id is not a
capability the way a file descriptor is — anything that can name a run can reach it,
read it, or CANCEL it, and cancelling a reviewer destroys the verdict that was the
point of running it. Ownership is therefore replayed from the durable start row:
a restarted worker keeps its runs, and an id with NO durable record is UNKNOWN
(refused as unresolvable), which is a different fact from a run that demonstrably
belongs to someone else.
"""
status, entry = custody.lookup(custody.custody_root(ctx), str(getattr(ctx, "task_id", "") or ""), run_id)
if status == custody.UNKNOWN:
return _fail(tool, "run_ownership_unknown",
"No durable record of that run id exists on this drive, so ownership "
"cannot be established. Unknown ownership is refused, not waved through.",
run_id=run_id,
hint="The run may belong to a different drive or the id may be "
"mistyped; get_task_result(<task_id>) is the ownership-free "
"way to read another task's delegated-run outcome."), None
if status == custody.FOREIGN:
# The refusal stays a refusal; the additive facts give the caller the
# two things it needs to stop being stuck — the run is over, and whom
# to ask (get_task_result(owner_task_id) is the legitimate cross-task
# read that already carries the delegated_runs_* counters).
return _fail(tool, "run_not_owned",
"That run belongs to another task. A delegated run may only be "
"waited on or cancelled by the task that started it.",
run_id=run_id,
owner_task_id=str(getattr(entry, "task_id", "") or ""),
run_settled=bool(getattr(entry, "settled", False)),
run_terminal_state=str(getattr(entry, "terminal_state", "") or "")), None
return None, entry
def orphan_disposition_status(
ctx: ToolContext, drive: Any, run_id: str,
) -> Tuple[str, Optional[_RunCustody], str]:
"""Custody for a DISPOSITION, with the orphan rule applied.
An obligation is held by the run's durable rows, not by the task that
created them. While the owning task is LIVE, only that identity may decide
its captured patch. Once that task is terminal, a live TOP-LEVEL task may
apply or reject the orphan; every apply-path guard still runs unchanged
(recorded-target match, protected paths, proven drift, the whole-payload
CAS), and the PATCH_DISPOSED row records who wrote it.
This is a DISPOSITION-ONLY upgrade. ``_owned_run`` governs wait/cancel/
answer and is deliberately NOT widened: cancelling or answering a foreign
run destroys work instead of closing an obligation.
Returns ``(status, entry, orphan_of)``, where ``orphan_of`` is the terminal
owner's task id when the upgrade applied and "" otherwise.
"""
from ouroboros.delegate_terminal import _task_is_terminal
from ouroboros.tool_access import _TOP_LEVEL_PRINCIPAL_PROFILES, active_tool_profile
status, entry = custody.lookup(drive, str(getattr(ctx, "task_id", "") or ""), run_id)
if (status == custody.FOREIGN and entry is not None
and entry.settled and not entry.patch_disposed
and str(active_tool_profile(ctx)) in _TOP_LEVEL_PRINCIPAL_PROFILES
and _task_is_terminal(drive, entry.task_id)):
return custody.OWNED, entry, str(entry.task_id or "")
return status, entry, ""
def orphan_apply_target_ok(target: Any, active_root: Any) -> bool:
"""May a disposition APPLY a run recorded against ``target`` from ``active_root``?
The nanny's own run still needs exact equality. An ORPHAN of a terminal
owner may additionally apply into a target NESTED inside the caller's
active root, provided BOTH live under ``get_subagent_projects_root()`` —
the host-minted project area. That is the aggregator shape the host itself
creates: a swarm fans into ``<project>/contributions/<track>`` clones of
the very tree the parent works in, and the host already checkpoint-commits
exactly those descendants (``coop_checkpoint._task_tree_coop_roots``), so
the widened set adds no tree the host was not already writing to. An
owner-attached folder never lives there, so this can never reach one.
ONE predicate for the apply gate, the health invariant, and the tool
description: a rule stated three ways drifts into three rules.
"""
from ouroboros.config import get_subagent_projects_root
from ouroboros.tool_access import path_is_relative_to
try:
target_path = pathlib.Path(str(target or "")).expanduser().resolve(strict=False)
root_path = pathlib.Path(str(active_root or "")).expanduser().resolve(strict=False)
except (OSError, ValueError):
return False
if not str(target or "").strip() or not str(active_root or "").strip():
return False
if target_path == root_path:
return True
projects_root = pathlib.Path(get_subagent_projects_root()).expanduser().resolve(strict=False)
return (path_is_relative_to(target_path, root_path)
and path_is_relative_to(target_path, projects_root)
and path_is_relative_to(root_path, projects_root))
def orphan_capture_read_target(
ctx: ToolContext, candidate: Any, *,
snapshot: Optional[Mapping[str, Any]] = None,
) -> Optional[pathlib.Path]:
"""READ anchor for the capture of a TERMINAL OWNER's orphan, or None.
``artifacts.delegated_capture_read_target`` rebinds ``artifact_store``
reads for the caller's OWN ``delegated_runs/`` prefix only, so the task the
orphan rule already authorizes to APPLY a foreign capture could not READ
it: the sanctioned recovery root got ``outside selected root=artifact_store``
twice on the very patch it was told to dispose. Authority is not widened
here -- ``orphan_disposition_status`` remains the single decision, and this
only lets the actor that MAY apply also read.
Resolution is by PATH SHAPE first: a candidate must sit under
``<canonical data root>/task_results/artifacts/<owner_tid>/delegated_runs/
<capture>/``. Everything else returns before custody is touched, so a plain
refusal never pays for an event-log replay. The custody row is read from a
SHARED ``delegate_terminal.custody_audit_snapshot`` when the caller has one,
and only without one does this replay -- exactly once.
"""
from ouroboros.artifacts import DELEGATED_CAPTURE_PREFIX
from ouroboros.headless import ARTIFACTS_DIR
from ouroboros.tool_access import canonical_data_root, path_is_relative_to
try:
resolved = pathlib.Path(candidate).expanduser().resolve(strict=False)
artifacts_root = (pathlib.Path(canonical_data_root(ctx)) / ARTIFACTS_DIR).resolve(strict=False)
parts = resolved.relative_to(artifacts_root).parts
except (OSError, TypeError, ValueError):
return None
if len(parts) < 3 or parts[1] != DELEGATED_CAPTURE_PREFIX:
return None
owner_tid, capture_name = parts[0], parts[2]
if not owner_tid or owner_tid == str(getattr(ctx, "task_id", "") or ""):
return None # the caller's OWN prefix is delegated_capture_read_target's job
drive = custody.custody_root(ctx)
state = (snapshot or {}).get("state")
rows = (custody.undisposed_patches(drive, state=state) if state is not None
else custody.undisposed_patches(drive))
for row in rows:
if str(row.task_id or "") != owner_tid:
continue
cap_dir = custody.delegated_capture_dir(drive, row.task_id, row.snapshot_id or row.run_id)
if cap_dir.name != capture_name or not path_is_relative_to(resolved, cap_dir):
continue
# The rows we just read ARE the durable authority; seeding the memo the
# ownership lookup consults keeps this read at the traversal budget
# above instead of replaying the same log a second time.
custody._CUSTODY.setdefault(str(row.run_id), row)
status, _entry, orphan_of = orphan_disposition_status(ctx, drive, str(row.run_id))
if status == custody.OWNED and orphan_of:
return resolved
return None
__all__ = ["AGENT_FAULT_CODE", "SUBSTRATE_REFUSAL_CODE", "_emit", "_fail", "_owned_run",
"delegate_payload", "delegate_result", "orphan_apply_target_ok",
"orphan_capture_read_target", "orphan_disposition_status",
"publish_delegate_result", "refusal_host_code"]