返回设计文档

CAPABILITY · 07/08

ToolLoop

Dynamic router、fixed batch 和 registered tool execution。

真相源
docs/DESIGN_ENGINES_TOOLLOOP.md
读取方式
构建时本地读取
规模
612 行

Status: active Scope: nexrur.engines.toolloop, nexrur.prompts.toolloop, app-owned ToolLoop registries Tracking: nexrur GitHub Issues #130, #140, #272; WQ-YI #351

1. Purpose

ToolLoop is a substrate execution engine for bounded, registered callables. It answers two questions only:

  1. which registered callable may run;
  2. how that callable is admitted, executed, audited, timed out, retried, and returned.

It does not decide what a tool means to an App, which source should be queried, which candidate is best, or whether an App workflow should continue.

2. Ownership boundary

The substrate owns:

  • registry loading and ToolSpec construction;
  • callable resolution and invocation;
  • input/output schema gates;
  • timeout, bounded retry, cache, redaction, audit, and evidence hooks;
  • generic dynamic routing among explicitly allowed tools;
  • fixed sequential batches;
  • governed parallel batches over opaque keys;
  • deterministic receipt and result ordering.

The App owns:

  • tool names, descriptions, schemas, and callable registrations;
  • prompts and model profiles for App production;
  • source-specific adapters and result validation;
  • query construction, ranking, filtering, and selection;
  • concurrency choices required by a particular external source;
  • workflow order, terminal rules, diagnosis meaning, and projection policy.

No source family, provider, product, App, field name, ranking rule, or business fallback may be encoded as a ToolLoop Python default.

The active implementation split is explicit:

  • contract.py owns declaration types, explicit YAML loading, mechanical contract validation, and contract compilation;
  • router.py owns the dynamic decision contract, formal Prompt assembly, LLM decisions, observation summarization, and evidence-bounded closeout;
  • toolloop.py owns watchdogs, callable execution, runtime state, contract snapshots, audit/evidence handoff, sequential batches, and parallel batches.

Router code must not execute tools, write contract snapshots, or own a generic execution watchdog. ToolLoop code must not infer App semantics while executing Router decisions.

3. Registry contract

Runtime truth comes from the App registry, normally:

<app_root>/.gemini/skills/_shared/engines/toolloop.yml

A minimal owner block is:

subagents:
  worker-a:
    tools:
      transform:
        callable: app.tools.transform:run
        description: Transform one admitted work item.
        input_schema: {}
        output_schema: {}
        cacheable: false
        deadline_plan: resolver
    runner_defaults:
      allowed_tools: [transform]
      max_calls: 4
      tool_timeout_s: 120

Tool ids must be unique. Callables must resolve to public entrypoints. Allowed tools must be registered. Input and output schemas are mechanical gates, not places for substrate business judgment.

Composite topology is declared by the public callable's canonical __toolloop_deadline_plan_resolver__ attribute. Without an explicit _meta.deadline_plan_classification: required opt-in, that callable attribute is sufficient: an omitted YAML deadline_plan marker is legal and must not reject the registry. This does not introduce a legacy callable alias or an alternative compiler path.

An App may opt in to strict classification. Every callable in that registry must then declare deadline_plan: direct or deadline_plan: resolver. direct states that the callable has no registered child; resolver explicitly requires a callable resolver. Neither marker describes children, counts, waves, profiles, or time. In both strict and non-strict registries, an explicit direct marker contradicting a resolver or an explicit resolver marker without its callable implementation fails before returning a Runner. App CI verifies that a callable classified direct does not reach a registered child. The policy accepts only the exact value required; an unknown policy/marker or a missing strict classification fails before callable-module import. Resolver/compiler failure never falls back to configured tool timeouts.

3.1 Public-contract change gate

The root AGENTS P0 public-contract discipline applies to every expansion of the registry or plan rejection surface. Issue #272 records the correction of an unrequested marker gate and root-metadata rejection affecting current consumers; WQ-YI #351 tracks the App impact. A change must identify the concrete problem and evidence, compare a minimal fix with alternatives, inventory current consumers and affected paths, and pass offline cross-App checks using their actual registries and representative real resolver plans. YAML parsing and fake-only tests are insufficient. Breaking rollout requires explicit user approval of its impact and plan, migration order, joint acceptance, and rollback scope/triggers. Missing evidence or consumer acceptance forbids a new global gate. An accidental rejection is contained by withdrawing the unnecessary gate, not by forcing App business changes, adding App-specific substrate exceptions, or disabling genuine safety/schema validation. Offline checks do not authorize external calls.

4. Execution modes

4.1 Dynamic router

ToolLoopRunner.run() lets an LLM choose the next action from an explicit allowlist. The caller must provide router_profile and final_profile. ToolLoop never maps friendly tiers, boolean flags, App names, or tool names to a provider/model.

The router receives only:

  • the goal and caller-provided context;
  • registered tool descriptions and schemas;
  • generic observations and budgets;
  • an optional caller-provided final schema.

It may reject an unknown tool, invalid arguments, an exact repeated call, or a budget breach. It may not infer domain-specific identifiers or apply source-specific ordering, query, filtering, or selection rules.

Prompt truth lives in src/nexrur/prompts/toolloop.md. Missing or invalid formal prompts fail closed; Python contains no emergency Prompt copy.

4.2 Sequential batch

run_batch() executes a caller-declared list in order. It is mechanical: the caller chooses the calls and ToolLoop applies registry, schema, timeout, audit, and receipt rules.

Before the first call, the sequential batch compiles every callable deadline plan and expands the batch parent envelope to the legal serial path. Resolver or compiler failure rejects the whole batch before execution. It must never be caught and replaced by the configured tool timeout: that timeout is a lower bound for a valid compiled plan, not a fallback for an unknown topology. This fail-closed rule is identical for sequential batch, parallel batch, direct callable execution, and Orchestrator graph compilation. All four entry paths call the same executor/compiler method. They may aggregate already compiled envelopes according to their own execution topology, but they must not invoke resolvers, merge configured floors, or translate compiler failures independently. One exact invocation produces one immutable receipt bound to the tool id and normalized argument hash. Nested Orchestrator, batch, and callable execution consume that same receipt; none may re-run its resolver.

4.3 Governed parallel batch

run_parallel_batch() accepts bounded lanes. Each lane contains an opaque item_key and ordered calls identified by opaque call_key values.

lane A: call 1 -> call 2
lane B: call 1 -> call 2

Calls inside one lane remain serial. Different lanes may run concurrently up to the caller's explicit max_concurrency. Before fan-out, ToolLoop rejects the whole plan when keys are duplicated, limits are exceeded, a tool is unknown, or arguments fail schema validation.

Every child receipt carries parallel_batch_id, item_key, and call_key. Completion may be out of order, but convergence and returned lanes preserve input order. ToolLoop treats all three identifiers as opaque strings.

Provider admission and rate limits remain independent hard limits. A local parallelism value never overrides them.

Concurrent deadline waves use the longest complete compiled child branch (including nested serial work), while max_child_timeout_s remains leaf-level telemetry and must not replace that branch budget (Issue #285).

Optional trusted lifecycle (Issue #281)

Implemented in the local working tree with offline acceptance; this does not authorize real calls or deployment. run_parallel_batch(..., lifecycle=...) optionally accepts exactly three trusted Python callables, never payload/YAML callbacks: before_call, after_call, and deadline_plan. Existing callers without this argument keep the same admission, execution, receipts, and deadlines.

deadline_plan(call) is a read-only declaration using the existing registered child/topology compiler. It declares the per-call upper bound for lifecycle work, including any registered Projection writes; it cannot supply raw timeout fields. All lifecycle plans and ordinary calls compile before any hook or dispatch. Because lifecycle hooks are serialized across lanes, the batch envelope is the existing provider lane/wave envelope plus the sum of all lifecycle envelopes and the existing convergence tail, not the maximum of lane-local hook budgets.

before_call(call) runs immediately before an eligible call, not during fan-out compilation. Its closed return shape is action: execute | reuse | skip, optional opaque state, a result for reuse, and a bounded reason for skip. The call description contains opaque lane/call keys, indices, registered tool, and an independent copy of the frozen arguments. Hooks cannot replace tool, arguments, deadline, or dispatch target; a recovery subset must already be frozen before compilation. The opaque state is passed only to after_call(receipt, state) and is never serialized to audit, progress, or returned receipts.

after_call returns no replacement result. It runs for executed, reused, skipped, and not-admitted calls, and receives a copy of the original mechanical receipt, including a missing result or execution exception. App-owned validation, Projection, journal completion, and bounded source-stop facts remain App logic. The original execution/admission/result facts survive hook exceptions; lifecycle failure is reported separately and prevents subsequent calls in that lane. A returned provider payload never becomes a substrate source/rate-limit decision.

Hooks run serially under the owning ToolLoop context; provider execution does not hold that serialization lock. The next call in a lane cannot begin before its predecessor's after hook finishes. Apps must not create threads/locks, pre-claim all lane work, dispatch external providers from hooks, change graph routes, or merge final evidence in completion order. Final convergence remains input-ordered. No new registered tool, global registry requirement, or parent-journal delegation is introduced. Acceptance must cover isolated lane progress, skip/reuse without dispatch, raw-result preservation, callback/context safety, immutable compiled arguments, Projection budget accounting, and unchanged no-hook consumers. Local verification passed 19 lifecycle/parallel focused cases and the complete runtime-root file (174 passed, one pre-existing Windows symlink-privilege skip). Cross-App integration and rollout remain separately tracked by Issue #281 and WQ-YI #356; these local results do not declare deployment or real-provider success.

Deadline plans additionally support nested maximum nodes for mutually exclusive legal branches whose choice depends on a child result. maximum selects the longest compiled branch; it is not concurrency and must not declare a concurrency value. Its receipt count, unit, wave, and child-time metadata all come from that same selected branch.

The deadline-plan calculation vocabulary is topology, exactly one of children|child, singular-child actual_calls, concurrent-only concurrency, and optional audit-only batch_size|batch_count. Only the root plan may carry additional opaque, non-execution App audit fields. The compiler does not interpret their names or values, use them in deadline calculation, or copy them into the compiled receipt. Known execution fields in the wrong position (such as root tool_name, args, or count) and raw-time control fields remain forbidden; audit extensions cannot make malformed structural fields valid.

Nested plan nodes and leaf children remain closed schemas. A leaf child accepts only tool_name, args, and count; a nested child accepts the node vocabulary plus its parent-level count. Canonical topology values are exactly sequential|concurrent|maximum. Unknown structural fields in nested nodes or leaves, topology aliases, raw time fields on any structural node/leaf, unregistered child tools, invalid counts/concurrency, mutually present children and child, and contradictory batch metadata fail before execution admission. Zero-count children are still schema validated. Opaque root audit values are not recursively treated as execution nodes.

4.4 Registered production router

A registered production callable may dispatch work items to other registered tool ids. The dispatch target must resolve through the same registry; a private helper name or orchestrator step is not a valid target.

5. Generic runtime state

Dynamic routing may retain only generic state:

  • completed tool call count;
  • per-tool call and result counts;
  • exact (tool_id, normalized_args) fingerprints;
  • same-tool streak;
  • observation summaries;
  • token, latency, and deadline accounting;
  • schema, timeout, and execution error counts;
  • explicit final/abort status.

ToolLoop must not inspect argument or result fields to infer source identity, candidate class, App phase, ranking, or business success.

6. Tool input schema

Optional production batches (Issue #288)

An owning leaf may opt in with __toolloop_item_journal_mode__ = "batches" and the existing toolloop_item_journal injection. No other consumer changes. The injected begin_batch(input_identity) binds one opaque input digest to a durable generation: exact failed retry/resume retains it; an explicitly admitted rerun or upstream replacement starts a new generation. Re-entry by the same run is idempotent. Different inputs during retry require an explicit rerun, never a new cache key that silently repeats old requests. Apps own input/request meaning.

Normal authorized first adoption creates tracking without claiming that legacy requests never happened. The manifest records whether prior history is unknown; no legacy success is imported. This is new production, not legacy reconciliation. Existing scope/item corruption or missing claimed slots is not first adoption. Tracked unknown/in-flight items prevent automatic retry and generation rollover. Claims reserve their path in the existing manifest before dispatch so a missing item record cannot become an unstarted slot. Prior generations and snapshots remain immutable history; no status read initializes anything. No extra UI confirmation/action or global registry gate is introduced.

WQ Plan is the initial opt-in consumer. WQ Retrieve and AUDIT retain the #280 interface below. Acceptance covers real owning execution, retry/rerun/input isolation, immutable results, lost/corrupt slots, stale writers and existing consumer contracts with external calls isolated. Deploy the substrate and its opt-in consumer together; withdraw this opt-in if ordinary phase operation regresses, retaining runtime history. This implementation task does not deploy or initialize real batches.

Opt-in per-item journal (Issue #280)

Implementation status: available in the local working tree and covered by offline fixture tests; not deployed or initialized for real Traces by this coding task.

Before a proven first owning-root admission, the journal durably initializes its scope manifest. Orchestrator freezes this private evidence from the original checkpoint before replacing it with running state; ordinary resume alone is not such proof. Retry/rerun or ordinary resume with no manifest and no first-admission proof returns legacy_item_progress_unavailable: absence of old per-item records is not proof that old external calls never happened. No automatic old-history migration or whole-batch replay is authorized. Within an initialized scope, missing item slots mean not started; unknown/admitted slots remain non-reclaimable. This deliberately stops an old root that ran before per-item tracking even when a nested leaf might not yet have run: absence of that leaf is not reconstructed as proof. Recovery is promised within initialized scopes, not from every arbitrary legacy checkpoint. Issue #283 adds only the explicit maintenance admission below; ordinary execution still cannot infer prior item outcomes or initialize a legacy scope.

Explicit legacy item-scope maintenance (Issue #283)

Status diagnostics preserve the existing denial scope while distinguishing an invalid scope manifest from a changed/unavailable formal source, an unproven source replacement, unresolved claims, and a changed graph. These read-only reason codes do not authorize rollover, repair records, or expose raw exception text. A completed invocation is still historical completion when its input was subsequently changed; it does not prove that a new invocation may reuse that ledger. Normal upstream execution must establish the required succession proof.

The local implementation and offline regressions are bounded to the existing journal, owning Orchestrator and maintenance CLI. No real Trace migration or provider invocation is authorized by this code change. A missing legacy manifest is not repaired with a bootstrap flag or synthetic completed items. The operator must acknowledge unknown prior outcomes, provide one exact formal input reference and byte checksum, one opaque operation key and an explicit total max_new_claims allowance. The App owns input semantics; the substrate checks identity, safe formal storage, checksum and execution ownership.

An idle writable exact leaf that already opts into toolloop_item_journal may be verified read-only. Apply additionally requires the verified checkpoint checksum and exclusive maintenance acknowledgement. The existing short namespace lock protects publication; no tool, graph, authentication or Product writer is called. The checkpoint keeps the prior node and unknown history in a separate maintenance audit without changing Phase success/failure or ordinary continuation authority. The scope manifest binds this audit, graph version, formal input revision and only the approved operation. Unknown old counts remain unknown; new records report their known counts separately and cannot claim historical attempt completeness.

Publication is recoverably idempotent: a pending checkpoint audit precedes the manifest, and a final audit closes admission. A partial publication is not runnable; the identical maintenance request may finish it after fresh verification with the current checksum, without refilling allowance. A different intervening checkpoint writer prevents completing the pending publication. A missing target observation may be maintained only when the same-Cycle owning supersession record explicitly lists that exact invalidated leaf; no synthetic prior node is created. An initialized ordinary scope is not converted or reset by this entry. The total new-claim allowance is spent durably before each item claim, including explicit later retries, in addition to the existing per-item maximum. A crash between those publications can consume allowance without dispatch; it never grants extra work. Replies expose the bounded (1–4096) total, reserved/remaining new claims and unclosed reservations, separately from provider attempt facts and historical unknown counts. An existing reservation is durable evidence that a logical slot was consumed: if its per-item file disappears, inspect/claim must report an unknown unavailable record, never treat it as a new item or reserve another slot. This includes a crash after reservation but before item publication. Likewise a pending root with retained assets or an error is not proof of first admission; only opted-in bootstrap checks consume this evidence, with no new requirement for other callables. Completed or unknown new items retain existing no-replay rules. Operation, source, graph, Phase or checkpoint conflicts reject only this opted-in maintenance path.

journal.resolve_call_identity(tool_name, args) is an optional read-only child identity operation. It rechecks the current owning run and delegates only to an already registered callable allowed by its existing block, through that callable's optional __toolloop_request_identity_resolver__. The result is only an opaque SHA-256 digest; ToolLoop never interprets provider, model or App semantics. Missing resolvers reject this optional operation, not ordinary tool execution. See the LLM owner design for effective configuration/account resolution without provider calls.

An Orchestrator-owned leaf may declare the keyword-only injectable parameter toolloop_item_journal. Existing callables need not declare it; payload schemas, ordinary batch behavior and asynchronous earlier-Cycle handoffs stay unchanged. For #283, an opt-in tool may register item_recovery_resolver: <registered-tool>. Only an exact admitted run with request-local item_recovery_confirmation equal to its Phase path may initialize missing legacy tracking. The resolver is a registered, allowed deterministic App callable, receives the normal leaf input, and returns the existing formal source/ref/hash/operation/cap descriptor. No provider is invoked by preparation. ToolLoop verifies the descriptor and current owner under the existing namespace lock, then writes one owning-run admission inside the existing scope manifest. It never replaces an initialized scope or refills a budget. Unknown prior history stays unknown. Ordinary inspect/status and unconfirmed execution cannot create admission. The confirmation is never persisted in checkpoint config or inherited by subsequent runs. Offline recovery remains available but is not impersonated by normal Web requests. Non-opt-in consumers, including AUDIT, retain their existing behavior; no global gate is added.

Formal upstream replacement after legacy admission (#280, WQ #358)

Producer authority also survives recursive dependency reuse: the engine's same_trace_restart_handoff receipt retains the successful producer run identity, even though attempt_admitted=false describes the current invocation. Require that receipt's run identity to match the reused node; a completed label alone is not proof.

An initialized legacy scope must not permanently pin a mutable upstream Product to its old revision. A normal, exact downstream invocation may append an input succession only when the owning checkpoint's existing supersession scope includes that leaf, its successful origin receipt names the same formal source, and all old reservations have verifiable terminal item records. Missing/corrupt records, in-flight/unknown requests, graph changes and unproven source edits still reject. The registered App recovery resolver validates the new formal input and supplies its opaque operation identity; no Research semantics enter the substrate.

Status/readiness use the same read-only proof and offer ordinary continuation, not a new UI status/confirmation action. Actual admission rechecks ownership, source bytes and proof under the existing namespace lock before atomically appending to the existing scope manifest. The original request/audit, reservations and item files remain; the original total allowance is never refilled. Reservations are qualified by operation identity so an old same-named item cannot shadow a new operation. A retry after publication uses the persisted input succession; same-input request-identity conflicts remain errors. No status read writes data. This replaces only the initialized-source pinning rule above; missing-manifest maintenance and non-opt-in consumers remain unchanged. Acceptance must include normal upstream rerun -> exact downstream execution, restart, no-refill and unknown/corrupt/conflicting source cases with fake external calls.

This optional synchronous callback records mechanical item execution only. It does not execute work, retry providers, select App candidates or judge results.

The callback accepts an object with action: inspect | claim | complete and opaque operation_key, item_key, request_identity strings (at most 256 characters). The owning runtime, not the payload, supplies App root, exact graph path/content digest, canonical Phase path, registered tool, Trace, Cycle and run. An operation key should bind the App's formal input revision; changing the request identity for an existing item is an error, never an implicit retry.

Explicit terminal request succession (#280 / WQ #358)

inspect may omit request_identity to read the stored opaque identity and execution facts, without mutation or admission. Supplying an identity retains the existing strict comparison. claim may explicitly supply previous_request_identity as a compare-and-swap precondition for a different current identity in the same operation/item. Only a terminal completed/failed record, a different owning run and exact-Phase retry/rerun permit succession. Unknown/in-flight records, stale identities, missing records and exhausted caps remain non-runnable. Existing ordinary claims are unchanged.

Succession appends identity history and one attempt under the original per-item and scope budgets; it neither deletes old attempts/results nor refills allowance. The namespace lock covers comparison, owner validation and admission. The App must verify the prior snapshot before requesting succession and owns reuse compatibility; the substrate interprets no request/provider/business semantics. This opt-in extension changes no non-consumer/AUDIT execution path or global gate.

claim may specify max_claims (default 1, bounded 1–64), frozen by the first claim. This is a cap on logical invocations, not on provider-internal attempts. Only admitted: true together with a new claim_token permits the caller's next invocation. A completed item is reused. A known failed item can be claimed again only by a different run with owning exact-Phase retry/rerun admission, within its stored claim budget. In-flight or unknown items are never automatically reclaimed. Re-entering the same run does not grant another claim. Thus a remote request whose completion was lost is honestly unknown, not exactly-once success or permission to resubmit. Any explicit future reconciliation protocol requires separate design.

complete requires that token, status: completed | failed | unknown, nullable provider_attempts, and optional bounded reason_code/facts (4 KiB, depth 3). A result_ref/result_sha256 pair is required for completed and optional for failed/unknown. The referenced file must already exist in the App's formal docs/ai-skills root with matching byte checksum; the App validates its Product identity and semantics. Content is written by the existing Projection capability, not embedded in this journal. Terminal completion is immutable and exact replay is idempotent. Replies are flat: status, admitted, attempt_no, known_provider_attempts, attempts_complete, refs, reason and facts. Unknown provider attempt counts remain null in history and make attempts_complete=false; known counts accumulate without inventing an unknown count of zero.

The approved future runtime carriers are an initial items/<scope-hash>/scope.json manifest and one bounded JSON record per item under the existing ToolLoop Trace/Cycle items/<scope-hash>/<operation-hash>/<item-hash>.json namespace. Each record preserves all admitted attempts, refs, checksum and current state; atomic stage/replace writes flush and fsync before claim returns. POSIX also syncs publication directories, including newly created directory links; Windows acceptance proves process-interruption recovery, not power-loss durability. Graph path and content digest are validated in the record, not used to choose a fresh path on graph edits: an unresolved item cannot disappear merely due to a version change or relocation. A changed graph identity conflicts until an explicitly designed reconciliation; it is not an automatic recovery shortcut. A genuinely new operation still requires explicit App input/operation ownership. Existing Orchestrator namespace-lock mechanics serialize short read/modify/write sections across local Windows processes in the same login session or supported local POSIX processes. No lock spans a provider/LLM call; no index, new database or service is initialized. Crashes leave admitted/unknown, never a missing successful claim. This coding authorization does not initialize real records or call providers.

The owning checkpoint/run is revalidated on each mutation. Full lifecycle admission is checked at journal construction, not by rescanning unrelated history per item. This guarantees competing item-claim exclusion inside the same exact scope; it does not retroactively fence legacy/uninitialized checkpoint writers against every cross-lifecycle race. The existing opt-in lineage protocol retains that separate responsibility. Callback shutdown closes authority and records unresolved owned claims as unknown where possible; a surviving timeout worker cannot publish through an expired callback.

Current opted-in consumer: WQ-YI Research Retrieve. Existing async consumers and AUDIT fact-cache consumers remain on their existing interfaces. Acceptance uses offline real contracts, temporary checkpoints, competing processes, lost-result and owner/scope isolation cases. Formal provider facts remain App-owned; neither paper/claim semantics nor App retry decisions enter substrate Python.

Every callable may declare an args_schema. ToolLoop validates the exact arguments it receives and fails closed on missing, unknown, or invalid fields. It does not rename fields or synthesize aliases.

Trace, cycle, run, asset, and campaign identity belongs to ToolLoop execution context. ToolLoop may use that out-of-band context for audit, evidence, usage, progress, and resume governance, but it must not inject identity fields into a callable payload after schema validation. A callable receives exactly the arguments admitted by its declared schema. Business identity required by the callable must be declared in that schema and supplied explicitly.

Orchestrator maintains a larger internal graph payload. For a closed object schema (type: object, additionalProperties: false), Orchestrator mechanically projects that payload onto the schema's declared top-level properties before deadline resolution and ToolLoop execution. This is a generic contract operation: neither Orchestrator nor ToolLoop recognizes App field names or individual tools. Open/empty schemas receive the full graph payload.

7. Control envelope

When an App tool is consumed by Orchestrator, the App registration declares an explicit generic control_envelope. ToolLoop compiles only the named mappings:

control_envelope:
  version: 1
  role: production
  graph:
    continue_statuses: [success, partial]
    stop_statuses: [failed]
  diagnosis:
    ready_field: diagnosis_ready
    reason_code_field: reason_code
    failure_kind_field: failure_kind
    facts_field: diagnosis_facts
    fields: [errors, warnings]
  handoff_fields: []

The receipt remains producer-owned truth. The compiled envelope is the only control-plane interpretation surface. Missing, ambiguous, or invalid mappings fail closed; ToolLoop does not discover fields by name or directory layout.

8. Failure semantics

ToolLoop distinguishes transport/runtime failure from producer-declared business status. A failed schema gate, unresolved callable, missing profile, missing Prompt, timeout, invalid parallel plan, or conflicting key is never returned as success.

Generic bounded exits may stop a dynamic loop after repeated malformed router output or an exact duplicate action. Such exits report an explicit failure or closeout reason; they do not fabricate App results.

9. Non-goals

ToolLoop does not own:

  • orchestrator graphs or phase dependencies;
  • campaign route, restart, halt, or terminal authorization;
  • diagnosis ontology or root-cause selection;
  • evidence source interpretation;
  • projection layout, binding, archival, or active promotion;
  • LLM/MCP clients inside Apps;
  • provider credentials;
  • compatibility aliases for retired App payloads or callables.

9. Acceptance criteria

The ToolLoop boundary is accepted when:

  1. active substrate code and prompts contain no App/source/provider defaults;
  2. dynamic routing requires explicit model profiles;
  3. exact-call deduplication is schema-agnostic;
  4. parallel lanes use opaque keys and converge deterministically;
  5. invalid plans fail before fan-out;
  6. App registries retain all business semantics and source concurrency policy;
  7. missing Prompt/profile/schema truth fails closed;
  8. resolver/compiler failures stop sequential and parallel batches before any child call and never fall back to a configured timeout;
  9. focused sequential, parallel, router, schema, audit, and timeout tests pass.
  10. strict registries reject unclassified callables, and resolver markers and callable resolver attributes are bidirectionally validated before any child admission.
  11. non-strict registries accept canonical callable resolvers without a redundant YAML marker; explicit contradictory markers still fail.
  12. opaque root audit extensions leave compiled deadlines/receipts unchanged; nested and leaf structural errors remain rejected.
  13. public-contract rejection changes satisfy the root AGENTS evidence, consumer-impact, offline cross-App acceptance, and rollout/rollback rules.