fix(escalate): an optional question no longer trips over a habit-filled wait bound

A schema-filling model names max_wait_minutes on a question it does not wait
for. The validator refused that as a contradiction, and in the live log the
asker reacted by switching to blocking questions asked one by one. Naming the
documented default is not a different request: on an optional question the
bound now takes the omitted path and the receipt says it was ignored. A
required wait keeps its refusal, and both refusals now name their repair.

A bounded wait also leaked its deadline into a later unbounded question of the
same tool batch; the wait fields are now reset per question.

The tool description carries the criterion for waiting (irreversible or costly
next step, or a choice that is the owner's to make) and the one mechanical
fact that waiting questions in a batch share one wait. 935 -> 927 bytes.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-18 22:10:00 +03:00 • committed by Ouroboros
parent 72fd62f2e7
commit f554260be4
7 changed files with 72 additions and 21 deletions

View file

@ -26,7 +26,7 @@ This chapter owns the ABI promise: which typed shapes and their parsing, normali
| `ChatOutbound.initiator` — additive origin label of a self-initiated turn (`"consciousness"` on every frame and chat/progress/summary row of a consciousness wake-up and of the roots it starts; absent on an owner's turn); stamped by the turn's own event queue and the agent's frame meta, persisted by `log_chat`/the authored summary row/the task result, replayed by history on each row | `ouroboros/gateway/contracts.py`, `supervisor/log_addressing.py`, `ouroboros/subagent_messages.py`, `supervisor/message_bus.py`, `ouroboros/gateway/history.py`, `web/modules/api_types.js` | `tests/test_consciousness_initiator_label.py`, `tests/test_consciousness_wake_lane.py`, `web/tests/consciousness_label.test.js` |
| `project_thread` stamp on all seven outbound frame types, stamped at the message-bus broadcast choke; a stamped frame is never adopted by Main (`chat_activity.mainThreadAccepts`) | `supervisor/message_bus.py`, `ouroboros/projects_registry.py` | `tests/test_message_bus.py`, `web/tests/chat_thread_routing.test.js` |
| Media/link envelopes — media `task_id`/`size_bytes`/`download_url`; `LinkAction {label,url}` with at most twelve absolute HTTP(S) actions; `links` in `WS_MESSAGE_TYPES`; `chat.links` host topic | `ouroboros/gateway/contracts.py`, `ouroboros/tools/core.py`, `ouroboros/event_bus.py` | `tests/test_contracts.py` |
| Owner quiz ABI — `QuizOption {label, detail?, recommended?}` (the asker marks its recommendation on that option; the web card badges it, Telegram stars its button, the durable block keeps `recommended_index`), `QuizOutbound` (quiz_id, question, options, stake, `assumption` (required for optional clarification), additive `wait_for_answer` for a live pooled or ordinary-conversation root that must wait, lifecycle state open/answered/expired_terminal/superseded), separate `QuizStateOutbound` discriminator, `chat.quiz` host topic; the producer is the one escalation verb `escalate(question, options, stake, assumption, wait_for_answer=False, max_wait_minutes=None)` (the bound applies to a required wait only, never past the task's own deadline: the wait resumes with a system notice and the card stays open) — a ROOT asks the owner, a SUBAGENT delivers a typed frame to its nearest LIVE ancestor, which answers via `forward_to_worker` or escalates verbatim, so the owner sees only what no ancestor answered; answers arrive through the ONE ingress `POST /api/decisions` (family ids `quiz:{task_id}:{quiz_id}`, `routing:{client_message_id}:{routing_token}`; `interaction:` reserved), request-id idempotent, first answer wins, validated against the STORED options; `option_index` is optional for the quiz family alone — a comment-only answer writes NO `answered_index`, because a stored 0 would replay as "chose the first option"; injected as the typed `KIND_QUIZ_ANSWER` mailbox control and broadcast as `quiz_state` (carrying the recorded `comment` when the owner answered in their own words, so the live card shows `Owner's answer:` exactly as replay does); expiry is structural only (the task-done seam flips open quizzes to `expired_terminal`, and the SAME reconcile closes the paired `owner_wait` so a terminal task never projects `quiz=expired_terminal` beside `owner_wait=waiting`; `owner_wait.set_owner_wait`'s refusal to continue waiting on a terminal result is preserved, not caught); a LATE answer to such an expired card is nevertheless ACCEPTED at the same ingress (the projection records `answered_after_terminal`) and, because no mailbox will ever be drained, is delivered as the owner's OWN message into the card's chat through the named ingress `supervisor.message_bus.accept_local_message`, idempotent on `client_message_id = quiz_late_answer:<task>:<quiz>`, its provenance in the message's own `late_answer` metadata rather than a substituted `client_surface`; the 2xx says `forwarded` so no surface claims a delivery that did not happen, and 409 is left for what is genuinely settled (an already answered card, a non-root addressee). History replay merges the projection state | `ouroboros/gateway/contracts.py`, `ouroboros/gateway/task_decision.py`, `ouroboros/owner_quiz.py`, `ouroboros/tools/core.py` | `tests/test_gateway_parity.py`, `tests/test_quiz_display.py`, `tests/test_quiz_answer.py`, `web/tests/chat_decision.test.js` |
| Owner quiz ABI — `QuizOption {label, detail?, recommended?}` (the asker marks its recommendation on that option; the web card badges it, Telegram stars its button, the durable block keeps `recommended_index`), `QuizOutbound` (quiz_id, question, options, stake, `assumption` (required for optional clarification), additive `wait_for_answer` for a live pooled or ordinary-conversation root that must wait, lifecycle state open/answered/expired_terminal/superseded), separate `QuizStateOutbound` discriminator, `chat.quiz` host topic; the producer is the one escalation verb `escalate(question, options, stake, assumption, wait_for_answer=False, max_wait_minutes=None)` (the bound applies to a required wait only, never past the task's own deadline: the wait resumes with a system notice and the card stays open; named on an optional question it takes the omitted path and the asker's receipt says so) — a ROOT asks the owner, a SUBAGENT delivers a typed frame to its nearest LIVE ancestor, which answers via `forward_to_worker` or escalates verbatim, so the owner sees only what no ancestor answered; answers arrive through the ONE ingress `POST /api/decisions` (family ids `quiz:{task_id}:{quiz_id}`, `routing:{client_message_id}:{routing_token}`; `interaction:` reserved), request-id idempotent, first answer wins, validated against the STORED options; `option_index` is optional for the quiz family alone — a comment-only answer writes NO `answered_index`, because a stored 0 would replay as "chose the first option"; injected as the typed `KIND_QUIZ_ANSWER` mailbox control and broadcast as `quiz_state` (carrying the recorded `comment` when the owner answered in their own words, so the live card shows `Owner's answer:` exactly as replay does); expiry is structural only (the task-done seam flips open quizzes to `expired_terminal`, and the SAME reconcile closes the paired `owner_wait` so a terminal task never projects `quiz=expired_terminal` beside `owner_wait=waiting`; `owner_wait.set_owner_wait`'s refusal to continue waiting on a terminal result is preserved, not caught); a LATE answer to such an expired card is nevertheless ACCEPTED at the same ingress (the projection records `answered_after_terminal`) and, because no mailbox will ever be drained, is delivered as the owner's OWN message into the card's chat through the named ingress `supervisor.message_bus.accept_local_message`, idempotent on `client_message_id = quiz_late_answer:<task>:<quiz>`, its provenance in the message's own `late_answer` metadata rather than a substituted `client_surface`; the 2xx says `forwarded` so no surface claims a delivery that did not happen, and 409 is left for what is genuinely settled (an already answered card, a non-root addressee). History replay merges the projection state | `ouroboros/gateway/contracts.py`, `ouroboros/gateway/task_decision.py`, `ouroboros/owner_quiz.py`, `ouroboros/tools/core.py` | `tests/test_gateway_parity.py`, `tests/test_quiz_display.py`, `tests/test_quiz_answer.py`, `web/tests/chat_decision.test.js` |
| Managed update ABI — preflight, `UpdateMergePlan`, pinned apply, process-local `update_progress`, `update_progress_changed` invalidation and boot-only `update_status_ready` | `ouroboros/gateway/contracts.py` | `tests/test_update_apply_routing.py` |
| `ChatOutbound.review_projection` — bounded actor findings via `utils.truncate_review_artifact`, at most `MAX_PROJECTED_ACTOR_FINDINGS` rows (`review_execution_projection.py`) | `ouroboros/gateway/contracts.py` | `tests/test_review_substrate_v2.py`, `web/tests/review_truth.test.js` |
| Skill preflight statuses — `preflight_failed` is fresh-only; a stale failure surfaces as `preflight_failed_stale`; absence means the caller could not know | `ouroboros/skill_review_status.py` | `tests/test_skill_preflight_repair.py`, `web/tests/skill_preflight_repair.test.js` |

View file

@ -2,7 +2,7 @@
Machine extraction of `docs/ARCHITECTURE.md` §11.1 (the frozen-ABI SSOT), regenerated by `python scripts/regenerate_inventories.py`. Do not edit — edit the owning chapter named in the Source line and regenerate; `tests/test_generated_inventories.py` pins byte-identity and the resolution invariants (a §11.1 row whose owner or anchor file disappeared from the tree = red).
Source: `docs/architecture/11-frozen-contracts-v1.md`, physical LF lines 7-38; UTF-8 SHA-256 `3cd0c34dc3ac5ccccbac7386dcc49e0ad16cdfcafabb4e9c7207003b4cff9104`.
Source: `docs/architecture/11-frozen-contracts-v1.md`, physical LF lines 7-38; UTF-8 SHA-256 `a380f96a0c06837bad004044e0ecb802900fbfee778ad77081a26e93dddf1553`.
- table rows: **27**
- browser-envelope prose owners:

View file

@ -1456,12 +1456,13 @@ def get_tools() -> List[ToolEntry]:
"A root task asks the OWNER (a typed quiz card with option buttons); "
"a subagent asks its PARENT task (a typed mailbox frame the parent "
"answers with forward_to_worker or escalates higher, verbatim). "
"For optional clarification, state an assumption and continue independent work. "
"A live root, including ordinary Main or Project conversation, may set wait_for_answer=true when the answer is necessary: "
"after the current tool batch it waits without model calls, preserving its browser "
"and freeing active worker capacity. Addressed owner text resumes your judgment. "
"Stop and existing task deadlines remain effective. The card outlives the task: "
"an answer that arrives later reaches the chat as an ordinary owner message."
"By default name your recommended option as the assumption and keep working: "
"the card stays answerable, and a late answer still arrives. "
"Set wait_for_answer=true (a live root, ordinary conversation included) when the next step "
"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."
),
"parameters": {"type": "object", "properties": {
"question": {"type": "string", "description": "The decision being escalated (markdown renders in chat)"},

View file

@ -359,7 +359,8 @@ def validate_quiz_payload(
if not assumption_text and not wait_for_answer:
raise QuizValidationError(
"QUIZ_ASSUMPTION_REQUIRED",
"state the assumption you continue under until the owner answers.",
"state the assumption you continue under until the owner answers "
"(what you do meanwhile, for example your recommended option).",
)
bound = _validate_wait_bound(max_wait_minutes, wait_for_answer=wait_for_answer)
return {
@ -373,17 +374,15 @@ def validate_quiz_payload(
def _validate_wait_bound(max_wait_minutes: Any, *, wait_for_answer: bool) -> Optional[int]:
"""The optional wait bound in whole minutes, capped by the task ceiling."""
if max_wait_minutes is None:
if max_wait_minutes is None or not wait_for_answer:
# On an optional question a bound only spells out the documented default (no wait):
# it takes the omitted path, and the asker's receipt says so.
return None
if not wait_for_answer:
raise QuizValidationError(
"QUIZ_WAIT_BOUND_INVALID",
"max_wait_minutes applies only to wait_for_answer=true.",
)
if (isinstance(max_wait_minutes, bool) or not isinstance(max_wait_minutes, int)
or max_wait_minutes < 1):
raise QuizValidationError(
"QUIZ_WAIT_BOUND_INVALID", "max_wait_minutes must be a positive integer.",
"QUIZ_WAIT_BOUND_INVALID",
"max_wait_minutes must be a positive integer; omit it for an unbounded wait.",
)
from ouroboros.config import get_task_abs_ceiling_sec
@ -453,6 +452,8 @@ def _escalate(
max_wait_minutes=max_wait_minutes)
except QuizValidationError as exc:
return f"⚠️ {exc.code}: {exc}"
ignored_bound = (" max_wait_minutes ignored: it applies only to wait_for_answer=true."
if max_wait_minutes is not None and not wait_for_answer else "")
task_id = str(getattr(ctx, "task_id", "") or "").strip()
if not task_id:
return "⚠️ ESCALATE_UNAVAILABLE: escalate requires an active task context."
@ -550,7 +551,7 @@ 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']}")
f"assumption: {payload['assumption']}{'.' if ignored_bound else ''}{ignored_bound}")
# Root task: the owner gets a typed quiz card.
from ouroboros.owner_quiz import record_asked
@ -598,6 +599,8 @@ def _escalate(
if wait_for_answer:
bound = payload.get("max_wait_minutes")
ctx._owner_wait_requested = quiz_id
# One batch shares one wait: an earlier question's bound must not outlive it.
ctx._owner_wait_deadline_at, ctx._owner_wait_max_minutes = "", 0
if bound:
# An ABSOLUTE stamp, not a countdown: the bound must survive a
# planned restart instead of starting over on the warm resume.
@ -616,4 +619,4 @@ def _escalate(
return (f"OK: quiz {quiz_id} {delivered}; continuing under assumption: "
f"{payload['assumption']}. The answer (if any) arrives as an owner "
"quiz answer in a later round; the card stays answerable after this "
"task ends — a later answer reaches this chat as an ordinary owner message.")
f"task ends — a later answer reaches this chat as an ordinary owner message.{ignored_bound}")

View file

@ -233,6 +233,46 @@ def test_escalate_records_the_bound_and_says_what_the_wait_promises(tmp_path):
optional = _escalate(ctx, question="Which one?", options=["a", "b"], assumption="a meanwhile")
assert "the card stays answerable after this task ends" in optional
assert "a later answer reaches this chat as an ordinary owner message" in optional
assert "max_wait_minutes ignored" not in optional
def test_optional_question_with_a_habit_filled_bound_is_asked_and_says_so(tmp_path):
"""A schema-filling model names max_wait_minutes on a question it does not wait for. That
is the documented default spelled out, not a different request: the card is asked, no wait
starts, and the receipt discloses the ignored argument."""
from ouroboros.tools.core_artifacts import _escalate
from ouroboros.tools.registry import ToolContext
ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="native",
is_direct_chat=True, current_chat_id=1, event_queue=queue.Queue())
ctx.owner_wait_callback = direct_owner_wait
for named_default in (0, 1, 60):
result = _escalate(ctx, question="Which one?", options=["a", "b"],
assumption="a meanwhile", max_wait_minutes=named_default)
assert result.startswith("OK: quiz "), result
assert "max_wait_minutes ignored: it applies only to wait_for_answer=true." in result
event = ctx.event_queue.get_nowait()
assert event["type"] == "send_quiz" and "wait_for_answer" not in event
block = load_task_result(tmp_path, "native")["owner_quiz"][event["quiz_id"]]
assert "max_wait_minutes" not in block
assert not getattr(ctx, "_owner_wait_requested", "")
def test_a_bounded_wait_does_not_lend_its_bound_to_the_next_question_of_the_batch(tmp_path):
"""One tool batch shares one wait, named after its LAST waiting question. A bound the
earlier question asked for must not survive into a wait that asked for none."""
from ouroboros.tools.core_artifacts import _escalate
from ouroboros.tools.registry import ToolContext
ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="native",
is_direct_chat=True, current_chat_id=1, event_queue=queue.Queue())
ctx.owner_wait_callback = direct_owner_wait
_escalate(ctx, question="First?", options=["a", "b"], wait_for_answer=True, max_wait_minutes=20)
assert ctx._owner_wait_max_minutes == 20 and ctx._owner_wait_deadline_at
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 == ""
assert ctx._owner_wait_requested == list(ctx.event_queue.queue)[-1]["quiz_id"]
@pytest.mark.parametrize("required", [False, True])

View file

@ -429,6 +429,7 @@ def test_escalate_invalid_payload_is_typed(tmp_path):
assert out.startswith("⚠️ QUIZ_OPTIONS_INVALID")
out = _escalate(ctx, question="?", options=["a", "b"], assumption="")
assert out.startswith("⚠️ QUIZ_ASSUMPTION_REQUIRED")
assert "what you do meanwhile" in out # the refusal names its own repair

View file

@ -60,10 +60,16 @@ class TestValidateQuizPayload:
validate_quiz_payload("q", ["a", "b"], "", "",
wait_for_answer=True, max_wait_minutes=bad)
assert err.value.code == "QUIZ_WAIT_BOUND_INVALID"
# A bound without waiting is a contradiction, not a silent no-op.
# On an optional question a bound only names the documented default (no wait), so it
# takes the omitted path whatever a habit-filled schema put there; the asker's receipt
# discloses it (tests/test_native_owner_wait.py).
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"], "", "assume", max_wait_minutes=5)
assert err.value.code == "QUIZ_WAIT_BOUND_INVALID"
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)
def test_empty_or_oversized_question_refused(self):
with pytest.raises(QuizValidationError):