mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 20:27:56 +00:00
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:
parent
72fd62f2e7
commit
f554260be4
7 changed files with 72 additions and 21 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)"},
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue