Introduce src/hooks.rs as a types-only module (HookDef, HooksMap) and
plumb hooks:/global_hooks: config fields through every scope:
- Config + AppConfig hooks field, both Default impls, the manual
from_config copy, and a COYOTE_HOOKS env override
- Role frontmatter: hand-parsed hooks arm, private field, getter, and
JSON-inline re-serialization in Role::export (malformed maps degrade
to None)
- AgentConfig + Graph: hooks + global_hooks on both structs, copied in
AgentConfig::from_graph, with a per-agent global_hooks env override
- paths::hooks_dir() = <config_dir>/hooks with COYOTE_HOOKS_DIR override
- Commented hooks: block in the global config template (global scope
only; no global_hooks key)
No engine or dispatch logic yet; existing configs parse unchanged.
is_transient_error: a typed crate::client::ApiStatusError anywhere in the
chain is now authoritative (positive and negative) — catch_error renders
429s as "… (type: rate_limit_error)" / "… (status: 429)", which none of
the old string anchors matched, so provider rate limits were never
retried, while a 400 whose body mentioned "rate limit" was. Transient
statuses: 429, 500, 502, 503, 504, 529; message text only classifies
untyped errors, and those anchors are now case-insensitive.
validator: warn for an llm/agent node whose output_schema lacks a
non-empty object `properties` map (auto-merge writes nothing since
402d25a) unless the node lifts keys via a non-empty state_updates; the
same absent-or-empty predicate now also gates validate_fallback_capture.
Bundled and graph.example.yaml warning baselines are unchanged.
Tests build the 429 fixture through real catch_error output and a
.context() chain, cover the status table, typed-overrides-text, and all
validator branches.
Agent nodes gain an optional fallback target with llm-node parity: on any
failure (retries exhausted, hard error, or structured-extraction failure)
the failure chain is written into state as 'Agent node failed: <chain>'
and the chain routes to the fallback node. Without a fallback the error
propagates unchanged. Validator collects agent fallback edges for
missing-target checks, reachability, and map-branch locality.
Hoist the llm node's transient-error matcher to graph::is_transient_error,
extend it with broken-pipe / connection-error / error-sending-request
signatures, and give AgentNode an opt-in max_attempts retry loop with
llm-parity semantics: contexts applied before the transient check, fresh
wall-clock budget per attempt, peer identity (assignment + registry)
re-armed before each retry, retirement exactly once after the final
attempt, and structured extraction outside the loop.
A task aborted before its first poll never runs its body, so a guard
constructed inside the future did not exist yet and the pre-provisioned
teammate identity stayed messageable. Built outside and moved in, the
guard drops with the unpolled future on both the frontier and map items.
Also rewords the two timeout: 0 validator warnings: the graph-level one
no longer claims max_loop_iterations always applies (it may be 0 too),
and the node-level one names the enclosing bounds that still end the
node instead of saying it runs until it returns.
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.
A string max_concurrency passed the load-time check whenever it merely
contained a {{...}} pair, so forms like "n={{k}}" or "{{ key }}" that
can never resolve at runtime were only caught after the map's inputs had
been computed. The check now requires the trimmed string to be exactly
one template reference, matching what interpolate_raw resolves as a pure
reference; padded forms like " {{budget}} " stay valid.
Also: the bash tool build-failure context names the source file instead
of the destination path, and the map-subgraph validator no longer
shadows its member id list with the resolved member nodes.
A map node whose item binding shares its name with output_key would have
the binding collected as the chain's result whenever the chain never
wrote it, while the missing-writer warning stayed silent. The collision
is now a validator error attributed to the map node and, with validation
off, a runtime error in run_map before any item forks.
validate_timeouts now also flags settings.max_loop_iterations: 0, ahead
of the settings.timeout: 0 warning so both opt-outs read top-to-bottom
as they appear in settings. (design §3.14 / C22)
A timeout: 0 on settings.timeout or on an agent/script/llm/rag node is
the author's explicit opt-out of the wall-clock bound. It stays legal;
the validator now emits one warning per zero so the choice is visible in
--validate output. Non-zero and unset values do not warn (unset already
means no bound for settings.timeout and llm nodes, matching the
explicit-zero-only convention of the orchestration-limit warnings).
Design §3.13 / C21.
Inside validate_agents: an agent node with inputs whose target has a
config.yaml but no graph.yaml is an error, and an inputs key named
initial_prompt is rejected. primary_templated_fields now includes every
inputs value for agent nodes, so validate_parallel_reads treats them as
cross-branch reads with no new pass.
Design: plans/map-subgraph-branches-design.md §3.12 V10a–V10c, task T5.
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.
`validate_map_branches` checked only the entry node and forbade `next`,
non-`output_key` `state_updates` and `output_schema` on it. The runtime
now executes the whole subgraph reachable from `branch`, so the validator
follows suit as `validate_map_subgraphs`, at the same position in
`validate()`:
- approval/input/end/map nodes are rejected anywhere in the subgraph,
attributed to the map node; when the offending node is a `fallback`
target the message says why a previously accepted `fallback` now fails
- a fan-out `next` inside the subgraph is rejected
- a subgraph that shares nodes with the main flow (reachable from
`start` without entering any map branch, via the new
`main_flow_reachable`) is rejected, naming the shared nodes
- a new warning fires when no subgraph node declares a write to
`output_key` via `state_updates` or `output_schema`; any script in the
subgraph suppresses it, since scripts may write any key
- the `next`, `state_updates` and `output_schema` restrictions are gone:
intra-branch writes are fork-local scratch
Two maps may share a subgraph. `find_reachable_nodes` and
`compute_parallel_groups` are unchanged.
Tests: the three rules that inverted are renamed to say what they now
assert (`map_branch_with_in_subgraph_next_passes`,
`map_branch_state_updates_scratch_key_passes`,
`map_branch_with_output_schema_passes`); the four
`map_branch_cannot_be_*` tests pass unmodified. Thirteen new tests cover
each rule, the fallback hint, the missing-writer warning and its three
suppressions, a three-node chain, and `main_flow_reachable`.
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.
`MapNode.max_concurrency` becomes `Option<ConcurrencyCap>`, where
`ConcurrencyCap` is `Fixed(usize) | Template(String)`. Integers parse
exactly as before (`4` -> `Fixed(4)`, `0` -> `Fixed(0)`, absent -> `None`);
a string parses as a template that the map executor resolves against the
parent state right before the fan-out via `StateManager::interpolate_raw`,
mirroring `over:`. A template must resolve to an integer >= 1 or a numeric
string (scripts often emit numbers as strings); anything else, including 0,
is an error naming the map node, the template, and the resolved value/type.
The fixed path keeps its `.max(1)` clamp for byte-compatibility.
`Deserialize` is hand-written so a malformed value (`-1`, `4.5`, `true`,
a list, a map) reports the offending value and the accepted forms rather
than serde's opaque untagged-enum "did not match any variant".
Validator: a string cap that contains no `{{ ... }}` pair is a load-time
error (catches `"4"` typed for `4`); a templated cap joins the map node's
read set so `validate_parallel_reads` flags a read of a sibling branch's
write. The existing `Fixed(0)` rule and every other rule keep their order.
Design: plans/map-subgraph-branches-design.md §3.5, §3.11 (C3, I3).
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.
Graph agents could only inherit the app-wide job budget; the agent-level
header in graph.yaml now accepts max_concurrent_jobs alongside
model/temperature, flowing through AgentConfig::from_graph into the
run-wide supervisor. Deliberately graph-wide, not per-node: jobs outlive
the node that started them.
`RagNode` gains an optional `driver`, forwarded into `RagInitConfig` so a
graph node can build its knowledge base on duckdb instead of yaml. Nodes
that name no driver forward `None`, which still resolves to yaml, so
existing workflows are unaffected.
An unknown driver is rejected up front rather than at construction time.
`Rag::create` dispatches unknown drivers to its yaml catch-all, so a typo
would otherwise embed every document and persist the bogus string, after
which every subsequent load fails validation and the agent cannot start.
The check asks `RagData::validate()` through a probe value instead of
restating the list of valid drivers, so the two cannot drift.