Wire the hook engine into the three runtime seams:
- tool.*: ToolCall::eval fires tool.started once the name and arguments
are resolved, then exactly one of tool.completed/tool.failed,
discriminated on both the Err path and tool_call_error-shaped outputs.
The arguments JSON is handed to the engine as the payload; eval itself
performs no payload-file I/O.
- llm.request.*: the three transport free functions
(call_chat_completions, call_chat_completions_streaming, and the quiet
graph-node variant) fire started/completed/failed for every provider
and caller, classifying failures as aborted, api_error (with
COYOTE_LLM_STATUS), or other.
- job.*: handle_job_tool fires job.started before either spawn and the
terminal event inside the detached closures via a pre-resolved
fire_resolved snapshot, so job__start's return is never delayed.
The test sink now captures the payload and cwd of each dispatch
(sink-only change), and seam tests pin event cadence, env contracts, and
byte-identical tool results and errors.
Fire agent.started before the supervisor spawn and agent.completed/failed
from the spawn task's result arms, resolved against the child context so
global hooks stay gated by the child's own whitelist. Graph AgentNode
failures fire agent.failed at the point of failure, before a declared
fallback converts them into a successful route. The graph executor fires
graph.node.started before each branch spawn and graph.node.completed/failed
beside the narrate calls, with per-branch pre-resolved snapshots and
metadata so parallel super-step branches never cross-contaminate. Base
envs for detached dispatches are rebuilt at fire time via a shared
hook_base_envs helper.
Completes the loop-tracker exemption change (8be9c8e) riding this
branch: the explanatory comment above LOOP_TRACKER_EXEMPT_TOOLS was
dropped in that commit; restore it, updated for the new entries.
Child escalations (coder plan approvals, deviation gates, user__* calls
from subagents) previously expired after the escalating agent's
escalation_timeout, defaulting to 300 s, and 0 timed out instantly. In
autonomous runs the orchestrator often could not reply within that
window, so approvals silently fell through to the best-judgment
fallback.
- Extract await_escalation_reply as the wait seam: 0 awaits the oneshot
with no deadline; nonzero keeps tokio::time::timeout with the same
timeout message. A dropped sender still resolves to the existing
cancelled reply either way.
- DEFAULT_ESCALATION_TIMEOUT_SECS and default_escalation_timeout() -> 0,
so graph agents (which inherit the default via from_graph) are covered.
- Document the new semantics in config.agent.example.yaml and drop the
5-minute-timeout claim from the default spawn instructions.
- Tests: paused-clock unlimited wait (3600 s), nonzero expiry message,
dropped-sender completion, default pin, and a grep pin that the stale
timeout wording is gone from src/, assets/, and the example config.
Plan: PLAN-review-retry §C (D3).
- agent__spawn accepts an optional variables object (string->string);
values overlay the parent-inherited agent variables per key, with
inheritance preserved as the fallback layer
- unknown variable names, non-string values, and non-object shapes
return self-correcting status:error JSON listing the agent's
declared variables
- spawned children can no longer trigger the interactive variable
prompt: a new interactive gate in Agent::init_agent_variables routes
missing required variables to the existing bail path while leaving
CLI/REPL prompting and --info skip semantics unchanged
- agent__task_create persists variables on the task, agent__task_list
echoes them, and task auto-dispatch threads them into the spawn
- tool schemas and built-in spawn docs updated; unit tests extended
in agents.rs and taskqueue.rs
Semaphore::new panics above tokio's MAX_PERMITS, so a fixed or templated
map cap (or settings.max_concurrency) that large crashed the run instead
of erroring. The validator rejects both at load, resolve_max_concurrency
rejects every arm at run time, and the executor checks the graph setting
before building its own semaphore, so validation mode makes no difference.
Also corrects run_child_agent_with_graph_inputs' contract comment: inputs
for a config-only agent are rejected, not ignored.
LlmNodeExecutor::execute now takes the graph's abort signal and threads
it through run -> run_with_retries -> run_chat_loop, replacing the
loop-private signal, so an in-flight model call is interrupted and every
turn starts with an abort check that bails 'llm node aborted' before any
client is created.
run_llm_function gains an optional abort signal: the synchronous tool
wait loop kills the child and returns a 'Tool call ... aborted' error
within one poll interval when it is set. The tool dispatcher passes the
session abort; the agent _instructions runner passes None.
A graph run now observes user interrupts wherever it is: GraphExecutor::run
spawns a run-scoped bridge that maps SIGINT or the enclosing session abort
onto the graph's own abort flag (only when a session signal exists; a
pre-set session trips a synchronous check). The frontier join selects on
the abort and, like the timeout path, cancels every in-flight branch task,
failing the run with 'Graph ... aborted during super-step'.
Branch and map item tasks are cancellation-owned via TaskCancelGuard, so
dropping a map task's future no longer detaches its item chains. Peer
retirement is a Drop guard (PeerRetireGuard) constructed before the
semaphore acquire and dropped explicitly right after step(), so the
success-path ordering is unchanged while task abort, panic, and the
pre-step abort return all still retire the identity.
A child agent spawned from a graph node now carries its parent's session
abort signal, so a user interrupt is observable from every sync wait in
the agent tree. Background agent__spawn children are unchanged.
run_child_agent_with_graph_inputs also guards the child's own supervisor
with a disarmable CancelOnDrop: if the future is dropped mid-flight or
the child fails before its normal cleanup, its spawned subagents are
cancelled instead of running on invisibly.
Two offline chain tests materialize an echo-inputs graph agent whose
script reports the width it sees: a parent with inputs.width gets the
parent's value back, a parent without inputs gets the child's YAML
default. Two agents.rs tests pin the exact runtime guard texts for a
config-only target and a reserved initial_prompt key.
Task T5 of plans/map-subgraph-branches-design.md §3.12.
Agent nodes gain an optional `inputs:` map of templates resolved against
the parent state and overlaid on the child graph agent's initial_state
before its executor starts. A lone {{key}} keeps the state value's JSON
type; mixed text renders to a string; initial_prompt is still owned by the
dispatcher. run_child_agent and run_active_agent_graph keep their
signatures as wrappers over the new *_with_inputs variants, and
run_agent_for_graph rejects inputs aimed at a config-only agent or
containing initial_prompt.
Design: plans/map-subgraph-branches-design.md §3.12 (C18), task T5.
PR #24 follow-up 1. The bundled-tools test called the writing
generate_bash_declarations on the repo's assets/functions/tools/*.sh,
so cargo test could rewrite tracked files. Split the build+export step
into a pure build_bash_tool so the test never touches the source tree.
A map item no longer runs a single branch node; it walks the subgraph
reachable from `branch` over `next`/`fallback` edges (branch_subgraph),
one chain per item, holding one semaphore permit for the whole chain.
Chain steps go through the shared `step()` so llm retries, fallbacks,
script `_next` and agent timeouts keep their existing semantics. A node
without `next` ends the chain (StepContext.branch_mode), a route outside
the subgraph, a fan-out, an end node or a loop past
max_loop_iterations fails the item, and every item error carries
`map node 'X': sub-branch [i] failed at node 'Y' (step n)`.
`output_key` is removed from each item's fork before the chain runs, so
the collected value must be written by the chain rather than inherited
from the parent.
Teammate identities are now per item for the whole chain: provisioned
when any flagged agent is in the subgraph, armed only before
`teammates: true` agent steps, and retired at chain exit. mark_finished
moves out of run_agent_for_graph to the two provisioning sites (frontier
task and chain exit), so timed-out and early-failing agents are also
marked finished.
Agent nodes marked teammates: true whose sub-agents run concurrently (map fan-out branches or multiple flagged agent nodes in one super-step) now form a team: each member gets a pre-provisioned inbox in a messaging-only PeerRegistry before any branch starts, a teammate roster injected into its prompt, peer resolution in agent__send_message, and a peers section in agent__list_running. Registries are scoped to a single fan-out and carry no lifecycle semantics: outputs still flow exclusively through collect_into/state reducers, finished peers reject new messages, and nothing registers in a supervisor. Also hardens the agent-recursion depth guard to work without a supervisor and adds validator warnings for zero-valued orchestration limits.
The user-scope MCP config historically lived at
<config-dir>/functions/mcp.json, a leftover from when MCP support was
part of the llm-functions tooling. It now resolves through a single
choke point with these semantics:
- Preferred location: <config-dir>/mcp.json (created there on first run)
- Historical <config-dir>/functions/mcp.json still honored when the
preferred file does not exist, so existing installs are unchanged
- If both exist, the preferred location wins
--info/.info now reports the resolved location as mcp_config_file, and
the --scope help text plus config.agent.example.yaml reference the new
default. This also removes the asymmetry with the workspace scope,
which already used .coyote/mcp.json directly.
The supervisor registry went kind-generic (TaskHandle::Agent | Job)
earlier in this branch, but the module holding the agent__* handlers
and two model-facing error strings still carried the old name:
- src/function/supervisor.rs -> src/function/agents.rs (it contains
only agent__* tool handlers, pairing with function/jobs.rs; the
kind-generic src/supervisor/ registry keeps its name)
- 'Supervisor tool failed' -> 'Agent tool failed'
- 'Unknown supervisor action' -> 'Unknown agent action'
Graph LLM nodes now own the jobs they start, on every exit path. A new
node_job_scope on RequestContext records job ids started while a node
runs: the turn-end guardrail nags only about the node's own jobs
(parallel branches no longer see each other's), and the node executor
reaps — cancels and deregisters — anything left registered when the
node exits, including error, timeout, and retry-exhaustion paths.
Cross-node job handoff is no longer possible; a crashed node takes its
in-flight jobs with it.
With inheritance gone, job__* declarations are gated on capability:
the family is only declared when at least one declared tool would pass
job__start's whitelist (shared predicate: is_backgroundable_tool). One
carve-out — while a context still owns registered jobs (job started,
tool disabled mid-session), the lifecycle verbs stay declared so a
running job can never become unreachable; job__start alone disappears.
A graph node with tools: [] now sees no job__* tools at all.
Prompt instructions, tool declarations, and graph.example.yaml updated
to the node-local semantics; +7 tests, 8 visibility pins rewritten.
job__collect's 50k-char tail cap is a safety default, but collect is
consume-once and the cap was mandatory — a model that genuinely needed
the complete output had no recourse. Add a full_result boolean that
skips the cap (tail_lines still honored; the session-wide
max_tool_result_chars limit still applies downstream), teach the
truncation banner to name the recourse, and point job__check's
output_bytes_captured at the collect decision.
Since the supervisor registry became kind-generic (agents AND jobs),
'supervisor' naming on the agent__* tool plumbing was misleading:
job__* handlers operate on the same supervisor. Rename
SUPERVISOR_FUNCTION_PREFIX -> AGENT_FUNCTION_PREFIX,
supervisor_function_declarations -> agent_function_declarations,
handle_supervisor_tool -> handle_agent_tool. No behavior change.
memory_config() only reports enabled when a global memory index or a
workspace memory store exists on disk, so asserting the handler's
'name is required' detail was environment-dependent even with the
memory pref forced on. The routing test now accepts either
memory-handler-owned message: the 'Memory tool failed' prefix alone
proves the memory__ prefix reached the memory handler.
The eval_routes_memory_prefix_to_memory_handler characterization test
inherited the host machine's memory configuration: on runners without a
memory setup, should_register_memory_tools() gates the handler off and the
error message differs. Force memory = Some(true) at ctx construction so
the test asserts the same handler path everywhere.
The spawned-agent task now pushes an agent_completed/agent_failed event
into the spawning context's notification queue before returning, so a
parent that keeps working learns mid-turn that a child finished instead
of discovering it only at the turn-end guardrail. Cancelled or
already-collected agents are suppressed by the existing drain-time
registration filter. This delivery applies regardless of whether
background jobs are enabled.