- strip interior NUL bytes from hook env values in fire_resolved so
agent-authored text (e.g. escalation questions) cannot suppress a hook
via a failed spawn; raise the spawn-failure log to warn!
- make Supervisor::has_active_tasks recurse into child supervisors,
mirroring cancel_recursive, so a prompt ctrl-c that cancels only a
grandchild still latches as an interruption
- document the signal-first vs error-first classification asymmetry at
top_level_agent_finished / SpawnResultHooks::fire and the deliberate
REPL teardown cancel; pin ctrlc-with-no-error => agent.interrupted
- probe Scenario J: pty-driven ctrl-c during a live headless --agent run
fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR
Ctrl-C at the reedline prompt no longer latches the abort signal
unconditionally: latch_prompt_interrupt only marks an interruption when
the supervisor had live agents/jobs to cancel (new
Supervisor::has_active_tasks), so the advertised exit path (ctrl-c at an
idle prompt, then ctrl-d) fires agent.completed at teardown instead of
agent.interrupted. Mid-turn ctrl-c still latches directly via
wait_user_interrupt/poll_abort_signal, preserving the
interrupted-then-exit classification.
Also: probe-lane tests for escalation.answered single-fire,
escalation question env truncation, agent.failed inverse gate, and
hooks probe Scenario I (session.started vs session.resumed); comment
notes on escalation.answered semantics and the agent__cancel blind
spot.
- escalation.raised fires in the child ctx after queue submit; escalation.answered
fires in the parent ctx from the taken request, so both agree on origin envs
- escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist
never applies to these root-level events
- session.resumed fires on the use_session resume leg, inverse of session.started
- agent.interrupted replaces agent.completed/failed when the abort signal was
ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm
(agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal;
exit_agent and the shell-exit path stay non-interrupted by passing no signal
- document the deliberate non-fire paths (direct prompts, escalation timeout and
parent-drop, graph approval nodes)
- unify hook base-env assembly on hooks::base_envs_parts (drop the
hand-copied hook_base_envs in function/agents.rs; graph executor and
spawn-result hooks now share the single construction site, test-pinned)
- factor the duplicated job.started dispatch in jobs.rs handle_start
into one fire_job_started helper
- COYOTE_LLM_ERROR now carries the immediate error (to_string()),
matching COYOTE_TOOL_ERROR; test pins the new format
- remove all stale #[allow(dead_code)] markers in src/hooks.rs
- test infra: rename install_warn_collector -> install_log_collector,
add poison-symmetric warn_snapshot/debug_snapshot helpers, hoist a
shared EnvVarGuard (TestConfigDirGuard now builds on it), add a
same-fire payload-uniqueness test, and give global_tools env
overrides the same malformed-JSON debug diagnostic as global_hooks
- inspection-only flags (--info et al) bypass the first-run wizard:
missing config + info_flag returns Config::default() without writing
anything, in both the sandbox and interactive arms (tests included)
- bound the shim-relocation test's exec in an ETXTBSY retry (10x50ms)
- document the intended 'roles' reserved-name asymmetry at
resolve_bundle_name's gate
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.