mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
`delegate_shared._fail` rendered `{"status":"refused", ...}` as a plain string,
and the registry's legacy text adapter classified it as OK: it only understands
a top-level `ok:false` or a first-line `⚠️ IDENTIFIER` marker. So a refused
`delegate_wait`/`delegate_cancel` — daemon unreachable, run not owned, a
containment fault, a cancel the daemon refused — was recorded as a SUCCESSFUL
tool call on the outcome axis, in the acceptance packet and in the supervising
task's own reasoning.
Owner decision Q8A: the fix is a native structured result INSIDE the family,
not a repo-wide ABI migration. `_fail` now returns a `ToolResult` whose text is
the same JSON the callers emitted, plus two ADDITIVE envelope keys — `ok:false`
and `host_code` — written beside the domain payload. The domain `reason` is
never renamed into `ToolResult.code`, and the domain extras
(`definitely_unrun`, `pending_invocation_id`, `run_id`, `reset_at`, the custody
facts) keep their places. One exact, closed table maps a reason to its class:
substrate refusals (the daemon, the engine, custody or the run said no) are
`TOOL_REPORTED_FAILURE`, recorded and never degrading; malformed or
self-contradictory calls are `TOOL_ARG_ERROR`, which degrades and feeds
reflection. Neither is a timeout or the generic tool error, and an unclassified
reason defaults to the substrate class — the safe direction.
The second literal refusal author, `supervised_wait`'s checkpoint argument
check, folds into `_fail`. `_delegate_cancel`'s own outcomes join it: `failed`
and `containment_fault_run_may_still_be_live` report a run that may still be
live and mutating, so they publish as failures, while `confirmed` and
`requested` stay successful observations of the control surface.
Publication happens once, at the four REGISTERED entries, after every
decoration and immediately before the string is returned: `subagent_runtime`
mutates the start payload after `_delegate_start`, and an earlier publish fails
the registry's equality gate silently. `exact_start` now reads the native
payload, adds the actor identity and the work-order source, and returns
`_replace_tool_result`; its `json.loads → TypeError → return result` bypass,
which dropped that decoration without saying so, is deleted.
`_mark_actor_physical_start` still reads the DOMAIN `started` /
`started_uncustodied` status, never the host class.
The consumers migrate in the same change as the type they consume: the
configured-session bootstrap, the recovery handoff, the unknown-provider hold
and the pending-wake replay all read the producer's own payload rather than a
stringified result — without this, every leaf wake would have failed its
acknowledgement and taken the no-resend terminal. The wake envelope carries
`ok`/`host_code` through both fitted-spill shapes, so a refusal too large to
inline cannot read as a successful wait. `wait_once` deliberately keeps its
`str` tick contract, and `integrate_delegated_patch` keeps its own string ABI
at the one helper the two families share.
Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
246 lines
12 KiB
Python
246 lines
12 KiB
Python
"""Caller-selected ordinary-folder geometry survives native scheduling transport."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import queue
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from ouroboros.tools.control_subagent_spec import _validated_schedule_fields
|
|
from ouroboros.delegate_shared import delegate_result
|
|
|
|
|
|
@pytest.mark.parametrize("options", [
|
|
{}, {"directory_strategy": "direct"},
|
|
{"directory_strategy": "copy", "scope_paths": ["."]},
|
|
{"directory_strategy": "copy", "scope_paths": ["documents", "images/cover.png"]},
|
|
])
|
|
def test_directory_options_have_one_public_parameter_surface(options):
|
|
from ouroboros.tools.control_subagent_spec import schedule_subagent_properties, schedule_subagent_param_names
|
|
|
|
properties = schedule_subagent_properties()
|
|
assert properties["directory_strategy"]["enum"] == ["direct", "copy"]
|
|
assert properties["scope_paths"]["type"] == "array"
|
|
assert {"directory_strategy", "scope_paths"} <= schedule_subagent_param_names()
|
|
assert "REAL external Git" not in properties["write_root"]["description"]
|
|
fields, error = _validated_schedule_fields({
|
|
"objective": "Edit", "expected_output": "Files",
|
|
"write_surface": "external_workspace", **options})
|
|
assert not error
|
|
assert {key: fields[key] for key in ("directory_strategy", "scope_paths") if key in fields} == options
|
|
|
|
|
|
@pytest.mark.parametrize("surface", [{}, {"write_surface": "read_only"}])
|
|
@pytest.mark.parametrize("options", [
|
|
{}, {"directory_strategy": "direct"}, {"scope_paths": []},
|
|
{"directory_strategy": "direct", "scope_paths": []},
|
|
])
|
|
def test_a_read_only_child_may_name_the_documented_default(surface, options):
|
|
"""#882: `direct` with no footprint is what omitting both already means.
|
|
|
|
Nothing is normalized away here — the attested value is stored exactly as the
|
|
parent passed it, because the same field carries a write-capable child's real
|
|
choice. It simply is not a contradiction, so it is not refused.
|
|
"""
|
|
fields, error = _validated_schedule_fields({
|
|
"objective": "Audit", "expected_output": "Findings", **surface, **options})
|
|
assert not error
|
|
assert {key: fields[key] for key in ("directory_strategy", "scope_paths") if key in fields} == options
|
|
|
|
|
|
@pytest.mark.parametrize("surface", [{}, {"write_surface": "read_only"}])
|
|
@pytest.mark.parametrize("options", [
|
|
{"directory_strategy": "copy", "scope_paths": ["."]},
|
|
{"directory_strategy": "direct", "scope_paths": ["out"]},
|
|
{"scope_paths": ["out"]},
|
|
])
|
|
def test_a_read_only_child_asking_for_real_geometry_is_refused_at_schedule_time(surface, options):
|
|
"""The contradiction is caught where the parent can still fix it in one move.
|
|
|
|
Accepted here, it used to ride the envelope into the child's bootstrap and die
|
|
at the host's pre-start — after a worker, a queue row and a paid round — with a
|
|
receipt that forbade the child any other substrate. The message names the repair
|
|
the parent can actually apply.
|
|
"""
|
|
fields, error = _validated_schedule_fields({
|
|
"objective": "Audit", "expected_output": "Findings", **surface, **options})
|
|
assert not fields
|
|
assert "TOOL_ARG_ERROR (schedule_subagent)" in error
|
|
assert "omit directory_strategy and scope_paths" in error
|
|
|
|
|
|
@pytest.mark.parametrize("options", [
|
|
{"directory_strategy": "copy", "scope_paths": ["."]},
|
|
{"directory_strategy": "direct", "scope_paths": ["out"]},
|
|
])
|
|
def test_a_write_capable_child_keeps_real_geometry(options):
|
|
"""Unchanged for every acting surface: geometry is exactly what they are for."""
|
|
for surface in ("self_worktree", "external_workspace", "genesis"):
|
|
fields, error = _validated_schedule_fields({
|
|
"objective": "Edit", "expected_output": "Files",
|
|
"write_surface": surface, **options})
|
|
assert not error, surface
|
|
assert {key: fields[key] for key in ("directory_strategy", "scope_paths")} == options
|
|
|
|
|
|
@pytest.mark.parametrize("options", [
|
|
{"directory_strategy": "invalid"}, {"directory_strategy": "copy"},
|
|
{"directory_strategy": "copy", "scope_paths": []},
|
|
{"scope_paths": "documents"}, {"scope_paths": [""]}, {"scope_paths": [1]},
|
|
{"scope_paths": ["/absolute"]}, {"scope_paths": ["C:\\absolute"]},
|
|
{"scope_paths": ["../outside"]},
|
|
])
|
|
def test_directory_options_refuse_an_unusable_scope_shape(options):
|
|
fields, error = _validated_schedule_fields({"objective": "Edit", "expected_output": "Files", **options})
|
|
assert not fields and "TOOL_ARG_ERROR" in error
|
|
|
|
|
|
def _schedule(tmp_path, monkeypatch, *, kind, options, surface="external_workspace"):
|
|
from ouroboros.tools.control_scheduling import _schedule_task
|
|
from ouroboros.tools.registry import ToolContext
|
|
from tests._shared import configure_test_subagent
|
|
|
|
actor = configure_test_subagent(
|
|
monkeypatch, kind=kind,
|
|
target="codex=gpt-5.6-sol" if kind == "agent_session" else "openai/gpt-5.6-sol",
|
|
)
|
|
monkeypatch.setenv("OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS", "1")
|
|
monkeypatch.setenv("OUROBOROS_MAX_SUBAGENT_DEPTH", "3")
|
|
repo, data, folder = (tmp_path / name for name in ("system", "data", "documents"))
|
|
for directory in (repo, data, folder):
|
|
directory.mkdir()
|
|
(folder / "draft.txt").write_text("owner draft\n", encoding="utf-8")
|
|
event_queue = queue.Queue()
|
|
ctx = ToolContext(repo_dir=repo, drive_root=data, task_id="parent",
|
|
workspace_root=folder, workspace_mode="external")
|
|
ctx.event_queue = event_queue
|
|
ctx.task_metadata = {"root_task_id": "parent", "budget_drive_root": str(data)}
|
|
acting = {"write_surface": surface, "write_root": str(folder)} if surface else {}
|
|
response = _schedule_task(
|
|
ctx, subagent_id=actor, objective="Revise draft.txt", expected_output="Updated draft",
|
|
memory_mode="empty", **acting, **options,
|
|
)
|
|
return ctx, event_queue, response
|
|
|
|
|
|
def test_native_copy_refusal_precedes_child_side_effects(tmp_path, monkeypatch):
|
|
ctx, events, response = _schedule(
|
|
tmp_path, monkeypatch, kind="api_model",
|
|
options={"directory_strategy": "copy", "scope_paths": ["."]},
|
|
)
|
|
assert "TOOL_ARG_ERROR" in response and "directory_strategy=copy is unsupported" in response
|
|
assert events.empty()
|
|
assert not list((ctx.drive_root / "task_results").glob("*.json"))
|
|
assert not (ctx.workspace_root / ".git").exists()
|
|
|
|
|
|
@pytest.mark.parametrize("options", [
|
|
{"directory_strategy": "copy", "scope_paths": ["."]},
|
|
{"scope_paths": ["draft.txt"]},
|
|
])
|
|
def test_read_only_geometry_refusal_precedes_child_side_effects(tmp_path, monkeypatch, options):
|
|
"""The whole point of moving the check here: nothing is created to clean up."""
|
|
ctx, events, response = _schedule(
|
|
tmp_path, monkeypatch, kind="agent_session", options=options, surface="")
|
|
assert "TOOL_ARG_ERROR" in response and "omit directory_strategy and scope_paths" in response
|
|
assert events.empty()
|
|
assert not list((ctx.drive_root / "task_results").glob("*.json"))
|
|
|
|
|
|
@pytest.mark.parametrize("options", [{}, {"directory_strategy": "direct"}, {"scope_paths": []}])
|
|
def test_a_read_only_child_naming_the_default_still_schedules(tmp_path, monkeypatch, options):
|
|
"""The surviving positive path: a read-only auditor is queued, not refused."""
|
|
ctx, events, response = _schedule(
|
|
tmp_path, monkeypatch, kind="agent_session", options=options, surface="")
|
|
assert "Subagent request queued" in response, response
|
|
event = events.get_nowait()
|
|
assert event["write_surface"] == ""
|
|
assert {key: event[key] for key in ("directory_strategy", "scope_paths") if key in event} == options
|
|
|
|
|
|
def test_a_read_only_native_child_may_no_longer_name_a_capture_footprint(tmp_path, monkeypatch):
|
|
"""The one capability this change narrows, pinned so it cannot drift unnoticed.
|
|
|
|
A read-only NATIVE/API child used to schedule and run with `scope_paths`: the
|
|
keys were inert there, because a native child never calls `delegate_start`. The
|
|
new schedule-time guard keys on the write surface, not on the route, so that
|
|
spelling is now a typed argument error that names the repair. Nothing documented
|
|
is lost — the schema already said native children declare process outputs on
|
|
their file/process tools — and a write-capable native child keeps the footprint
|
|
verbatim, which is the half that must not move.
|
|
"""
|
|
for case in ("read-only", "acting"):
|
|
(tmp_path / case).mkdir()
|
|
_, read_only_events, refused = _schedule(
|
|
tmp_path / "read-only", monkeypatch, kind="api_model",
|
|
options={"scope_paths": ["out"]}, surface="")
|
|
assert "TOOL_ARG_ERROR (schedule_subagent)" in refused
|
|
assert "omit directory_strategy and scope_paths" in refused
|
|
assert read_only_events.empty()
|
|
_, acting_events, queued = _schedule(
|
|
tmp_path / "acting", monkeypatch, kind="api_model",
|
|
options={"scope_paths": ["out"]}, surface="external_workspace")
|
|
assert "Subagent request queued" in queued, queued
|
|
assert acting_events.get_nowait()["scope_paths"] == ["out"]
|
|
|
|
|
|
@pytest.mark.parametrize("kind,options", [
|
|
("api_model", {}), ("api_model", {"directory_strategy": "direct"}),
|
|
("agent_session", {}), ("agent_session", {"directory_strategy": "direct"}),
|
|
("agent_session", {"directory_strategy": "copy", "scope_paths": ["draft.txt"]}),
|
|
("agent_session", {"directory_strategy": "copy", "scope_paths": ["."]}),
|
|
])
|
|
def test_directory_options_survive_schedule_result_dispatch_and_bootstrap(
|
|
tmp_path, monkeypatch, kind, options,
|
|
):
|
|
from ouroboros import subagent_runtime
|
|
from ouroboros.subagent_bootstrap import bootstrap_before_context
|
|
from ouroboros.task_results import load_task_result
|
|
from supervisor import events
|
|
from tests.test_nested_rights_depth import _fake_ctx
|
|
|
|
parent, event_queue, response = _schedule(tmp_path, monkeypatch, kind=kind, options=options)
|
|
assert not event_queue.empty(), response
|
|
event = event_queue.get_nowait()
|
|
tid = event["task_id"]
|
|
keys = ("directory_strategy", "scope_paths")
|
|
selected = lambda row: {key: row[key] for key in keys if key in row}
|
|
assert selected(event) == options
|
|
assert selected(load_task_result(parent.drive_root, tid)) == options
|
|
enqueued = []
|
|
supervisor = _fake_ctx(parent.drive_root, enqueued)
|
|
supervisor.REPO_DIR = parent.repo_dir
|
|
events._handle_schedule_task(event, supervisor)
|
|
assert len(enqueued) == 1
|
|
task = enqueued[0]
|
|
assert selected(task) == selected(task["metadata"]) == options
|
|
result = load_task_result(parent.drive_root, tid)
|
|
assert result["status"] == "scheduled" and selected(result) == options
|
|
assert task["workspace_root"] == str(parent.workspace_root)
|
|
assert task["task_constraint"]["write_root"] == str(parent.workspace_root)
|
|
assert not (parent.workspace_root / ".git").exists()
|
|
|
|
starts = []
|
|
def exact_start(ctx, prompt, spec):
|
|
starts.append((ctx, prompt, spec))
|
|
return delegate_result({"status": "started", "run_id": "directory-run"})
|
|
monkeypatch.setattr(subagent_runtime, "exact_start", exact_start)
|
|
child = SimpleNamespace(
|
|
task_id=tid, drive_root=Path(task["drive_root"]), budget_drive_root=str(parent.drive_root),
|
|
task_metadata=task["metadata"], workspace_root=Path(task["workspace_root"]),
|
|
workspace_mode=task["workspace_mode"],
|
|
)
|
|
wake = bootstrap_before_context(child, task, SimpleNamespace(blocked=False))
|
|
if kind == "api_model":
|
|
assert wake == "" and starts == []
|
|
else:
|
|
assert json.loads(wake)["status"] == "configured_session_started"
|
|
assert len(starts) == 1 and selected(starts[0][2]) == options
|
|
assert selected(child._configured_actor_bootstrap) == options
|
|
assert starts[0][0].workspace_root == parent.workspace_root
|
|
assert starts[0][1] == child._configured_actor_bootstrap["canonical_work_order"]
|
|
if "scope_paths" in task:
|
|
task["scope_paths"].append("later-change")
|
|
assert child._configured_actor_bootstrap["scope_paths"] == options["scope_paths"]
|