fix(delegate): bound delegated sessions by default - #298
Conversation
|
Holding this for a revision rather than merging or closing. The machinery here is correct and is being kept verbatim — Two problems with it as the primary bound:
The revision puts an LLM-call budget in front of it (default 300 calls per session leg, enforced in the orchestrator loop, exiting on the child's own turn boundary with a complete transcript and Net change to this diff: |
…terations (Layer 1) Layered Bounding for Delegated Sessions (spec: 298-replacement, replacing the wall-clock-only default in #298). Adds a per-session-leg LLM-call budget as the first line of defense in front of the delegate's existing settings.timeout wall-clock backstop, delivered with zero new kernel surface: tool-delegate writes max_iterations (and a new budget_warn_ratio) into the orchestrator_config dict it already passes to spawn_fn, and amplifier-app-cli's session_spawner already does a caller-wins .update() into the child's config -- zero app-cli changes needed. Enforcement itself lives in the orchestrator loop (see companion PR microsoft/amplifier-module-loop-streaming#43), which already counts LLM calls via max_iterations and already exits exhaustion via a normal return (graceful wrap-up), so the resulting transcript is complete and resumable -- unlike a cancellation-based timeout. Ships DARK: settings.max_llm_calls defaults to None, so no budget is injected into any child session and orchestrator_config is byte-for-byte what it was before this change. Nothing here changes behavior until an operator explicitly sets settings.max_llm_calls. Precedence chain (highest first): 1. Per-call tool input (`max_llm_calls`) -- implemented 2. Per-agent frontmatter (`agents[name]["budget"]["max_llm_calls"]`) -- NOT implemented, see below 3. This module's settings.max_llm_calls (default None) -- implemented 4. Inherited parent orchestrator_config's max_iterations -- implemented (the pre-existing inheritance path, left untouched when no budget applies) Per-agent frontmatter override (rank 2) does not ship: verified empirically (not just read from source) that a top-level `budget:` block in an agent .md's frontmatter is dropped by amplifier_foundation.bundle._dataclass._load_agent_file_metadata, which only forwards a fixed allowlist of top-level keys (tools, providers, hooks, session, provider_preferences, model_role, agents) -- budget is not among them. Reproduced in tests/test_delegate_call_budget.py::test_agent_frontmatter_budget_key_is_dropped. Ranks 1, 3, and 4 ship; rank 2 is a follow-up requiring a change to the frontmatter loader itself, documented in this module's README "Known gaps" section. Also adds: - Eager validation (_validate_call_budget / _check_call_budget_type): reject bool, non-int, and negative values at the point supplied (module construction for the settings default, execute() for the per-call override) -- never at spawn time. - Negotiated-feature warning (spec §4.4): if a budget was requested but the child's orchestrator reports no llm_call_budget telemetry (e.g. a third-party orchestrator with no max_iterations support), logs a warning and sets metadata.budget_enforced = false on the returned ToolResult, so the gap is loud rather than silent. - max_llm_calls entry in the tool's input schema (kept a pure literal for the static token-cost estimator). Files: - modules/tool-delegate/amplifier_module_tool_delegate/__init__.py: _check_call_budget_type / _validate_call_budget module functions; settings.max_llm_calls / budget_warn_ratio in __init__; per-call max_llm_calls parsing + validation in execute(); _resolve_call_budget method; orchestrator_config build (copy-not-mutate + budget injection) and negotiated-feature warning in _spawn_new_session; max_llm_calls schema entry - modules/tool-delegate/README.md: "Layer 1 call budget" section + "Known gaps" - modules/tool-delegate/tests/test_delegate_call_budget.py (new): T2.1, T2.2, T2.4, T2.5, T2.6, T2.7, T2.8, T2.9, T2.10, T2.11 + the frontmatter round-trip verification test (14 tests) Testing: - New tests: 14 passed - Full tool-delegate module suite: 80 passed (was ~66; zero regressions) - Full foundation repo suite (tests/): 1634 passed, 1 skipped -- matches pre-change baseline exactly - ruff/pyright: no new issues vs baseline Part of the 298-replacement design (Layer 1 of 3). Companion PR: microsoft/amplifier-module-loop-streaming#43 (orchestrator-side enforcement). #298 is being revised separately to reframe its wall-clock default as the Layer 3 backstop behind this budget. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…terations (Layer 1) (#325) Layered Bounding for Delegated Sessions (spec: 298-replacement, replacing the wall-clock-only default in #298). Adds a per-session-leg LLM-call budget as the first line of defense in front of the delegate's existing settings.timeout wall-clock backstop, delivered with zero new kernel surface: tool-delegate writes max_iterations (and a new budget_warn_ratio) into the orchestrator_config dict it already passes to spawn_fn, and amplifier-app-cli's session_spawner already does a caller-wins .update() into the child's config -- zero app-cli changes needed. Enforcement itself lives in the orchestrator loop (see companion PR microsoft/amplifier-module-loop-streaming#43), which already counts LLM calls via max_iterations and already exits exhaustion via a normal return (graceful wrap-up), so the resulting transcript is complete and resumable -- unlike a cancellation-based timeout. Ships DARK: settings.max_llm_calls defaults to None, so no budget is injected into any child session and orchestrator_config is byte-for-byte what it was before this change. Nothing here changes behavior until an operator explicitly sets settings.max_llm_calls. Precedence chain (highest first): 1. Per-call tool input (`max_llm_calls`) -- implemented 2. Per-agent frontmatter (`agents[name]["budget"]["max_llm_calls"]`) -- NOT implemented, see below 3. This module's settings.max_llm_calls (default None) -- implemented 4. Inherited parent orchestrator_config's max_iterations -- implemented (the pre-existing inheritance path, left untouched when no budget applies) Per-agent frontmatter override (rank 2) does not ship: verified empirically (not just read from source) that a top-level `budget:` block in an agent .md's frontmatter is dropped by amplifier_foundation.bundle._dataclass._load_agent_file_metadata, which only forwards a fixed allowlist of top-level keys (tools, providers, hooks, session, provider_preferences, model_role, agents) -- budget is not among them. Reproduced in tests/test_delegate_call_budget.py::test_agent_frontmatter_budget_key_is_dropped. Ranks 1, 3, and 4 ship; rank 2 is a follow-up requiring a change to the frontmatter loader itself, documented in this module's README "Known gaps" section. Also adds: - Eager validation (_validate_call_budget / _check_call_budget_type): reject bool, non-int, and negative values at the point supplied (module construction for the settings default, execute() for the per-call override) -- never at spawn time. - Negotiated-feature warning (spec §4.4): if a budget was requested but the child's orchestrator reports no llm_call_budget telemetry (e.g. a third-party orchestrator with no max_iterations support), logs a warning and sets metadata.budget_enforced = false on the returned ToolResult, so the gap is loud rather than silent. - max_llm_calls entry in the tool's input schema (kept a pure literal for the static token-cost estimator). Files: - modules/tool-delegate/amplifier_module_tool_delegate/__init__.py: _check_call_budget_type / _validate_call_budget module functions; settings.max_llm_calls / budget_warn_ratio in __init__; per-call max_llm_calls parsing + validation in execute(); _resolve_call_budget method; orchestrator_config build (copy-not-mutate + budget injection) and negotiated-feature warning in _spawn_new_session; max_llm_calls schema entry - modules/tool-delegate/README.md: "Layer 1 call budget" section + "Known gaps" - modules/tool-delegate/tests/test_delegate_call_budget.py (new): T2.1, T2.2, T2.4, T2.5, T2.6, T2.7, T2.8, T2.9, T2.10, T2.11 + the frontmatter round-trip verification test (14 tests) Testing: - New tests: 14 passed - Full tool-delegate module suite: 80 passed (was ~66; zero regressions) - Full foundation repo suite (tests/): 1634 passed, 1 skipped -- matches pre-change baseline exactly - ruff/pyright: no new issues vs baseline Part of the 298-replacement design (Layer 1 of 3). Companion PR: microsoft/amplifier-module-loop-streaming#43 (orchestrator-side enforcement). #298 is being revised separately to reframe its wall-clock default as the Layer 3 backstop behind this budget. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Reframes this PR's timeout as Layer 3 of the "Layered Bounding for Delegated Sessions" design (spec: 298-replacement) -- the orchestrator- independent wall-clock backstop that sits behind a per-leg LLM-call budget (Layer 1, see microsoft#325 and companion PR microsoft/amplifier-module-loop-streaming#43), not the primary bound. Net functional change: `1800` -> `14400` for `settings.timeout`'s default. Everything else in this PR is kept verbatim: `_DelegateTimeoutExpired`, `_validate_timeout`, `_await_child_with_deadline`, the hard parent-release semantics, the honest `resumable: false` / `resume_status: pending_child_cleanup` reporting, and all 30 focused timeout tests (only the default-value assertions are retargeted). Why 14400s: ~12x the measured healthy sub-session upper bound (996-1168s), and ~2x below the worst observed runaway (17h34m) -- generous enough that a working Layer 1 budget should make this backstop fire zero times in practice. If it ever fires with Layer 1 active, that's a Layer 1 bug report, not evidence this default is wrong. Docs updated to frame this as the backstop: module docstring, README's "Delegate Timeout" section (retitled "Layered bounding: call budget (Layer 1) + wall-clock backstop (Layer 3)"), and the settings.timeout config comment. Files: - modules/tool-delegate/amplifier_module_tool_delegate/__init__.py: module docstring `settings.timeout` description; default 1800 -> 14400 - modules/tool-delegate/README.md: "Delegate Timeout" section rewritten as "Layered bounding"; config example comment - modules/tool-delegate/tests/test_delegate_timeout.py: default-value assertions retargeted to 14400 (T3.1) Testing: - modules/tool-delegate/tests/test_delegate_timeout.py: 30 passed - Full tool-delegate module suite: 90 passed (zero regressions) - Full foundation repo suite (tests/, this branch's own base): 1549 passed - python_check: no new issues vs this branch's own baseline (pre-existing I001 import-sort warning unchanged, confirmed via stash diff) 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
57a2778 to
6eb0fda
Compare
Revision rebased onto current
|
…ot bool(text) (#356) `_partial_output_fields` picked its timeout guidance from `bool(text)` alone, so a partial recovered from the REASONING channel got the sentence written for unfinished prose: "the text in 'partial_response' is unfinished work salvaged from the agent mid-flight -- it has NOT been checked, concluded, or self-reviewed" True of unfinished assistant prose. False of raw private reasoning, which was never addressed to a reader at all -- and framing it as unreviewed draft output invites the calling model to read it as a draft answer. Reachable only since app-cli 8c83a9b (PR #298) widened the accumulator to recover `thinking` + `tool_call` traces when no assistant text exists (k64: recoverable window 0.05% -> 82.2% of a leg). That half is the producer; this is the consumer. Branches on `partial.source`, never on the prose: no text -> _NO_PARTIAL_GUIDANCE (byte-identical) "spawn-accumulator:reasoning"-> _REASONING_PARTIAL_GUIDANCE (new) anything else -> _PARTIAL_GUIDANCE (byte-identical) Exact match, deliberately: an unknown or non-string source degrades to the incumbent behaviour rather than inheriting a frame that may be wrong for it. `source` is compared, never parsed, so it cannot raise on the timeout path -- the one path where raising discards every completed sibling in a batch. Byte-identity verified against the parent blob, not against this module's own constants: _PARTIAL_GUIDANCE sha256 b1d9796d1a9adf29 (416 B) and _NO_PARTIAL_GUIDANCE sha256 d73f51f164c545d3 (245 B) are unchanged, and the parent's selector re-run against this build agrees on every case except the reasoning one. Tests land in tests/ (not modules/tool-delegate/tests/) because CI runs `pytest tests/` only. Fail-before on 5d8db2f: 3 failed / 16 passed; after: 19 passed. Full suite 1939 -> 1958 passed, 1 skipped. Refs: model_performance-yiy Co-authored-by: amplifier-lane <amplifier-lane@localhost>
Summary
Real-world runaway delegated sessions have reached 17h34m / 8,036 calls and 3h49m / 992 calls. This adds a delegate-owned circuit breaker without exposing or depending on any private session data.
settings.timeoutto 1800 seconds for both spawn and resume; an explicitnullremains the opt-out for unbounded delegation.status: timed_out,resumable: false, andresume_status: pending_child_cleanup; the response never claims that interrupted-session persistence has completed.delegate:errorwitherror_type: delegate_timeout, notdelegate:agent_completed, while preserving ordinary external-cancellation semantics..next/working-session artifacts.Coordinated prerequisite
This rollout depends on microsoft/amplifier-app-cli#260, which persists interrupted child sessions and guarantees shielded cleanup after the Foundation layer releases the parent at its deadline. The pending-cleanup response is deliberately conservative until that app-layer work completes.
Verification
tool-delegatemodule tests passed.