mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
fix(escalate): review round 1 — say what really ends the shared wait, name the repair on every bound refusal
The description claimed the shared wait "ends on the first owner reply". Any incoming message ends it (a descendant's message, a hurry, a system notice), so it now says "the first incoming message"; 935 -> 932 bytes against the base, and the catalog pin is rolled with that number. The above-ceiling refusal now carries the same "omit it for an unbounded wait" repair as the other invalid required bounds. The child's receipt discloses an ignored bound in brackets instead of gluing a second period onto an assumption that already ends with one. Tests pin the child path, the ceiling repair and the consumer of the wait fields (the parked wait carries no bound). Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
parent
42cb27ec19
commit
4bc05ffb44
6 changed files with 19 additions and 11 deletions
|
|
@ -1462,7 +1462,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
"is irreversible or costly to redo, or the choice is the owner's to make "
|
||||
"(spending, publishing, deleting); your judgment decides. The task then waits after the "
|
||||
"current tool batch without model calls; waiting questions in one batch share one wait, "
|
||||
"which ends on the first owner reply."
|
||||
"which ends on the first incoming message."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"question": {"type": "string", "description": "The decision being escalated (markdown renders in chat)"},
|
||||
|
|
|
|||
|
|
@ -391,7 +391,7 @@ def _validate_wait_bound(max_wait_minutes: Any, *, wait_for_answer: bool) -> Opt
|
|||
raise QuizValidationError(
|
||||
"QUIZ_WAIT_BOUND_INVALID",
|
||||
f"max_wait_minutes must be at most {ceiling_minutes} "
|
||||
"(the task's absolute wall-clock ceiling).",
|
||||
"(the task's absolute wall-clock ceiling); omit it for an unbounded wait.",
|
||||
)
|
||||
return int(max_wait_minutes)
|
||||
|
||||
|
|
@ -551,7 +551,8 @@ def _escalate(
|
|||
if not written:
|
||||
return f"⚠️ ESCALATE_UNWRITTEN: the escalation to parent {parent_task_id} was not persisted."
|
||||
return (f"OK: escalated to parent task {parent_task_id}; continuing under "
|
||||
f"assumption: {payload['assumption']}{'.' if ignored_bound else ''}{ignored_bound}")
|
||||
f"assumption: {payload['assumption']}"
|
||||
+ (f" ({ignored_bound.strip().rstrip('.')})" if ignored_bound else ""))
|
||||
|
||||
# Root task: the owner gets a typed quiz card.
|
||||
from ouroboros.owner_quiz import record_asked
|
||||
|
|
|
|||
|
|
@ -115,11 +115,10 @@ def test_core_catalog_schema_bytes_and_handler_owners_are_stable():
|
|||
# bound joins its parameters. Rolled again for the owner's "autonomy first" decision:
|
||||
# the escalate description states when waiting is worth it (an irreversible or costly
|
||||
# next step, or a choice that is the owner's to make) and that waiting questions of one
|
||||
# batch share one wait; 935 -> 927 bytes. Diffing the whole catalog base to head shows
|
||||
# exactly those edits and nothing else.
|
||||
# Workflow scope: describe lazy artifact writes and the shared brace-mask selector.
|
||||
# batch share one wait, ended by the first incoming message; 935 -> 932 bytes. Diffing the
|
||||
# whole catalog base to head shows exactly those edits and nothing else.
|
||||
assert hashlib.sha256(schema_bytes).hexdigest() == (
|
||||
"aa36894863d4b5df69520c86026bb29e62743fd36c8caff6faa5eb8627b5764d"
|
||||
"c4804f21764713b866e114eb8a7fee56501f84f1584dd502085e80c9362f8a2b"
|
||||
)
|
||||
assert {
|
||||
entry.name: (entry.handler.__module__, entry.handler.__name__)
|
||||
|
|
|
|||
|
|
@ -272,6 +272,9 @@ def test_a_bounded_wait_does_not_lend_its_bound_to_the_next_question_of_the_batc
|
|||
second = _escalate(ctx, question="Second?", options=["a", "b"], wait_for_answer=True)
|
||||
assert "the task waits after this tool batch" in second and "up to" not in second
|
||||
assert ctx._owner_wait_max_minutes == 0 and ctx._owner_wait_deadline_at == ""
|
||||
from ouroboros.owner_wait import _wait_bound_fields
|
||||
|
||||
assert _wait_bound_fields(ctx) == {}, "the parked wait carries no bound either"
|
||||
assert ctx._owner_wait_requested == list(ctx.event_queue.queue)[-1]["quiz_id"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -410,6 +410,10 @@ def test_escalate_subagent_writes_parent_mailbox_frame(tmp_path, monkeypatch):
|
|||
assert "forward_to_worker(task_id=child-9" in text
|
||||
# No owner card, no projection for the child hop.
|
||||
assert not [e for e in ctx.pending_events if e.get("type") == "send_quiz"]
|
||||
# A habit-filled bound is disclosed on the child's receipt too, never sent up the chain.
|
||||
out = _escalate(ctx, question="Again?", options=["a", "b"], assumption="keep going.", max_wait_minutes=1)
|
||||
assert out.endswith("assumption: keep going. (max_wait_minutes ignored: it applies only to wait_for_answer=true)")
|
||||
assert "max_wait_minutes" not in drain_owner_entries(tmp_path, "root-1", set())[-1]["text"]
|
||||
assert quiz_states(tmp_path, "child-9") == {}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -66,10 +66,11 @@ class TestValidateQuizPayload:
|
|||
for named_default in (0, 1, 5, 60, -5, True, "30"):
|
||||
assert "max_wait_minutes" not in validate_quiz_payload(
|
||||
"q", ["a", "b"], "", "assume", max_wait_minutes=named_default)
|
||||
# A required wait keeps the refusal, and the refusal names the repair.
|
||||
with pytest.raises(QuizValidationError) as err:
|
||||
validate_quiz_payload("q", ["a", "b"], "", "", wait_for_answer=True, max_wait_minutes=0)
|
||||
assert "omit it for an unbounded wait" in str(err.value)
|
||||
# A required wait keeps the refusal, and every such refusal names the repair.
|
||||
for bad in (0, 361):
|
||||
with pytest.raises(QuizValidationError) as err:
|
||||
validate_quiz_payload("q", ["a", "b"], "", "", wait_for_answer=True, max_wait_minutes=bad)
|
||||
assert "omit it for an unbounded wait" in str(err.value)
|
||||
|
||||
def test_empty_or_oversized_question_refused(self):
|
||||
with pytest.raises(QuizValidationError):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue