Compare commits

138 Commits
Author SHA1 Message Date
Dark-Alex-17 2846d488b6 docs: Updated the pull request template
CI / All (ubuntu-latest) (push) Failing after 28s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-09-30 00:11:20 -06:00
Dark-Alex-17 4c3c3e69b2 docs: Updated the AI assistance attribution requirements to read as less discouraging of AI assistance and more "allowed only when disclosed"
CI / All (ubuntu-latest) (push) Failing after 27s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-09-30 00:07:45 -06:00
github-actions[bot] 3a017d7fda chore: refresh generated model catalog [skip ci] 2026-09-30 00:55:37 +00:00
github-actions[bot] 53e0f3b54b chore: refresh generated model catalog [skip ci] 2026-09-29 12:37:32 +00:00
github-actions[bot] 22809e2a1b chore: refresh generated model catalog [skip ci] 2026-09-29 00:54:47 +00:00
Dark-Alex-17 9ae26f4194 tests: fixed failing Windows test for odd session names that doesn't allow escape characters in file names 2026-09-28 16:03:48 -06:00
Dark-Alex-17 a3c4b6a364 feat: applied the whetstone's adversary and security-review change suggestions 2026-09-28 15:43:50 -06:00
Dark-Alex-17 72eb5a100c feat: apply whetstone retry handler fixes 2026-09-28 15:10:11 -06:00
Dark-Alex-17 afab1b6da5 feat: increment the max_fix_attempts in the coder from 3 to 5 2026-09-28 13:30:10 -06:00
Dark-Alex-17 c9fcea2849 feat: support workspace-level sessions as well as global sessions 2026-09-28 13:29:25 -06:00
github-actions[bot] e7a7cd9704 chore: refresh generated model catalog [skip ci] 2026-09-28 12:37:40 +00:00
github-actions[bot] 2b3839881c chore: refresh generated model catalog [skip ci] 2026-09-28 00:56:58 +00:00
github-actions[bot] c32ba9157e chore: refresh generated model catalog [skip ci] 2026-09-27 12:31:15 +00:00
github-actions[bot] 0442d77059 chore: refresh generated model catalog [skip ci] 2026-09-27 00:58:07 +00:00
Dark-Alex-17 4e33979a92 fix: sbx bug for reaping zombie processes 2026-09-26 16:19:04 -06:00
github-actions[bot] fac1569646 chore: refresh generated model catalog [skip ci] 2026-09-26 12:32:24 +00:00
github-actions[bot] 6490128f07 chore: refresh generated model catalog [skip ci] 2026-09-26 00:50:35 +00:00
github-actions[bot] 3e496ec2fd chore: refresh generated model catalog [skip ci] 2026-09-25 12:35:35 +00:00
github-actions[bot] 57809ddf29 chore: refresh generated model catalog [skip ci] 2026-09-25 00:52:18 +00:00
github-actions[bot] 184712621e chore: refresh generated model catalog [skip ci] 2026-09-24 12:34:05 +00:00
github-actions[bot] c45d73c94a chore: refresh generated model catalog [skip ci] 2026-09-24 00:50:14 +00:00
github-actions[bot] 4b2ab2f867 chore: refresh generated model catalog [skip ci] 2026-09-23 12:34:07 +00:00
github-actions[bot] 4cec912f8e chore: refresh generated model catalog [skip ci] 2026-09-23 00:48:22 +00:00
github-actions[bot] e0bcf340fe chore: refresh generated model catalog [skip ci] 2026-09-22 12:33:41 +00:00
github-actions[bot] 9609e333f3 chore: refresh generated model catalog [skip ci] 2026-09-22 00:51:55 +00:00
Dark-Alex-17 a8c0ec64fa feat: Deep research upgrade so sisyphus and the architect now have 3 possible research types: librarian standard, librarian deep, and true deep research with reflexion 2026-09-21 12:29:07 -06:00
Dark-Alex-17 37ba25adc5 Merge branch 'main' of github.com:Dark-Alex-17/coyote 2026-09-21 12:20:33 -06:00
Dark-Alex-17 a65280754e feat: Support the merging of custom user-defined models on clients configured in the global configuration file with the built in models 2026-09-21 12:08:53 -06:00
github-actions[bot] b6022654eb chore: refresh generated model catalog [skip ci] 2026-09-21 12:33:59 +00:00
github-actions[bot] 0c17076bdf chore: refresh generated model catalog [skip ci] 2026-09-21 00:54:08 +00:00
github-actions[bot] a85d933d33 chore: refresh generated model catalog [skip ci] 2026-09-20 12:32:28 +00:00
github-actions[bot] 13640981c7 chore: refresh generated model catalog [skip ci] 2026-09-20 00:56:01 +00:00
github-actions[bot] f106b101ab chore: refresh generated model catalog [skip ci] 2026-09-19 12:31:07 +00:00
github-actions[bot] 77322698e4 chore: refresh generated model catalog [skip ci] 2026-09-19 00:50:24 +00:00
Dark-Alex-17 e237845827 feat: Improved comment etiquette for the sisyphus suite 2026-09-18 12:31:48 -06:00
Dark-Alex-17 4571ed647e feat: Improved PR hygiene for commenting and posting review summaries on PRs created by the architect and Sisyphus 2026-09-18 12:27:37 -06:00
Dark-Alex-17 0b55f7c735 test: update agent suite tests to work with new code review suite changes 2026-09-18 11:42:54 -06:00
Dark-Alex-17 461245a57c fix: force code review lanes to be additive only, not opt-in, and bump the verifier run cap 2026-09-18 10:44:04 -06:00
github-actions[bot] dcf2d99fa9 chore: refresh generated model catalog [skip ci] 2026-09-18 12:32:28 +00:00
github-actions[bot] d524328d86 chore: refresh generated model catalog [skip ci] 2026-09-18 00:51:35 +00:00
Dark-Alex-17 e002bd6727 feat: Suppor mid-turn auto compressions so sufficiently large histories don't create deadlocks for max input tokens 2026-09-17 17:12:18 -06:00
Dark-Alex-17 56ed4664cd Merge branch 'fix/proper-round-pairs'
CI / All (ubuntu-latest) (push) Failing after 30s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-09-17 16:33:31 -06:00
Dark-Alex-17 3ee78a46df fix: improved caching by simply keeping groupings of tool calls by terminating a round on each tool merge into the bucket, rather than waiting for LLM narration to end a round. 2026-09-17 16:32:21 -06:00
Alex Clarke dc6c176b4f Merge pull request #31 from Dark-Alex-17/feat/PLAN-hooks-v2
PLAN-hooks-v2: hook system follow-ups and v2 features
2026-09-17 15:26:41 -06:00
Dark-Alex-17 00de9d40d1 Merge remote-tracking branch 'origin/main' into feat/PLAN-hooks-v2 2026-09-17 14:55:31 -06:00
Dark-Alex-17 08dfbac6f6 style: applied uniform style across the new rag and mcp hook implementation 2026-09-17 14:39:33 -06:00
Dark-Alex-17 74e7bc54eb feat(TASK-061): assert COYOTE_ROLE on all session/turn events; fix two stale comments
Cover the remaining role-carrying events with test_sink assertions:
session.started (ctx-held role), session.resumed and session.ended
(session-held role), turn.interrupted and turn.failed. Correct the
mcp_factory race comment (the loser fires a second plain connected and
overwrites the winner's map entry, not a reconnect) and the role_extras
doc (derived roles fall through to the session-held role).
2026-09-17 14:00:07 -06:00
Dark-Alex-17 dc2f26d0e6 feat(TASK-061): fix reconnect-flag race and completed-after-save hook ordering
- McpFactory::acquire now reads the live probe and the reconnect flag
  under one lock section so a racing first connect cannot be
  misreported as COYOTE_MCP_RECONNECT=true; the residual window across
  the spawn await (no singleflight) is documented. try_get_active,
  now unused, is removed.
- All three RAG sync funnels (init, init_with_config,
  refresh_document_paths) fire rag.sync.completed only after a
  successful save(), matching the documented contract; a save error
  routes through rag.sync.failed with COYOTE_ERROR before propagating.
- New funnel-level tests pin init_with_config's started-then-completed
  ordering, the failed event on a save error, and the failed event on
  the sync Err arm that a user abort surfaces through.
2026-09-17 13:43:16 -06:00
Dark-Alex-17 4e53e143bb fix: use ssh to clone private git repos in sandboxes
CI / All (ubuntu-latest) (push) Failing after 30s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-09-17 13:29:45 -06:00
Dark-Alex-17 166a3807f8 fix: Route git clones through the sbx proxy so the proxy can inject credentials 2026-09-17 13:16:47 -06:00
Dark-Alex-17 04b983459b feat(TASK-061): add rag.sync.* and mcp.server.* hook events plus COYOTE_ROLE
- Register five new events (26 -> 31): rag.sync.started/completed/failed
  and mcp.server.connected/failed, whitelist-gated for agent global hooks
  like every non-escalation event
- Bracket every real RAG document sync with rag.sync.* via pre-resolved
  RagSyncHooks snapshots carried into the build funnels; cache hits, pure
  loads, and attaches fire nothing, and a user abort mid-sync counts as a
  failure
- Fire mcp.server.connected only on real spawns in McpFactory::acquire
  (reusing a live server is silent), marking respawns of a previously
  live key with COYOTE_MCP_RECONNECT; mcp.server.failed carries
  COYOTE_ERROR and COYOTE_MCP_AUTH_REQUIRED on auth failures. Boot-time
  registry connects deliberately fire nothing
- Carry COYOTE_ROLE on session.* and turn.* events, resolved at fire
  time so mid-session role switches and temp roles are reflected
2026-09-17 13:16:05 -06:00
Dark-Alex-17 fcdcb303a3 style: Cleaned up unnecessary path prefixes, imports, stray scripts, and some documentation 2026-09-17 12:00:16 -06:00
Dark-Alex-17 5bd1174ddd fix: isolate the adversary review-stack lock for parallel tests and non-POSIX platforms
The advisory flock in run_checks.py uses one system-wide lock path
(~/.cache/coyote/review-stack.lock). Concurrent runs of the timeout/deadline
tests contend on it: the losers sleep in the bounded retry loop, the total
deadline (computed before lock acquisition) expires, and commands record
'skipped: deadline' instead of the expected timeout/kill behavior.

- run_checks.py: ADVERSARY_STACK_LOCK_PATH env override relocates the lock
  file (isolation seam, documented alongside the other env seams); every
  other lock semantic (default path, bounded wait, proceed-unlocked warning,
  fd held for process lifetime, skip when no commands) is unchanged.
- run_checks.py: guard the POSIX-only fcntl import — windows-latest CI has
  python3 and runs these tests, so the unconditional import crashed the
  script there; on non-POSIX the lock is now skipped with a stderr WARN,
  matching the script's documented non-POSIX limitations.
- tests: the shared adversary_script_command helper points every spawned
  script at its own throwaway lock path, isolating the run_checks tests from
  each other and from any real review running on the same machine.
- tests: adversary_run_checks_stack_lock_path_is_env_overridable pins the
  override (lock file appears at the overridden path across two sequential
  runs; no lock file is created when no commands are declared).
2026-09-17 10:49:35 -06:00
Dark-Alex-17 bfa058130a merge: absorb main (concurrent-build file locks) into feat/PLAN-hooks-v2 2026-09-17 10:34:23 -06:00
Dark-Alex-17 1c73b82812 feat: create file locks on concurrent builds and scaffolding verification/tests to prevent race conditions and conflicts while reviewing 2026-09-17 10:34:00 -06:00
Dark-Alex-17 9e811c088f merge: absorb main (model catalog refresh) into feat/PLAN-hooks-v2 2026-09-17 10:17:38 -06:00
github-actions[bot] 02b9986807 chore: refresh generated model catalog [skip ci] 2026-09-17 12:35:37 +00:00
Dark-Alex-17 ad9b319149 test(TASK-050): give each macro-bracketing marker its own file
cmd opens '>>' redirection targets write-exclusive, so when the STARTED
and terminal hook children appended to one shared log concurrently, the
loser of the open race hit a sharing violation and silently dropped its
marker line -- a permanent loss that surfaced as an intermittent
Windows-CI-only failure of the macro-bracketing suite (the engine's exit
drain was verified sound: spawn intent is registered synchronously at
fire time and covers macro-context fires). One file per event makes
writer overlap impossible while keeping every exact-count assertion
byte-identical.
2026-09-17 01:00:48 -06:00
Dark-Alex-17 611900057c test(TASK-050): wait on every expected marker before asserting hook counts
Hook processes are fire-and-forget: the binary's exit drain acknowledges
spawns only, so the STARTED writer can land after the terminal one. The
macro-bracketing tests waited only for the terminal marker and then
asserted STARTED's count immediately, flaking on Windows CI and loaded
Linux runners. The bounded wait now covers every expected marker before
the exact-count and absence assertions run; the probe script's scenario K
comment is corrected to state the spawn-ack-only contract it already
waits around.
2026-09-17 00:33:37 -06:00
Dark-Alex-17 ec66494e8f docs(TASK-052): add hooks feature bullet to the README quick links 2026-09-17 00:16:08 -06:00
Dark-Alex-17 3d37ed237a test(TASK-050): build the macro-bracketing hook commands in cmd's dialect on windows
The fixture's hook commands were sh-only: single-quoted redirect targets
are literal under cmd /C, so the marker log never received content and
all six tests timed out on windows-latest. Build the marker command
per-platform — parenthesized `(echo MARKER)>> "log"` on Windows to
avoid cmd's trailing-space echo gotcha — and move the interpolated
paths into single-quoted YAML scalars so Windows backslashes are not
read as double-quoted-scalar escapes. Also replace the cleanup call
that a wait timeout skipped with a drop guard, so failed runs no
longer leak temp dirs.
2026-09-16 23:51:18 -06:00
Dark-Alex-17 83978617bc fix: bootstrap the async entry point on a 16 MiB thread to survive Windows
Windows links the process main thread with a ~1 MiB stack (unix defaults
to 8 MiB). Coyote's root async future is a compiler-generated state
machine whose debug-build poll frames blow through 1 MiB the moment
block_on polls it, so every invocation of the binary on Windows died at
startup with STATUS_STACK_OVERFLOW (0xC00000FD) before doing any work.

Replace #[tokio::main] with an explicit bootstrap: main() spawns a
dedicated thread (named "main", 16 MiB stack) that builds the same
multi-thread enable_all runtime and block_on's the async body, then joins
and propagates the Result unchanged; panics are re-raised on the real
main thread via resume_unwind without re-invoking the panic hook, so
panic output and exit codes stay identical. Runtime worker threads get
the same 16 MiB headroom since spawned tasks embed the same class of
giant state machine.

The bootstrap is unconditional rather than cfg(windows)-gated: the unix
arrangement is behaviorally identical (same runtime flavor and driver
set — tokio's ctrl_c signal driver is thread-agnostic), and one shared
code path removes the entire small-main-stack class everywhere instead
of special-casing one OS. Verified on Linux with the full test suite
(inspection_flags 12/12) and the hooks usage probe end-to-end, including
the terminal ctrl-c -> agent.interrupted scenario.
2026-09-16 23:38:28 -06:00
Dark-Alex-17 bf10aeb74d test(TASK-047): split the ntfs short-name guard test by platform reality
On NTFS the fixture self-collides: .builtin-manifest auto-generates the
8.3 short name BUILTI~1, so writing that fixture file opens the manifest
through its alias and clobbers it -- the exact threat the guard models.
Gate the fixture-based test to unix, and add a windows counterpart that
proves the collision where 8.3 generation is enabled and asserts the
filter invariant (alias-shaped entries are neither removal candidates
nor claimed) either way.
2026-09-16 23:24:18 -06:00
Dark-Alex-17 c2e30c12a6 fix(TASK-046): honor the --mcp-list inspection contract without a vault
Pure --mcp-list never reads secrets (only --mcp-add does), yet the mcp
action path always ran strict Vault::init, so a virgin environment with
no password file exited 1 instead of printing the empty listing. Route
the mcp_inspect path through the established Vault::init_lenient
constructor; get/remove/add stay on the strict init.

Make tests/inspection_flags.rs hermetic against host state: every
spawned binary gets a fresh fake HOME (and USERPROFILE) so a real
~/.coyote_password can't mask virgin-environment regressions, scrubs
IS_SANDBOX/COYOTE_PROVIDER/COYOTE_PLATFORM outside the sandbox-mode
tests, and pins CWD so workspace mcp.json discovery can't leak in.
New tests pin both sides of the boundary: the lenient list path (empty
and configured, never bootstrapping a password file) and the strict
vault gate for get/remove/add.
2026-09-16 23:09:59 -06:00
Dark-Alex-17 879cf8e097 merge: absorb main (PR #29 merged, PR #30 responses-api, catalog refresh) into feat/PLAN-hooks-v2 2026-09-16 22:34:23 -06:00
Dark-Alex-17 22078b1ddc test(TASK-051): cover windows hook dispatch, payload posture, and install semantics
Add a #[cfg(windows)] dispatch test module exercising the real cmd /C
raw_arg path: env delivery through a verbatim command line, fire()
detachment via a gate-file batch loop, payload-file lifecycle with the
per-user temp-dir posture assertion, backslashed COYOTE_HOOKS_DIR
install override, per-scope cwd resolution with an end-to-end %CD%
check, and exec-bit-free install. Hoist wait_for to shared test scope
and move the two OS-agnostic dispatch tests (spawn-failure payload
cleanup, debug-only error paths) into an ungated dispatch_portable
module so both cfg lanes share them instead of duplicating.
2026-09-16 22:32:42 -06:00
Dark-Alex-17 f7d0f93fa1 fix(TASK-050): flag invalid whitelist globs that extend no event name
The unknown-entry diagnostic only inspected gate entries prefixed by the
current event's full name, so a partial glob like 'tool.st*' — which
extends no event name at all — was silently inert on every event.
Entries containing '*' now validate against the wildcard grammar
(*, *.*, *.<name>, <event>.* over a real event) and anything outside it
gets the same debug-level diagnostic; exact entries keep the existing
event-prefix path unchanged.
2026-09-16 21:54:25 -06:00
Dark-Alex-17 4b2428b334 test(TASK-050): probe wildcard whitelist grammar and macro-without-agent inertness
Scenario K pins the global_hooks wildcard forms (exact, <event>.*, *.<name>, *.*) and partial-glob inertness against a real binary; Scenario L proves --macro without --agent fires zero agent.* events.
2026-09-16 21:42:15 -06:00
Dark-Alex-17 dee89d4ca3 feat(TASK-050): bracket --macro runs like --execute and close the isolated-macro leak
The --macro dispatch arm now opens the top-level agent bracket before
macro_execute and closes it signal-aware afterwards, draining pending
hooks like the --execute arm; both bracket calls no-op without a loaded
agent, so agent-less macro runs are unaffected. The isolated arm of
macro_execute closes the bracket an in-macro .agent step opens on its
fresh context, on the step-failure path too. Binary-level tests pin
agent.started plus exactly one terminal event for the --agent flag and
for in-macro .agent steps in both isolation modes, on success and
failure.
2026-09-16 21:23:04 -06:00
Dark-Alex-17 90f7631b35 feat(TASK-050): add wildcard grammar to the global-hooks whitelist
Whitelist entries now admit hooks via <event>.* (every hook of that
event), *.<name> (that hook name on every event), and * / *.*
(everything), alongside the unchanged exact <event>.<name> form.
Partial globs like tool.st* are not wildcards: they admit nothing and
still land in the unknown-entry debug diagnostics, while <event>.*
entries are exempt from them. Escalation events keep resolving ungated
regardless of whitelist content.
2026-09-16 21:23:04 -06:00
Dark-Alex-17 6ae093a482 fix(TASK-049): cover role and agent scopes in escalation bypass test; keep spawn-failure logging at debug 2026-09-16 20:58:24 -06:00
Dark-Alex-17 c73d5317a0 feat(TASK-049): harden hook dispatch against NUL env values and nested-supervisor latch misses
- 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
2026-09-16 20:31:36 -06:00
Dark-Alex-17 a3b71a6889 feat(TASK-049): classify idle-prompt ctrl-c + ctrl-d as a completed exit
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.
2026-09-16 19:55:52 -06:00
Dark-Alex-17 739c1be8bb feat(TASK-049): add escalation.raised/answered, session.resumed, and agent.interrupted hook events
- 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)
2026-09-16 19:30:59 -06:00
Dark-Alex-17 1744d2ba52 test(TASK-048): cover canonicalized hooks-dir override warning and payload orphan unlink on spawn failure
Two conformance gaps were tests-only:

- install_builtin_hooks warning: a #[serial] test proves the canonicalized
  compare stays quiet when COYOTE_HOOKS_DIR merely respells the default
  (via a '..' hop and, on unix, a symlink), and a second test proves a
  genuine override still warns on a fresh install where the default hooks
  dir does not exist (fallback compare).
- run_hook payload cleanup: a dispatch test fires a hook with a payload
  and an unspawnable cwd, anchors on the spawn-failure debug log (and the
  absence of a payload-write failure) so the assertion cannot pass
  vacuously, then polls with a bounded deadline until the payload file is
  unlinked.

Both tests were verified red-capable: reverting the canonicalization or
skipping the unlink on the error branch makes them fail.
2026-09-16 19:05:17 -06:00
github-actions[bot] 6804f198bc chore: refresh generated model catalog [skip ci] 2026-09-17 00:50:55 +00:00
Dark-Alex-17 7ace1731c6 fix(TASK-048): cover agent and repo scripts in eol=lf rules; probe Scenario H for installed bundled scripts
Extends the .gitattributes eol=lf guard to assets/agents/** (33 embedded
shell scripts share the CRLF failure mode the hooks rules fix) and
scripts/*.sh. Adopts the probe-authored Scenario H: black-box checks of
the installed log-events.sh (XDG default, 0600, UTC header, secret
filter, symlink refusal) and notify.sh (control-byte-stripped fallback),
plus non-executable .builtin-manifest.
2026-09-16 18:48:15 -06:00
Dark-Alex-17 89a9398c28 feat(TASK-048): install polish, hooks-dir mixin scan, script and probe hardening
- one shared exec-bit predicate (Language-recognized extensions => 0755) used by
  every hook/agent installer; non-script files in hooks dirs stay non-executable
- mixin discovery scans the global hooks dir and each agent's hooks dir;
  .builtin-manifest is never scanned
- NTFS 8.3 short-name aliases rejected as builtin-manifest entries
- hook-script install announcements restricted to real hook locations
- COYOTE_HOOKS_DIR override warning compares canonicalized paths
- embedded assets decode via checked from_utf8 instead of from_utf8_unchecked
- log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log
  keeping 0600/symlink-refusal/secret-filter hardening; notify.sh tty fallback strips
  control bytes; both use UTC ISO-8601 timestamps
- payload files orphaned by a write failure are unlinked; stale coyote-hook-*.json
  payloads swept best-effort at startup
- sweep_removed_agent_hooks never follows symlinked agent dirs
- usage-probe-hooks.sh replaces bare sleeps with a bounded poll, re-verifies 0755 on
  restored scripts, and covers roles/agent hook locations
- TestConfigDirGuard hoisted into crate::testing
- .gitattributes pins LF line endings for bundled scripts

hooks is now a reserved bundle name.
2026-09-16 18:30:02 -06:00
Dark-Alex-17 e7bd75947c feat(TASK-047): three-state hook install mode, top-level manifest reconcile, removed-agent sweep
- Thread an explicit InstallMode (Skip/Prompt/Force) through the hook
  installers: startup install_builtins() is pinned to Skip and never
  prompts (test-pinned on both mocked-TTY and TTY-less paths);
  --install-builtins hooks runs Prompt mode across all three hook
  locations (global hooks dir, roles/hooks, agents/<name>/hooks) under
  one shared keep-all/replace-all sticky scope; force paths keep
  unconditional overwrite semantics.
- Hoist the bundle resolve_conflict/StickyMode/ConflictAction prompt
  into shared config::conflict with a NonInteractive policy: bundle
  installs keep their non-TTY bail, builtin hook refreshes resolve
  non-TTY conflicts to keep. Bundle UX preserved byte-for-byte,
  pinned by tests.
- Extend .builtin-manifest reconcile to the resolved top-level hooks
  dir (shipped set from HookAssets::iter()); malformed-manifest
  fail-safe (zero deletions) preserved and test-pinned.
- Sweep removed builtin agents' hooks: agent dirs absent from the
  embed with a hooks/.builtin-manifest reconcile against an empty
  shipped set; empty hooks dirs are removed, user files and
  manifest-less agent dirs are never touched.
- Update usage-probe-hooks.sh Scenario C to the new non-interactive
  keep contract and add black-box Scenarios E-G covering role/agent
  hook reconcile, top-level manifest fail-safety, and the
  removed-agent sweep.
2026-09-16 17:59:09 -06:00
Dark-Alex-17 d89d24aff4 fix(TASK-046): keep inspection leniency off mutating and writing flags
The look-don't-touch bypass was keyed on load_with_interpolation's
pre-existing bool, which mutating early paths (--authenticate, --auth-mcp,
mcp manage, vault flags) already passed as true meaning only 'skip vault
interpolation' — so they silently lost their first-run config creation.
Split the parameter: skip_interpolation keeps the historical meaning,
a distinct inspection flag alone gates the missing-config default/wizard
bypass.

inspection_only is now computed from an explicit read-only whitelist:
--sync-models is excluded (it downloads and writes models-override.yaml)
and returns to the bootstrap/strict path, keeping only its historical
interpolation-skip and vault leniency. mcp/vault inspection sub-conditions
are hoisted and shared so mutating flag combos never inherit the bypass.

Coverage: sandbox vault contract tests, a sync-models bootstrap-path pin,
a --sync-models scenario in usage-probe-inspection.sh, and a PTY wizard
regression probe (scripts/usage-probe-wizard.sh).
2026-09-16 16:48:57 -06:00
Alex Clarke f59421844a Merge pull request #30 from Dark-Alex-17/feat/PLAN-responses-api
PLAN-responses-api: OpenAI Responses API as the default wire format
2026-09-16 15:51:37 -06:00
Dark-Alex-17 3dc84f5aef fix: revert manual changelog update, as it should only be updated by CI/CD 2026-09-16 15:33:35 -06:00
Dark-Alex-17 23fe2c1231 chore(TASK-057): drop the web-search stub harness
The harness served as one-time review evidence for the tool's attempt
chain; nothing in CI executes bash harnesses and the tool script rarely
changes, so it is removed rather than left to rot (user decision).
2026-09-16 15:14:49 -06:00
Dark-Alex-17 9da0b91673 fix(TASK-046): make inspection flags look-don't-touch and pin the contracts
- Skip the builtins bootstrap, function-binary build, and tool-scope
  rebuild for inspection-only runs (--info/--list-*/--mcp-list/
  --list-secrets without --agent/--role): a pristine COYOTE_CONFIG_DIR
  now stays byte-empty (was 269 files written before dispatch)
- Tolerate a model-less default config on the inspection surface via
  AppConfig::from_config_lenient + Model::default() in bootstrap;
  strict 'No available model' behavior unchanged for the REPL path and
  all mutating mcp/vault flags
- --list-secrets degrades to an rc=0 informational message in sandbox
  mode (mutating vault flags still fail there), pinned by test
- Add the 041.7-4 regression test: a malformed COYOTE_<NAME>_GLOBAL_TOOLS
  env override logs the debug message and leaves global_tools untouched
- Adopt scripts/usage-probe-inspection.sh (10 flags: rc=0 + zero writes,
  output required only for --info) and add binary-level
  tests/inspection_flags.rs, incl. the agent+--info still-bootstraps pin
2026-09-16 15:07:21 -06:00
Dark-Alex-17 5495813baf feat(TASK-057): web_search_coyote works on both OpenAI wire APIs
- openai searches run an attempt chain: the chat-completions mechanism
  (search-preview model + web_search_options patch) first, then the
  Responses-native web_search tool on a responses-capable model; the
  search fails only when both attempts fail
- both wire-keyed patches (COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS /
  COYOTE_PATCH_OPENAI_RESPONSES) are exported per attempt; the client's
  resolved wire applies exactly one
- supports_native_search accepts responses-capable openai families
  (gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4*)
- stub-coyote harness pins attempt order, patch export, model mapping,
  hard failure, and untouched non-openai paths
  (scripts/test-web-search-tool.sh)
- CHANGELOG: responses-default breakage caveat replaced with the
  both-wire feature note
2026-09-16 14:41:05 -06:00
Dark-Alex-17 717a48bef2 Merge remote-tracking branch 'origin/main' into feat/PLAN-responses-api
# Conflicts:
#	src/client/common.rs
2026-09-16 14:33:29 -06:00
Dark-Alex-17 6514588e05 fix(TASK-046): engine polish + wizard bypass + ETXTBSY retry
- 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
2026-09-16 14:02:42 -06:00
Alex Clarke 73cf4a625b Merge pull request #29 from Dark-Alex-17/feat/PLAN-hooks
PLAN-hooks: external fire-and-forget lifecycle hook system
2026-09-16 13:59:45 -06:00
Dark-Alex-17 4087f3b3d0 style: applied improved styling to the whole of the hooks integration 2026-09-16 13:44:35 -06:00
Dark-Alex-17 0defc61bec test(TASK-035): run deep compress tests on a big-stack thread
The .compress-session success test polls through the whole summarization
call chain; those nested poll frames overflow the default test-thread
stack on Windows debug builds even with the outer future boxed. Drive
all four deep-compress tests through the same big-stack run_async
harness the macro tests already use.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 7155b1b4fb docs(TASK-040): add hooks block to config.example.yaml 2026-09-16 13:44:35 -06:00
Dark-Alex-17 42380d4b82 feat(TASK-039): wire hooks into bundle installs and reconcile builtin hook dirs
Bundles: RemoteLayout.hooks + scan/is_empty/plan routing into hooks_dir,
a functional --filter hooks arm replacing the inert interim arm,
TopCategory::Hooks provenance rows, and per-file 'bundle defines hook
script' summary lines (visibility only, no approval prompt).

Reconciliation: new builtin_manifest module — a hidden per-directory
.builtin-manifest records the files the builtin installer actually owns
(written this run, previously owned, or pending failed deletions);
deletion is strictly manifest-minus-embed so user-created files in the
same directory are never touched. Wired into install_builtin_agents for
agents/<name>/hooks/ and a greenfield minimal Role::install_builtin_role_hooks
for roles/hooks/.

Hardening: bundles are barred from shipping any path component that
aliases .builtin-manifest (Unicode case fold + trailing dot/space,
directories included); manifest entries reuse the bundle store's
is_safe_component rules; malformed manifests fail safe toward keeping
files; role-side reconcile failures degrade to a warning like the
agent side.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 e62502c862 test(TASK-038): adopt hook usage-probe regression script
Black-box scenarios against a built binary with dry_run configs: hook
cadence + env scoping, runtime inertness of failing hooks, --info firing
zero agent.* events, --install-builtins hooks install/force/cold-start
semantics, and end-to-end firing of the installed log-events.sh via the
documented relative-path wiring.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 fc3bb259cb feat(TASK-038): add hooks builtin-asset category with example scripts and roles mixin scanning
- AssetCategory::Hooks + InstallFilter::Hooks (NAMES/parse/clap), reserving
  the 'hooks' bundle name via the existing AssetCategory::parse check
- HookAssets embed + install_builtin_hooks(force): installs the bundled
  example scripts 0755 into paths::hooks_dir() via write_file_atomic, wired
  into install_builtins() and install_assets(); warns once when
  COYOTE_HOOKS_DIR diverges from the config-dir default
- assets/hooks/notify.sh: notify-send -> osascript -> terminal fallback,
  with AppleScript-literal sanitization of model-influenced values
- assets/hooks/log-events.sh: appends a COYOTE_* env snapshot (secrets
  excluded, 0600 log, symlink-refusing) to ${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}
- sbx-mixin discovery now scans roles/*.sbx-mixin.yaml and
  roles/hooks/*.sbx-mixin.yaml (mirroring the role-hook cwd)
- tests: install+0755+force, install_assets(Hooks) end-to-end, discover()
  roles scanning, reserved bundle name, no-ARGC-BUILD embed content, script
  content order + real execution (secret exclusion, tty-less fallback),
  CLI parser round-trips; hooks_dir env test made #[serial]
2026-09-16 13:44:35 -06:00
Dark-Alex-17 0c6b7c8594 fix(TASK-035): close agent-bracket leaks and prove compression/role-hook seams
- Open the top-level agent bracket only at committed dispatch sites so
  inspection-flag early returns (--list-sessions, --info, --macro,
  --rebuild-rag, --init-*, --build-tools) never leak agent.started
- Close the agent bracket ahead of process::exit in shell_execute's
  execute branch, where the dispatch wrapper's close is unreachable
- Extract the ask-loop auto-compression seam and cover the
  session.compressed success path at both call sites with sink tests
- Prove Session.role_hooks never serializes and that injected
  role_hooks YAML is ignored on deserialization
2026-09-16 13:44:35 -06:00
Dark-Alex-17 8710737964 feat(TASK-035): wire turn/session/compression seams + top-level agent bracket
Turn bracket (repl::TurnBracket): turn.started on entry and exactly one of
turn.{completed,interrupted,failed} on exit, with the interrupted-vs-failed
discrimination (abort_signal.aborted_ctrlc()) in one place. Wired at the
three outer boundaries: ask(), start_directive, and shell_execute — never at
the per-LLM-round before/after_chat_completion seams.

Session seams: session.started fires for newly created sessions only
(use_session's new_from_ctx paths; resume fires nothing), session.ended in
exit_session while the session is still attached, session.compressed on the
success path of both compress_session call sites (auto-compression and the
.compress command).

Session-held role hooks: Session snapshots the role's HooksMap on set_role,
clears it on clear_role/sync_agent, and re-reads it from the role file on
load; resolved_hooks gains a session leg so role hooks keep firing when the
role lives in the session (fresh set_role and resumed sessions alike).

Top-level agent bracket: agent.started/completed/failed once per agent RUN —
headless --agent dispatch and the interactive agent-context lifetime (enter
via .agent or startup; exit via switch-away or REPL teardown), never per
message. COYOTE_AGENT_ID is synthesized as top-<agent-name>-<nonce>.

Exit-path seams call hooks::drain_pending with a 2s bound so exit hooks
finish spawning before process teardown; interactive mid-session events
never drain.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 363104fe77 fix(TASK-036): reuse hooks::base_envs_parts in job terminal hook firing 2026-09-16 13:44:35 -06:00
Dark-Alex-17 8f775dc2d9 fix(TASK-036): pin exact user__-prefixed COYOTE_TOOL_NAME on tool hook events 2026-09-16 13:44:35 -06:00
Dark-Alex-17 2731e98ed1 feat(TASK-036): fire tool, LLM, and job hook events at their seams
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.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 0c2a68ecc4 fix(TASK-037): cover genuine node failure payload and malformed graph.yaml hook degradation
Two test gaps: no test fired a genuine graph.node.failed and inspected
its payload (the only node-failure test asserted the hook's absence
because a fallback absorbed the error), and no test drove a malformed
hook definition through the new graph seams. The first test fails an
agent node with no fallback and asserts the captured envs carry the
node id, node type, a numeric duration, and a non-empty error while
the failure still propagates out of the executor. The second routes a
blank-command hook from graph.yaml's top level through the executor
seam and proves it degrades safely: the node completes, the malformed
hook is skipped with a debug log, and a well-formed sibling on the
same event still fires.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 bd7cad21e2 feat(TASK-037): wire supervisor-agent and graph hook seams
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.
2026-09-16 13:44:34 -06:00
Dark-Alex-17 0c4d0d777c feat(TASK-034): hook dispatch engine in src/hooks.rs
- HookEvent (22 dotted variants) + owned ResolvedHook snapshots
- RequestContext::resolved_hooks: global (gated by agent global_hooks)
  -> role (additive) -> agent precedence; per-scope cwds; unknown
  whitelist entries inert + debug-logged with agent name
- fire/fire_resolved: fire-and-forget detached dispatch via platform
  shell (sh -c / cmd /C raw_arg), null stdio, fresh unix process group,
  reap-only await, no-runtime guard (skip + debug, never panic)
- tool.* payload files: crypto-random nonce, create_new + 0600, verbatim
  JSON, best-effort delete after reap, hook still runs on write failure
- base env (COYOTE_EVENT/HOOK_NAME/CONFIG_DIR/EVENT_TIMESTAMP RFC3339
  UTC, SESSION_ID/AGENT_NAME when present) + per-event extras; every
  value truncated engine-side to 2048 bytes (UTF-8-safe; payload path
  documented exempt)
- drain_pending(timeout): opt-in exit-path seam with spawn-ack
  semantics (Drop-guard counter; never waits for child completion)
- #[cfg(test)] synchronous capture sink (records instead of spawning)
  + hooks-scoped debug capture in src/testing.rs (poison-proof,
  boundary-aware target prefix)
- Agent::hooks()/global_hooks() accessors; Role::hooks() dead_code
  marker removed

Engine compiles unused by design; call sites land in TASK-035/036/037.
2026-09-16 13:44:34 -06:00
Dark-Alex-17 b020e1b75e fix(TASK-033): cover hooks env overrides with tests and debug-log malformed hook config 2026-09-16 13:44:34 -06:00
Dark-Alex-17 9693cdd4ac feat(TASK-033): add hook config schema across all scopes
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.
2026-09-16 13:44:34 -06:00
Dark-Alex-17 a0cc5f291e docs(TASK-044): review polish — visible-output wording, web-search wire pin note, truncation debug line, thinking-exclusion pin test 2026-09-16 13:42:42 -06:00
Dark-Alex-17 ce20927713 fix(TASK-044): streaming response.incomplete parity — keep partial output, record usage
Review findings: the streaming arm now mirrors the non-streaming twin
(openai_extract_responses): partial text/tool calls are returned rather
than discarded by a hard error; only an empty truncated response bails.
Billed usage on truncated turns is recorded before either outcome.
Also: CHANGELOG notes the streaming-incomplete surfacing and corrects
the include/strict phrasing (sent on every responses-wire body).
2026-09-16 13:31:52 -06:00
Dark-Alex-17 9e7c957a3d docs(TASK-044): document wire_api selection, defaults, and wire-aware patch migration 2026-09-16 13:21:10 -06:00
Dark-Alex-17 3b66101361 feat(TASK-044): surface the streaming response.incomplete event with its reason 2026-09-16 13:21:10 -06:00
Dark-Alex-17 0dc9dd91c3 chore(TASK-043): regenerate model catalog
Add responses sub-patches to the six o-series quirks entries (temperature
and top_p removal; reasoning.effort high for the -high aliases) and
regenerate models.yaml from live models.dev/openrouter data, picking up
expected upstream price and model drift.
2026-09-16 12:51:49 -06:00
Dark-Alex-17 9825ccd31e feat(TASK-043): Responses usage parsing, failure hardening, responses default for stock openai
Parse Responses-API token usage on both wires: non-streaming responses
fill ChatCompletionsOutput.usage and response.completed events feed the
SseHandler accumulator, with cached input mapped from
input_tokens_details.cached_tokens. Harden failure paths: bail with the
payload message on response.failed and error stream events, and surface
incomplete_details.reason when a non-streaming response comes back
incomplete and empty.

Replay hardening: id-only Reasoning blocks (no encrypted_content) are
dropped at replay time since the API 400s on them under store:false,
which would permanently wedge a persisted session; a null summary is
normalized to [] at the same site. Claude's replay guards flip to a
positive Thinking/RedactedThinking allow-list.

Flip the default wire for stock openai (api.openai.com, no custom
api_base) to Responses. The resolver call site now passes a genuine
stock-openai predicate: api-key clients or stock oauth without api_base
qualify, config-oauth gateways never do. Custom api_base and
openai-compatible clients keep defaulting to chat; explicit wire_api
still always wins. Also reject a non-map responses section in
RequestPatch via test.
2026-09-16 12:51:44 -06:00
Dark-Alex-17 688ababecc test(TASK-042): prove serialize leg for old-session blocks and YAML round-trip
- assert old Thinking/RedactedThinking blocks re-serialize to the
  original wire shape, proving both legs of old-session compat
- add serde_yaml round-trip test for Vec<ThinkingBlock> covering the
  on-disk session format, including a Reasoning block with summary
  payload and the encrypted_content: None omitted-field case
2026-09-16 12:24:30 -06:00
Dark-Alex-17 d4605794e8 feat(TASK-042): Responses reasoning-item replay, strict:false, cross-client portability
Add ThinkingBlock::Reasoning to carry OpenAI Responses reasoning items
verbatim. The Responses body builder now requests
reasoning.encrypted_content, marks tool defs strict:false, and replays
each round's reasoning items ahead of the assistant text and function
calls. Both extraction paths (non-streaming and streaming) capture
reasoning output items. Claude and Bedrock replay paths drop the
OpenAI-only variant so mid-session model switches keep working.
2026-09-16 12:13:21 -06:00
Dark-Alex-17 3a2736b667 test(TASK-041): pin silent-ignore of malformed model-patch responses sub-key 2026-09-16 11:58:12 -06:00
Dark-Alex-17 003d036e31 docs: restore the loop-tracker exemption comment
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.
2026-09-16 11:52:09 -06:00
Dark-Alex-17 e4dfbe906c feat: updated sisyphus and architect agents so that when reviewing a PR, they add review round summaries as new comments rather than updating the PR description 2026-09-16 11:36:34 -06:00
Dark-Alex-17 57c1a86195 feat(TASK-041): add WireApi enum and wire-aware Responses dispatch
Introduce a WireApi enum (responses|chat) with an optional wire_api
config field on the openai and openai-compatible clients, resolved
through a single resolve_wire_api fn: codex forces the responses wire
(an explicit wire_api: chat there is a hard config error), an explicit
wire_api wins everywhere else, and the default stays chat for now.
The openai dispatch generalizes from (RequestData, bool) to
(RequestData, WireApi), and the openai-compatible client gains opt-in
Responses dispatch (URL, body, extractor, and streaming forks).

Both patch layers are now wire-aware: RequestData carries its wire,
RequestPatch gains a responses section, and patch_request_data selects
patches per wire. This deliberately FIXES a codex bug: chat-shaped
model patches (e.g. o3-high's max_tokens/temperature/top_p/
reasoning_effort tweaks) previously merged wire-blind into codex
responses bodies, poisoning them with chat-only keys. On the responses
wire only an explicit responses sub-patch applies; chat-wire patching
is byte-identical to before (pinned by a regression test).
2026-09-16 11:29:31 -06:00
Dark-Alex-17 8be9c8e152 fix: exempt agent__check_inbox and agent__task_list from loop 2026-09-16 11:07:10 -06:00
Dark-Alex-17 fa7f96e9e8 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-09-16 10:18:21 -06:00
Dark-Alex-17 5046aebca3 feat: support config-driven OAuth endpoints on the openai and gemini clients 2026-09-16 10:17:05 -06:00
github-actions[bot] 1d6823ca0c chore: refresh generated model catalog [skip ci] 2026-09-16 12:33:39 +00:00
Dark-Alex-17 9fb90be851 fix: added additional constraints around parallelization bugs in verification tasks 2026-09-16 00:36:33 -06:00
Dark-Alex-17 4cc5563ba8 feat: support config-driven OAuth endpoints on the claude client
- ClaudeConfig gains an oauth: block (custom endpoints, e.g. a private
  gateway); without it, auth: oauth keeps the stock Claude Pro/Max provider
- Claude-Code system-prefix spoof + anthropic-beta header are only sent on
  the stock provider path
- OAuth tokens are stamped with an issuer fingerprint (token_url|client_id)
  so switching endpoints never replays a token against the wrong issuer
- All auth flows refresh the in-memory access-token cache on save, so
  .authenticate mid-session takes effect immediately
- A custom oauth: block now requires api_base: refusing to silently fall
  back to api.anthropic.com prevents sending a gateway token to the wrong
  host
2026-09-15 23:51:51 -06:00
Dark-Alex-17 fcc0049ab2 feat: further increased budgets for adversary and code reviewer agents 2026-09-15 23:09:59 -06:00
Dark-Alex-17 dca9b1553e test: bumped the adversary's holistic max iterations from 20 to 30 in the tests 2026-09-15 21:41:13 -06:00
Dark-Alex-17 ea31236d29 Merge branch 'main' of github.com:Dark-Alex-17/coyote 2026-09-15 20:06:16 -06:00
Dark-Alex-17 d16db663df feat: increased resource budgets for review suite 2026-09-15 20:06:07 -06:00
github-actions[bot] 94d48fb76e chore: refresh generated model catalog [skip ci] 2026-09-16 00:52:03 +00:00
Dark-Alex-17 6c50c60c22 feat: improve the code review suite to support larger PRs 2026-09-15 15:14:25 -06:00
Dark-Alex-17 f31885ba07 test: fixed tests for the new finding-verifier agent changes 2026-09-15 15:04:48 -06:00
Dark-Alex-17 fd26f43b84 feat: Improved the code-reviewer suite so it doesn't need to loop as frequently for failed reviews based on the finding-verifier (which may need many round trips to verify large PRs) 2026-09-15 14:50:39 -06:00
Dark-Alex-17 e240be8bda feat: improved the adversary agent to look for facts and not criteria. 2026-09-15 12:02:16 -06:00
Dark-Alex-17 9e2f3c0f12 feat: auto continuation is unavailable in llm nodes; continuation driven loops should be in agent nodes only
CI / All (ubuntu-latest) (push) Failing after 28s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-09-15 11:42:39 -06:00
Dark-Alex-17 8746e3e29a Merge branch 'main' of github.com:Dark-Alex-17/coyote 2026-09-15 11:14:24 -06:00
Dark-Alex-17 5b9852d80e fix: Require injection of user interaction functions for LLM nodes to be explicit 2026-09-15 11:13:12 -06:00
github-actions[bot] 99d4b20ac3 chore: refresh generated model catalog [skip ci] 2026-09-15 12:32:39 +00:00
github-actions[bot] 17864c8864 chore: refresh generated model catalog [skip ci] 2026-09-15 01:14:26 +00:00
github-actions[bot] 2a04c4f5e1 chore: bump Cargo.toml and sandbox image to 0.10.3 2026-09-15 00:00:52 +00:00
github-actions[bot] ca58176ef9 bump: version 0.10.2 → 0.10.3 [skip ci] 2026-09-15 00:00:48 +00:00
103 changed files with 21989 additions and 1898 deletions

No files matched your search

+6
View File
@@ -1 +1,7 @@
assets/config-template.yaml text eol=lf
assets/hooks/*.sh text eol=lf
assets/agents/** text eol=lf
assets/functions/tools/* text eol=lf
assets/functions/scripts/* text eol=lf
assets/functions/utils/*.sh text eol=lf
scripts/*.sh text eol=lf
@@ -1,11 +0,0 @@
### AI assistance (if any):
- List tools here and files touched by them
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented below
+18
View File
@@ -0,0 +1,18 @@
### Description
<!-- What problem is this PR solving? -->
### Alternatives Considered (Optional)
<!-- What alternatives did you consider? Why did you choose this approach? -->
### AI assistance (if any)
<!-- Required if AI assistance was used to write or modify code in this PR. -->
<!-- List tools here -->
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself. If I didn't, I gave proper attribution to AI assistance above.
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented above
+110
View File
@@ -1,3 +1,113 @@
## v0.10.3 (2026-09-15)
### Feat
- create a config alias for default_reasoning_level (to match some wording and make the config flexible)
- support custom resource definitions for openai-compatible Oauth customization
- Support specifying the compression model to use when compressing a session for greater cost controls
- Added full cost tracking to Coyote and added it to the default right prompt
- added support for input/output token cache cost tracking
- Support persisting paused todo lists to sessions so users can exit and resume paused todo sessions; Also fixed a bug so todo list pausing is only supported when being used in a session since it typically requires multi-turn communication
- Support token caching to reduce LLM costs in Anthropic models (not typically handled automatically by APIs)
- **TASK-030**: callers escalate on GAUNTLET_REVIEW_INCOMPLETE instead of findings-routing
- **TASK-032**: review-gauntlet incomplete-review machine line + ×N attempt details
- **TASK-029**: review-gauntlet fault-only lane re-run loop
- **TASK-028**: escalation_timeout 0 waits indefinitely and is the default
- **graph**: stream llm-node completions so node timeout governs long generations
- **TASK-023**: deep-research hardening — fail-safe scripts, fault fallbacks, pipeline notes
- **TASK-020**: finding-verifier + step-runner fail-closed hardening
- **TASK-019**: code-reviewer fail-closed review pipeline
- **TASK-018**: review-gauntlet lane degradation + verification passthrough
- **TASK-017**: adversary execution evidence + fail-closed routing
- **TASK-016**: engine + test fixes from the Phase-1 re-review
- **TASK-011**: traverse agent fallback edges in map peer-discovery BFS
- **TASK-010**: add max_attempts to read-only agent lanes in bundled graphs
- **TASK-009**: add fallback routing to agent nodes
- **TASK-008**: add shared transient matcher and retry support to agent nodes
- Fixed graph agents with new agent variable passing functionality
- **graph**: warn when a declared variable is shadowed by an initial_state key
- **agents**: advertise declared agent variables in agent__list_available
- **agents**: allow passing agent variables to agent__spawn and agent__task_create
- seed variables into initial graph state
- Significant code review suite rewrite with improved graph agent features
- let install commands remove files that have been removed from bundles, both internal and external
- update the web_search_coyote tool to attempt to use the current model first then fallback to the configured model for web searches
- expose the current coyote model to tools
- removed temperature specification in deep-research and finding-verifier agents to make them more univerally usable
- **graph**: thread the graph abort into llm nodes and tool execution
- **graph**: abort a graph run on Ctrl-C from anywhere
- **graph**: warn when an llm node's turn cap is disabled
- **graph**: treat llm max_iterations: 0 as no turn cap
- **graph**: warn when the visit cap is disabled
- **graph**: treat max_loop_iterations: 0 as no visit cap
- **graph**: warn when a timeout is disabled
- **graph**: treat timeout: 0 as no wall-clock bound
- **graph**: validate agent node inputs at load time
- **graph**: seed a child graph agent's state from an agent node's inputs map
- **graph**: validate map branch subgraphs instead of single branch nodes
- **graph**: run map branches as per-item subgraph chains
- **graph**: accept a `{{template}}` for a map node's `max_concurrency`
- created the finding-verifier and whetstone agents for continuous code quality improvements
- teammates flag enabling peer messaging between concurrent graph agent nodes
- support explicit can_spawn_agents, max_concurrent_agents, and max_agent_depth keys in graph.yaml
- support setting the tool_timeout in the main config file instead of only via an environment variable
- Added new environment variable to force showing tool call logging when not in a tty
### Fix
- empty tool results can't cause errors in bedrock, claude, openai, or vertexai clients anymore
- proper support for reasoning/thinking in bedrock models
- **TASK-032**: enforce incomplete⇒BLOCKED structurally; anchor adversary degraded detection
- **TASK-028**: pin example-doc wording; make not-found escalation hint match queue semantics
- **TASK-028**: pin global-template exclusion, tighten timeout boundary test, reword stale hint
- **adversary**: red verification runs block CONFORMS; re-derive timeout envelope; robust runner
- **review-suite**: detect nested finding-verifier faults; harden render/fault markers
- **review-gauntlet**: summary-line critical count, nested-fault hook, fail-closed lane fallback
- **graph**: typed ApiStatusError transient classification; warn on bare output_schema
- **review-suite**: mark example verification commands as placeholders; pin variable description and prompt note
- **graph**: auto-merge only schema-declared keys from llm output
- **review-suite**: pass verification_commands as a declared variable, never LLM-extracted from prose
- **agents**: scrub GRAPH_STATE* from adversary verification commands; raise review-suite max_agent_depth
- **adversary**: narrow died-marker predicate, degraded banner for died criteria, hermetic script tests
- **adversary**: fail closed on PIPELINE-FAULT evidence regardless of status; pin bail-chain and validator shape
- **adversary**: fail closed per criterion when check_criterion dies
- **engine**: carry full error chain in llm node fault text
- **TASK-020**: skip POSIX bash script tests on Windows
- **TASK-020**: surface step-runner LLM-fault captures where the fallback lands
- **TASK-019**: carry PIPELINE-FAULT prefix through verdict.py's crash guard
- **TASK-018**: harden verdict_gate fault routing and widen default_lanes fallback
- **TASK-017**: bound run_checks runtime and fail-close its node-level death
- **tests**: collision-proof parser test temp files to deflake macOS CI
- Don't infinitely compress a session once it passes a threshold when the threshold is close to the max token count
- fix spinner ctrl-c early exit not clearing message and restoring cursor
- **graph**: build peer retirement guards before spawning their tasks
- **graph**: reject agents that ship both config.yaml and graph.yaml at load
- **graph**: bound concurrency caps by the semaphore permit limit
- **graph**: make turn-cap and template checks consistent across validation modes
- **graph**: leave output absent after state_updates when it was absent before
- **graph**: validate the whole max_concurrency template string
- **graph**: reject as == output_key on map nodes
- **graph**: inherit the session abort in agent-node children
- **graph**: retire the teammate identity before structured extraction
- **graph**: reject a literal max_concurrency of 0 instead of clamping it
- **graph**: clear peer identity fields after every map chain step
- **graph**: pick the map teammate identity name by BFS from the branch entry
- **graph**: name the offending node type when a map branch reaches a disallowed node
- **function**: build bundled bash tools without writing back to the source tree
- **agents**: name the map-branch output key in deep-research and finding-verifier
- **graph**: honor an explicit `output` key in state_updates
- Fixed the bad formatting in the finding-verifier agent
- properly anchored the role frontmatter regex so it doesn't catch in-instruction separators
- support non-object tool result outputs for bedrock
- exclude null bytes from fs_grep output on binaries
- suppress subagent error and warning output to stderr/stdout
### Refactor
- **graph**: keep the ordered visit log test-only
- **graph**: move the cfg(test) OUTPUT_KEY import into llm's test module
- **graph**: drop the duplicated locator prefix from map chain step errors
## v0.10.2 (2026-09-04)
### Feat
+3 -2
View File
@@ -78,8 +78,9 @@ act -W .github/workflows/release.yml --input_type bump=minor
## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is not permitted unless explicitly disclosed and approved.
All code in this repository is written and reviewed by humans, or AI-generated where explicitly disclosed.
AI-generated code (e.g., Copilot, ChatGPT, Claude, etc.) is permitted only when explicitly disclosed and
approved. For example: disclosing the use of AI-generated code in a PR description.
Submissions must certify that the contributor understands and can maintain the code they submit.
Generated
+299 -215
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "coyote-ai"
version = "0.10.2"
version = "0.10.3"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "The batteries-included runtime for LLMs"
+6 -1
View File
@@ -82,6 +82,8 @@ RUN set -euo pipefail; \
chown 1000:1000 /home/agent/.cargo/bin/coyote; \
rm -rf "$TMPDIR"
COPY --chmod=0755 scripts/docker-entrypoint.sh /usr/local/bin/coyote-entrypoint
FROM scratch
ARG COYOTE_VERSION
@@ -107,4 +109,7 @@ WORKDIR /home/agent/workspace
USER 1000
ENTRYPOINT ["coyote"]
# tini as PID 1 (as in the sandbox-templates base) reaps orphans and forwards
# signals; coyote-entrypoint runs coyote by default, or the given command
# (e.g. the Docker Sandboxes keep-alive) when the first arg is sh/bash/a path.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/coyote-entrypoint"]
+8
View File
@@ -56,6 +56,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
* [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns).
* [Background Jobs](https://github.com/Dark-Alex-17/coyote/wiki/Background-Jobs): Run long tool calls (builds, test suites, slow MCP calls) in the background with the `job__*` tools while the model keeps working, and completion arrives as a push notification.
* [Hooks](https://github.com/Dark-Alex-17/coyote/wiki/Hooks): Run your own fire-and-forget commands when lifecycle events fire. Turns, tools, LLM requests, agents, sub-agent escalations, and more (26 events). Perfect for desktop notifications, activity logging, and downstream automation; hooks never block Coyote or change its behavior.
* [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models.
* [Environment Variables](https://github.com/Dark-Alex-17/coyote/wiki/Environment-Variables): Override and customize your Coyote configuration at runtime with environment variables.
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
@@ -143,6 +144,13 @@ docker run --rm -it \
darkalex17/coyote
```
Arguments are passed to `coyote`. If the first argument is `sh`, `bash`, or an absolute path, that command runs instead,
e.g. to get a shell inside the image:
```bash
docker run --rm -it darkalex17/coyote bash
```
### Scripts
#### Linux/MacOS (`bash`)
You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
+40 -5
View File
@@ -37,7 +37,9 @@ variables:
description: |
JSON array of shell commands the CALLER declares for the deterministic
run_checks stage — e.g. '["cargo test --all"]'. Never inferred from the
prompt; empty array = none declared.
prompt; empty array = none declared. Must not boot shared stacks or
services (compose projects, fixed ports) — those collide with the probe
lane running in parallel.
default: '[]'
settings:
@@ -52,6 +54,7 @@ settings:
initial_state:
project_dir_in: ''
parse_retries: 0
criteria: []
diff_spec: auto
plan_notes: ''
@@ -88,6 +91,18 @@ nodes:
Split compound criteria ("A and B") into separate entries when they are
independently verifiable. When the caller supplied NO plan/criteria at
all, emit an empty array — downstream fails closed.
An empty array is ONLY correct when the prompt genuinely contains no
plan/criteria section — when the prompt carries one (a "Plan /
acceptance criteria:" or "PLAN —" block), returning zero criteria is
an extraction FAILURE, never a valid result.
The caller's explicit enumeration is AUTHORITATIVE: when the caller
declares "these N are the (only) criteria" or supplies a numbered
criteria list, extract EXACTLY those N — never add entries they did
not enumerate. NEVER mine criteria from sections the caller labels as
context ("FACTS", "Quality bar", "context, not criteria", background
material) — route those into plan_notes / scope_notes / extra_context
instead. Splitting applies only WITHIN a declared criterion; when in
doubt, FEWER criteria, verbatim.
- `diff_spec`: the git diff basis the caller named — a range, a ref/SHA,
"staged", "worktree", or "auto" when unnamed (a caller phrase like
"get_diff --base X" means diff_spec = "X").
@@ -159,7 +174,7 @@ nodes:
branch: check_criterion
output_key: crit_verdict
collect_into: crit_verdicts
max_concurrency: 4
max_concurrency: 6
next: holistic
check_criterion:
@@ -190,9 +205,26 @@ nodes:
confirm the change makes the criterion true, confirm the test targets
BEHAVIOR (not implementation echo), use ast_grep for structural claims.
Watch for gamed evidence: a test weakened/deleted so it passes, hardcoded
values shaped to the criterion's example, TODO/stub presented as done —
values shaped to the criterion's example, TODO/stub presented as done, a
mock expectation whose count is relaxed (`AnyTimes`, `MinTimes`, ignored
request body) AND whose matcher is permissive (`gomock.Any()`,
`mock.Anything`) — it proves a call happened, never that it carried the
criterion's values, so it is not a proving test —
those are PARTIAL or DIVERGED with the gaming named in the complaint.
Retry / redelivery / idempotency demands: before you mark a criterion PARTIAL
or UNMET for lacking a retry, re-run, or "second attempt emits nothing" spec,
classify the handler. STATEFUL — it carries something across attempts (emits
events, calls an external system, performs an unconditional write, keeps an
in-memory or ledger record) — a retry spec is a legitimate demand. STATELESS —
each attempt is a pure function of the request plus a conditional store
predicate (`WHERE status = 'pending'`, `ended_at IS NULL`, `INSERT … ON
CONFLICT DO NOTHING`) and nothing is emitted or ledgered outside that
predicate — then "retry produces no event" is a tautology of the mechanism,
not a missing behavior: demand at most one test of the predicate itself,
never a retry spec. Name the classification and its file:line evidence in
the complaint either way.
Execution criteria ("tests pass", "lint clean", "build succeeds") are
judged ONLY against the recorded verification runs in your prompt — you
have no execution tools BY DESIGN and must never claim to have run
@@ -282,7 +314,10 @@ nodes:
The checklist (each hit becomes one complaint with kind, file:line
location, the violation, and the fix):
- **Absence** — something the PLAN requires that maps to no criterion and
never showed up in the diff (the dog that didn't bark).
never showed up in the diff (the dog that didn't bark). A missing
retry/redelivery spec is an absence ONLY for a handler that carries state
or side effects across attempts; a handler that is a pure function of the
request plus a conditional store predicate owes none.
- **Scope drift / out-of-scope** — the diff touches what the plan's scope
notes say not to touch, or quietly does extra unplanned work.
- **Interface/approach substitution** — the plan named an interface,
@@ -340,7 +375,7 @@ nodes:
observations:
type: string
required: [extra_complaints, observations]
max_iterations: 20
max_iterations: 45
max_attempts: 2
state_updates:
holistic_failure: '{{output}}'
+23 -1
View File
@@ -8,6 +8,7 @@ review proceeds fail-closed rather than crashing.
"""
import json
import re
import os
import subprocess
@@ -22,6 +23,22 @@ def load_state():
state = load_state()
# Zero criteria from a prompt that visibly carries a plan is an extraction
# failure (observed non-deterministically live), not a fail-closed no-plan
# run — re-run the parse ONCE before the verdict fails closed on it.
_crit = state.get("criteria") or []
_prompt = state.get("initial_prompt") or ""
if not _crit and isinstance(_prompt, str) and re.search(
r"acceptance criteria|PLAN\s*—|Plan / acceptance", _prompt, re.I
):
try:
_pr = int(state.get("parse_retries") or 0)
except (TypeError, ValueError):
_pr = 0
if _pr < 1:
print(json.dumps({"_next": "parse", "parse_retries": _pr + 1}))
raise SystemExit(0)
proj = os.path.expanduser(
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
)
@@ -56,8 +73,13 @@ try:
if line.startswith("+++ b/")
})
out["changed_files"] = files[:300]
if len(files) > 300:
out["diff_note"] += (
f" (file list capped at 300 of {len(files)} changed files — use the fs "
"tools for the rest)"
)
if len(text) > MAX_DIFF_CHARS:
out["diff_note"] = (
out["diff_note"] += (
f" (truncated to {MAX_DIFF_CHARS} chars of {len(text)} — use the fs tools "
"to inspect files beyond the cut)"
)
+46 -1
View File
@@ -31,7 +31,9 @@ in-script degradation would never fire. Each command gets
min(PER_COMMAND_TIMEOUT_SECS, remaining budget); commands the deadline
leaves no budget for are recorded as {"cmd", "skipped": "deadline"} entries,
so the in-script handling stays authoritative. The env var
ADVERSARY_RUN_CHECKS_DEADLINE_SECS overrides the deadline (test seam).
ADVERSARY_RUN_CHECKS_DEADLINE_SECS overrides the deadline (test seam), and
ADVERSARY_STACK_LOCK_PATH overrides the review-stack lock file location
(isolation seam — see acquire_stack_lock).
Process hygiene: commands run through the shell. On POSIX each command gets
its own session (start_new_session) and a timeout SIGKILLs the whole process
@@ -52,10 +54,52 @@ import json
import os
import signal
import subprocess
import sys
import time
try:
import fcntl
except ImportError: # non-POSIX (Windows): advisory flock unavailable
fcntl = None
PER_COMMAND_TIMEOUT_SECS = 900
TOTAL_DEADLINE_SECS = float(os.environ.get("ADVERSARY_RUN_CHECKS_DEADLINE_SECS") or 3300)
STACK_LOCK_PATH = os.environ.get("ADVERSARY_STACK_LOCK_PATH") or os.path.expanduser(
"~/.cache/coyote/review-stack.lock"
)
STACK_LOCK_WAIT_SECS = float(os.environ.get("ADVERSARY_STACK_LOCK_WAIT_SECS") or 1200)
def acquire_stack_lock():
"""Serialize with the probe lane's shared-stack work (advisory flock on a
well-known path; the probe is instructed to flock the same file around
stack boot/teardown). Bounded wait so a hogged lock can never turn into a
false red verification: on timeout we proceed WITHOUT the lock and note it
on stderr. The fd is held for the process lifetime (released on exit).
On non-POSIX platforms (no fcntl) the lock is skipped entirely."""
if fcntl is None:
sys.stderr.write("WARN: review-stack lock unsupported on this platform; running unlocked\n")
return None
try:
os.makedirs(os.path.dirname(STACK_LOCK_PATH), exist_ok=True)
fd = os.open(STACK_LOCK_PATH, os.O_CREAT | os.O_RDWR)
except OSError as e:
sys.stderr.write(f"WARN: review-stack lock unavailable ({e}); running unlocked\n")
return None
limit = time.monotonic() + STACK_LOCK_WAIT_SECS
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return fd
except OSError:
if time.monotonic() >= limit:
os.close(fd)
sys.stderr.write(
"WARN: review-stack lock still held after bounded wait — proceeding "
"WITHOUT it; shared-stack collisions with the probe lane are possible\n"
)
return None
time.sleep(5)
DRAIN_TIMEOUT_SECS = 10
TAIL_LINES = 50
MAX_TAIL_CHARS = 8000
@@ -181,6 +225,7 @@ def main():
)
deadline = time.monotonic() + TOTAL_DEADLINE_SECS
results = []
_stack_lock = acquire_stack_lock() if cmds else None # noqa: F841 — held until exit
for cmd in cmds:
remaining = deadline - time.monotonic()
if remaining <= 0:
+27 -3
View File
@@ -6,7 +6,7 @@ description: |
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and
finishes with ONE draft PR (CI checks watched to green) plus tracked follow-up tasks. Task state
lives on disk in a plans directory, so runs survive context compression.
version: 2.5.0
version: 2.8.0
agent_session: temp
auto_continue: true
max_auto_continues: 100
@@ -18,6 +18,7 @@ spawnable_agents:
- oracle
- explore
- librarian
- deep-research
- adversary
- probe
- review-gauntlet
@@ -180,7 +181,9 @@ instructions: |
<label>; authoritative sources preferred; return a distilled, deduplicated checklist"), put the
returned checklist through the same accept/drop round with the user, and write the ACCEPTED
items into the plan — as measurable acceptance criteria on the relevant tasks where possible,
otherwise as a checklist under `## Quality bar → Long-tail criteria`.
otherwise as a checklist under `## Quality bar → Long-tail criteria`. Spawn checklist
librarians with `--variables {"research_depth": "deep"}` — checklist distillation is
broad-topic research and the default standard depth (3 searches / 8 fetches) starves it.
Ground the breakdown in real code: fan out `explore` agents (load `parallel-research`) across
{{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
@@ -340,6 +343,12 @@ instructions: |
Verification commands go in `--variables` (a JSON-array string), never in the prompt: the
prompt also carries pasted plan text from the repo under review, and whatever is declared
here is executed with a shell — a trust boundary the prose must not cross.
Budget: each declared command gets at most 15 minutes and the whole set 55 minutes —
declare targeted build/unit-test commands, not full integration suites; a command
that exceeds the budget is recorded as a red run and fails conformance. Never declare
commands that boot shared stacks or services (docker compose projects, dev servers,
fixed ports) — the probe lane owns end-to-end runs and WILL collide with them in
parallel; keep verification to generate/lint/build/unit/integration commands.
It returns `GAUNTLET: PASS/BLOCKED`. On BLOCKED with real review findings apply the same
fix-and-rerun rules as below (resume the SAME Sisyphus session with the findings verbatim,
@@ -363,6 +372,16 @@ instructions: |
The direct `adversary`/probe spawns below are the FALLBACK when the gauntlet is not installed;
on that path an adversary/probe result that is ONLY a `PIPELINE-FAULT` (no verdict on the
code) is likewise an infrastructure fault to retry, never a divergence.
Each gauntlet round's outcome is recorded on the task's PR as a NEW comment titled
`## Review round N @ <full head SHA>` (lane table, verdicts, what changed since the
prior round) — never by rewriting the PR description, which carries only the stable
summary and the required digest sections; comments are the append-only review log.
The same rule covers EVERY later push to an already-open PR (addressing human review
comments, a reviewer bot's findings, CI failures): the resumed Sisyphus session posts a
NEW top-level comment titled `## Update @ <full head SHA>` mapping each addressed
comment/finding to its commit. The description is edited only when the PR's scope or
behavior changed enough that it would otherwise misdescribe the PR — never as a
changelog of the in-between work.
- **Spawn `adversary`** with the diff base and the criteria pasted in:
```
@@ -577,7 +596,8 @@ instructions: |
|-------|---------|
| `sisyphus` | Implement ONE task's code in {{project_dir}} (its own explore/coder/verify/review loop). One per task. |
| `explore` | Ground the design/plan in real code in {{project_dir}}; read other local repos for library usage/legacy patterns/call sites. Fan out in parallel. |
| `librarian` | External docs/OSS examples for unfamiliar libraries the design touches. |
| `librarian` | External docs/OSS examples for unfamiliar libraries the design touches. Design-time/broad topics: pass `--variables {"research_depth": "deep"}` (~3× budgets); default depth is for narrow lookups. One librarian per question beats one broad librarian — deep is for inherently broad questions. |
| `deep-research` | Open-ended/exhaustive research that should churn until its critique loop passes (plans sub-questions, deep librarian per question, credibility vetting). Spawn with `--variables {"autonomy": "autonomous"}` — MANDATORY, the supervised default blocks on a human approval gate; raise `reflexion_budget` (default 2) for longer churn. |
| `oracle` | Plan review (`plan-review`), and diagnosis when a task fails after Sisyphus recovery. |
| `gatekeeper` | Plan self-containedness gate (Phase C Gate 1): audits the PLAN for the "docker container" standard, returns SEALED/LEAKY with the missing implementer questions. |
| `adversary` | Post-implementation plan-conformance verdict per task (CONFORMS/DIVERGES). |
@@ -612,6 +632,10 @@ instructions: |
an unprobeable consumer-facing change is a plan gap to fix, not a checkbox to skip.
- Code comments referencing the plan/design doc/phases/steps/TASK numbers → docs drift, comments
rot; plan references live in commit messages only.
- Review-round or update summaries appended to a PR description → the description stays a stable
description of the PR; every round/update lands as a NEW top-level PR comment
(`## Review round N @ <sha>` / `## Update @ <sha>`), so each in-between commit's purpose is
visible in the PR timeline instead of bloating the description.
- A `.env` file landing in any repo → only `.env.example` with placeholder keys is committable;
`.env` stays gitignored and real values are a follow-up.
- Dropping a Sisyphus-reported follow-up (not logged in the task's log.md, not in the PR's
+18 -2
View File
@@ -56,6 +56,7 @@ settings:
initial_state:
project_dir_in: ''
verify_retries: 0
diff_spec: auto
intent: ''
rigor_in: ''
@@ -146,7 +147,7 @@ nodes:
extra_context:
type: string
required: [diff_spec, intent, rigor_in, surfaces_in, project_dir_in, extra_context]
max_iterations: 3
max_iterations: 6
max_attempts: 2
state_updates:
parse_failure: '{{output}}'
@@ -227,7 +228,7 @@ nodes:
type: string
required: [domain, files]
required: [groups]
max_iterations: 3
max_iterations: 6
max_attempts: 2
state_updates:
refine_failure: '{{output}}'
@@ -435,6 +436,13 @@ nodes:
(cross-domain concerns from the leaders' reports + teammate alerts, or "None"),
`operational_history` (from prior_art_notes; "" when the lane didn't run),
`org_context_section` (from org_context_notes; "" when the lane didn't run).
Prior review decisions supplied in the caller context are SETTLED: never
invert or re-litigate one on the same line without NEW evidence (the line
changed, a new failure appeared). If you believe a settled decision is
wrong, do not re-flag it as a finding — surface it in `cross_file` as an
explicit conflict citing the prior decision, so the human sees a
disagreement, not a flip-flop.
prompt: |
Domain reports ({{file_count}} changed files, grouped into the domain slices below):
{{domain_reports}}
@@ -522,6 +530,14 @@ nodes:
state_updates:
verifier_output: '{{output}}'
fallback: verdict
next: verify_gate
verify_gate:
id: verify_gate
type: script
description: Local verifier-fault retry — when the verifier completed but its output carries PIPELINE-FAULT markers (or the verifier died), re-run the cheap finding-verifier ONCE instead of letting the fault escalate into a full lane re-run at the gauntlet. Falls through to verdict when clean or out of retries; a gate crash falls forward on the declared edge.
script: scripts/verify_gate.py
timeout: 60
next: verdict
verdict:
@@ -80,6 +80,11 @@ except Exception as e: # noqa: BLE001
out["resolved_diff_spec"] = resolved
out["changed_files"] = files[:300]
out["file_count"] = len(files)
if len(files) > 300:
notes.append(
f"file list truncated: {len(files)} changed files, only the first 300 are "
"grouped and reviewed"
)
def diff_text():
+43 -5
View File
@@ -165,14 +165,45 @@ def main():
if v.get("id") == "pipeline-fault"
or (isinstance(v.get("note"), str) and v["note"].lstrip().startswith("PIPELINE-FAULT:"))
]
if fv_faults:
first = fv_faults[0].get("note")
# parse_fault's sentinel = the verifier verified NOTHING (hard fault, lane-
# degrading). Per-finding verify_fault / crash-guard notes = those findings
# simply stand UNVERIFIED (soft: proportionate NEEDS-HUMAN attention — a
# ~1 h lane re-run to re-check a few findings is the wrong trade, and the
# verify_gate node already retried the verifier locally once).
hard_fv = [v for v in fv_faults if v.get("id") == "pipeline-fault"]
soft_fv = [v for v in fv_faults if v.get("id") != "pipeline-fault"]
def _fv_detail(entries):
first = entries[0].get("note")
detail = first.strip().replace("\n", " ") if isinstance(first, str) else ""
if len(detail) > MAX_DETAIL_CHARS:
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
return detail
soft_notes = []
if hard_fv:
faults.append(
f"PIPELINE-FAULT: finding verification degraded — {len(fv_faults)} verifier "
f"verdict(s) carry a pipeline fault ({detail})"
"PIPELINE-FAULT: finding verification degraded — the verifier verified "
f"nothing ({_fv_detail(hard_fv)})"
)
if soft_fv:
soft_notes.append(
f"Verifier: {len(soft_fv)} finding(s) could not be verified (verifier "
f"branch fault: {_fv_detail(soft_fv)}) — they stand above UNVERIFIED; "
"verify them manually"
)
# Files beyond facts.py's 300-file cap were never grouped or reviewed —
# disclose the gap and force NEEDS-HUMAN with wording that does NOT match
# the gauntlet's degraded-run anchors (a lane re-run cannot fix a cap).
coverage_notes = []
try:
total_files = int(state.get("file_count") or 0)
except (TypeError, ValueError):
total_files = 0
if total_files > len(changed):
coverage_notes.append(
f"Coverage gap: {total_files - len(changed)} of {total_files} changed files "
"were NOT reviewed (file-list cap) — review them manually or split the PR"
)
# Keyed off synth_failure + a non-empty diff, NEVER off findings == [] —
# a clean review legitimately has zero findings and stays MERGE-READY.
@@ -190,7 +221,7 @@ def main():
# faults go FIRST and uncapped so the display cap can never hide one;
# the trigger reason keys off the human-facing triggers alone
has_triggers = bool(attention)
attention = faults + attention[:5]
attention = faults + soft_notes + coverage_notes + attention[:5]
reasons = []
if counts["🔴"]:
@@ -200,6 +231,13 @@ def main():
if faults:
# CONTRACT: review-gauntlet/verdict_gate.py anchors on this wording
reasons.append("pipeline fault(s) recorded — degraded run")
if soft_notes:
# Deliberately does NOT match the gauntlet's degraded-run anchors:
# unverified findings are a human-attention item, not a re-runnable
# lane fault.
reasons.append(f"{len(soft_fv)} finding(s) unverified (verifier branch fault)")
if coverage_notes:
reasons.append(f"{total_files - len(changed)} changed file(s) not reviewed (file cap)")
if has_triggers:
reasons.append("always-human trigger(s) fired")
verdict = "NEEDS-HUMAN" if reasons else "MERGE-READY"
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Local verifier-fault retry gate for the code-reviewer graph.
finding-verifier converts its own branch faults into DATA (UNVERIFIABLE
verdicts with a PIPELINE-FAULT: note, or parse_fault's pipeline-fault
sentinel), so the verify agent node completes "successfully" and its
max_attempts never fires. Without this gate the fault escalates all the
way to the gauntlet, which re-runs the ENTIRE code-review lane (~1 h) to
recover a ~4-minute verifier — the wrong trade. This gate re-runs just
the verifier ONCE; if faults persist, verdict.py downgrades them to a
proportionate NEEDS-HUMAN (findings stand, unverified).
Anchored detection only: the JSON-encoded note prefix and the sentinel id
— a finding merely QUOTING the marker mid-text does not trip it. Always
exits 0 with valid JSON; a crash falls forward on the declared edge.
"""
import json
import os
import sys
def load_state():
if path := os.environ.get("GRAPH_STATE_FILE"):
with open(path) as f:
return json.load(f)
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
try:
state = load_state()
raw = state.get("verifier_output")
text = raw if isinstance(raw, str) else (json.dumps(raw) if raw is not None else "")
faulted = (
text.lstrip().startswith("Agent node failed:")
or '"note": "PIPELINE-FAULT:' in text
or "'note': 'PIPELINE-FAULT:" in text
or '"id": "pipeline-fault"' in text
)
try:
retries = int(state.get("verify_retries") or 0)
except (TypeError, ValueError):
retries = 0
if faulted and retries < 1:
sys.stderr.write(
"WARN: verifier output carries pipeline-fault markers — re-running "
"finding-verifier locally (attempt 2/2) before computing the verdict.\n"
)
print(json.dumps({"_next": "verify", "verify_retries": retries + 1}))
else:
print(json.dumps({"verify_retries": retries}))
except Exception as e: # never block the verdict
print(json.dumps({"verify_gate_error": f"verify_gate crashed: {e}"}))
+5 -3
View File
@@ -39,7 +39,7 @@ settings:
initial_state:
project_dir: ''
fix_attempts: 0
max_fix_attempts: 3
max_fix_attempts: 5
fix_instructions: ''
build_output: ''
tests_output: ''
@@ -222,7 +222,8 @@ nodes:
3. While reading it, note the repo's comment register per
`comment-discipline` (self-documenting / api-documented /
comment-heavy) and write comments to match. When the signal is
weak, write NO comment.
weak, write NO comment. Every comment must EARN ITS KEEP — if
the code already tells the reader, don't add it.
4. If the change touches boundaries, error paths, jobs, or state
transitions, also note the logging register per
`logging-discipline` (logger, message style, payload vs IDs,
@@ -323,7 +324,8 @@ nodes:
- Suppressed errors (as any, @ts-ignore, #[allow(...)] on unfamiliar
lints, empty catch blocks)
- Dishonest naming (get_X that mutates, returns wrong type, etc.)
- Useless comments that restate the code
- Useless comments that restate the code, or that otherwise don't earn
their keep (the code already tells the reader)
- AI slop (filler prose, multi-paragraph docstrings, defensive
handling of impossible cases)
+7
View File
@@ -13,6 +13,13 @@ this agent runs a fixed graph: every request goes through the same
`plan -> parallel research -> vet -> critique -> synthesize -> verify -> approve`
pipeline.
Two variables tune a run (pass via `--variables`): `reflexion_budget`
(default 2) caps the automated critique -> re-research passes — raise it
to let the run churn until the critique passes — and
`autonomy: autonomous` skips the final human approval gate so other
agents can spawn this one autonomously. Each sub-question is researched
by a spawned `librarian` running at deep budgets.
This agent is also the **canonical reference for the Coyote graph
system**: it exercises every node type (`script`, `llm`, `rag`, `map`,
`agent`, `input`, `approval`, `end`) and both static fan-out and
+40 -26
View File
@@ -7,7 +7,12 @@ description: |
delegates the final write-up to a focused sub-agent, checks that the
cited sources are reachable, and gates the result behind human
approval. A reviewer's free-form feedback at the approval step feeds
back into another research pass.
back into another research pass. Each sub-question is researched by a
spawned librarian running at deep budgets. Tunable via --variables:
reflexion_budget (default 2) caps the automated critique->re-research
passes - raise it to let the run churn until the critique passes;
autonomy=autonomous skips the human approval gate (REQUIRED when
another agent spawns this one).
This is the canonical Coyote graph-agent reference: it exercises every
node type (script, llm, rag, map, agent, input, approval, end) and
@@ -27,8 +32,16 @@ conversation_starters:
- 'How does HTTP/3 differ from HTTP/2?'
- 'Summarize recent advances in solid-state battery chemistry'
variables:
- name: reflexion_budget
description: "Max automated critique->re-research passes before synthesis proceeds anyway. Default 2; raise it (4-8) for exhaustive runs that should churn until the critique passes."
default: '2'
- name: autonomy
description: "supervised (default): the finished report stops at the human approval gate. autonomous: skip the gate and return the report directly - REQUIRED when another agent spawns this one (a human gate would block the run)."
default: 'supervised'
settings:
max_loop_iterations: 40
max_loop_iterations: 120
log_state_snapshots: false
validate_before_run: true
max_concurrency: 4
@@ -142,23 +155,11 @@ nodes:
research_one_question:
id: research_one_question
type: llm
instructions: |
You are a web research assistant. Investigate the SINGLE question
given to you using your tools: search the web, fetch and read
pages, and search arXiv for academic sources.
Rules:
- Every factual claim must be backed by a real source you
actually retrieved. Never fabricate URLs, page titles,
authors, or DOIs.
- Prefer primary and authoritative sources over aggregators.
- Where sources disagree, report the disagreement rather than
papering over it.
- Put the URL (or DOI) inline next to each claim it supports.
Return organized findings in plain text. Do not include
meta-commentary about the process.
type: agent
agent: librarian
# The leaf research primitive: triage -> doc+OSS search -> cited
# synthesis. `inputs:` overlays the librarian graph's initial state,
# pinning research_depth to deep for every sub-question lane.
prompt: |
Research question: {{question}}
@@ -166,16 +167,13 @@ nodes:
{{local_context}}
{{research_feedback}}
tools:
- web_search_coyote
- fetch_url_via_curl
- search_arxiv
- mcp:ddg-search
max_iterations: 10
inputs:
research_depth: deep
timeout: 1900
max_attempts: 2
state_updates:
finding: '{{output}}'
# On failure the engine writes "LLM node ...failed: <chain>" into `finding`
# On failure the engine writes "Agent node failed: <chain>" into `finding`
# via the state_updates above BEFORE routing to the fallback. The marker
# is branch-local: a map branch's fallback must stay inside the branch.
fallback: question_fault
@@ -312,6 +310,13 @@ nodes:
id: verify_sources
type: script
script: scripts/verify_sources.py
next: approval_gate
approval_gate:
id: approval_gate
type: script
description: Autonomy router - autonomous runs (spawned by another agent) skip the human approval gate and end accepted with the source check + pipeline notes attached; supervised (default) proceeds to the approval node. A crash fails forward via `next` to approve, the safe human-gated direction.
script: scripts/approval_gate.py
next: approve
approve:
@@ -348,6 +353,15 @@ nodes:
type: end
output: '{{report}}{{pipeline_notes}}'
end_autonomous:
id: end_autonomous
type: end
output: |
{{report}}
----
{{source_check}}{{pipeline_notes}}
end_rejected:
id: end_rejected
type: end
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Autonomy router for deep-research's approval gate.
Runs after verify_sources. When the `autonomy` variable is
'autonomous' (the run was spawned by another agent — architect or
sisyphus research tiers — with no human at the terminal), the report
skips the human approval node and ends accepted: the reflexion loop
already served as the quality gate, and end_autonomous carries the
source check + pipeline notes with the report so the caller sees them.
Any other value (default 'supervised') proceeds to the human approval
gate unchanged.
Routing (`_next`):
- autonomy == 'autonomous' -> end_autonomous
- anything else -> approve
Fail-safe: a crash here fails FORWARD via the node's `next` to
`approve` — the safe, human-gated direction.
"""
import json
import os
def load_state():
if path := os.environ.get("GRAPH_STATE_FILE"):
with open(path) as f:
return json.load(f)
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
def main():
state = load_state()
autonomy = str(state.get("autonomy") or "supervised").strip().lower()
target = "end_autonomous" if autonomy == "autonomous" else "approve"
print(json.dumps({"_next": target}))
main()
@@ -4,10 +4,11 @@
Reached ONLY via research_one_question's `fallback:` route inside the
`research_each_question` map branch (branch-local — a map branch's fallback
must stay inside the branch subgraph). Grounded in engine behavior: on
llm-node failure the engine writes "LLM node …failed: <chain>" into
`finding` via the node's state_updates BEFORE routing to the fallback (on
success the same key holds the researched findings, which never start with
the "LLM node" prefix). This node normalizes the failure text into a
branch failure the engine writes its failure text into `finding` via the
node's state_updates BEFORE routing to the fallback — "Agent node failed:
<chain>" now that the branch spawns a librarian agent, "LLM node …failed:
<chain>" for an llm branch (on success the same key holds the researched
findings, which never start with either prefix). This node normalizes the failure text into a
PIPELINE-FAULT finding and the chain ends here — the map collects the fault
as the lane's `finding`, so one dead research lane never sinks the whole
map. combine_findings later lifts PIPELINE-FAULT findings into
@@ -53,7 +54,7 @@ def main():
state = load_state()
raw = state.get("finding")
detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else ""
if not detail.startswith("LLM node"):
if not detail.startswith(("LLM node", "Agent node")):
detail = "the research lane died without recording a failure detail"
if len(detail) > MAX_DETAIL_CHARS:
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
@@ -2,7 +2,8 @@
"""Reflexion gate for deep-research.
Runs after `critique` has reviewed the current research findings. If the
critique's verdict is REVISE and the reflexion budget is not spent,
critique's verdict is REVISE and the reflexion budget (the graph's
`reflexion_budget` variable, default 2) is not spent,
loops back to `research` with the critique attached as
`research_feedback`, so the retry is informed rather than a blind
re-run. Otherwise it proceeds to `synthesize`.
@@ -26,9 +27,11 @@ import json
import os
import re
# Automated revision passes allowed. `research` runs at most
# MAX_REFLEXION_REVISIONS + 1 times per user pass. Bump to allow more.
MAX_REFLEXION_REVISIONS = 2
# Default automated revision passes when the `reflexion_budget` variable
# is absent or malformed. `research` runs at most budget + 1 times per
# user pass. Callers raise it via --variables {"reflexion_budget": "6"}
# to let the run churn until the critique passes.
DEFAULT_REFLEXION_REVISIONS = 2
def load_state():
@@ -69,8 +72,9 @@ def main():
critique = state.get("critique") or ""
verdict = parse_verdict(critique)
attempts = as_int(state.get("research_attempts"))
budget = as_int(state.get("reflexion_budget"), DEFAULT_REFLEXION_REVISIONS)
if verdict == "REVISE" and attempts < MAX_REFLEXION_REVISIONS:
if verdict == "REVISE" and attempts < budget:
feedback = (
"A reviewer judged the previous research pass incomplete. "
"Address every point in the critique below:\n\n" + critique
+2 -2
View File
@@ -25,7 +25,7 @@ variables:
settings:
max_loop_iterations: 8
log_state_snapshots: true
timeout: 900
timeout: 3000
initial_state:
findings_list: []
@@ -174,7 +174,7 @@ nodes:
- fs_read
- fs_cat
- fs_grep
max_iterations: 8
max_iterations: 28
max_attempts: 2
state_updates:
verdict: '{{output}}'
+43 -17
View File
@@ -6,13 +6,16 @@ description: |
preamble. The "external grep" sibling of explore (which handles
internal/codebase grep). Designed to be fanned out 1-3 in parallel by
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
involved.
involved. Supports research_depth=deep (via --variables) for broad
design-time topics: ~3x the search/OSS/fetch budgets of the standard
quick-lookup mode.
version: '1.0'
global_tools:
- web_search_coyote.sh
- fetch_url_via_curl.sh
- search_arxiv.sh
mcp_servers:
- ddg-search
@@ -26,11 +29,14 @@ variables:
- name: project_dir
description: Project directory for context (unused in MVP but reserved for future iterations).
default: '.'
- name: research_depth
description: "standard (default) = quick targeted lookup budgets. deep = design-time research: ~3x search/OSS/fetch budgets for broad topics (best-practice checklists, architecture surveys)."
default: 'standard'
settings:
max_loop_iterations: 12
log_state_snapshots: true
timeout: 600
timeout: 1800
initial_state:
language_ecosystem: 'general'
@@ -117,7 +123,7 @@ nodes:
search:
id: search
type: llm
description: Identify 3-5 authoritative documentation sources via ddg-search.
description: Identify authoritative documentation sources via ddg-search (3-5 standard depth, 6-12 deep).
skills_enabled: true
enabled_skills:
- ai-slop-remover
@@ -129,7 +135,10 @@ nodes:
Priority order:
1. Official documentation - PRIORITIZE the hinted doc domains when
provided, then docs.X.org / readthedocs.io / MDN / vendor docs
2. Specifications (RFCs, W3C, ECMA, IEEE)
2. Specifications (RFCs, W3C, ECMA, IEEE) and academic sources -
the search_arxiv tool is available; use it when the topic is
academic/research-flavored (arXiv/DOI links count as
authoritative)
3. Credible secondary references (PEPs, official blog posts) - only
if 1-2 are sparse
@@ -142,14 +151,19 @@ nodes:
## Search budget and fail-fast rules
You have a HARD BUDGET of 3 search calls total. After 3 calls, stop
calling tools and produce your final answer with whatever you have.
Your budget is set by the `Research depth` line in the prompt:
- standard -> HARD BUDGET of 3 search calls total.
- deep -> HARD BUDGET of 10 search calls total; spend the extra
calls covering DISTINCT subtopics of the research topic, never
re-running near-duplicate queries.
After the budget is spent, stop calling tools and produce your final
answer with whatever you have.
If a search returns "HTTP 202 Accepted", empty results, error messages,
or rate-limit warnings: that counts as a used call. Do not retry the
same query - either rephrase OR give up.
If after 3 calls you have NO usable URLs, output exactly:
If after spending the budget you have NO usable URLs, output exactly:
NO_AUTHORITATIVE_SOURCES_FOUND
Reason: <one line>
@@ -168,9 +182,11 @@ nodes:
URL: <full url>
...
Output 3-5 source blocks. No prose intro, no closing summary.
Output 3-5 source blocks (standard depth) or 6-12 (deep). No prose
intro, no closing summary.
prompt: |
Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Triage hints:
- Language/ecosystem: {{language_ecosystem}}
@@ -183,8 +199,9 @@ nodes:
pathlib` style queries).
tools:
- mcp:ddg-search
- search_arxiv
- web_search_coyote
max_iterations: 15
max_iterations: 30
state_updates:
search_output: '{{output}}'
fallback: synthesize
@@ -193,7 +210,7 @@ nodes:
search_oss:
id: search_oss
type: llm
description: Find 2-3 production OSS examples relevant to the topic via the personal-github MCP.
description: Find production OSS examples relevant to the topic via the personal-github MCP (2-3 standard depth, 4-8 deep).
skills_enabled: true
enabled_skills:
- ai-slop-remover
@@ -219,8 +236,12 @@ nodes:
## Search budget and fail-fast rules
HARD BUDGET: 8 tool calls total. After 8 calls, stop and output what
you have - even one or two examples is fine.
Your budget is set by the `Research depth` line in the prompt:
- standard -> HARD BUDGET of 8 tool calls total; output 2-3 blocks.
- deep -> HARD BUDGET of 20 tool calls total; output 4-8 blocks
across DIFFERENT repos/approaches, not near-duplicates.
After the budget is spent, stop and output what you have - even one or
two examples is fine.
If you find no production examples, output exactly:
@@ -240,10 +261,12 @@ nodes:
REPO: ...
Output 2-3 blocks. The URL should point to a specific file that
Output 2-3 blocks (standard depth) or 4-8 (deep). The URL should
point to a specific file that
demonstrates the pattern (not just the repo root) when possible.
prompt: |
Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Triage hints:
- Language/ecosystem: {{language_ecosystem}}
@@ -254,7 +277,7 @@ nodes:
Filter to {{language_ecosystem}} repositories when the API allows.
tools:
- mcp:personal-github
max_iterations: 15
max_iterations: 30
state_updates:
oss_output: '{{output}}'
fallback: synthesize
@@ -308,8 +331,10 @@ nodes:
5. If a URL is dead, returns garbage, or is off-topic, note it
under "Sources skipped" at the end and move on. Do not retry.
Budget: max 8 fetches total (across both source lists). Skip
aggressively.
Fetch budget, set by the `Research depth` line in the prompt:
- standard -> max 8 fetches total (across both source lists).
- deep -> max 25 fetches total. Depth buys MORE SOURCES, never
re-fetching dead or off-topic URLs - skip aggressively either way.
## Output format
@@ -332,6 +357,7 @@ nodes:
No flattery, no preamble. Start with `## Findings`.
prompt: |
Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Documentation sources (from doc search branch):
{{search_output}}
@@ -340,7 +366,7 @@ nodes:
{{oss_output}}
tools:
- fetch_url_via_curl
max_iterations: 20
max_iterations: 45
state_updates:
findings: '{{output}}'
fallback: final_format
+17 -1
View File
@@ -101,6 +101,14 @@ instructions: |
## Rules
0. **Shared-stack discipline (parallel review lanes).** Wrap every stack boot/teardown
(docker compose, dev servers, fixed-port services) in
`flock "$HOME/.cache/coyote/review-stack.lock" <command>` — the adversary's
verification stage holds the same lock. A held port NEVER justifies removing or
stopping a container you did not create THIS RUN: wait on the lock, pick another
port, or return INCONCLUSIVE naming the conflict. Killing another lane's container
is a defect, not a remedy.
1. **Never modify implementation code.** Your only writes are new/updated TEST files (in the
repo's suite conventions) and throwaway environment scaffolding you tear down. The
implementer owns all fixes.
@@ -112,7 +120,15 @@ instructions: |
update — never delete or silence it).
4. **Clean, local, isolated.** Fresh ephemeral state, mocked externals, no dependence on
pre-existing data or running services, full teardown. Bounded retries for startup only —
never to mask a flaky assertion.
never to mask a flaky assertion. Teardown MUST NOT depend on a shell trap firing (a
killed or timed-out lane leaks containers — it has happened): pick one unique run
prefix up front (e.g. `probe-$$`), give EVERY container you create `--name <prefix>-…`
and `--label coyote-probe=<prefix>` (compose: `-p <prefix>`), prefer `docker run --rm`,
and make the LAST action before emitting your verdict an explicit sweep:
`docker ps -aq --filter label=coyote-probe=<prefix> | xargs -r docker rm -f` (compose:
`docker compose -p <prefix> down -v`). The label filter keeps the sweep scoped to YOUR
resources — rule 0 still forbids touching anyone else's. A leftover you cannot remove
is a finding to REPORT in the verdict, never to silence.
5. **Classify every failure** as BUG / EXPECTED-CHANGE / ENV per the skill table. The verdict
depends on the classification being honest.
6. **Committed tests are the deliverable** alongside the verdict: write them where the repo's
+5 -5
View File
@@ -14,7 +14,7 @@ flowchart TD
parse -. "fallback" .-> pfault["parse_fault<br/>(script: PIPELINE-FAULT marker)"]
pfault --> gate
signals --> select["select_lanes<br/>(llm: ADDITIVE-only judgment)"]
select --> build["build_items<br/>(script: rules floor ∪ additions;<br/>forced lanes override exactly)"]
select --> build["build_items<br/>(script: rules floor ∪ additions;<br/>forced lanes add)"]
select -. "fallback" .-> dlanes["default_lanes<br/>(script: deterministic degraded<br/>selection — wider, never narrower)"]
dlanes --> build
build --> mcr["map: code-review"] & madv["map: adversary"] & msec["map: security"] & mpb["map: probe"]
@@ -155,7 +155,7 @@ Plan / acceptance criteria:
Local-run recipe / usage suites:
<how to boot the service locally + where existing suites live — enables the probe lane>
Forced lanes: (optional — e.g. 'adversary, probe' to run exactly those)" \
Forced lanes: (optional, ADDITIVE — e.g. 'adversary, probe' run on top of the computed selection; forcing never drops the always-on floor)" \
--variables {"verification_commands": "[\"cargo test --all\", \"cargo clippy -- -D warnings\"]"}
```
@@ -210,9 +210,9 @@ verbatim.
(`'[]'` ⇒ "none declared"); a declaration that is not a JSON array of
strings executes nothing and records a `PIPELINE-FAULT`, which the
adversary lane reports as DIVERGES and the gate as BLOCKED.
- Signals failures degrade gracefully: if git can't compute the diff, lane
selection falls back to caller context + forced lanes, and the report notes
it. The auth-path regex deliberately over-fires (`auth(?!or)` matches
- Signals failures degrade WIDER: if git can't compute the diff, the surface
is unknown — the security lane is added (and probe, when a local-run recipe
exists) on top of caller context + forced lanes, and the report notes it. The auth-path regex deliberately over-fires (`auth(?!or)` matches
auth/authn/authz/oauth but not "author") — the safe direction, since signals
only ever ADD the security lane.
- Requires the `code-reviewer`, `adversary`, `security-reviewer`, and `probe`
+21 -8
View File
@@ -51,6 +51,7 @@ settings:
timeout: 69600
initial_state:
retry_notes: []
diff_spec: worktree
plan_context: ''
rigor: production
@@ -106,16 +107,22 @@ nodes:
You extract the facts of a review request into structured fields.
EXTRACTION ONLY — do not decide which review lanes should run.
- `diff_spec`: the git diff specification the caller named — a ref
range ("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
"worktree" for uncommitted changes against HEAD. When the caller
did not name one, use "worktree".
- `diff_spec`: the git diff specification the caller named, as a BARE
revision token usable verbatim in `git diff <spec>` — a ref range
("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
"worktree" for uncommitted changes against HEAD. NEVER prose, a
command, or a path: from "run get_diff --base origin/main" extract
"origin/main...HEAD"; from "diff origin/main...HEAD in ~/code/x"
extract "origin/main...HEAD". When the caller did not name one, use
"worktree".
- `project_dir_in`: the repository directory the caller named as holding
the change (absolute path preferred); empty string when the caller
named none.
- `plan_context`: the plan / spec / acceptance-criteria text the
implementation must satisfy, verbatim (or a file path the caller
pointed at). Empty string when the caller supplied none.
implementation must satisfy, copied through VERBATIM — never
summarize it and never replace pasted text with file paths or a
sentence saying it was included; the adversary lane fails closed on
whatever you drop. Empty string when the caller supplied none.
- `rigor`: poc | prototype | production. Default "production" when
not stated.
- `security_posture`: prototype | standard | hardened. Default
@@ -124,7 +131,8 @@ nodes:
pointers for black-box probing (how to boot the service, where the
suites live). Empty string when the caller supplied none.
- `forced_lanes`: lanes the caller explicitly demanded (verbatim
names). Empty array = automatic selection.
names). Empty array = automatic selection. Forcing is ADDITIVE
downstream: it widens the computed selection, never replaces it.
- `extra_context`: any remaining caller context reviewers should see,
condensed.
prompt: |
@@ -258,7 +266,7 @@ nodes:
build_items:
id: build_items
type: script
description: Deterministic lane builder — hard rules are the floor, LLM flags may only add, forced lanes override exactly. Emits one 0-or-1-item list per lane; an empty list skips that lane's map. On a re-run pass (retry_lanes non-empty) it skips selection and emits items only for those lanes, each carrying its attempt number.
description: Deterministic lane builder — hard rules are the floor, LLM flags may only add, forced lanes are ADDITIVE (selection only ever widens; unavailable signals widen it too). Emits one 0-or-1-item list per lane; an empty list skips that lane's map. On a re-run pass (retry_lanes non-empty) it skips selection and emits items only for those lanes, each carrying its attempt number.
script: scripts/build_items.py
timeout: 30
next: [map_code_review, map_adversary, map_security, map_probe]
@@ -404,6 +412,11 @@ nodes:
The workspace may contain test files from an aborted earlier attempt of
this lane; reconcile rather than duplicate.
Shared-stack rule: wrap any stack boot/teardown in
`flock "$HOME/.cache/coyote/review-stack.lock" <command>` (the adversary's
verification stage holds the same lock), and NEVER remove or stop containers
you did not create this run — a held port means wait, re-port, or INCONCLUSIVE.
Derive expected behaviors from the plan/contract BEFORE reading
implementation code, run existing suites first, then author spec-first
tests for the uncovered delta. Return your USAGE_PROBE verdict.
@@ -1,14 +1,20 @@
#!/usr/bin/env python3
"""Deterministic lane builder for the review gauntlet.
Selection = hard rules (the floor) ∪ LLM additions; forced lanes override
everything exactly. Emits one 0-or-1-item list per lane: an empty list makes
that lane's map node run zero branches (map-over-nothing), which IS the
skip mechanism — no dynamic routing anywhere.
Selection = hard rules (the floor) ∪ LLM additions ∪ caller-forced lanes.
Forcing is ADDITIVE: it can only widen the computed selection — honoring a
forced list as the exact set once dropped the always-on code-review lane in
a live run. Emits one 0-or-1-item list per lane: an empty list makes that
lane's map node run zero branches (map-over-nothing), which IS the skip
mechanism — no dynamic routing anywhere.
The LLM's add_* flags can only ADD lanes. A flag that is false never
removes a lane the hard rules selected.
Unavailable diff signals (malformed spec, git failure) mean the surface is
UNKNOWN — selection degrades WIDER (security on; probe when a recipe
exists), never narrower.
A re-run pass (retry_gate emitted `retry_lanes`) skips selection: only the
named lanes get an item, stamped with their attempt number, and every other
lane maps over nothing — retry_gate restores their earlier reports.
@@ -101,56 +107,90 @@ def main():
(forced.append(canon) if canon else unknown.append(str(lane)))
reasons = []
if forced:
lanes = set(forced)
reasons.append(f"forced lanes honored exactly: {sorted(lanes)}")
else:
lanes = {"code-review"}
lanes = {"code-review"}
reasons.append(
"code-review: always on — the gauntlet is only spawned for non-trivial work"
)
if text("plan_context"):
lanes.add("adversary")
reasons.append("adversary: a spec/plan/acceptance-criteria context is present")
if (
state.get("touches_auth")
or state.get("touches_deps")
or state.get("touches_exec")
or state.get("security_posture") == "hardened"
):
lanes.add("security")
reasons.append(
"code-review: always on — the gauntlet is only spawned for non-trivial work"
"security: attack-surface signals (auth/deps/exec) or hardened posture"
)
if text("plan_context"):
lanes.add("adversary")
reasons.append("adversary: a spec/plan/acceptance-criteria context is present")
if (
state.get("touches_auth")
or state.get("touches_deps")
or state.get("touches_exec")
or state.get("security_posture") == "hardened"
):
if state.get("consumer_surface") and text("probe_context"):
lanes.add("probe")
reasons.append(
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists"
)
# Unavailable signals (malformed diff spec, git failure) = surface UNKNOWN.
# Degrade WIDER, never narrower — a run without signals once silently
# dropped the security and probe lanes.
if text("signals_summary") == "signals unavailable":
if "security" not in lanes:
lanes.add("security")
reasons.append(
"security: attack-surface signals (auth/deps/exec) or hardened posture"
"security: diff signals unavailable — surface unknown, degrading wider"
)
if state.get("consumer_surface") and text("probe_context"):
if "probe" not in lanes and text("probe_context"):
lanes.add("probe")
reasons.append(
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists"
"probe: diff signals unavailable and a local-run recipe/suite pointer "
"exists — degrading wider"
)
# LLM judgment is additive-only.
llm_why = text("lane_reasons")[:300]
for flag, lane in (
("add_code_review", "code-review"),
("add_adversary", "adversary"),
("add_security", "security"),
("add_probe", "probe"),
):
if state.get(flag) and lane not in lanes:
if lane == "probe" and not text("probe_context"):
reasons.append(
"probe: requested by lane judgment but skipped — no local-run "
"recipe/suite pointer (a probe without one is guaranteed INCONCLUSIVE)"
)
continue
if lane == "adversary" and not text("plan_context"):
reasons.append(
"adversary: requested by lane judgment but skipped — no spec/plan "
"to check conformance against"
)
continue
lanes.add(lane)
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}")
# LLM judgment is additive-only.
llm_why = text("lane_reasons")[:300]
for flag, lane in (
("add_code_review", "code-review"),
("add_adversary", "adversary"),
("add_security", "security"),
("add_probe", "probe"),
):
if state.get(flag) and lane not in lanes:
if lane == "probe" and not text("probe_context"):
reasons.append(
"probe: requested by lane judgment but skipped — no local-run "
"recipe/suite pointer (a probe without one is guaranteed INCONCLUSIVE)"
)
continue
if lane == "adversary" and not text("plan_context"):
reasons.append(
"adversary: requested by lane judgment but skipped — no spec/plan "
"to check conformance against"
)
continue
lanes.add(lane)
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}")
# Caller-forced lanes are ADDITIVE: they widen the computed selection,
# never replace it. (Honoring them as the exact set dropped code-review —
# documented as always-on — and adversary in a live run.) Forcing also
# bypasses the context guards above: the caller demanded the lane, so it
# runs, with the expectation named in the summary.
if forced:
newly = sorted(set(forced) - lanes)
lanes |= set(forced)
reasons.append(
f"forced lanes (additive): {sorted(set(forced))}"
+ (f" — newly added: {newly}" if newly else " — all already selected")
)
if "probe" in forced and not text("probe_context"):
reasons.append(
"probe: forced without a local-run recipe/suite pointer — expect INCONCLUSIVE"
)
if "adversary" in forced and not text("plan_context"):
reasons.append(
"adversary: forced without spec/plan context — it fails closed "
"(DIVERGES) without extractable criteria"
)
if unknown:
reasons.append(f"ignored unknown forced lane name(s): {unknown}")
@@ -8,8 +8,10 @@ AND a local-run recipe exists (build_items gates probe the same way — a
probe without a recipe is INCONCLUSIVE by construction),
plus security when the attack-surface signals (auth/deps/exec) or a hardened
posture demand it, unioned with any lanes the caller forced (canonicalized
with the same aliases build_items uses) — which build_items honors exactly,
so selection degrades WIDER, never narrower. The degradation note goes into
with the same aliases build_items uses) — which build_items honors
ADDITIVELY, so selection degrades WIDER, never narrower. Unavailable diff
signals also widen: unknown surface adds security (and probe when a
local-run recipe exists). The degradation note goes into
`lanes_degraded`; build_items folds it into lanes_summary so the final
report names the degradation.
@@ -72,6 +74,11 @@ def main():
or state.get("security_posture") == "hardened"
):
lanes.add("security")
# Unavailable signals = surface UNKNOWN → widen (mirrors build_items).
if (state.get("signals_summary") or "") == "signals unavailable":
lanes.add("security")
if isinstance(probe_context, str) and probe_context.strip():
lanes.add("probe")
# Caller-forced lanes survive degradation — union, never overwrite.
for lane in state.get("forced_lanes") or []:
if canon := ALIASES.get(str(lane).strip().lower()):
@@ -30,6 +30,7 @@ hard rules:
import json
import os
import re
import sys
import time
# 69600 (graph timeout) − 2 × 11400 (largest lane envelope) − 1200 (script stages)
@@ -184,6 +185,26 @@ def main():
"review_incomplete": incomplete,
}
)
prior_notes = state.get("retry_notes")
notes = [n for n in prior_notes if isinstance(n, str)] if isinstance(prior_notes, list) else []
for lane in declined:
notes.append(
f"lane {lane} faulted and was NOT re-run "
f"(attempts {attempts.get(lane, '?')}/{MAX_ATTEMPTS}"
+ ("" if within_budget else "; wall-clock budget exhausted")
+ ") — recorded in review_incomplete"
)
for lane in retry:
notes.append(
f"lane {lane} faulted (missing / sentinel-less / degraded report) — "
f"re-running: attempt {attempts.get(lane, 0) + 1}/{MAX_ATTEMPTS}"
)
if retry or declined:
# stderr is discarded on success by today's engine but is the
# forward-compatible live-visibility channel; retry_notes carries the
# same lines into the final report either way.
sys.stderr.write("WARN: " + " | ".join(notes[-(len(retry) + len(declined)):]) + "\n")
out["retry_notes"] = notes
if retry:
out["_next"] = "build_items"
print(json.dumps(out))
@@ -3,9 +3,10 @@
Pure git — no LLM judgment. Every signal is computed from the changed-file
list and the added lines of the diff. Failures never sink the gauntlet:
on any error the script reports `signals_error` and leaves the defaults
(false/empty) in place, which biases lane selection toward the caller's
context and forced lanes.
on any error the script reports `signals_error` and marks the signals
unavailable — downstream (build_items / default_lanes) treats that as
UNKNOWN surface and degrades WIDER (security on; probe when a recipe
exists), on top of caller context and forced lanes.
"""
import json
@@ -26,6 +27,19 @@ proj = os.path.expanduser(
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
)
spec = (state.get("diff_spec") or "worktree").strip()
spec_raw = spec
# The parse stage should emit a bare revision token, but callers write prose
# and extraction drifts ("run get_diff --base origin/main" once reached
# `git diff` verbatim, killing the signals — and with them the security and
# probe lanes). Deterministically recover a usable spec before giving up.
_TOKEN = r"[\w./~^@{}-]+"
if spec not in ("", "worktree") and not re.fullmatch(
rf"{_TOKEN}(\.\.\.?{_TOKEN})?", spec
):
if m := re.search(rf"({_TOKEN}\.\.\.?{_TOKEN})", spec):
spec = m.group(1) # a revision range buried in prose
elif m := re.search(rf"--base[= ]({_TOKEN})", spec):
spec = f"{m.group(1)}...HEAD" # the get_diff idiom
def git(*args):
@@ -38,6 +52,25 @@ def git(*args):
out = {"project_dir": proj}
# Deterministic plan_context recovery: the parse LLM proved a lossy channel
# for verbatim spec text (observed replacing ~4 KB of pasted criteria with
# file paths + a summary sentence). When the raw prompt carries the callers'
# labeled plan block, cut it out verbatim and override the extraction — but
# only when the recovered block is LONGER, so a good extraction is never
# degraded. The adversary lane fails closed on whatever goes missing here.
_prompt = state.get("initial_prompt")
if isinstance(_prompt, str) and _prompt:
_m = re.search(
r"Plan / acceptance criteria:\s*(.+?)"
r"(?=\n\s*(?:Local-run recipe|Forced lanes|Rigor:|Security posture:|Extra context)\b|\Z)",
_prompt,
re.S | re.I,
)
if _m:
_block = _m.group(1).strip()
if len(_block) > len((state.get("plan_context") or "").strip()):
out["plan_context"] = _block
# retry_gate measures its wall-clock budget from this stamp; it is set
# before the git work so a failed diff still starts the clock.
if not state.get("gauntlet_started_at"):
@@ -93,13 +126,18 @@ try:
),
"docs_only": bool(files)
and all(re.search(r"\.(md|rst|txt|adoc)$", f, re.I) for f in files),
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})",
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})"
+ (f" — spec normalized from {spec_raw!r}" if spec != spec_raw else ""),
}
)
except Exception as e: # noqa: BLE001 — degraded signals must not sink the gauntlet
what = f"diff spec {spec!r}" + (
f", normalized from {spec_raw!r}" if spec != spec_raw else ""
)
out["signals_error"] = (
f"NOTE: diff signals could not be computed ({e}); "
"lane selection falls back to caller context and forced lanes."
f"NOTE: diff signals could not be computed ({what}: {e}); surface UNKNOWN — "
"lane selection degrades WIDER (security lane added; probe eligible when a "
"local-run recipe exists), plus caller context and forced lanes."
)
out["signals_summary"] = "signals unavailable"
@@ -360,6 +360,11 @@ def main():
+ [f"| {lane} | {status} | {detail} |" for lane, (status, detail) in rows.items()]
)
md += "\n\n## Lane selection\n" + (state.get("lanes_summary") or "(none recorded)")
_notes = state.get("retry_notes")
if isinstance(_notes, list):
_shown = [n for n in _notes if isinstance(n, str)][:8]
if _shown:
md += "\n\n## Pipeline notes\n" + "\n".join(f"- {n}" for n in _shown)
if blockers:
md += "\n\n## Blockers\n" + "\n".join(f"- {b}" for b in blockers)
if attention:
+31 -3
View File
@@ -1,6 +1,6 @@
name: sisyphus
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline
version: 3.13.0
version: 3.17.0
agent_session: temp
auto_continue: true
@@ -11,6 +11,7 @@ can_spawn_agents: true
spawnable_agents:
- explore
- librarian
- deep-research
- coder
- oracle
- code-reviewer
@@ -157,7 +158,8 @@ instructions: |
| Agent | Use For | Characteristics |
|-------|---------|-----------------|
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 5–15+. |
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. |
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. Default depth = quick-lookup budgets; for a BROAD topic (design research, best-practice surveys, architecture comparisons) pass `--variables {"research_depth": "deep"}` (~3× search/fetch budgets). Still prefer several narrow librarians over one giant one — deep is for questions that are inherently broad, not a substitute for fan-out. |
| `deep-research` | Open-ended investigation that must CHURN until solid — plans sub-questions, fans out a deep librarian per question, vets source credibility, runs a critique→re-research loop | Spawn with `--variables {"autonomy": "autonomous"}` — MANDATORY when spawned as a subagent (the supervised default blocks on a human approval gate). Raise `reflexion_budget` (e.g. "6", default 2) to let it churn longer. Expect hours, not minutes; one per investigation — its internal map is the fan-out. |
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
@@ -314,6 +316,9 @@ instructions: |
Diff: <git range like main...HEAD, or 'worktree' for uncommitted changes>
Rigor: <rigor> Security posture: <posture>
Plan / acceptance criteria: <pasted verbatim — enables the adversary lane; omit when none>
Prior review decisions: <on re-review rounds ONLY: earlier rounds' resolved/rejected
findings verbatim (rejected-finding log lines, round-comment reconciliations) — settled
decisions the reviewers must not re-litigate; omit on the first round>
Local-run recipe / usage suites: <from the plan — enables the probe lane; omit when none>"
--variables {"verification_commands": "[\"cargo test --all\", \"cargo clippy -- -D warnings\"]"}
<— the cargo commands above are only an EXAMPLE: replace them with THIS project's exact build/test/lint commands; never guess, and pass "[]" when none apply>
@@ -322,6 +327,12 @@ instructions: |
Verification commands go in `--variables` (a JSON-array string), never in the prompt: the prompt
also carries pasted plan text from the repo under review, and whatever is declared here is
executed with a shell — a trust boundary the prose must not cross.
Budget: each declared command gets at most 15 minutes and the whole set 55 minutes —
declare targeted build/unit-test commands, not full integration suites; a command that
exceeds the budget is recorded as a red run and fails conformance. Never declare commands
that boot shared stacks or services (docker compose projects, dev servers, fixed ports) —
the probe lane owns end-to-end runs and WILL collide with them in parallel; keep
verification to generate/lint/build/unit/integration commands.
It computes diff signals deterministically, selects lanes (code-review always; adversary when a
spec/plan is present; security on attack-surface signals; probe on consumer-facing surface with a
@@ -566,7 +577,7 @@ instructions: |
When you write or modify files yourself (rather than delegating to coder):
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment.
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment. In every register, a comment must EARN ITS KEEP — if the code already tells the reader, don't add it.
- **Calibrate logging before writing.** When the change touches boundaries, error paths, jobs, or state transitions, load `logging-discipline` and note the repo's logging register (logger, message style, payload vs IDs, level semantics) from the same sibling reads; match it. No discernible convention → its best-judgment defaults. Never leave a new error path silently swallowed, and never delete existing log lines as drive-by cleanup.
- **For editing an existing file**, prefer `fs_patch`. It's a surgical edit that preserves unchanged content. Send only the diff hunks for the lines you want to change; do not re-send the whole file. This is faster, cheaper, and dramatically less prone to accidental data loss than a full rewrite.
- **For writing a NEW file or doing a COMPLETE rewrite**, use `fs_write`. Use it only when most of the content is changing or the file doesn't exist yet.
@@ -691,6 +702,23 @@ instructions: |
and what changed (including any plan amendment). Zero autonomous decisions → omit the section.
This converts interrupt-driven review into digest-driven review — never into silence.
**The PR review log (MANDATORY when you author a PR).** The PR DESCRIPTION is a stable
document: what the PR does, how to verify it, and the required digest sections (autonomous
decisions, skipped reviews, observability). Every review round's evidence — the gauntlet
lane table and verdicts, the gates you ran, rulings recorded that round, and what changed
since the previous round — is posted as a NEW PR comment (`gh pr comment`) titled
`## Review round N @ <full head SHA>`. NEVER record round evidence by rewriting the
description: in-place edits destroy the review history and hide which commits each round
actually reviewed. Comments are the append-only review log; the SHA in the title names
exactly what was reviewed.
The same discipline covers EVERY later push to a PR you (or your run) opened — addressing
human review comments, a reviewer bot's findings, CI failures, or any other PR event: post
a NEW top-level PR comment titled `## Update @ <full head SHA>` mapping each addressed
comment/finding to the commit that addresses it (one line each), plus anything
deliberately NOT addressed and why. NEVER append update summaries to the description.
Edit the description ONLY when the PR's actual scope or behavior changed enough that it
would otherwise misdescribe the PR — it stays a description, never a changelog.
**Close the loop.** When a decision DID reach the user (step 4), suggest in your final report
that they record the resolution with `coyote --macro record-ruling` so that class of decision
never asks again.
+38
View File
@@ -149,6 +149,18 @@ mcp_tools: null # Per-server MCP tool allowlists (glob patterns
# - list_*
# slack: []
# ---- Hooks ----
# Fire-and-forget external commands that run when lifecycle events fire. Each event name
# maps to a list of named hooks; every listed hook's command runs when that event fires.
# Hooks never block coyote and never change its output or exit code.
# See the [Hooks documentation](https://github.com/Dark-Alex-17/coyote/wiki/Hooks) for more details
hooks: {} # Event -> list of named hook commands (default: none)
# Example:
# hooks:
# turn.completed:
# - name: notify
# command: ./hooks/notify.sh
# ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically
@@ -174,6 +186,7 @@ save_session: null # Controls the persistence of the session. If t
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
compression_model: null # Model to use for session compression (e.g. 'openai:gpt-4o-mini'); defaults to the session's current model
compression_safety_valve: 0.8 # Force compression when the last real request's prompt size (or the local estimate) reaches this fraction of the model's max_input_tokens, even if compression_threshold would not fire (0 = disabled)
summarization_prompt: null # The text prompt used for creating a concise summary of session messages. If null, uses built-in default
summary_context_prompt: null # The text prompt used for including the summary of the entire session as context to the model. If null, uses built-in default
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
@@ -264,6 +277,7 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons
# All clients have the following configuration:
# - type: xxxx
# name: xxxx # Only use it to distinguish clients with the same client type. Optional
# extend_models: true # Merge the models below on top of the catalog models for this client instead of replacing them. Optional, default false
# models:
# - name: xxxx # Chat model
# max_input_tokens: 100000
@@ -290,4 +304,28 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons
#
# The claude, bedrock, and vertexai clients also support:
# prompt_cache: true # Enable Anthropic prompt caching (default: true)
#
# The openai and openai-compatible clients also support:
# wire_api: responses # Which OpenAI wire protocol to use; Possible values: responses and chat
# # Defaults when unset: stock openai (no api_base) -> responses;
# # openai with a custom api_base -> chat; openai-compatible -> chat.
# # Codex OAuth always uses responses (`wire_api: chat` there is a config error)
#
# Patches are selected per wire: `chat_completions` patches only ever touch chat bodies. To patch
# requests on the responses wire, use the `responses` key on the client `patch` block (same
# <regex> -> url/body/headers shape as chat_completions):
# patch:
# responses:
# <regex>:
# body:
# <json>
# Model patches likewise: the top-level `patch` keys apply to the chat wire only, and a
# `responses:` sub-key applies to the responses wire only:
# models:
# - name: xxxx
# patch:
# responses:
# body:
# <json>
# Env override per wire: COYOTE_PATCH_<client>_RESPONSES / COYOTE_PATCH_<client>_CHAT_COMPLETIONS
__CLIENTS_BLOCK__
+66 -10
View File
@@ -23,10 +23,16 @@ set -e
# - perplexity:*
# - ernie:*
# - claude:* (Anthropic native web_search server tool)
# - openai:gpt-4o-search-preview (and -mini-; requires an api-key openai
# client — the codex OAuth path uses the
# Responses API where this parameter
# does not exist)
# - openai:gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4*
# OpenAI serves web search through two different mechanisms depending on
# the wire API the client resolves (the script cannot pick the wire):
# Chat Completions serves it via `web_search_options` on the chat-only
# *-search-preview models, while the Responses API serves it via its
# native `web_search` tool on regular models. Both request patches are
# exported (request patching is wire-aware, so the resolved wire applies
# exactly one) and openai searches run as an attempt chain: the
# chat-completions mechanism first, then the Responses mechanism, and
# the search fails only when both attempts fail.
# @env WEB_SEARCH_USE_CURRENT_MODEL=true Prefer the running session's model when it natively supports web search.
# @env LLM_OUTPUT=/dev/stdout The output path
@@ -34,7 +40,7 @@ set -e
supports_native_search() {
case "$1" in
gemini:* | vertexai:gemini-* | perplexity:* | ernie:* | claude:*) return 0 ;;
openai:gpt-4o*search-preview*) return 0 ;;
openai:gpt-4o* | openai:gpt-4.1* | openai:gpt-5* | openai:o3* | openai:o4*) return 0 ;;
*) return 1 ;;
esac
}
@@ -46,7 +52,8 @@ export_search_patch() {
COYOTE_PATCH_VERTEXAI_CHAT_COMPLETIONS \
COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS \
COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS \
COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS
COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS \
COYOTE_PATCH_OPENAI_RESPONSES
case "${1%%:*}" in
gemini)
@@ -65,18 +72,67 @@ export_search_patch() {
export COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS='{".*":{"body":{"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}}}'
;;
openai)
# Chat Completions native search exists only on the search-preview
# models; the regex scopes the patch so other OpenAI models run
# unpatched instead of erroring on an unsupported parameter.
# Both patches are exported; request patching is wire-aware, so the
# client's resolved wire applies exactly one of them. Each regex
# scopes its patch to the models that actually support that
# mechanism, so other OpenAI models run unpatched instead of
# erroring on an unsupported parameter:
# - Chat Completions native search exists only on the chat-only
# *-search-preview models (`web_search_options`).
# - The Responses API serves search through its native
# `web_search` tool, which only some model families support.
export COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS='{"gpt-4o.*search-preview.*":{"body":{"web_search_options":{}}}}'
export COYOTE_PATCH_OPENAI_RESPONSES='{"(gpt-4o|gpt-4\\.1|gpt-5|o3|o4).*":{"body":{"tools":[{"type":"web_search"}]}}}'
;;
esac
}
# Maps an openai model to the one used for the chat-completions attempt:
# Chat Completions web search is served only by the *-search-preview models.
openai_chat_search_model() {
case "${1#openai:}" in
gpt-4o*search-preview*) echo "$1" ;;
gpt-4o-mini*) echo "openai:gpt-4o-mini-search-preview" ;;
*) echo "openai:gpt-4o-search-preview" ;;
esac
}
# Maps an openai model to the one used for the Responses attempt: the
# *-search-preview models are chat-only and do not exist on /v1/responses,
# so they map back to their base models.
openai_responses_search_model() {
case "${1#openai:}" in
gpt-4o-mini-search-preview*) echo "openai:gpt-4o-mini" ;;
gpt-4o*search-preview*) echo "openai:gpt-4o" ;;
*) echo "$1" ;;
esac
}
# OpenAI attempt chain: try the chat-completions mechanism first (works when
# the client's wire is chat); if that fails — e.g. the client resolved the
# responses wire, where the search-preview models do not exist — retry with
# the Responses mechanism on a responses-capable model. Fails when both
# attempts fail.
# shellcheck disable=SC2154
run_openai_search() {
if coyote -m "$(openai_chat_search_model "$1")" "$argc_query" >>"$LLM_OUTPUT"; then
return 0
fi
echo "web_search: openai chat-completions attempt failed; retrying via the Responses API" >&2
coyote -m "$(openai_responses_search_model "$1")" "$argc_query" >>"$LLM_OUTPUT"
}
# shellcheck disable=SC2154
run_search() {
export_search_patch "$1"
coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT"
case "$1" in
openai:*)
run_openai_search "$1"
;;
*)
coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT"
;;
esac
}
main() {
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Event-logging hook: appends a timestamped snapshot of every COYOTE_* env var
# to a log file, so you can see exactly which events fire and what context
# each one carries.
#
# Env vars read:
# COYOTE_HOOK_LOG log file to append to
# (default: ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log)
# COYOTE_* everything coyote sets for the event is dumped verbatim
#
# Secrets (COYOTE_SECRET_*) are excluded from the snapshot; widening the grep
# filter below opts into logging them.
#
# Wire it up under `hooks:` in your config.yaml (any event works):
# hooks:
# tool.completed:
# - name: log-events
# command: ./hooks/log-events.sh
#
# A relative command path like ./hooks/log-events.sh resolves against the
# coyote config directory for global hooks, so this works with the default
# hooks dir.
#
# See https://github.com/Dark-Alex-17/coyote/wiki/Hooks for the full list of
# events and env vars. Hooks are fire-and-forget: this script never fails
# loudly, and coyote ignores its exit code either way.
set -u
log_file="${COYOTE_HOOK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log}"
# COYOTE_HOOK_LOG may point into a shared directory: refuse to append through
# a symlink someone else planted there.
[ -L "$log_file" ] && exit 0
umask 077
mkdir -p "$(dirname "$log_file")" 2> /dev/null || true
{
echo "=== $(date -u '+%Y-%m-%dT%H:%M:%SZ') ${COYOTE_EVENT:-unknown}"
env | grep '^COYOTE_' | grep -v '^COYOTE_SECRET_' | sort
echo
} >> "$log_file" 2> /dev/null || true
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Desktop-notification hook: posts a notification describing the event that
# fired, falling back to a message on the controlling terminal when no
# notifier is present.
#
# Env vars read (set by coyote when the hook fires):
# COYOTE_EVENT dotted event name, e.g. turn.completed
# COYOTE_AGENT_NAME agent name, when the event carries one
# COYOTE_TOOL_NAME tool name, when the event carries one
#
# Wire it up under `hooks:` in your config.yaml:
# hooks:
# turn.completed:
# - name: notify
# command: ./hooks/notify.sh
#
# A relative command path like ./hooks/notify.sh resolves against the coyote
# config directory for global hooks, so this works with the default hooks dir.
#
# See https://github.com/Dark-Alex-17/coyote/wiki/Hooks for the full list of
# events and env vars. Hooks are fire-and-forget: this script never fails
# loudly, and coyote ignores its exit code either way.
set -u
title="coyote"
body="${COYOTE_EVENT:-event}"
[ -n "${COYOTE_AGENT_NAME:-}" ] && body="$body agent=$COYOTE_AGENT_NAME"
[ -n "${COYOTE_TOOL_NAME:-}" ] && body="$body tool=$COYOTE_TOOL_NAME"
if command -v notify-send > /dev/null 2>&1; then
notify-send "$title" "$body" 2> /dev/null || true
elif command -v osascript > /dev/null 2>&1; then
# $body carries model-influenced values (e.g. tool names); strip quotes and
# backslashes so it cannot escape the AppleScript string literal.
safe_body=${body//[\"\\]/}
osascript -e "display notification \"$safe_body\" with title \"$title\"" 2> /dev/null || true
else
# coyote discards hook stdout, so aim the fallback at the controlling
# terminal when one exists; plain stdout keeps direct/manual runs working.
# $body carries model-influenced values: strip control bytes (including
# ESC, so ANSI/OSC sequences cannot drive the terminal) before echoing.
safe_line="$(printf '%s' "[$title] $body" | tr -d '\000-\010\013\014\016-\037\177')"
{ echo "$safe_line" > /dev/tty; } 2> /dev/null || echo "$safe_line"
fi
+1 -1
View File
@@ -13,7 +13,7 @@ description: >
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
sandbox:
image: 'darkalex17/coyote:v0.10.2'
image: 'darkalex17/coyote:v0.10.3'
entrypoint: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
permissions:
+24
View File
@@ -89,6 +89,15 @@ These are review findings that only surface in a diff context, not in a whole-fi
finding (dead weight or a forgotten wiring step). Used in SOME applicable in-diff sites but
not others = a finding naming the sites that didn't adopt it — half-adopted artifacts are how
two mechanisms for the same job end up coexisting forever.
- **"Ignored / redundant request field" claims need a wire-validation check first** — before
calling a request/message field ignored, redundant, or dead on some code path because the
handler never reads it there, `fs_grep` the boundary contract for that field: proto validation
rules (`(validate.rules)`, `buf.validate`), OpenAPI/JSON-schema `required`, struct-tag
`required`/`binding`/serde attributes, or a hand-written validator/interceptor. A field the
validator MANDATES is load-bearing at the boundary even when the handler ignores it — the
honest finding (if any) is "validator requires `<field>` on the `<path>` path that never reads
it (`path:line`); relax with `ignore_empty`/`oneof` or document why", never "`<field>` is
ignored". No validation found ⇒ the ignored-field finding stands, citing the grep you ran.
- **Version-literal consistency and freshness** — when the diff bumps a version (image tag,
tool version, dependency pin), `fs_grep` the repo for other occurrences of the OLD version
string (compose files, workflows, docs, sibling Dockerfiles) — stragglers are findings. For
@@ -143,6 +152,21 @@ Named adequacy anti-patterns — each is a finding even when coverage looks gree
- **Leaky fixture** — an integration test against a shared/persistent database inserts fixture
rows directly with no paired cleanup (deferred delete/teardown/transaction rollback). The
test passes today and leaves a primary-key landmine for the next run.
- **Struct-typed args against an inferred schema** — when a struct drives an inferred wire schema
(MCP go-sdk, OpenAPI/JSON-schema generators, serde-derived schemas), every field without
`omitempty`/pointer/`Option` becomes schema-REQUIRED. Cross-check the optionality the doc
strings promise ("X as an alternative to Y") against the tags — a documented-optional field
without the omission marker is a live validation bug, not a style nit. And tests that build the
request from the typed struct serialize EVERY field (zero values included), so they can never
exercise the required-ness check: demand raw-JSON / map-typed tests that OMIT each
documented-optional field.
- **Relaxed call count with a permissive matcher** — a mock expectation whose COUNT is relaxed
(`AnyTimes()`, `MinTimes(1)`, `Maybe()`, "called at least once") leaves the ARGUMENT matcher as
the only thing asserted; pair it with `gomock.Any()` / `mock.Anything` / `mock.ANY` /
`expect.anything()` / an ignored request body and the expectation proves nothing — a call with
the wrong ID, wrong quantity, or wrong principal satisfies it. Flag every relaxed-count
expectation whose matcher is permissive; the fix pins the load-bearing fields in the matcher
(or exact count + captured args).
## 3. Clarity
+1 -1
View File
@@ -1,7 +1,7 @@
---
description: Calibrate comment density and style to the repository's existing conventions before writing code. Detects the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you already read for pattern matching, or from a declared policy in workspace instructions, then dictates when a comment is warranted. Default when signal is weak - write NO comment. Complements ai-slop-remover (which bans comments that restate code in every register).
---
You are about to write or modify code. LLMs systematically over-comment — narrating every block, restating signatures, banner-ing sections — and that default is wrong in most repositories. Before writing, determine the repo's **comment register** and match it, exactly the way you already match imports, naming, and error handling.
You are about to write or modify code. LLMs systematically over-comment — narrating every block, restating signatures, banner-ing sections — and that default is wrong in most repositories. Before writing, determine the repo's **comment register** and match it, exactly the way you already match imports, naming, and error handling. Across every register, one test governs each comment you write: it must **earn its keep**. If the code already tells the reader, the comment doesn't get written — don't add comments you don't need; make the code tell them instead.
## Step 0: Check for a declared policy first
+10
View File
@@ -56,6 +56,16 @@ If no posture is given, assume `standard` and say so in the report.
8. **Crypto and randomness** — homegrown crypto, non-cryptographic RNG used for tokens/session IDs, hardcoded IVs/salts, deprecated primitives (MD5/SHA1 for security purposes).
9. **Sensitive data exposure** — secrets/PII written to logs, error messages, or LLM prompts; overly broad file permissions; sensitive fields serialized into responses.
10. **Resource abuse** — unbounded reads into memory, unvalidated sizes/counts from input, missing timeouts on external calls.
11. **Error-text passthrough on a NEW surface** — a new mount, gateway, adapter, or endpoint that
writes an upstream/middleware error's text (`err.Error()`, connect/gRPC `Message()`, a wrapped
library message) into its own response body. `fs_grep` the new surface's response writers for
interpolated error strings. Default expectation: fixed strings per error class; passthrough
needs POSITIVE justification (the upstream text is a documented, stable, caller-facing
contract). "The same principal already sees this text on another surface" is NOT a clearance
— upstream strings are not a stable contract, grow internal detail over time (verifier
internals, allow-list names, hostnames), and the new surface may acquire callers with
different trust. Medium by default; High when the passed-through source is an authorizer,
token verifier, or anything that names internal identifiers.
## Ground-truth verification (verify, don't pattern-match)
+29
View File
@@ -206,6 +206,18 @@ enabled_macros: null # Which macros are invocable by default (no ro
# Example (comma-separated form):
# enabled_macros: generate-commit-message,review
# ---- Hooks ----
# Fire-and-forget external commands that run when lifecycle events fire. Each event name
# maps to a list of named hooks; every listed hook's command runs when that event fires.
# Hooks never block coyote and never change its output or exit code.
# See the [Hooks documentation](https://github.com/Dark-Alex-17/coyote/wiki/Hooks) for more details
hooks: {} # Event -> list of named hook commands (default: none)
# Example:
# hooks:
# turn.completed:
# - name: notify
# command: ./hooks/notify.sh
# ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically
@@ -338,6 +350,9 @@ clients:
api_base: https://api.openai.com/v1 # Optional
api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
organization_id: org-xxx # Optional
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
# Supports a custom `oauth:` block like the claude client below. `api_base` is REQUIRED with it
# For any platform compatible with OpenAI's API
- type: openai-compatible
@@ -385,6 +400,20 @@ clients:
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
prompt_cache: true # Optional - enable Anthropic prompt caching (default: true)
# Optional: custom OAuth endpoints (e.g. a private gateway). Requires `auth: oauth` above.
# With `auth: oauth` and no `oauth:` block you get the stock Claude Pro/Max provider; adding
# `oauth:` switches to generic OAuth against your endpoints (the Claude-Code system-prefix
# and anthropic-beta header are not sent). An `oauth:` block makes `api_base` REQUIRED —
# Coyote refuses to fall back to api.anthropic.com with a gateway token.
# auth: oauth
# api_base: https://gateway.example/anthropic/v1
# oauth:
# client_id: '{{GATEWAY_CLIENT_ID}}'
# device_authorization_url: https://auth.gateway.example/oauth/device_authorization
# token_url: https://auth.gateway.example/oauth/token
# flow: device_code
# scopes: [claude.chat]
# resource: https://gateway.example/v1
# See https://docs.mistral.ai/
- type: openai-compatible
+1331 -541
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -51,6 +51,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
- match: "o3"
system_prompt_prefix: Formatting re-enabled
patch:
@@ -58,6 +62,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
- match: "o3-mini"
system_prompt_prefix: Formatting re-enabled
patch:
@@ -65,6 +73,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
models:
- name: text-embedding-3-large
type: embedding
@@ -94,6 +106,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: o3-high
real_name: o3
max_input_tokens: 200000
@@ -108,6 +126,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: o3-mini-high
max_output_tokens: 100000
real_name: o3-mini
@@ -123,6 +147,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: chat-latest
max_input_tokens: 400000
max_output_tokens: 128000
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Entrypoint for the coyote Docker image. Runs under tini:
# ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/coyote-entrypoint"]
#
# tini (PID 1) reaps orphaned processes and forwards signals; coyote is not an
# init and does neither.
case "$1" in
sh | bash | /*) exec "$@" ;;
esac
exec coyote "$@"
+135 -18
View File
@@ -1,7 +1,9 @@
use crate::client::{ModelType, list_models};
use crate::config::paths;
use crate::config::{AppConfig, Config, list_agents_with_descriptions, list_sessions};
use crate::utils::list_file_names;
use crate::config::{
AppConfig, Config, labeled_session_names, list_agents_with_descriptions, sanitize_display_text,
session_scope_dirs,
};
use crate::vault::Vault;
use clap_complete::{CompletionCandidate, Shell, generate};
use clap_complete_nushell::Nushell;
@@ -51,12 +53,12 @@ pub(super) fn model_completer(current: &OsStr) -> Vec<CompletionCandidate> {
fn load_app_config_for_completion() -> anyhow::Result<AppConfig> {
let h = tokio::runtime::Handle::try_current().ok();
let cfg = match h {
Some(handle) => {
tokio::task::block_in_place(|| handle.block_on(Config::load_with_interpolation(true)))?
}
Some(handle) => tokio::task::block_in_place(|| {
handle.block_on(Config::load_with_interpolation(true, true))
})?,
None => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(Config::load_with_interpolation(true))?
rt.block_on(Config::load_with_interpolation(true, true))?
}
};
AppConfig::from_config(cfg)
@@ -115,7 +117,11 @@ pub(super) fn bundle_completer(current: &OsStr) -> Vec<CompletionCandidate> {
}
fn extract_agent_from_args() -> Option<String> {
let args: Vec<String> = env::args().collect();
extract_agent_from_args_in(env::args())
}
fn extract_agent_from_args_in<I: IntoIterator<Item = String>>(args: I) -> Option<String> {
let args: Vec<String> = args.into_iter().collect();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
@@ -134,19 +140,21 @@ fn extract_agent_from_args() -> Option<String> {
}
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
session_candidates(
&current.to_string_lossy(),
extract_agent_from_args().as_deref(),
)
}
let sessions = if let Some(agent_name) = extract_agent_from_args() {
let sessions_dir = paths::agent_data_dir(&agent_name).join("sessions");
list_file_names(sessions_dir, ".yaml")
} else {
list_sessions()
};
sessions
fn session_candidates(cur: &str, agent: Option<&str>) -> Vec<CompletionCandidate> {
let (workspace_dir, global_dir) = session_scope_dirs(agent);
labeled_session_names(&workspace_dir, &global_dir)
.into_iter()
.filter(|s| s.starts_with(&*cur))
.map(CompletionCandidate::new)
.filter(|(name, _)| name.starts_with(cur))
.map(|(name, scope)| {
CompletionCandidate::new(sanitize_display_text(&name))
.help(Some(scope.to_string().into()))
})
.collect()
}
@@ -194,3 +202,112 @@ pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
Err(_) => vec![],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::default_sessions_dir;
use crate::testing::{EnvVarGuard, TestConfigDirGuard};
use serial_test::serial;
use std::path::Path;
fn seed(dir: &Path, name: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(format!("{name}.yaml")), "model: x\n").unwrap();
}
fn labeled(candidates: Vec<CompletionCandidate>) -> Vec<(String, Option<String>)> {
candidates
.into_iter()
.map(|c| {
(
c.get_value().to_string_lossy().into_owned(),
c.get_help().map(|h| h.to_string()),
)
})
.collect()
}
#[test]
#[serial]
fn session_completer_labels_scope_and_prefers_workspace() {
let _guard = TestConfigDirGuard::new("cli-session-completer");
seed(&paths::workspace_sessions_dir(), "both");
seed(&default_sessions_dir(), "both");
seed(&default_sessions_dir(), "only-global");
seed(&paths::workspace_sessions_dir(), "only-ws");
assert_eq!(
labeled(session_completer(OsStr::new(""))),
vec![
("both".to_string(), Some("workspace".to_string())),
("only-ws".to_string(), Some("workspace".to_string())),
("only-global".to_string(), Some("global".to_string())),
]
);
assert_eq!(
labeled(session_completer(OsStr::new("on"))),
vec![
("only-ws".to_string(), Some("workspace".to_string())),
("only-global".to_string(), Some("global".to_string())),
]
);
}
#[test]
#[serial]
fn session_candidates_use_the_agent_scope_dirs() {
let _guard = TestConfigDirGuard::new("cli-agent-session-completer");
let _agent_data_dir = EnvVarGuard::unset("A_DATA_DIR");
seed(&paths::workspace_agent_sessions_dir("a"), "both");
seed(&paths::agent_sessions_dir("a"), "both");
seed(&paths::agent_sessions_dir("a"), "agent-global");
seed(&default_sessions_dir(), "plain-global");
assert_eq!(
labeled(session_candidates("", Some("a"))),
vec![
("both".to_string(), Some("workspace".to_string())),
("agent-global".to_string(), Some("global".to_string())),
]
);
assert_eq!(
labeled(session_candidates("", None)),
vec![("plain-global".to_string(), Some("global".to_string()))]
);
}
#[test]
fn extract_agent_from_args_in_reads_both_flag_forms() {
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(str::to_string).collect()
}
assert_eq!(
extract_agent_from_args_in(args("coyote --agent a --session x")),
Some("a".to_string())
);
assert_eq!(
extract_agent_from_args_in(args("coyote -a b")),
Some("b".to_string())
);
assert_eq!(
extract_agent_from_args_in(args("coyote --agent=c")),
Some("c".to_string())
);
assert_eq!(extract_agent_from_args_in(args("coyote --agent")), None);
assert_eq!(extract_agent_from_args_in(args("coyote --session x")), None);
}
#[test]
#[serial]
fn config_dir_guard_clears_and_restores_the_sessions_dir_override() {
let key = crate::utils::get_env_name("sessions_dir");
let junk = std::path::PathBuf::from("/nonexistent/junk-sessions");
let _outer = EnvVarGuard::set(&key, &junk);
{
let guard = TestConfigDirGuard::new("cli-sessions-dir-guard");
assert!(default_sessions_dir().starts_with(&guard.path));
}
assert_eq!(default_sessions_dir(), junk);
}
}
+10
View File
@@ -609,6 +609,10 @@ mod tests {
parse(&["--install-builtins", "mcp_config"]).install_builtins,
Some(AssetCategory::McpConfig)
);
assert_eq!(
parse(&["--install-builtins", "hooks"]).install_builtins,
Some(AssetCategory::Hooks)
);
}
#[test]
@@ -620,12 +624,14 @@ mod tests {
assert!(category.get_name_and_aliases().any(|n| n == "mcp_config"));
assert!(AssetCategory::NAMES.contains(&"mcp-config"));
assert!(!AssetCategory::NAMES.contains(&"mcp_config"));
assert!(AssetCategory::NAMES.contains(&"hooks"));
let filter = InstallFilter::McpConfig.to_possible_value().unwrap();
assert_eq!(filter.get_name(), "mcp-config");
assert!(filter.get_name_and_aliases().any(|n| n == "mcp_config"));
assert!(InstallFilter::NAMES.contains(&"mcp-config"));
assert!(!InstallFilter::NAMES.contains(&"mcp_config"));
assert!(InstallFilter::NAMES.contains(&"hooks"));
}
#[test]
@@ -685,6 +691,10 @@ mod tests {
.filter,
Some(InstallFilter::McpConfig)
);
assert_eq!(
parse(&["--install", "https://github.com/x/y", "--filter", "hooks"]).filter,
Some(InstallFilter::Hooks)
);
}
#[test]
+3 -1
View File
@@ -4,13 +4,15 @@ use super::*;
use anyhow::Result;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AzureOpenAIConfig {
pub name: Option<String>,
pub api_base: Option<String>,
pub api_key: Option<String>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
+146 -15
View File
@@ -16,7 +16,7 @@ use reqwest::{Client as ReqwestClient, Method, RequestBuilder};
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct BedrockConfig {
pub name: Option<String>,
pub access_key_id: Option<String>,
@@ -25,6 +25,8 @@ pub struct BedrockConfig {
pub session_token: Option<String>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
@@ -73,6 +75,7 @@ impl BedrockClient {
url: _,
headers,
body,
..
} = request_data;
let builder = aws_fetch(
@@ -126,6 +129,7 @@ impl BedrockClient {
url: _,
headers,
body,
..
} = request_data;
let builder = aws_fetch(
@@ -421,13 +425,16 @@ fn build_chat_completions_body(
tool_results,
text,
sequence,
round_starts,
}) => {
if !sequence {
let mut assistant_parts = vec![];
let mut user_parts = vec![];
for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking {
assistant_parts.push(converse_reasoning_block(block));
if let Some(part) = converse_reasoning_block(block) {
assistant_parts.push(part);
}
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
@@ -468,11 +475,15 @@ fn build_chat_completions_body(
}
} else {
// One pair per round: Claude can reuse tool_use IDs across API calls.
// A round boundary is detected by the presence of round text, but
// rounds where the model emitted only tool calls (no narration)
// carry no text marker. As a backstop, also split whenever a
// toolUseId would repeat within the current assistant message.
// Converse rejects duplicate toolUseIds in a single message.
// Round boundaries are recorded in `round_starts` at merge time.
// Splitting per round also keeps the serialized history append-only
// across tool-loop iterations, so prompt caching keeps hitting on
// earlier rounds instead of rewriting them every request.
// Sessions recorded before round tracking carry no `round_starts`;
// for those, fall back to the presence of round text, plus an ID
// backstop: split whenever a toolUseId would repeat within the
// current assistant message. Converse rejects duplicate toolUseIds
// in a single message.
let mut messages = vec![];
let mut assistant_parts: Vec<Value> = vec![];
let mut user_parts: Vec<Value> = vec![];
@@ -483,7 +494,11 @@ fn build_chat_completions_body(
.id
.as_deref()
.is_some_and(|id| chunk_ids.contains(id));
if index > 0 && (tool_result.text.is_some() || id_collision) {
if index > 0
&& (round_starts.contains(&index)
|| tool_result.text.is_some()
|| id_collision)
{
messages.push(
json!({ "role": "assistant", "content": assistant_parts }),
);
@@ -496,7 +511,9 @@ fn build_chat_completions_body(
chunk_ids.insert(id);
}
for block in &tool_result.thinking {
assistant_parts.push(converse_reasoning_block(block));
if let Some(part) = converse_reasoning_block(block) {
assistant_parts.push(part);
}
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
@@ -661,23 +678,26 @@ fn bedrock_tool_result_content(output: &Value) -> Value {
}
/// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse
/// reasoningContent union so signed blocks replay verbatim.
fn converse_reasoning_block(block: &ThinkingBlock) -> Value {
/// reasoningContent union so signed blocks replay verbatim. OpenAI Responses
/// reasoning items have no Converse encoding, so they are dropped when the
/// session moves to Bedrock.
fn converse_reasoning_block(block: &ThinkingBlock) -> Option<Value> {
match block {
ThinkingBlock::Thinking {
thinking,
signature,
} => json!({
} => Some(json!({
"reasoningContent": {
"reasoningText": {
"text": thinking,
"signature": signature,
}
}
}),
ThinkingBlock::RedactedThinking { data } => json!({
})),
ThinkingBlock::RedactedThinking { data } => Some(json!({
"reasoningContent": { "redactedContent": data }
}),
})),
ThinkingBlock::Reasoning { .. } => None,
}
}
@@ -887,6 +907,7 @@ mod tests {
tool_results,
text: text.to_string(),
sequence,
round_starts: vec![],
}),
),
],
@@ -903,6 +924,29 @@ mod tests {
build_body_with(tool_results, "", true)
}
fn build_body_rounds(tool_results: Vec<ToolResult>, round_starts: Vec<usize>) -> Value {
let data = ChatCompletionsData {
messages: vec![
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
Message::new(
MessageRole::Assistant,
MessageContent::ToolCalls(MessageContentToolCalls {
tool_results,
text: String::new(),
sequence: true,
round_starts,
}),
),
],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
build_chat_completions_body(data, &Model::new("bedrock", "test"), false).unwrap()
}
fn tool_result_content_block(output: Value) -> Value {
let body = build_body(vec![tool_result(output)]);
body["messages"][2]["content"][0]["toolResult"]["content"][0].clone()
@@ -1058,6 +1102,9 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body);
}
/// Legacy sessions recorded before round tracking carry no
/// `round_starts`; without a text marker or an ID collision their
/// textless rounds still serialize merged (the pre-round-tracking shape).
#[test]
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
let body = build_body(vec![
@@ -1072,6 +1119,64 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body);
}
/// Regression: textless tool-loop rounds must split into one
/// assistant/user pair per recorded round so the serialized history stays
/// append-only across iterations and prompt caching keeps hitting on
/// earlier rounds (mirrors the claude client's round_starts handling).
#[test]
fn sequence_splits_on_round_starts_without_text() {
let body = build_body_rounds(
vec![
sequence_tool_result("toolu_A", None),
sequence_tool_result("toolu_B", None),
sequence_tool_result("toolu_C", None),
],
vec![1, 2],
);
let messages = body["messages"].as_array().unwrap();
// user + 3 × (assistant, user) — one pair per round.
assert_eq!(messages.len(), 7, "body: {body}");
for pair in messages[1..].chunks(2) {
assert_eq!(pair[0]["role"], "assistant", "body: {body}");
assert_eq!(pair[0]["content"].as_array().unwrap().len(), 1);
assert_eq!(pair[1]["role"], "user", "body: {body}");
}
assert_unique_tool_use_ids_per_message(&body);
}
/// The serialized history must be append-only across tool-loop
/// iterations: request N+1's messages must start with exactly request N's
/// messages, so the prompt-cache prefix keeps matching.
#[test]
fn round_history_is_append_only_across_iterations() {
let two = build_body_rounds(
vec![
sequence_tool_result("toolu_A", None),
sequence_tool_result("toolu_B", None),
],
vec![1],
);
let three = build_body_rounds(
vec![
sequence_tool_result("toolu_A", None),
sequence_tool_result("toolu_B", None),
sequence_tool_result("toolu_C", None),
],
vec![1, 2],
);
let two_messages = two["messages"].as_array().unwrap();
let three_messages = three["messages"].as_array().unwrap();
assert_eq!(
&three_messages[..two_messages.len()],
&two_messages[..],
"two: {two}, three: {three}"
);
}
#[test]
fn non_sequence_emits_assistant_user_pair() {
let body = build_body_with(
@@ -1185,6 +1290,32 @@ mod tests {
}
}
#[test]
fn skips_openai_reasoning_blocks_in_converse_shape() {
let reasoning = ToolResult {
call: ToolCall::new(
"fs_read".into(),
json!({"path": "x"}),
Some("tool_A".into()),
),
output: json!("ok"),
text: None,
thinking: vec![ThinkingBlock::Reasoning {
id: "rs_1".into(),
summary: json!([{ "type": "summary_text", "text": "thinking" }]),
encrypted_content: Some("enc123".into()),
}],
};
for sequence in [false, true] {
let body = build_body_with(vec![reasoning.clone()], "", sequence);
let content = body["messages"][1]["content"].as_array().unwrap();
assert_eq!(content.len(), 1, "body: {body}");
assert!(content[0].get("toolUse").is_some(), "body: {body}");
}
}
#[test]
fn extract_chat_completions_captures_reasoning_content() {
let data = json!({
+61 -1
View File
@@ -572,7 +572,7 @@ pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 {
kept as f64 / old.len() as f64
}
fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
pub(crate) fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
ModelData {
name: hand.name,
model_type: hand.model_type,
@@ -604,6 +604,23 @@ fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
}
}
pub(crate) fn extend_catalog_models(
catalog: &[ModelData],
overrides: &[ModelData],
) -> Vec<ModelData> {
let mut models = catalog.to_vec();
for override_model in overrides {
match models.iter_mut().find(|model| {
model.name == override_model.name && model.model_type == override_model.model_type
}) {
Some(model) => *model = overlay_hand_owned(override_model.clone(), model.clone()),
None => models.push(override_model.clone()),
}
}
models
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderQuirks {
@@ -1724,4 +1741,47 @@ rules:
);
}
}
fn typed_model(name: &str, model_type: &str) -> ModelData {
ModelData {
name: name.to_string(),
model_type: model_type.to_string(),
..Default::default()
}
}
#[test]
fn extend_catalog_models_merges_collisions_field_wise() {
let mut catalog_model = ModelData::new("m");
catalog_model.max_input_tokens = Some(100_000);
catalog_model.input_price = Some(1.0);
catalog_model.supports_vision = true;
let mut override_model = ModelData::new("m");
override_model.input_price = Some(9.0);
let merged = extend_catalog_models(&[catalog_model], &[override_model]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].input_price, Some(9.0));
assert_eq!(merged[0].max_input_tokens, Some(100_000));
assert!(merged[0].supports_vision);
}
#[test]
fn extend_catalog_models_appends_net_new_after_catalog() {
let catalog = [ModelData::new("a"), ModelData::new("b")];
let merged = extend_catalog_models(&catalog, &[ModelData::new("c")]);
let names: Vec<&str> = merged.iter().map(|model| model.name.as_str()).collect();
assert_eq!(names, ["a", "b", "c"]);
}
#[test]
fn extend_catalog_models_collides_on_name_and_type() {
let mut catalog_model = typed_model("m", "embedding");
catalog_model.default_chunk_size = Some(1500);
let merged = extend_catalog_models(&[catalog_model], &[typed_model("m", "reranker")]);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].model_type, "embedding");
assert_eq!(merged[0].default_chunk_size, Some(1500));
assert_eq!(merged[1].model_type, "reranker");
}
}
+581 -25
View File
@@ -2,13 +2,12 @@ use std::collections::HashSet;
use std::mem;
use super::access_token::get_access_token;
use super::claude_oauth::ClaudeOAuthProvider;
use super::oauth::{self, OAuthProvider};
use super::oauth::{self, OAuthConfig};
use super::*;
use crate::utils::strip_think_tag;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result, anyhow, bail};
use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -16,14 +15,17 @@ use serde_json::{Value, json};
const API_BASE: &str = "https://api.anthropic.com/v1";
const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ClaudeConfig {
pub name: Option<String>,
pub api_key: Option<String>,
pub api_base: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
@@ -71,9 +73,17 @@ async fn prepare_chat_completions(
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<RequestData> {
let api_base = self_
.get_api_base()
.unwrap_or_else(|_| API_BASE.to_string());
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if !uses_oauth && self_.config.oauth.is_some() {
bail!(
"'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.",
self_.name(),
self_.name()
);
}
let api_base = resolve_api_base(self_)?;
let url = format!("{}/messages", api_base.trim_end_matches('/'));
let body = claude_build_chat_completions_body(
@@ -86,24 +96,27 @@ async fn prepare_chat_completions(
request_data.header("anthropic-version", "2023-06-01");
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if uses_oauth {
let provider = ClaudeOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
let client_name = self_.name();
let cc = locate_client_config(self_)?;
let (provider, uses_stock_provider) =
oauth::claude_oauth_provider_for_client(cc, &ALL_PROVIDER_MODELS);
let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
self_.name(),
self_.name()
client_name,
client_name
);
}
let token = get_access_token(self_.name())?;
let token = get_access_token(client_name)?;
request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
inject_oauth_system_prompt(&mut request_data.body);
if uses_stock_provider {
inject_oauth_system_prompt(&mut request_data.body);
}
} else if let Ok(api_key) = self_.get_api_key() {
request_data.header("x-api-key", api_key);
} else {
@@ -117,6 +130,50 @@ async fn prepare_chat_completions(
Ok(request_data)
}
fn resolve_api_base(self_: &ClaudeClient) -> Result<String> {
resolve_api_base_against(self_, &ALL_PROVIDER_MODELS)
}
fn resolve_api_base_against(
self_: &ClaudeClient,
all_provider_models: &[ProviderModels],
) -> Result<String> {
if let Ok(api_base) = self_.get_api_base() {
return Ok(api_base);
}
let uses_config_oauth = self_.config.auth.as_deref() == Some("oauth")
&& match locate_client_config(self_) {
Ok(cc) => oauth::config_oauth_for_client(cc, all_provider_models).is_some(),
// Safe: oauth prepare paths bail on this locate failure before attaching any token.
Err(_) => self_.config.oauth.is_some(),
};
if uses_config_oauth {
bail!(
"'{}' has a config-driven OAuth configuration (an `oauth:` block in the client entry or models catalog) but no `api_base`; refusing to fall back to {} (the oauth token would be sent to the wrong host). Set `api_base` on the '{}' client entry.",
self_.name(),
API_BASE,
self_.name()
);
}
Ok(API_BASE.to_string())
}
fn locate_client_config(self_: &ClaudeClient) -> Result<&ClientConfig> {
let client_name = self_.name();
self_
.app_config()
.clients
.iter()
.find(|cc| {
matches!(
cc,
ClientConfig::ClaudeConfig(c)
if c.name.as_deref().unwrap_or(ClaudeClient::NAME) == client_name
)
})
.ok_or_else(|| anyhow!("Could not locate ClientConfig entry for '{}'", client_name))
}
/// Anthropic requires OAuth-authenticated requests to include a Claude Code
/// system prompt prefix in order to consider a request body as "valid".
///
@@ -346,13 +403,23 @@ pub fn claude_build_chat_completions_body(
tool_results,
text,
sequence,
round_starts,
}) => {
if !sequence {
let mut assistant_parts = vec![];
let mut user_parts = vec![];
for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking {
assistant_parts.push(json!(block));
// Only Anthropic thinking blocks replay here;
// Responses reasoning items (and any future
// variant) are dropped.
if matches!(
block,
ThinkingBlock::Thinking { .. }
| ThinkingBlock::RedactedThinking { .. }
) {
assistant_parts.push(json!(block));
}
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
@@ -392,14 +459,18 @@ pub fn claude_build_chat_completions_body(
}
} else {
// One pair per round: Claude can reuse tool_use IDs across API calls.
// A round boundary is detected by the presence of round text, but
// rounds where the model emitted only tool calls (no narration)
// carry no text marker. As a backstop, also split whenever a
// tool_use ID would repeat within the current assistant message —
// the API rejects duplicate tool_use IDs in a single message.
// Round boundaries are recorded in `round_starts` at merge time.
// Splitting per round also keeps the serialized history append-only
// across tool-loop iterations, so prefix-based prompt caching keeps
// hitting on earlier rounds instead of rewriting them every request.
// Sessions recorded before round tracking carry no `round_starts`;
// for those, fall back to the presence of round text, plus an ID
// backstop: split whenever a tool_use ID would repeat within the
// current assistant message — the API rejects duplicate tool_use
// IDs in a single message.
let mut messages = vec![];
let mut assistant_parts: Vec<serde_json::Value> = vec![];
let mut user_parts: Vec<serde_json::Value> = vec![];
let mut assistant_parts: Vec<Value> = vec![];
let mut user_parts: Vec<Value> = vec![];
let mut chunk_ids: HashSet<&str> = HashSet::new();
for (index, tool_result) in tool_results.iter().enumerate() {
let id_collision = tool_result
@@ -407,7 +478,11 @@ pub fn claude_build_chat_completions_body(
.id
.as_deref()
.is_some_and(|id| chunk_ids.contains(id));
if index > 0 && (tool_result.text.is_some() || id_collision) {
if index > 0
&& (round_starts.contains(&index)
|| tool_result.text.is_some()
|| id_collision)
{
messages.push(
json!({ "role": "assistant", "content": assistant_parts }),
);
@@ -420,7 +495,16 @@ pub fn claude_build_chat_completions_body(
chunk_ids.insert(id);
}
for block in &tool_result.thinking {
assistant_parts.push(json!(block));
// Only Anthropic thinking blocks replay here;
// Responses reasoning items (and any future
// variant) are dropped.
if matches!(
block,
ThinkingBlock::Thinking { .. }
| ThinkingBlock::RedactedThinking { .. }
) {
assistant_parts.push(json!(block));
}
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
@@ -632,7 +716,11 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
#[cfg(test)]
mod tests {
use super::*;
use crate::client::access_token::set_access_token;
use crate::config::AppConfig;
use crate::function::{FunctionDeclaration, ToolCall, ToolResult};
use chrono::Utc;
use std::sync::Arc;
fn tool_result(id: &str, text: Option<&str>) -> ToolResult {
ToolResult {
@@ -658,6 +746,7 @@ mod tests {
tool_results,
text: text.to_string(),
sequence,
round_starts: vec![],
}),
),
],
@@ -675,6 +764,30 @@ mod tests {
build_body_with(tool_results, "", true, prompt_cache)
}
fn build_body_rounds(tool_results: Vec<ToolResult>, round_starts: Vec<usize>) -> Value {
let data = ChatCompletionsData {
messages: vec![
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
Message::new(
MessageRole::Assistant,
MessageContent::ToolCalls(MessageContentToolCalls {
tool_results,
text: String::new(),
sequence: true,
round_starts,
}),
),
],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
claude_build_chat_completions_body(data, &Model::new("claude", "claude-test"), false)
.unwrap()
}
fn multi_turn_data(functions: Option<Vec<FunctionDeclaration>>) -> ChatCompletionsData {
ChatCompletionsData {
messages: vec![
@@ -746,6 +859,63 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body);
}
/// Regression: tool-loop rounds where the model emitted no narration text
/// (the norm with thinking models) must still serialize as one
/// assistant/user pair per round. Collapsing them into one growing pair
/// rewrites the whole accumulated history on every request, which defeats
/// prefix-based prompt caching (cache reads stall at the system prompt).
#[test]
fn sequence_splits_on_round_starts_without_text() {
let body = build_body_rounds(
vec![
tool_result("toolu_A", None),
tool_result("toolu_B", None),
tool_result("toolu_C", None),
],
vec![1, 2],
);
let messages = body["messages"].as_array().unwrap();
// user + 3 × (assistant, user) — one pair per round.
assert_eq!(messages.len(), 7, "body: {body}");
for pair in messages[1..].chunks(2) {
assert_eq!(pair[0]["role"], "assistant", "body: {body}");
assert_eq!(pair[0]["content"].as_array().unwrap().len(), 1);
assert_eq!(pair[1]["role"], "user", "body: {body}");
}
assert_unique_tool_use_ids_per_message(&body);
}
/// The serialized history must be append-only across tool-loop
/// iterations: request N+1's messages must start with exactly request N's
/// messages, so the prompt-cache prefix keeps matching and the cache read
/// grows monotonically instead of re-writing every round each request.
#[test]
fn round_history_is_append_only_across_iterations() {
let two = build_body_rounds(
vec![tool_result("toolu_A", None), tool_result("toolu_B", None)],
vec![1],
);
let three = build_body_rounds(
vec![
tool_result("toolu_A", None),
tool_result("toolu_B", None),
tool_result("toolu_C", None),
],
vec![1, 2],
);
let two_messages = two["messages"].as_array().unwrap();
let three_messages = three["messages"].as_array().unwrap();
assert_eq!(
&three_messages[..two_messages.len()],
&two_messages[..],
"two: {two}, three: {three}"
);
}
#[test]
fn sequence_splits_on_reused_id_in_textless_round() {
let body = build_body(
@@ -763,6 +933,9 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body);
}
/// Legacy sessions recorded before round tracking carry no
/// `round_starts`; without a text marker or an ID collision their
/// textless rounds still serialize merged (the pre-round-tracking shape).
#[test]
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
let body = build_body(
@@ -816,6 +989,48 @@ mod tests {
assert_eq!(messages.len(), 1, "body: {body}");
}
#[test]
fn replays_only_anthropic_thinking_blocks() {
let mixed = ToolResult {
call: ToolCall::new(
"fs_read".into(),
json!({"path": "x"}),
Some("toolu_A".into()),
),
output: json!("ok"),
text: None,
thinking: vec![
ThinkingBlock::Thinking {
thinking: "hmm".into(),
signature: "sig123".into(),
},
ThinkingBlock::RedactedThinking {
data: "b64data".into(),
},
ThinkingBlock::Reasoning {
id: "rs_1".into(),
summary: json!([{ "type": "summary_text", "text": "thinking" }]),
encrypted_content: Some("enc123".into()),
},
],
};
for sequence in [false, true] {
let body = build_body_with(vec![mixed.clone()], "", sequence, false);
let content = body["messages"][1]["content"].as_array().unwrap();
let types: Vec<_> = content
.iter()
.map(|block| block["type"].as_str().unwrap())
.collect();
assert_eq!(
types,
["thinking", "redacted_thinking", "tool_use"],
"body: {body}"
);
}
}
#[test]
fn prompt_cache_places_breakpoints() {
let functions = vec![function_declaration("a"), function_declaration("b")];
@@ -905,4 +1120,345 @@ mod tests {
assert_eq!(usage.cache_creation_input_tokens, Some(100));
assert_eq!(usage.cache_read_input_tokens, Some(200));
}
fn claude_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> ClaudeConfig {
ClaudeConfig {
name: Some(name.into()),
api_base: oauth
.as_ref()
.map(|_| "https://gateway.example/v1".to_string()),
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
..Default::default()
}
}
fn minimal_oauth_config() -> OAuthConfig {
serde_yaml::from_str("client_id: gateway\ntoken_url: https://gateway.example/token")
.unwrap()
}
fn make_client(config: ClaudeConfig, clients: Vec<ClientConfig>) -> ClaudeClient {
ClaudeClient {
app_config: Arc::new(AppConfig {
clients,
..AppConfig::default()
}),
config,
model: Model::new("claude", "claude-test"),
}
}
fn prepare(client: &ClaudeClient) -> Result<RequestData> {
let data = ChatCompletionsData {
messages: vec![Message::new(
MessageRole::User,
MessageContent::Text("hello".to_string()),
)],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_chat_completions(
client,
&ReqwestClient::new(),
data,
))
}
#[test]
fn oauth_stock_provider_applies_claude_code_spoof() {
let name = "claude-gate-stock-test";
let config = claude_config(name, Some("oauth"), None);
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
set_access_token(name, "stock-at".into(), Utc::now().timestamp() + 3600, None);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer stock-at")
);
assert_eq!(
request_data
.headers
.get("anthropic-beta")
.map(String::as_str),
Some("oauth-2025-04-20")
);
assert_eq!(request_data.body["system"][0]["text"], CLAUDE_CODE_PREFIX);
}
#[test]
fn oauth_config_provider_skips_claude_code_spoof() {
let name = "claude-gate-gateway-test";
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
set_access_token(
name,
"gateway-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer gateway-at")
);
assert!(
!request_data.headers.contains_key("anthropic-beta"),
"headers: {:?}",
request_data.headers
);
assert!(
request_data.body.get("system").is_none(),
"body: {}",
request_data.body
);
}
#[test]
fn api_key_auth_sets_x_api_key_without_spoof() {
let name = "claude-gate-apikey-test";
let mut config = claude_config(name, None, None);
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data.headers.get("x-api-key").map(String::as_str),
Some("sk-test")
);
assert!(!request_data.headers.contains_key("authorization"));
assert!(!request_data.headers.contains_key("anthropic-beta"));
assert!(
request_data.body.get("system").is_none(),
"body: {}",
request_data.body
);
}
#[test]
fn oauth_block_without_auth_oauth_is_rejected() {
let name = "claude-gate-contradiction-test";
let mut config = claude_config(name, None, Some(minimal_oauth_config()));
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let err = match prepare(&client) {
Ok(_) => panic!("expected the contradictory config to be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains("has an `oauth:` block configured but `auth: oauth` is not set"),
"unexpected error: {err}"
);
}
#[test]
fn oauth_block_without_api_base_is_rejected() {
let name = "claude-gate-apibase-missing-test";
let mut config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
config.api_base = None;
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let err = resolve_api_base(&client).unwrap_err().to_string();
assert!(
err.contains(name) && err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn catalog_only_oauth_block_without_api_base_is_rejected() {
let name = "claude-gate-catalog-oauth-test";
let config = claude_config(name, Some("oauth"), None);
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let catalog = vec![ProviderModels {
provider: name.into(),
oauth: Some(minimal_oauth_config()),
models: vec![],
}];
let err = resolve_api_base_against(&client, &catalog)
.unwrap_err()
.to_string();
assert!(
err.contains(name) && err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn catalog_oauth_block_with_api_key_auth_falls_back_to_stock_api_base() {
let name = "claude-gate-catalog-apikey-test";
let mut config = claude_config(name, None, None);
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let catalog = vec![ProviderModels {
provider: name.into(),
oauth: Some(minimal_oauth_config()),
models: vec![],
}];
let api_base = resolve_api_base_against(&client, &catalog).unwrap();
assert_eq!(api_base, API_BASE);
}
#[test]
fn auth_mismatch_bails_before_missing_api_base_guard() {
let name = "claude-gate-bail-order-test";
let mut config = claude_config(name, None, Some(minimal_oauth_config()));
config.api_base = None;
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let err = match prepare(&client) {
Ok(_) => panic!("expected the contradictory config to be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains("has an `oauth:` block configured but `auth: oauth` is not set")
&& !err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn oauth_block_with_api_base_resolves_it() {
let name = "claude-gate-apibase-set-test";
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let api_base = resolve_api_base(&client).unwrap();
assert_eq!(api_base, "https://gateway.example/v1");
}
#[test]
fn no_oauth_block_falls_back_to_stock_api_base() {
let name = "claude-gate-apibase-fallback-test";
let config = claude_config(name, None, None);
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let api_base = resolve_api_base(&client).unwrap();
assert_eq!(api_base, API_BASE);
}
#[test]
fn oauth_lookup_resolves_entry_matching_client_name() {
let name = "claude-gate-lookup-test";
// The sibling entry resolves to the stock provider; if the lookup
// ignored the name it would apply the Claude Code spoof here.
let sibling = claude_config("claude-gate-lookup-sibling-test", Some("oauth"), None);
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(
config.clone(),
vec![
ClientConfig::ClaudeConfig(sibling),
ClientConfig::ClaudeConfig(config),
],
);
set_access_token(
name,
"lookup-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer lookup-at")
);
assert!(!request_data.headers.contains_key("anthropic-beta"));
assert!(request_data.body.get("system").is_none());
}
#[test]
fn oauth_lookup_falls_back_to_default_name_for_unnamed_entry() {
// An entry with `name: None` must match a client named "claude" via
// the `unwrap_or(ClaudeClient::NAME)` fallback.
let sibling = claude_config("claude-gate-fallback-sibling-test", Some("oauth"), None);
let mut config = claude_config("ignored", Some("oauth"), Some(minimal_oauth_config()));
config.name = None;
let client = make_client(
config.clone(),
vec![
ClientConfig::ClaudeConfig(sibling),
ClientConfig::ClaudeConfig(config),
],
);
set_access_token(
ClaudeClient::NAME,
"fallback-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer fallback-at")
);
assert!(!request_data.headers.contains_key("anthropic-beta"));
assert!(request_data.body.get("system").is_none());
}
#[test]
fn oauth_lookup_errors_when_config_entry_missing() {
let config = claude_config("claude-gate-missing-test", Some("oauth"), None);
let client = make_client(config, vec![]);
let err = match prepare(&client) {
Ok(_) => panic!("expected the lookup to fail"),
Err(err) => err.to_string(),
};
assert!(
err.contains("Could not locate ClientConfig entry"),
"unexpected error: {err}"
);
}
#[test]
fn shipped_catalog_resolves_stock_claude_provider() {
let cc = ClientConfig::ClaudeConfig(claude_config("claude", Some("oauth"), None));
let bundled: Vec<ProviderModels> = serde_yaml::from_str(MODELS_YAML).unwrap();
let (_, is_stock) = oauth::claude_oauth_provider_for_client(&cc, &bundled);
assert!(
is_stock,
"bundled models.yaml must not carry a claude oauth block: it would silently drop the Claude Code spoof for stock Pro/Max users"
);
}
}
+2
View File
@@ -16,6 +16,8 @@ pub struct CohereConfig {
pub api_base: Option<String>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
+804 -16
View File
@@ -2,6 +2,7 @@ use super::*;
use super::access_token::{distrust_access_token, get_access_token};
use crate::config::{RenderMode, paths};
use crate::hooks::{self, HookEvent};
use crate::{
config::{AppConfig, Input, RequestContext},
function::{FunctionDeclaration, ToolCall, ToolResult, eval_tool_calls},
@@ -20,7 +21,7 @@ use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::LazyLock;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::mpsc::unbounded_channel;
pub const MODELS_YAML: &str = include_str!("../../models.yaml");
@@ -221,23 +222,39 @@ pub trait Client: Sync + Send {
request_data.into_builder(client)
}
/// Patches are chat-shaped unless keyed otherwise: on the Responses wire
/// only the `responses` sub-patch of a model patch and the `responses`
/// section of a client patch apply, so chat-only keys never leak into a
/// responses body.
fn patch_request_data(&self, request_data: &mut RequestData) {
let model_type = self.model().model_type();
let wire = request_data.wire;
if let Some(patch) = self.model().patch() {
request_data.apply_patch(patch.clone());
match wire {
WireApi::Chat => request_data.apply_patch(patch.clone()),
WireApi::Responses => {
if let Some(patch) = patch.get("responses") {
request_data.apply_patch(patch.clone());
}
}
}
}
let api_name = match wire {
WireApi::Responses => "responses",
WireApi::Chat => model_type.api_name(),
};
let patch_map = std::env::var(get_env_name(&format!(
"patch_{}_{}",
"patch_{}_{api_name}",
self.model().client_name(),
model_type.api_name(),
)))
.ok()
.and_then(|v| serde_json::from_str(&v).ok())
.or_else(|| {
self.patch_config()
.and_then(|v| model_type.extract_patch(v))
.cloned()
self.patch_config().and_then(|v| match wire {
WireApi::Responses => v.responses.clone(),
WireApi::Chat => model_type.extract_patch(v).cloned(),
})
});
let patch_map = match patch_map {
Some(v) => v,
@@ -271,16 +288,26 @@ pub struct ExtraConfig {
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RequestPatch {
pub chat_completions: Option<ApiPatch>,
pub responses: Option<ApiPatch>,
pub embeddings: Option<ApiPatch>,
pub rerank: Option<ApiPatch>,
}
pub type ApiPatch = IndexMap<String, Value>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WireApi {
Responses,
Chat,
}
#[derive(Debug)]
pub struct RequestData {
pub url: String,
pub headers: IndexMap<String, String>,
pub body: Value,
pub wire: WireApi,
}
impl RequestData {
@@ -292,9 +319,15 @@ impl RequestData {
url: url.to_string(),
headers: Default::default(),
body,
wire: WireApi::Chat,
}
}
pub fn wire(mut self, wire: WireApi) -> Self {
self.wire = wire;
self
}
pub fn bearer_auth<T>(&mut self, auth: T)
where
T: std::fmt::Display,
@@ -312,7 +345,9 @@ impl RequestData {
}
pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {
let RequestData { url, headers, body } = self;
let RequestData {
url, headers, body, ..
} = self;
debug!("Request {url} {body}");
let mut builder = client.post(url);
@@ -385,6 +420,15 @@ impl TokenUsage {
&& self.cache_read_input_tokens.is_none()
}
/// Prompt-side tokens the API reported for one request: fresh input plus
/// cache writes and cache reads. The buckets are disjoint (see
/// [`Self::cost_usd`]), so the terms sum without adjustment.
pub fn total_prompt_tokens(&self) -> u64 {
self.input_tokens.unwrap_or(0)
+ self.cache_creation_input_tokens.unwrap_or(0)
+ self.cache_read_input_tokens.unwrap_or(0)
}
pub fn accumulate(&mut self, other: &TokenUsage) {
fn add(acc: &mut Option<u64>, value: Option<u64>) {
if let Some(value) = value {
@@ -546,6 +590,47 @@ pub async fn create_openai_compatible_client_config(
Ok(Some((model, clients)))
}
fn llm_request_extras(client: &dyn Client) -> Vec<(&'static str, String)> {
vec![
("COYOTE_LLM_PROVIDER", client.name().to_string()),
("COYOTE_LLM_MODEL", client.model().id()),
]
}
fn llm_completed_extras(client: &dyn Client, started_at: Instant) -> Vec<(&'static str, String)> {
let mut extras = llm_request_extras(client);
extras.push((
"COYOTE_LLM_DURATION_MS",
started_at.elapsed().as_millis().to_string(),
));
extras
}
fn llm_failed_extras(
client: &dyn Client,
err: Option<&anyhow::Error>,
aborted: bool,
) -> Vec<(&'static str, String)> {
let mut extras = llm_request_extras(client);
if aborted {
extras.push(("COYOTE_LLM_ERROR_KIND", "aborted".to_string()));
} else if let Some(api_err) = err.and_then(|err| err.downcast_ref::<ApiStatusError>()) {
extras.push(("COYOTE_LLM_ERROR_KIND", "api_error".to_string()));
extras.push(("COYOTE_LLM_STATUS", api_err.status.to_string()));
} else {
extras.push(("COYOTE_LLM_ERROR_KIND", "other".to_string()));
}
extras.push((
"COYOTE_LLM_ERROR",
// The immediate error only, matching COYOTE_TOOL_ERROR; the context
// chain below it stays out of the env.
err.map(|err| err.to_string())
.unwrap_or_else(|| "Aborted.".to_string()),
));
extras
}
pub async fn call_chat_completions(
input: &Input,
print: bool,
@@ -557,15 +642,28 @@ pub async fn call_chat_completions(
let is_child_agent = ctx.current_depth > 0;
let suppress_spinner = is_child_agent || ctx.render_mode == RenderMode::Silent;
let spinner_message = if suppress_spinner { "" } else { "Generating" };
hooks::fire(
HookEvent::LlmRequestStarted,
ctx,
&llm_request_extras(client),
None,
);
let request_started_at = Instant::now();
let ret = abortable_run_with_spinner(
client.chat_completions(input.clone()),
spinner_message,
abort_signal,
abort_signal.clone(),
)
.await;
match ret {
Ok(ret) => {
hooks::fire(
HookEvent::LlmRequestCompleted,
ctx,
&llm_completed_extras(client, request_started_at),
None,
);
let ChatCompletionsOutput {
mut text,
tool_calls,
@@ -583,7 +681,15 @@ pub async fn call_chat_completions(
ctx.record_token_usage(usage, client.model());
finish_completion(ctx, text, tool_calls, thinking).await
}
Err(err) => Err(err),
Err(err) => {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), abort_signal.aborted()),
None,
);
Err(err)
}
}
}
@@ -600,6 +706,13 @@ pub async fn call_chat_completions_streaming(
handler.set_silent(true);
}
hooks::fire(
HookEvent::LlmRequestStarted,
ctx,
&llm_request_extras(client),
None,
);
let request_started_at = Instant::now();
let (send_ret, render_ret) = tokio::join!(
client.chat_completions_streaming(input, &mut handler),
render_stream(rx, client.app_config(), abort_signal.clone(), silent),
@@ -615,12 +728,32 @@ pub async fn call_chat_completions_streaming(
ctx.record_token_usage(usage, client.model());
if aborted_ctrld {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
bail!("Aborted.");
}
render_ret?;
if let Err(err) = render_ret {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), false),
None,
);
return Err(err);
}
if aborted_ctrlc {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
if !ctx.working_mode.is_repl() || ctx.session.is_none() {
bail!("Aborted.");
}
@@ -644,6 +777,12 @@ pub async fn call_chat_completions_streaming(
match send_ret {
Ok(_) => {
hooks::fire(
HookEvent::LlmRequestCompleted,
ctx,
&llm_completed_extras(client, request_started_at),
None,
);
if !silent && !text.is_empty() && !text.ends_with('\n') {
println!();
}
@@ -653,6 +792,12 @@ pub async fn call_chat_completions_streaming(
if !silent && !text.is_empty() {
println!();
}
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), false),
None,
);
Err(err)
}
}
@@ -675,10 +820,36 @@ pub async fn call_chat_completions_streaming_quiet(
ctx: &mut RequestContext,
abort_signal: AbortSignal,
) -> Result<(String, Vec<ToolResult>)> {
hooks::fire(
HookEvent::LlmRequestStarted,
ctx,
&llm_request_extras(client),
None,
);
let request_started_at = Instant::now();
if client.model().no_stream() {
let output = tokio::select! {
ret = client.chat_completions(input.clone()) => ret?,
_ = wait_abort_signal(&abort_signal) => bail!("Aborted."),
ret = client.chat_completions(input.clone()) => match ret {
Ok(output) => output,
Err(err) => {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), false),
None,
);
return Err(err);
}
},
_ = wait_abort_signal(&abort_signal) => {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
bail!("Aborted.")
}
};
let ChatCompletionsOutput {
text,
@@ -688,8 +859,20 @@ pub async fn call_chat_completions_streaming_quiet(
} = output;
ctx.record_token_usage(usage, client.model());
if abort_signal.aborted() {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
bail!("Aborted.");
}
hooks::fire(
HookEvent::LlmRequestCompleted,
ctx,
&llm_completed_extras(client, request_started_at),
None,
);
return finish_completion(ctx, text, tool_calls, thinking).await;
}
@@ -705,10 +888,30 @@ pub async fn call_chat_completions_streaming_quiet(
ctx.record_token_usage(usage, client.model());
if abort_signal.aborted() {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
bail!("Aborted.");
}
send_ret?;
if let Err(err) = send_ret {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), false),
None,
);
return Err(err);
}
hooks::fire(
HookEvent::LlmRequestCompleted,
ctx,
&llm_completed_extras(client, request_started_at),
None,
);
finish_completion(ctx, text, tool_calls, thinking).await
}
@@ -969,6 +1172,7 @@ mod tests {
use super::super::access_token::{is_rejected, set_access_token};
use crate::config::{AppState, WorkingMode};
use anyhow::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::time::Instant;
@@ -1185,8 +1389,8 @@ mod tests {
/// Wrapped in `.context(...)` so every test below proves the downcast
/// works through an anyhow context chain, as in the trait methods.
fn api_status_error(status: u16) -> anyhow::Error {
anyhow::Error::new(ApiStatusError {
fn api_status_error(status: u16) -> Error {
Error::new(ApiStatusError {
status,
message: format!("error (status: {status})"),
})
@@ -1524,4 +1728,588 @@ mod tests {
assert_eq!(client.streaming_calls.load(Ordering::SeqCst), 1);
assert_paced_reply(&text, &results);
}
/// Fake provider whose every request fails with an [`ApiStatusError`],
/// wrapped in the same context chain the real trait defaults produce.
struct FailingStatusClient {
config: AppConfig,
model: Model,
status: u16,
}
impl FailingStatusClient {
fn new(status: u16) -> Self {
Self {
config: AppConfig::default(),
model: Model::default(),
status,
}
}
fn error(&self) -> Error {
Error::new(ApiStatusError {
status: self.status,
message: format!("error (status: {})", self.status),
})
}
}
#[async_trait::async_trait]
impl Client for FailingStatusClient {
fn app_config(&self) -> &AppConfig {
&self.config
}
fn extra_config(&self) -> Option<&ExtraConfig> {
None
}
fn patch_config(&self) -> Option<&RequestPatch> {
None
}
fn name(&self) -> &str {
"failing-status"
}
fn model(&self) -> &Model {
&self.model
}
async fn chat_completions_inner(
&self,
_client: &ReqwestClient,
_data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
Err(self.error())
}
async fn chat_completions_streaming_inner(
&self,
_client: &ReqwestClient,
_handler: &mut SseHandler,
_data: ChatCompletionsData,
) -> Result<()> {
Err(self.error())
}
}
fn llm_hooks_map(marker: &str) -> hooks::HooksMap {
[
"llm.request.started",
"llm.request.completed",
"llm.request.failed",
]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![crate::hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
)
})
.collect()
}
fn llm_hooked_ctx(marker: &str) -> RequestContext {
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks: llm_hooks_map(marker),
..Default::default()
});
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
ctx.render_mode = RenderMode::Silent;
ctx
}
fn llm_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn plain_transport_fires_llm_request_started_and_completed() {
let marker = "llm-hooks-plain-ok-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let mut client = paced_client(false);
client.nonstream_replies = true;
let _guard = hooks::test_sink::install();
call_chat_completions(
&input,
false,
false,
&client,
&mut ctx,
create_abort_signal(),
)
.await
.unwrap();
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
assert_eq!(
captures[0]
.envs
.get("COYOTE_LLM_PROVIDER")
.map(String::as_str),
Some("paced-stream")
);
assert!(captures[0].envs.contains_key("COYOTE_LLM_MODEL"));
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
assert!(!captures[1].envs.contains_key("COYOTE_LLM_ERROR_KIND"));
}
#[tokio::test]
#[serial_test::serial]
async fn plain_transport_fires_llm_request_failed_with_api_error_kind() {
let marker = "llm-hooks-plain-api-err-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = FailingStatusClient::new(429);
let _guard = hooks::test_sink::install();
let err = call_chat_completions(
&input,
false,
false,
&client,
&mut ctx,
create_abort_signal(),
)
.await
.unwrap_err();
assert!(
err.downcast_ref::<ApiStatusError>().is_some(),
"hooks must not change the error: {err:?}"
);
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
Some("api_error")
);
assert_eq!(
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
Some("429")
);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR").map(String::as_str),
Some("Failed to call chat-completions api"),
"the env must carry the immediate error only, not the context chain"
);
assert!(!failed.envs.contains_key("COYOTE_LLM_DURATION_MS"));
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn streaming_transport_fires_llm_request_started_and_completed() {
let marker = "llm-hooks-stream-ok-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(false);
let _guard = hooks::test_sink::install();
call_chat_completions_streaming(&input, &client, &mut ctx, create_abort_signal())
.await
.unwrap();
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn streaming_transport_abort_fires_llm_request_failed_aborted() {
let marker = "llm-hooks-stream-abort-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(true);
let abort = create_abort_signal();
let trigger = abort.clone();
tokio::spawn(async move {
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
trigger.set_ctrlc();
});
let _guard = hooks::test_sink::install();
let err = call_chat_completions_streaming(&input, &client, &mut ctx, abort)
.await
.expect_err("an aborted stream must not succeed");
assert_eq!(err.to_string(), "Aborted.");
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
Some("aborted")
);
assert!(!failed.envs.contains_key("COYOTE_LLM_STATUS"));
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn quiet_transport_fires_llm_request_started_and_completed() {
let marker = "llm-hooks-quiet-ok-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(false);
let _guard = hooks::test_sink::install();
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
.await
.unwrap();
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
assert_eq!(
captures[0]
.envs
.get("COYOTE_LLM_PROVIDER")
.map(String::as_str),
Some("paced-stream")
);
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn quiet_transport_abort_fires_llm_request_failed_aborted() {
let marker = "llm-hooks-quiet-abort-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(true);
let abort = create_abort_signal();
let trigger = abort.clone();
tokio::spawn(async move {
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
trigger.set_ctrlc();
});
let _guard = hooks::test_sink::install();
let err = call_chat_completions_streaming_quiet(&input, &client, &mut ctx, abort)
.await
.expect_err("an aborted stream must not succeed");
assert_eq!(err.to_string(), "Aborted.");
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
Some("aborted")
);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR").map(String::as_str),
Some("Aborted.")
);
}
#[tokio::test]
#[serial_test::serial]
async fn quiet_transport_fires_llm_request_failed_with_api_error_kind() {
let marker = "llm-hooks-quiet-api-err-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = FailingStatusClient::new(500);
let _guard = hooks::test_sink::install();
let err =
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
.await
.unwrap_err();
assert!(err.downcast_ref::<ApiStatusError>().is_some());
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
Some("api_error")
);
assert_eq!(
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
Some("500")
);
}
/// Minimal client for exercising `patch_request_data`: only the model
/// and the client-level patch config matter.
struct PatchProbeClient {
config: AppConfig,
model: Model,
patch: Option<RequestPatch>,
}
#[async_trait::async_trait]
impl Client for PatchProbeClient {
fn app_config(&self) -> &AppConfig {
&self.config
}
fn extra_config(&self) -> Option<&ExtraConfig> {
None
}
fn patch_config(&self) -> Option<&RequestPatch> {
self.patch.as_ref()
}
fn name(&self) -> &str {
self.model.client_name()
}
fn model(&self) -> &Model {
&self.model
}
async fn chat_completions_inner(
&self,
_client: &ReqwestClient,
_data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
bail!("the patch probe makes no requests")
}
async fn chat_completions_streaming_inner(
&self,
_client: &ReqwestClient,
_handler: &mut SseHandler,
_data: ChatCompletionsData,
) -> Result<()> {
bail!("the patch probe makes no requests")
}
}
fn patch_probe(
client_name: &str,
model_patch: Option<Value>,
patch: Option<RequestPatch>,
) -> PatchProbeClient {
let mut data = ModelData::new("gpt-test");
data.patch = model_patch;
PatchProbeClient {
config: AppConfig::default(),
model: Model::from_config(client_name, &[data]).remove(0),
patch,
}
}
fn client_patch(json: Value) -> RequestPatch {
serde_json::from_value(json).unwrap()
}
fn wire_request(wire: WireApi) -> RequestData {
RequestData::new("https://example.test/v1", json!({"model": "gpt-test"})).wire(wire)
}
#[test]
fn request_patch_accepts_a_responses_section() {
let patch = client_patch(json!({
"responses": {".*": {"body": {"store": true}}},
"chat_completions": {".*": {"body": {"temperature": 0.5}}},
}));
assert!(patch.responses.is_some());
assert!(patch.chat_completions.is_some());
}
#[test]
fn non_map_request_patch_responses_section_is_rejected() {
let err = serde_json::from_value::<RequestPatch>(json!({"responses": "bogus"}))
.unwrap_err()
.to_string();
assert!(err.contains("expected a map"), "unexpected error: {err}");
}
#[test]
fn chat_client_patch_never_touches_the_responses_wire() {
let client = patch_probe(
"patchprobechatiso",
None,
Some(client_patch(json!({
"chat_completions": {".*": {"body": {"temperature": 0.5}}}
}))),
);
let mut request_data = wire_request(WireApi::Responses);
let before = request_data.body.clone();
client.patch_request_data(&mut request_data);
assert_eq!(request_data.body, before);
}
#[test]
fn responses_client_patch_never_touches_the_chat_wire() {
let patch = Some(client_patch(json!({
"responses": {".*": {"body": {"store": true}}}
})));
let client = patch_probe("patchproberespiso", None, patch);
let mut chat_request = wire_request(WireApi::Chat);
let before = chat_request.body.clone();
client.patch_request_data(&mut chat_request);
assert_eq!(chat_request.body, before);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
assert_eq!(responses_request.body["store"], json!(true));
}
#[test]
fn chat_shaped_model_patch_never_touches_the_responses_wire() {
let client = patch_probe(
"patchprobemodeliso",
Some(json!({"body": {"reasoning_effort": "high"}})),
None,
);
let mut request_data = wire_request(WireApi::Responses);
let before = request_data.body.clone();
client.patch_request_data(&mut request_data);
assert_eq!(request_data.body, before);
}
#[test]
fn model_patch_responses_subkey_is_selected_per_wire() {
let model_patch = json!({
"body": {"temperature": 0.2},
"responses": {"body": {"store": true}},
});
let client = patch_probe("patchprobesubkey", Some(model_patch), None);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
assert_eq!(responses_request.body["store"], json!(true));
assert!(responses_request.body.get("temperature").is_none());
let mut chat_request = wire_request(WireApi::Chat);
client.patch_request_data(&mut chat_request);
assert_eq!(chat_request.body["temperature"], json!(0.2));
assert!(chat_request.body.get("store").is_none());
}
/// Pins the silent-ignore contract for a malformed `responses` sub-key:
/// model patches are untyped JSON maps and the whole patch layer is
/// deliberately lenient — `apply_patch` only reads object-shaped
/// `url`/`body`/`headers` keys — so a non-object `responses` sub-key
/// degrades to a no-op on the Responses wire (never a request-time
/// error, never a partial merge) and stays inert on the Chat wire,
/// where the patch's top-level keys still apply normally.
#[test]
fn malformed_model_patch_responses_subkey_is_silently_ignored() {
for malformed in [json!("oops"), json!([{"body": {"store": true}}])] {
let model_patch = json!({
"body": {"temperature": 0.2},
"responses": malformed,
});
let client = patch_probe("patchprobemalformed", Some(model_patch), None);
let mut responses_request = wire_request(WireApi::Responses);
let before_url = responses_request.url.clone();
let before_body = responses_request.body.clone();
let before_headers = responses_request.headers.clone();
client.patch_request_data(&mut responses_request);
assert_eq!(responses_request.url, before_url);
assert_eq!(responses_request.body, before_body);
assert_eq!(responses_request.headers, before_headers);
let mut chat_request = wire_request(WireApi::Chat);
client.patch_request_data(&mut chat_request);
assert_eq!(chat_request.body["temperature"], json!(0.2));
assert!(chat_request.body.get("store").is_none());
}
}
#[test]
fn env_patch_override_is_selected_per_wire() {
// Unique client name so the env vars cannot race parallel tests.
let client_name = "patchprobeenvwire";
let responses_env = get_env_name(&format!("patch_{client_name}_responses"));
let chat_env = get_env_name(&format!("patch_{client_name}_chat_completions"));
unsafe {
std::env::set_var(&responses_env, r#"{".*": {"body": {"store": true}}}"#);
std::env::set_var(&chat_env, r#"{".*": {"body": {"temperature": 0.7}}}"#);
}
let client = patch_probe(client_name, None, None);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
let mut chat_request = wire_request(WireApi::Chat);
client.patch_request_data(&mut chat_request);
unsafe {
std::env::remove_var(&responses_env);
std::env::remove_var(&chat_env);
}
assert_eq!(responses_request.body["store"], json!(true));
assert!(responses_request.body.get("temperature").is_none());
assert_eq!(chat_request.body["temperature"], json!(0.7));
assert!(chat_request.body.get("store").is_none());
}
#[test]
fn chat_wire_patching_is_unchanged_by_wire_awareness() {
let client = patch_probe(
"patchproberegress",
Some(json!({"body": {"max_tokens": null}, "headers": {"x-model": "m"}})),
Some(client_patch(json!({
"chat_completions": {".*": {
"url": "https://patched.example/v1/chat/completions",
"body": {"temperature": 0.9},
}}
}))),
);
let mut request_data = RequestData::new(
"https://example.test/v1/chat/completions",
json!({"model": "gpt-test", "max_tokens": 100}),
);
request_data.bearer_auth("sk-test");
client.patch_request_data(&mut request_data);
assert_eq!(
request_data.url,
"https://patched.example/v1/chat/completions"
);
assert_eq!(
request_data.body,
json!({"model": "gpt-test", "temperature": 0.9})
);
assert_eq!(
request_data.headers.get("x-model").map(String::as_str),
Some("m")
);
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer sk-test")
);
}
}
+387 -15
View File
@@ -1,10 +1,10 @@
use super::access_token::get_access_token;
use super::gemini_oauth::GeminiOAuthProvider;
use super::oauth;
use super::oauth::{self, OAuthConfig, OAuthProvider};
use super::vertexai::*;
use super::*;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result, anyhow, bail};
use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -17,8 +17,11 @@ pub struct GeminiConfig {
pub api_key: Option<String>,
pub api_base: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
@@ -85,9 +88,17 @@ async fn prepare_chat_completions(
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<RequestData> {
let api_base = self_
.get_api_base()
.unwrap_or_else(|_| API_BASE.to_string());
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if !uses_oauth && self_.config.oauth.is_some() {
bail!(
"'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.",
self_.name(),
self_.name()
);
}
let api_base = resolve_api_base(self_)?;
let func = match data.stream {
true => "streamGenerateContent",
@@ -104,11 +115,9 @@ async fn prepare_chat_completions(
let body = gemini_build_chat_completions_body(data, &self_.model)?;
let mut request_data = RequestData::new(url, body);
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if uses_oauth {
let provider = GeminiOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
let (provider, _) = resolve_oauth_provider(self_)?;
let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
@@ -118,6 +127,9 @@ async fn prepare_chat_completions(
}
let token = get_access_token(self_.name())?;
request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
} else if let Ok(api_key) = self_.get_api_key() {
request_data.header("x-goog-api-key", api_key);
} else {
@@ -136,12 +148,18 @@ async fn prepare_embeddings(
client: &ReqwestClient,
data: &EmbeddingsData,
) -> Result<RequestData> {
let api_base = self_
.get_api_base()
.unwrap_or_else(|_| API_BASE.to_string());
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if !uses_oauth && self_.config.oauth.is_some() {
bail!(
"'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.",
self_.name(),
self_.name()
);
}
let api_base = resolve_api_base(self_)?;
let url = if uses_oauth {
format!(
"{}/models/{}:batchEmbedContents",
@@ -177,8 +195,8 @@ async fn prepare_embeddings(
let mut request_data = RequestData::new(url, body);
if uses_oauth {
let provider = GeminiOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
let (provider, _) = resolve_oauth_provider(self_)?;
let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
@@ -188,11 +206,72 @@ async fn prepare_embeddings(
}
let token = get_access_token(self_.name())?;
request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
}
Ok(request_data)
}
fn resolve_api_base(self_: &GeminiClient) -> Result<String> {
resolve_api_base_against(self_, &ALL_PROVIDER_MODELS)
}
fn resolve_api_base_against(
self_: &GeminiClient,
all_provider_models: &[ProviderModels],
) -> Result<String> {
if let Ok(api_base) = self_.get_api_base() {
return Ok(api_base);
}
let uses_config_oauth = self_.config.auth.as_deref() == Some("oauth")
&& match locate_client_config(self_) {
Ok(cc) => oauth::config_oauth_for_client(cc, all_provider_models).is_some(),
// Safe: on locate failure, provider resolution bails when an inline oauth block exists; otherwise the stock provider is used and issuer-stamped gateway tokens are rejected before attach.
Err(_) => self_.config.oauth.is_some(),
};
if uses_config_oauth {
bail!(
"'{}' has a config-driven OAuth configuration (an `oauth:` block in the client entry or models catalog) but no `api_base`; refusing to fall back to {} (the oauth token would be sent to the wrong host). Set `api_base` on the '{}' client entry.",
self_.name(),
API_BASE,
self_.name()
);
}
Ok(API_BASE.to_string())
}
fn locate_client_config(self_: &GeminiClient) -> Result<&ClientConfig> {
let client_name = self_.name();
self_
.app_config()
.clients
.iter()
.find(|cc| {
matches!(
cc,
ClientConfig::GeminiConfig(c)
if c.name.as_deref().unwrap_or(GeminiClient::NAME) == client_name
)
})
.ok_or_else(|| anyhow!("Could not locate ClientConfig entry for '{}'", client_name))
}
fn resolve_oauth_provider(self_: &GeminiClient) -> Result<(Box<dyn OAuthProvider>, bool)> {
match locate_client_config(self_) {
Ok(cc) => Ok(oauth::gemini_oauth_provider_for_client(
cc,
&ALL_PROVIDER_MODELS,
)),
Err(_) if self_.config.oauth.is_none() => Ok((Box::new(GeminiOAuthProvider), true)),
Err(err) => Err(err),
}
}
async fn embeddings(builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {
let res = builder.send().await?;
let status = res.status();
@@ -219,3 +298,296 @@ struct EmbeddingsResBody {
struct EmbeddingsResBodyEmbedding {
values: Vec<f32>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::access_token::set_access_token;
use crate::config::AppConfig;
use chrono::Utc;
use std::sync::Arc;
fn gemini_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> GeminiConfig {
GeminiConfig {
name: Some(name.into()),
api_base: oauth
.as_ref()
.map(|_| "https://gateway.example/v1".to_string()),
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
..Default::default()
}
}
fn make_client(config: GeminiConfig, clients: Vec<ClientConfig>) -> GeminiClient {
GeminiClient {
app_config: Arc::new(AppConfig {
clients,
..AppConfig::default()
}),
config,
model: Model::new("gemini", "gemini-test"),
}
}
fn minimal_oauth_config() -> OAuthConfig {
serde_yaml::from_str("client_id: gateway\ntoken_url: https://gateway.example/token")
.unwrap()
}
fn prepare_chat(client: &GeminiClient) -> Result<RequestData> {
let data = ChatCompletionsData {
messages: vec![Message::new(
MessageRole::User,
MessageContent::Text("hello".to_string()),
)],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_chat_completions(
client,
&ReqwestClient::new(),
data,
))
}
fn prepare_embed(client: &GeminiClient) -> Result<RequestData> {
let data = EmbeddingsData::new(vec!["hello".to_string()], false);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_embeddings(client, &ReqwestClient::new(), &data))
}
#[test]
fn oauth_block_without_api_base_is_rejected() {
let name = "gemini-gate-apibase-missing-test";
let mut config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config()));
config.api_base = None;
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let err = resolve_api_base(&client).unwrap_err().to_string();
assert!(
err.contains(name) && err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn catalog_only_oauth_block_without_api_base_is_rejected() {
let name = "gemini-gate-catalog-oauth-test";
let config = gemini_config(name, Some("oauth"), None);
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let catalog = vec![ProviderModels {
provider: name.into(),
oauth: Some(minimal_oauth_config()),
models: vec![],
}];
let err = resolve_api_base_against(&client, &catalog)
.unwrap_err()
.to_string();
assert!(
err.contains(name) && err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn oauth_block_with_api_base_resolves_it() {
let name = "gemini-gate-apibase-set-test";
let config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let api_base = resolve_api_base(&client).unwrap();
assert_eq!(api_base, "https://gateway.example/v1");
}
#[test]
fn no_oauth_block_falls_back_to_stock_api_base() {
let name = "gemini-gate-apibase-fallback-test";
let config = gemini_config(name, None, None);
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let api_base = resolve_api_base(&client).unwrap();
assert_eq!(api_base, API_BASE);
}
#[test]
fn oauth_block_without_auth_oauth_is_rejected() {
let name = "gemini-gate-contradiction-test";
let mut config = gemini_config(name, None, Some(minimal_oauth_config()));
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let err = match prepare_chat(&client) {
Ok(_) => panic!("expected the contradictory config to be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains("has an `oauth:` block configured but `auth: oauth` is not set"),
"unexpected error: {err}"
);
}
#[test]
fn config_oauth_embeddings_url_omits_key_param() {
let name = "gemini-gate-embed-oauth-test";
let config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
set_access_token(
name,
"gateway-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare_embed(&client).unwrap();
assert_eq!(
request_data.url,
"https://gateway.example/v1/models/gemini-test:batchEmbedContents"
);
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer gateway-at")
);
}
#[test]
fn api_key_embeddings_url_appends_key_param() {
let name = "gemini-gate-embed-apikey-test";
let mut config = gemini_config(name, None, None);
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let request_data = prepare_embed(&client).unwrap();
assert_eq!(
request_data.url,
format!("{API_BASE}/models/gemini-test:batchEmbedContents?key=sk-test")
);
}
#[test]
fn config_oauth_extra_request_headers_reach_request() {
let name = "gemini-gate-extra-headers-test";
let oauth: OAuthConfig = serde_yaml::from_str(
"client_id: gateway\ntoken_url: https://gateway.example/token\nextra_request_headers:\n x-gateway-tenant: acme",
)
.unwrap();
let config = gemini_config(name, Some("oauth"), Some(oauth));
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
set_access_token(
name,
"gateway-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare_chat(&client).unwrap();
assert_eq!(
request_data
.headers
.get("x-gateway-tenant")
.map(String::as_str),
Some("acme")
);
}
#[test]
fn catalog_oauth_block_with_api_key_auth_falls_back_to_stock_api_base() {
let name = "gemini-gate-catalog-apikey-test";
let mut config = gemini_config(name, None, None);
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let catalog = vec![ProviderModels {
provider: name.into(),
oauth: Some(minimal_oauth_config()),
models: vec![],
}];
let api_base = resolve_api_base_against(&client, &catalog).unwrap();
assert_eq!(api_base, API_BASE);
}
#[test]
fn missing_config_entry_without_inline_oauth_resolves_stock_provider() {
let config = gemini_config("gemini-gate-missing-entry-test", Some("oauth"), None);
let client = make_client(config, vec![]);
let (_, is_stock) = resolve_oauth_provider(&client).unwrap();
assert!(is_stock);
}
#[test]
fn missing_config_entry_with_inline_oauth_is_a_hard_error() {
let config = gemini_config(
"gemini-gate-missing-entry-inline-test",
Some("oauth"),
Some(minimal_oauth_config()),
);
let client = make_client(config, vec![]);
let err = match resolve_oauth_provider(&client) {
Ok(_) => panic!("expected the missing ClientConfig entry to be a hard error"),
Err(err) => err.to_string(),
};
assert!(
err.contains("Could not locate ClientConfig entry"),
"unexpected error: {err}"
);
}
#[test]
fn auth_mismatch_bails_before_missing_api_base_guard() {
let name = "gemini-gate-bail-order-test";
let mut config = gemini_config(name, None, Some(minimal_oauth_config()));
config.api_base = None;
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let err = match prepare_chat(&client) {
Ok(_) => panic!("expected the contradictory config to be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains("has an `oauth:` block configured but `auth: oauth` is not set")
&& !err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn shipped_catalog_resolves_stock_gemini_provider() {
let cc = ClientConfig::GeminiConfig(gemini_config("gemini", Some("oauth"), None));
let bundled: Vec<ProviderModels> = serde_yaml::from_str(MODELS_YAML).unwrap();
let (_, is_stock) = oauth::gemini_oauth_provider_for_client(&cc, &bundled);
assert!(
is_stock,
"bundled models.yaml must not carry a gemini oauth block: it would silently replace the stock Google oauth provider"
);
}
}
+16 -7
View File
@@ -51,15 +51,24 @@ macro_rules! register_client {
pub fn list_models(local_config: &$config) -> Vec<Model> {
let client_name = Self::name(local_config);
let find_catalog = || $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
v.provider == $name ||
($name == OpenAICompatibleClient::NAME
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
});
if local_config.models.is_empty() {
if let Some(v) = $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
v.provider == $name ||
($name == OpenAICompatibleClient::NAME
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
}) {
return Model::from_config(client_name, &v.models);
match find_catalog() {
Some(v) => Model::from_config(client_name, &v.models),
None => vec![],
}
} else if local_config.extend_models {
match find_catalog() {
Some(v) => Model::from_config(
client_name,
&$crate::client::catalog::extend_catalog_models(&v.models, &local_config.models),
),
None => Model::from_config(client_name, &local_config.models),
}
vec![]
} else {
Model::from_config(client_name, &local_config.models)
}
+167 -5
View File
@@ -188,15 +188,27 @@ pub struct ImageUrl {
pub url: String,
}
/// An extended-thinking block returned by Anthropic-protocol models.
/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`)
/// An extended-thinking block returned by Anthropic-protocol models or an
/// OpenAI Responses reasoning item. Serialized to match each API's wire
/// format (`type: thinking` / `type: redacted_thinking` / `type: reasoning`)
/// so blocks can be replayed verbatim, signature intact, in subsequent
/// tool-loop rounds as the API requires.
/// tool-loop rounds as the APIs require.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThinkingBlock {
Thinking { thinking: String, signature: String },
RedactedThinking { data: String },
Thinking {
thinking: String,
signature: String,
},
RedactedThinking {
data: String,
},
Reasoning {
id: String,
summary: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -204,6 +216,16 @@ pub struct MessageContentToolCalls {
pub tool_results: Vec<ToolResult>,
pub text: String,
pub sequence: bool,
/// Indices into `tool_results` where a new tool-call round begins (the
/// first round implicitly starts at index 0). Recorded by `merge()` on
/// every merge, so serializers can emit one assistant/user pair per
/// round even when the model produced no narration text for a round.
/// Keeping each round in its own message pair makes the request history
/// append-only across tool-loop iterations, which prefix-based prompt
/// caches require to produce growing cache reads instead of rewriting
/// the whole accumulated history on every request.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub round_starts: Vec<usize>,
}
impl MessageContentToolCalls {
@@ -212,6 +234,7 @@ impl MessageContentToolCalls {
tool_results,
text,
sequence: false,
round_starts: vec![],
}
}
@@ -221,6 +244,9 @@ impl MessageContentToolCalls {
{
first.text = Some(text);
}
if !self.tool_results.is_empty() && !tool_results.is_empty() {
self.round_starts.push(self.tool_results.len());
}
self.tool_results.extend(tool_results);
self.sequence = true;
}
@@ -263,3 +289,139 @@ pub fn extract_system_message(messages: &mut Vec<Message>) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn old_session_thinking_blocks_still_deserialize() {
let json = json!([
{ "type": "thinking", "thinking": "hmm", "signature": "sig123" },
{ "type": "redacted_thinking", "data": "b64data" },
]);
let blocks: Vec<ThinkingBlock> = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&blocks[0], ThinkingBlock::Thinking { thinking, signature } if thinking == "hmm" && signature == "sig123")
);
assert!(
matches!(&blocks[1], ThinkingBlock::RedactedThinking { data } if data == "b64data")
);
assert_eq!(serde_json::to_value(&blocks).unwrap(), json);
}
#[test]
fn reasoning_block_round_trips() {
let json = json!({
"type": "reasoning",
"id": "rs_1",
"summary": [{ "type": "summary_text", "text": "thinking about it" }],
"encrypted_content": "enc123",
});
let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&block, ThinkingBlock::Reasoning { id, encrypted_content, .. } if id == "rs_1" && encrypted_content.as_deref() == Some("enc123"))
);
assert_eq!(serde_json::to_value(&block).unwrap(), json);
}
#[test]
fn reasoning_block_without_encrypted_content_round_trips() {
let json = json!({
"type": "reasoning",
"id": "rs_1",
"summary": [],
});
let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&block, ThinkingBlock::Reasoning { encrypted_content, .. } if encrypted_content.is_none())
);
assert_eq!(serde_json::to_value(&block).unwrap(), json);
}
#[test]
fn thinking_blocks_round_trip_through_yaml() {
let json = json!([
{ "type": "thinking", "thinking": "hmm", "signature": "sig123" },
{ "type": "redacted_thinking", "data": "b64data" },
{
"type": "reasoning",
"id": "rs_1",
"summary": [{ "type": "summary_text", "text": "thinking about it" }],
"encrypted_content": "enc123",
},
{
"type": "reasoning",
"id": "rs_2",
"summary": [],
},
]);
let blocks: Vec<ThinkingBlock> = serde_json::from_value(json.clone()).unwrap();
let yaml = serde_yaml::to_string(&blocks).unwrap();
assert!(!yaml.contains("encrypted_content: null"));
let restored: Vec<ThinkingBlock> = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(serde_json::to_value(&restored).unwrap(), json);
}
/// Session files recorded before round tracking carry no `round_starts`
/// key — they must deserialize to an empty default, and an empty
/// `round_starts` must be skipped on serialize so new files keep the old
/// shape until a merge actually records a round boundary.
#[test]
fn tool_calls_round_starts_defaults_and_skips_when_empty() {
let json = json!({
"tool_results": [],
"text": "t",
"sequence": true,
});
let parsed: MessageContentToolCalls = serde_json::from_value(json).unwrap();
assert!(parsed.round_starts.is_empty());
let serialized = serde_json::to_value(&parsed).unwrap();
assert!(
serialized.get("round_starts").is_none(),
"serialized: {serialized}"
);
}
#[test]
fn tool_calls_round_starts_round_trip_through_yaml() {
let mut tool_calls = MessageContentToolCalls::new(vec![], "t".to_string());
tool_calls.round_starts = vec![1, 3];
let yaml = serde_yaml::to_string(&tool_calls).unwrap();
let restored: MessageContentToolCalls = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(restored.round_starts, vec![1, 3]);
}
/// Each `merge()` call is one tool-loop round: the boundary index must be
/// recorded even when the model produced no narration text for the round.
#[test]
fn merge_records_round_start_per_call() {
let tool_result = |id: &str| {
crate::function::ToolResult::new(
crate::function::ToolCall::new("t".into(), json!({}), Some(id.to_string())),
json!("ok"),
)
};
let mut tool_calls = MessageContentToolCalls::new(vec![tool_result("a")], String::new());
tool_calls.merge(vec![tool_result("b"), tool_result("c")], String::new());
tool_calls.merge(vec![tool_result("d")], String::new());
assert!(tool_calls.sequence);
assert_eq!(tool_calls.round_starts, vec![1, 3]);
}
}
+113
View File
@@ -536,4 +536,117 @@ reasoning_levels: [none, low, medium, high]
assert_eq!(data.default_reasoning_effort.as_deref(), Some("none"));
}
#[test]
fn shipped_catalog_carries_no_credentials_and_only_intended_oauth_defaults() {
assert!(
!super::super::common::MODELS_YAML.contains("api_key"),
"the shipped models.yaml must never carry an api key: the model catalog is credential-free by policy"
);
let bundled: Vec<ProviderModels> =
serde_yaml::from_str(super::super::common::MODELS_YAML).unwrap();
let with_oauth: Vec<&str> = bundled
.iter()
.filter(|p| p.oauth.is_some())
.map(|p| p.provider.as_str())
.collect();
assert_eq!(
with_oauth,
["xai"],
"oauth blocks in the shipped models.yaml are limited to providers with no built-in stock oauth path (currently only xai's bundled defaults); adding one for claude/openai/gemini would silently swap stock oauth users onto a non-stock provider"
);
}
#[test]
fn config_models_replace_catalog_by_default() {
let ClientConfig::ClaudeConfig(config) = serde_yaml::from_str::<ClientConfig>(
"type: claude\nmodels:\n - name: test-replace-model\n",
)
.unwrap() else {
panic!("expected a claude config");
};
assert!(!config.extend_models);
let models = crate::client::ClaudeClient::list_models(&config);
assert_eq!(models.len(), 1);
assert_eq!(models[0].name(), "test-replace-model");
}
#[test]
fn config_models_extend_catalog_when_flag_set() {
let ClientConfig::ClaudeConfig(config) = serde_yaml::from_str::<ClientConfig>(
"type: claude\nextend_models: true\nmodels:\n - name: test-extra-model\n",
)
.unwrap() else {
panic!("expected a claude config");
};
let catalog_len = crate::client::ALL_PROVIDER_MODELS
.iter()
.find(|v| v.provider == "claude")
.map(|v| v.models.len())
.unwrap();
let models = crate::client::ClaudeClient::list_models(&config);
assert_eq!(models.len(), catalog_len + 1);
assert_eq!(models.last().unwrap().name(), "test-extra-model");
}
#[test]
fn extend_models_with_empty_models_returns_catalog_exactly() {
let ClientConfig::ClaudeConfig(config) =
serde_yaml::from_str::<ClientConfig>("type: claude\nextend_models: true\n").unwrap()
else {
panic!("expected a claude config");
};
let catalog = crate::client::ALL_PROVIDER_MODELS
.iter()
.find(|v| v.provider == "claude")
.unwrap();
let models = crate::client::ClaudeClient::list_models(&config);
assert_eq!(models.len(), catalog.models.len());
for (model, catalog_model) in models.iter().zip(catalog.models.iter()) {
assert_eq!(model.name(), catalog_model.name);
}
}
#[test]
fn extend_models_without_catalog_match_returns_config_models_only() {
let ClientConfig::OpenAICompatibleConfig(config) = serde_yaml::from_str::<ClientConfig>(
"type: openai-compatible\nname: no-such-provider-xyz\nextend_models: true\nmodels:\n - name: test-only-model\n",
)
.unwrap() else {
panic!("expected an openai-compatible config");
};
assert!(
!crate::client::ALL_PROVIDER_MODELS
.iter()
.any(|v| "no-such-provider-xyz".starts_with(v.provider.as_str())),
"test premise: the client name must not prefix-match any catalog provider"
);
let models = crate::client::OpenAICompatibleClient::list_models(&config);
assert_eq!(models.len(), 1);
assert_eq!(models[0].name(), "test-only-model");
}
#[test]
fn extend_models_extends_catalog_via_provider_name_prefix_match() {
let ClientConfig::OpenAICompatibleConfig(config) = serde_yaml::from_str::<ClientConfig>(
"type: openai-compatible\nname: openrouter-custom\nextend_models: true\nmodels:\n - name: test-net-new-model\n",
)
.unwrap() else {
panic!("expected an openai-compatible config");
};
let catalog_len = crate::client::ALL_PROVIDER_MODELS
.iter()
.find(|v| v.provider == "openrouter")
.map(|v| v.models.len())
.unwrap();
let models = crate::client::OpenAICompatibleClient::list_models(&config);
assert_eq!(models.len(), catalog_len + 1);
assert_eq!(models.last().unwrap().name(), "test-net-new-model");
}
}
+608 -26
View File
@@ -1,5 +1,8 @@
use super::access_token::{clear_rejected, is_rejected, is_valid_access_token, set_access_token};
use super::claude_oauth::ClaudeOAuthProvider;
use super::gemini_oauth::GeminiOAuthProvider;
use super::openai_compatible_oauth::OpenAICompatibleOAuthProvider;
use super::openai_oauth::OpenAIOAuthProvider;
use super::{ClientConfig, ProviderModels};
use crate::config::paths;
use anyhow::{Context, Error, Result, anyhow, bail};
@@ -211,6 +214,9 @@ pub trait OAuthProvider: Send + Sync {
fn use_pkce_in_device_flow(&self) -> bool {
false
}
fn requires_issuer_stamp(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -221,6 +227,10 @@ pub struct OAuthTokens {
pub expires_at: i64,
#[serde(default)]
pub account_id: Option<String>,
/// `"{token_url}|{client_id}"` of the issuing provider, stamped on save.
/// `None` on files predating the stamp; those are grandfathered.
#[serde(default)]
pub issuer: Option<String>,
}
const TOKEN_ENDPOINT_TIMEOUT: Duration = Duration::from_secs(30);
@@ -376,9 +386,10 @@ async fn run_pkce_flow(provider: &dyn OAuthProvider, client_name: &str) -> Resul
refresh_token,
expires_at,
account_id,
issuer: None,
};
save_oauth_tokens(client_name, &tokens)?;
store_tokens(client_name, provider, tokens)?;
println!(
"Successfully authenticated client '{}' with {} via OAuth. Tokens saved.",
@@ -430,8 +441,9 @@ async fn run_client_credentials_flow(
refresh_token: None,
expires_at,
account_id: provider.extract_account_id(&response),
issuer: None,
};
save_oauth_tokens(client_name, &tokens)?;
store_tokens(client_name, provider, tokens)?;
Ok(())
}
@@ -597,8 +609,9 @@ async fn run_device_code_flow(provider: &dyn OAuthProvider, client_name: &str) -
refresh_token,
expires_at,
account_id,
issuer: None,
};
save_oauth_tokens(client_name, &tokens)?;
store_tokens(client_name, provider, tokens)?;
println!(
"Successfully authenticated client '{}' with {} via OAuth (device_code). Tokens saved.",
client_name,
@@ -637,12 +650,22 @@ pub fn load_oauth_tokens(client_name: &str) -> Option<OAuthTokens> {
serde_json::from_str(&content).ok()
}
fn save_oauth_tokens(client_name: &str, tokens: &OAuthTokens) -> Result<()> {
/// Stamps the issuer fingerprint of `provider` before writing so stored
/// tokens stay bound to the provider that issued them.
fn save_oauth_tokens(
client_name: &str,
provider: &(impl OAuthProvider + ?Sized),
tokens: &OAuthTokens,
) -> Result<()> {
let tokens = OAuthTokens {
issuer: Some(issuer_fingerprint(provider)),
..tokens.clone()
};
let path = paths::token_file(client_name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(tokens)?;
let json = serde_json::to_string_pretty(&tokens)?;
// Write-then-rename so a crash mid-write never truncates the live token file.
let mut tmp = path.clone().into_os_string();
tmp.push(".tmp");
@@ -660,6 +683,35 @@ fn save_oauth_tokens(client_name: &str, tokens: &OAuthTokens) -> Result<()> {
Ok(())
}
/// Persists freshly-minted tokens and primes the in-memory cache so a running
/// session picks them up immediately instead of the stale token.
fn store_tokens(
client_name: &str,
provider: &(impl OAuthProvider + ?Sized),
tokens: OAuthTokens,
) -> Result<()> {
save_oauth_tokens(client_name, provider, &tokens)?;
set_access_token(
client_name,
tokens.access_token,
tokens.expires_at,
tokens.account_id,
);
clear_rejected(client_name);
Ok(())
}
fn issuer_fingerprint(provider: &(impl OAuthProvider + ?Sized)) -> String {
format!("{}|{}", provider.token_url(), provider.client_id())
}
fn issuer_matches(tokens: &OAuthTokens, provider: &(impl OAuthProvider + ?Sized)) -> bool {
match &tokens.issuer {
Some(issuer) => *issuer == issuer_fingerprint(provider),
None => !provider.requires_issuer_stamp(),
}
}
pub(crate) fn token_response_keys(response: &Value) -> String {
match response.as_object() {
Some(map) => {
@@ -764,9 +816,10 @@ pub async fn refresh_oauth_token(
refresh_token,
expires_at,
account_id,
issuer: None,
};
save_oauth_tokens(client_name, &new_tokens)?;
save_oauth_tokens(client_name, provider, &new_tokens)?;
Ok(new_tokens)
}
@@ -799,6 +852,10 @@ pub async fn prepare_oauth_access_token(
None => return Ok(false),
};
if !issuer_matches(&tokens, provider) {
return Ok(false);
}
let tokens = if Utc::now().timestamp() >= tokens.expires_at
|| is_rejected(client_name, &tokens.access_token)
{
@@ -817,6 +874,10 @@ pub async fn prepare_oauth_access_token(
None => return Ok(false),
};
if !issuer_matches(&tokens, provider) {
return Ok(false);
}
if Utc::now().timestamp() >= tokens.expires_at
|| is_rejected(client_name, &tokens.access_token)
{
@@ -998,13 +1059,85 @@ fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> {
pub fn get_oauth_provider(provider_type: &str) -> Option<Box<dyn OAuthProvider>> {
match provider_type {
"claude" => Some(Box::new(super::claude_oauth::ClaudeOAuthProvider)),
"gemini" => Some(Box::new(super::gemini_oauth::GeminiOAuthProvider)),
"openai" => Some(Box::new(super::openai_oauth::OpenAIOAuthProvider)),
"claude" => Some(Box::new(ClaudeOAuthProvider)),
"gemini" => Some(Box::new(GeminiOAuthProvider)),
"openai" => Some(Box::new(OpenAIOAuthProvider)),
_ => None,
}
}
pub(crate) fn config_oauth_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
) -> Option<OAuthConfig> {
let (client_name, _, _) = client_config_info(client_config);
let user_oauth = match client_config {
ClientConfig::OpenAICompatibleConfig(c) => c.oauth.clone().map(|b| *b),
ClientConfig::ClaudeConfig(c) => c.oauth.clone().map(|b| *b),
ClientConfig::OpenAIConfig(c) => c.oauth.clone().map(|b| *b),
ClientConfig::GeminiConfig(c) => c.oauth.clone().map(|b| *b),
_ => return None,
};
let base = all_provider_models
.iter()
.find(|p| p.provider == client_name)
.and_then(|p| p.oauth.clone());
match (base, user_oauth) {
(None, None) => None,
(Some(b), None) => Some(b),
(None, Some(u)) => Some(u),
(Some(b), Some(u)) => Some(b.merge(u)),
}
}
/// Resolves a config-driven `oauth:` block to the generic provider, or falls back to `stock`.
/// Returns the provider and whether the stock provider is in use.
fn oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
stock: fn() -> Box<dyn OAuthProvider>,
) -> (Box<dyn OAuthProvider>, bool) {
let (client_name, _, _) = client_config_info(client_config);
match config_oauth_for_client(client_config, all_provider_models) {
Some(merged) => (
Box::new(OpenAICompatibleOAuthProvider {
config: merged,
client_name: client_name.to_string(),
requires_issuer_stamp: true,
}),
false,
),
None => (stock(), true),
}
}
pub(crate) fn claude_oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
) -> (Box<dyn OAuthProvider>, bool) {
oauth_provider_for_client(client_config, all_provider_models, || {
Box::new(ClaudeOAuthProvider)
})
}
pub(crate) fn openai_oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
) -> (Box<dyn OAuthProvider>, bool) {
oauth_provider_for_client(client_config, all_provider_models, || {
Box::new(OpenAIOAuthProvider)
})
}
pub(crate) fn gemini_oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
) -> (Box<dyn OAuthProvider>, bool) {
oauth_provider_for_client(client_config, all_provider_models, || {
Box::new(GeminiOAuthProvider)
})
}
pub fn get_oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
@@ -1015,23 +1148,29 @@ pub fn get_oauth_provider_for_client(
}
match client_config {
ClientConfig::OpenAICompatibleConfig(c) => {
let base = all_provider_models
.iter()
.find(|p| p.provider == client_name)
.and_then(|p| p.oauth.clone());
let user_oauth = c.oauth.clone().map(|b| *b);
let merged = match (base, user_oauth) {
(None, None) => return None,
(Some(b), None) => b,
(None, Some(u)) => u,
(Some(b), Some(u)) => b.merge(u),
};
ClientConfig::OpenAICompatibleConfig(_) => {
let merged = config_oauth_for_client(client_config, all_provider_models)?;
Some(Box::new(OpenAICompatibleOAuthProvider {
config: merged,
client_name: client_name.to_string(),
requires_issuer_stamp: false,
}))
}
ClientConfig::ClaudeConfig(_) => {
let (provider, _) =
claude_oauth_provider_for_client(client_config, all_provider_models);
Some(provider)
}
ClientConfig::OpenAIConfig(_) => {
let (provider, _) =
openai_oauth_provider_for_client(client_config, all_provider_models);
Some(provider)
}
ClientConfig::GeminiConfig(_) => {
let (provider, _) =
gemini_oauth_provider_for_client(client_config, all_provider_models);
Some(provider)
}
_ => get_oauth_provider(provider_type),
}
}
@@ -1097,6 +1236,9 @@ mod tests {
use super::*;
use crate::client::access_token::{distrust_access_token, get_access_token};
use crate::client::claude::ClaudeConfig;
use crate::client::gemini::GeminiConfig;
use crate::client::openai::OpenAIConfig;
use crate::client::openai_compatible::OpenAICompatibleConfig;
use crate::client::{ModelData, ProviderModels};
use crate::utils::get_env_name;
@@ -1315,12 +1457,48 @@ echo_pkce_in_token_exchange: true
ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig {
name: Some(name.into()),
api_base: Some("https://api.example/v1".into()),
api_key: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
models: vec![],
patch: None,
extra: None,
..Default::default()
})
}
fn make_claude_client(
name: &str,
auth: Option<&str>,
oauth: Option<OAuthConfig>,
) -> ClientConfig {
ClientConfig::ClaudeConfig(ClaudeConfig {
name: Some(name.into()),
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
..Default::default()
})
}
fn make_openai_client(
name: &str,
auth: Option<&str>,
oauth: Option<OAuthConfig>,
) -> ClientConfig {
ClientConfig::OpenAIConfig(OpenAIConfig {
name: Some(name.into()),
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
..Default::default()
})
}
fn make_gemini_client(
name: &str,
auth: Option<&str>,
oauth: Option<OAuthConfig>,
) -> ClientConfig {
ClientConfig::GeminiConfig(GeminiConfig {
name: Some(name.into()),
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
..Default::default()
})
}
@@ -1386,6 +1564,173 @@ echo_pkce_in_token_exchange: true
assert!(get_oauth_provider_for_client(&cc, &models).is_none());
}
#[test]
fn claude_client_with_user_oauth_block_uses_generic_provider() {
let mut user = empty_user_override("gateway-id", "https://gateway.example/token");
user.device_authorization_url = Some("https://gateway.example/device".into());
let cc = make_claude_client("claude", Some("oauth"), Some(user));
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "claude");
assert_eq!(provider.client_id(), "gateway-id");
assert_eq!(provider.token_url(), "https://gateway.example/token");
assert_eq!(
provider.device_authorization_url(),
Some("https://gateway.example/device")
);
}
#[test]
fn claude_client_without_oauth_block_falls_back_to_stock_provider() {
let cc = make_claude_client("claude", Some("oauth"), None);
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "claude");
assert_eq!(provider.client_id(), "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
assert_eq!(
provider.extra_request_headers(),
vec![("anthropic-beta", "oauth-2025-04-20")]
);
}
#[test]
fn claude_client_merges_bundled_defaults_with_user_override() {
let base = base_config();
let user = empty_user_override("user-id", "https://user.example/token");
let models = vec![make_provider_models("claude", Some(base))];
let cc = make_claude_client("claude", Some("oauth"), Some(user));
let provider = get_oauth_provider_for_client(&cc, &models).unwrap();
assert_eq!(provider.provider_name(), "claude");
assert_eq!(provider.client_id(), "user-id");
assert_eq!(provider.token_url(), "https://user.example/token");
assert_eq!(provider.authorize_url(), "https://base.example/authorize");
}
#[test]
fn claude_bundled_only_oauth_block_uses_generic_provider() {
let base = base_config();
let models = vec![make_provider_models("claude", Some(base))];
let cc = make_claude_client("claude", Some("oauth"), None);
let (provider, is_stock) = claude_oauth_provider_for_client(&cc, &models);
assert!(!is_stock);
assert_eq!(provider.client_id(), "base-id");
assert_eq!(provider.token_url(), "https://base.example/token");
assert!(provider.extra_request_headers().is_empty());
}
#[test]
fn claude_config_provider_requires_issuer_stamp() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_claude_client("claude", Some("oauth"), Some(user));
let (provider, is_stock) = claude_oauth_provider_for_client(&cc, &[]);
assert!(!is_stock);
assert!(provider.requires_issuer_stamp());
}
#[test]
fn claude_stock_provider_grandfathers_legacy_tokens() {
let cc = make_claude_client("claude", Some("oauth"), None);
let (provider, is_stock) = claude_oauth_provider_for_client(&cc, &[]);
assert!(is_stock);
assert!(!provider.requires_issuer_stamp());
}
#[test]
fn openai_client_with_user_oauth_block_uses_generic_provider() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_openai_client("openai", Some("oauth"), Some(user));
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "openai");
assert_eq!(provider.client_id(), "gateway-id");
assert_eq!(provider.token_url(), "https://gateway.example/token");
}
#[test]
fn openai_client_without_oauth_block_falls_back_to_stock_provider() {
let cc = make_openai_client("openai", Some("oauth"), None);
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "openai");
assert_eq!(provider.client_id(), "app_EMoamEEZ73f0CkXaXp7hrann");
}
#[test]
fn openai_config_provider_requires_issuer_stamp() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_openai_client("openai", Some("oauth"), Some(user));
let (provider, is_stock) = openai_oauth_provider_for_client(&cc, &[]);
assert!(!is_stock);
assert!(provider.requires_issuer_stamp());
}
#[test]
fn openai_stock_provider_grandfathers_legacy_tokens() {
let cc = make_openai_client("openai", Some("oauth"), None);
let (provider, is_stock) = openai_oauth_provider_for_client(&cc, &[]);
assert!(is_stock);
assert!(!provider.requires_issuer_stamp());
}
#[test]
fn gemini_client_with_user_oauth_block_uses_generic_provider() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_gemini_client("gemini", Some("oauth"), Some(user));
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "gemini");
assert_eq!(provider.client_id(), "gateway-id");
assert_eq!(provider.token_url(), "https://gateway.example/token");
}
#[test]
fn gemini_client_without_oauth_block_falls_back_to_stock_provider() {
let cc = make_gemini_client("gemini", Some("oauth"), None);
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "gemini");
assert_eq!(provider.token_url(), "https://oauth2.googleapis.com/token");
}
#[test]
fn gemini_config_provider_requires_issuer_stamp() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_gemini_client("gemini", Some("oauth"), Some(user));
let (provider, is_stock) = gemini_oauth_provider_for_client(&cc, &[]);
assert!(!is_stock);
assert!(provider.requires_issuer_stamp());
}
#[test]
fn gemini_stock_provider_grandfathers_legacy_tokens() {
let cc = make_gemini_client("gemini", Some("oauth"), None);
let (provider, is_stock) = gemini_oauth_provider_for_client(&cc, &[]);
assert!(is_stock);
assert!(!provider.requires_issuer_stamp());
}
#[test]
fn openai_compatible_provider_joins_scopes_with_spaces() {
let mut cfg = base_config();
@@ -1394,6 +1739,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(provider.scopes(), "one two three");
@@ -1408,6 +1754,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(
@@ -1425,6 +1772,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.uses_localhost_redirect());
@@ -1436,6 +1784,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.extra_token_params().is_empty());
@@ -1546,6 +1895,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(
@@ -1559,6 +1909,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.device_authorization_url().is_none());
@@ -1569,6 +1920,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(!provider.use_pkce_in_device_flow());
@@ -1582,6 +1934,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.use_pkce_in_device_flow());
@@ -1596,6 +1949,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(provider.resource(), Some("https://rs.example/"));
@@ -1655,6 +2009,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.fixed_redirect_uri().is_none());
@@ -1669,6 +2024,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(
@@ -1686,6 +2042,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(
@@ -1815,6 +2172,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
let request = build_token_request(
@@ -1967,6 +2325,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
let request = build_token_request(
@@ -1991,6 +2350,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
let url = build_authorize_url(
@@ -2010,6 +2370,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
let url = build_authorize_url(
@@ -2032,6 +2393,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
let url = build_authorize_url(
@@ -2062,6 +2424,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
let params = build_device_authorization_params(&provider, "a b", None);
@@ -2076,6 +2439,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
let params = build_device_authorization_params(&provider, "a b", Some("challenge"));
@@ -2094,9 +2458,10 @@ extra_token_params:
refresh_token: Some("rt-456".into()),
expires_at: 1234567890,
account_id: Some("acct-789".into()),
issuer: None,
};
save_oauth_tokens("atomic-test", &tokens).unwrap();
save_oauth_tokens("atomic-test", &ResourceStubProvider, &tokens).unwrap();
let loaded = load_oauth_tokens("atomic-test").unwrap();
assert_eq!(loaded.access_token, "at-123");
@@ -2130,11 +2495,13 @@ extra_token_params:
let expires_at = Utc::now().timestamp() + 3600;
save_oauth_tokens(
client_name,
&ResourceStubProvider,
&OAuthTokens {
access_token: "rejected-at".into(),
refresh_token: None,
expires_at,
account_id: None,
issuer: None,
},
)
.unwrap();
@@ -2169,11 +2536,13 @@ extra_token_params:
assert!(distrust_access_token(client_name, "rejected-at"));
save_oauth_tokens(
client_name,
&ResourceStubProvider,
&OAuthTokens {
access_token: "fresh-at".into(),
refresh_token: None,
expires_at,
account_id: None,
issuer: None,
},
)
.unwrap();
@@ -2198,6 +2567,219 @@ extra_token_params:
});
}
#[test]
#[serial]
fn store_tokens_caches_token_and_clears_rejection() {
with_temp_cache(|| {
let client_name = "store-tokens-test";
let expires_at = Utc::now().timestamp() + 3600;
set_access_token(client_name, "old-at".into(), expires_at, None);
assert!(distrust_access_token(client_name, "old-at"));
store_tokens(
client_name,
&ResourceStubProvider,
OAuthTokens {
access_token: "fresh-at".into(),
refresh_token: None,
expires_at,
account_id: None,
issuer: None,
},
)
.unwrap();
assert_eq!(get_access_token(client_name).unwrap(), "fresh-at");
assert!(is_valid_access_token(client_name));
assert!(
!is_rejected(client_name, "old-at"),
"rejection marker not cleared by store_tokens"
);
});
}
fn write_raw_token_file(client_name: &str, json: &str) {
let path = paths::token_file(client_name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, json).unwrap();
}
#[test]
#[serial]
fn save_oauth_tokens_stamps_issuer_fingerprint() {
with_temp_cache(|| {
save_oauth_tokens(
"issuer-stamp-test",
&ResourceStubProvider,
&OAuthTokens {
access_token: "at".into(),
refresh_token: None,
expires_at: 1234567890,
account_id: None,
issuer: None,
},
)
.unwrap();
let loaded = load_oauth_tokens("issuer-stamp-test").unwrap();
assert_eq!(
loaded.issuer.as_deref(),
Some("https://as.example/token|stub-client")
);
});
}
#[test]
#[serial]
fn prepare_rejects_unexpired_tokens_with_mismatched_issuer() {
with_temp_cache(|| {
let client_name = "issuer-mismatch-valid-test";
let expires_at = Utc::now().timestamp() + 3600;
write_raw_token_file(
client_name,
&format!(
r#"{{"access_token":"foreign-at","expires_at":{expires_at},"issuer":"https://old.example/token|old-client"}}"#
),
);
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&ResourceStubProvider,
client_name,
))
.unwrap();
assert!(!ready);
assert!(
get_access_token(client_name).is_err(),
"foreign token must not enter the cache"
);
});
}
#[test]
#[serial]
fn prepare_rejects_expired_mismatched_issuer_without_refreshing() {
with_temp_cache(|| {
let client_name = "issuer-mismatch-expired-test";
write_raw_token_file(
client_name,
r#"{"access_token":"foreign-at","refresh_token":"foreign-rt","expires_at":0,"issuer":"https://old.example/token|old-client"}"#,
);
// A refresh attempt would POST to the stub's token_url and fail
// with a transport error; Ok(false) proves the refresh token was
// never forwarded.
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&ResourceStubProvider,
client_name,
))
.unwrap();
assert!(!ready);
});
}
#[test]
#[serial]
fn prepare_accepts_legacy_tokens_without_issuer() {
with_temp_cache(|| {
let client_name = "issuer-legacy-test";
let expires_at = Utc::now().timestamp() + 3600;
write_raw_token_file(
client_name,
&format!(r#"{{"access_token":"legacy-at","expires_at":{expires_at}}}"#),
);
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&ResourceStubProvider,
client_name,
))
.unwrap();
assert!(ready);
assert_eq!(get_access_token(client_name).unwrap(), "legacy-at");
});
}
#[test]
#[serial]
fn prepare_rejects_legacy_tokens_for_claude_config_provider() {
with_temp_cache(|| {
let client_name = "issuer-legacy-claude-config-test";
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_claude_client(client_name, Some("oauth"), Some(user));
let (provider, _) = claude_oauth_provider_for_client(&cc, &[]);
write_raw_token_file(
client_name,
r#"{"access_token":"legacy-at","refresh_token":"legacy-rt","expires_at":0}"#,
);
// Legacy files predate config-driven claude oauth, so they are
// Anthropic-issued; Ok(false) proves neither the access token
// nor the refresh token reaches the configured endpoint.
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&*provider,
client_name,
))
.unwrap();
assert!(!ready);
assert!(
get_access_token(client_name).is_err(),
"legacy token must not enter the cache"
);
});
}
#[test]
#[serial]
fn prepare_accepts_legacy_tokens_for_stock_claude_provider() {
with_temp_cache(|| {
let client_name = "issuer-legacy-claude-stock-test";
let cc = make_claude_client(client_name, Some("oauth"), None);
let (provider, _) = claude_oauth_provider_for_client(&cc, &[]);
let expires_at = Utc::now().timestamp() + 3600;
write_raw_token_file(
client_name,
&format!(r#"{{"access_token":"legacy-at","expires_at":{expires_at}}}"#),
);
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&*provider,
client_name,
))
.unwrap();
assert!(ready);
assert_eq!(get_access_token(client_name).unwrap(), "legacy-at");
});
}
#[test]
fn parse_refresh_response_invalid_grant_redacts_and_prompts_reauth() {
let response = serde_json::json!({
+1497 -154
View File
File diff suppressed because it is too large. Load diff
+158 -10
View File
@@ -9,15 +9,18 @@ use serde::Deserialize;
use serde_json::{Value, json};
use oauth::OAuthConfig;
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct OpenAICompatibleConfig {
pub name: Option<String>,
pub api_base: Option<String>,
pub api_key: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
pub wire_api: Option<WireApi>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
@@ -42,10 +45,13 @@ impl Client for OpenAICompatibleClient {
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
let request_data = prepare_chat_completions(self, client, data).await?;
let (request_data, wire) = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
openai_chat_completions(builder, self.model()).await
match wire {
WireApi::Responses => openai_responses_chat_completions(builder, self.model()).await,
WireApi::Chat => openai_chat_completions(builder, self.model()).await,
}
}
async fn chat_completions_streaming_inner(
@@ -54,10 +60,15 @@ impl Client for OpenAICompatibleClient {
handler: &mut SseHandler,
data: ChatCompletionsData,
) -> Result<()> {
let request_data = prepare_chat_completions(self, client, data).await?;
let (request_data, wire) = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
openai_chat_completions_streaming(builder, handler, self.model()).await
match wire {
WireApi::Responses => openai_responses_streaming(builder, handler).await,
WireApi::Chat => {
openai_chat_completions_streaming(builder, handler, self.model()).await
}
}
}
async fn embeddings_inner(
@@ -87,15 +98,22 @@ async fn prepare_chat_completions(
self_: &OpenAICompatibleClient,
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<RequestData> {
) -> Result<(RequestData, WireApi)> {
let wire = resolve_wire_api(self_.config.wire_api, false, false)?;
let api_base = get_api_base_ext(self_)?;
let url = format!("{api_base}/chat/completions");
let body = openai_build_chat_completions_body(data, &self_.model);
let mut request_data = RequestData::new(url, body);
let url = match wire {
WireApi::Responses => format!("{api_base}/responses"),
WireApi::Chat => format!("{api_base}/chat/completions"),
};
let body = match wire {
WireApi::Responses => openai_build_responses_body(data, &self_.model),
WireApi::Chat => openai_build_chat_completions_body(data, &self_.model),
};
let mut request_data = RequestData::new(url, body).wire(wire);
apply_auth(self_, client, &mut request_data).await?;
Ok(request_data)
Ok((request_data, wire))
}
async fn prepare_embeddings(
@@ -246,3 +264,133 @@ pub fn generic_build_rerank_body(data: &RerankData, model: &Model) -> Value {
}
body
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use std::sync::Arc;
fn compat_config(wire_api: Option<WireApi>) -> OpenAICompatibleConfig {
OpenAICompatibleConfig {
name: Some("compat-test".into()),
api_base: Some("https://compat.example/v1".into()),
api_key: Some("sk-test".into()),
wire_api,
..Default::default()
}
}
fn make_client(config: OpenAICompatibleConfig) -> OpenAICompatibleClient {
OpenAICompatibleClient {
app_config: Arc::new(AppConfig::default()),
config,
model: Model::new("compat-test", "gpt-test"),
}
}
fn prepare(
client: &OpenAICompatibleClient,
stream: bool,
) -> Result<(RequestData, WireApi)> {
let data = ChatCompletionsData {
messages: vec![Message::new(
MessageRole::User,
MessageContent::Text("hello".to_string()),
)],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream,
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_chat_completions(
client,
&ReqwestClient::new(),
data,
))
}
#[test]
fn wire_api_deserializes_on_openai_compatible_config() {
let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: responses").unwrap();
assert_eq!(config.wire_api, Some(WireApi::Responses));
let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: chat").unwrap();
assert_eq!(config.wire_api, Some(WireApi::Chat));
}
#[test]
fn bogus_wire_api_is_a_config_error() {
let err = serde_yaml::from_str::<OpenAICompatibleConfig>("wire_api: bogus")
.unwrap_err()
.to_string();
assert!(
err.contains("responses") && err.contains("chat"),
"unexpected error: {err}"
);
}
#[test]
fn default_wire_is_chat() {
let client = make_client(compat_config(None));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Chat);
assert_eq!(
request_data.url,
"https://compat.example/v1/chat/completions"
);
assert!(
request_data.body.get("messages").is_some(),
"body: {}",
request_data.body
);
}
#[test]
fn explicit_responses_wire_posts_a_responses_body_to_the_responses_endpoint() {
let client = make_client(compat_config(Some(WireApi::Responses)));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, "https://compat.example/v1/responses");
assert_eq!(request_data.body["store"], json!(false));
assert!(
request_data.body.get("input").is_some() && request_data.body.get("messages").is_none(),
"body: {}",
request_data.body
);
}
#[test]
fn explicit_responses_wire_forks_the_streaming_path_too() {
let client = make_client(compat_config(Some(WireApi::Responses)));
let (request_data, wire) = prepare(&client, true).unwrap();
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, "https://compat.example/v1/responses");
assert_eq!(request_data.body["stream"], json!(true));
}
#[test]
fn explicit_chat_wire_keeps_the_chat_endpoint() {
let client = make_client(compat_config(Some(WireApi::Chat)));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Chat);
assert_eq!(
request_data.url,
"https://compat.example/v1/chat/completions"
);
}
}
+5
View File
@@ -5,6 +5,7 @@ use super::oauth::{OAuthConfig, OAuthFlow, OAuthProvider, TokenRequestFormat};
pub struct OpenAICompatibleOAuthProvider {
pub config: OAuthConfig,
pub client_name: String,
pub requires_issuer_stamp: bool,
}
fn is_loopback_uri(uri: &str) -> bool {
@@ -122,4 +123,8 @@ impl OAuthProvider for OpenAICompatibleOAuthProvider {
fn use_pkce_in_device_flow(&self) -> bool {
self.config.use_pkce_in_device_flow
}
fn requires_issuer_stamp(&self) -> bool {
self.requires_issuer_stamp
}
}
+25
View File
@@ -215,6 +215,14 @@ impl SseHandler {
!self.buffer.is_empty() || !self.tool_calls.is_empty() || !self.thinking.is_empty()
}
/// Whether visible output (text or tool calls) has been accumulated.
/// Unlike [`Self::has_received_content`], thinking blocks do not count:
/// a stream truncated before any visible output is still an empty
/// response to the caller.
pub fn has_received_visible_output(&self) -> bool {
!self.buffer.is_empty() || !self.tool_calls.is_empty()
}
pub fn abort(&self) -> AbortSignal {
self.abort_signal.clone()
}
@@ -544,6 +552,23 @@ mod tests {
assert!(handler.has_received_content());
}
/// Pins the deliberate exclusion: thinking blocks count as received
/// content but not as visible output, so a thinking-only stream is
/// still an empty response to the caller.
#[test]
fn thinking_blocks_do_not_count_as_visible_output() {
let (mut handler, _rx) = new_handler();
handler.thinking_block(ThinkingBlock::Thinking {
thinking: "hmm".to_string(),
signature: "sig".to_string(),
});
assert!(handler.has_received_content());
assert!(!handler.has_received_visible_output());
handler.text("hello").unwrap();
assert!(handler.has_received_visible_output());
}
/// Claude splits usage across events: `message_start` carries the input
/// and cache counts with a provisional output count, `message_delta`
/// carries only the final output count. Later non-None fields must win.
+3
View File
@@ -18,6 +18,8 @@ pub struct VertexAIConfig {
pub adc_file: Option<String>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
@@ -600,6 +602,7 @@ mod tests {
tool_results: vec![],
text: "leftover text".to_string(),
sequence: false,
round_starts: vec![],
}),
),
],
+510 -19
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::hooks::{HooksMap, RagSyncHooks};
use crate::{
client::Model,
config::memory,
@@ -12,11 +13,14 @@ use crate::{
};
use super::rag_cache::RagKey;
use crate::config::builtin_manifest;
use crate::config::conflict::{self, InstallMode, StickyMode};
use crate::config::paths;
use crate::config::prompts::{
DEFAULT_JOB_INSTRUCTIONS, DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS,
DEFAULT_TODO_INSTRUCTIONS, DEFAULT_USER_INTERACTION_INSTRUCTIONS,
};
use crate::function::write_file_atomic;
use crate::graph::types::RagNode;
use crate::graph::{Graph, GraphParser, NodeType};
use crate::mcp::McpServerFeatures;
@@ -27,7 +31,8 @@ use fancy_regex::Captures;
use inquire::{Text, validator::Validation};
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
use std::{env, ffi::OsStr, path::Path};
use std::collections::{BTreeMap, BTreeSet};
use std::{env, path::Path};
const DEFAULT_AGENT_NAME: &str = "rag";
@@ -46,6 +51,115 @@ pub type AgentVariables = IndexMap<String, String>;
#[folder = "assets/agents/"]
struct AgentAssets;
/// Splits an embedded asset path of the form `<agent>/hooks/<file>` into
/// its agent and hook-file names. Only direct children of `hooks/` count:
/// they are the only shape the builtin manifest tracks.
fn parse_direct_hook_path(path: &str) -> Option<(&str, &str)> {
let (agent, rest) = path.split_once('/')?;
let hook = direct_hook_name(rest)?;
Some((agent, hook))
}
fn direct_hook_name(rest: &str) -> Option<&str> {
let name = rest.strip_prefix("hooks/")?;
(!name.is_empty() && !name.contains('/')).then_some(name)
}
/// Installs one bundled agent's hook scripts under `<agent>/hooks/` and
/// reconciles that directory through its builtin manifest, mirroring the
/// role-side hook installer.
pub(crate) fn install_and_reconcile_agent_hooks(
agent: &str,
shipped: &[(String, String)],
mode: InstallMode,
sticky: &mut StickyMode,
) -> Result<()> {
let dir = paths::agents_data_dir().join(agent).join("hooks");
let mut written = BTreeSet::new();
for (name, content) in shipped {
let path = dir.join(name);
if path.exists()
&& !conflict::should_replace_existing(&path, content, "agents", mode, sticky)?
{
debug!(
"Agent hook file already exists, skipping: {}",
path.display()
);
continue;
}
ensure_parent_exists(&path)?;
info!("Creating agent hook file: {}", path.display());
write_file_atomic(&path, content, None)?;
set_executable_bit_if_script(&path)?;
written.insert(name.clone());
}
let names: BTreeSet<String> = shipped.iter().map(|(name, _)| name.clone()).collect();
// Reconciliation is best-effort housekeeping: a failure here must not
// abort the install, matching the role-side policy.
if let Err(err) = builtin_manifest::reconcile_builtin_dir(&dir, &names, &written) {
warn!("Failed to reconcile builtin hooks for agent '{agent}': {err}");
}
Ok(())
}
/// An agent the embed stopped bundling is never visited by the per-agent
/// reconcile loop, so its installed hooks and manifest would orphan forever.
/// Any agent directory that carries a builtin hooks manifest but is absent
/// from the current embed gets its manifest-owned hooks removed, each
/// deletion logged individually by the reconcile. Only the installer writes
/// manifests, but a manual copy of a builtin agent directory carries the
/// hidden manifest along: files it lists are treated as installer-owned and
/// swept, while files absent from the manifest always survive.
/// Best-effort: a failure here must not abort the install.
fn sweep_removed_agent_hooks(bundled_files: &HashMap<String, HashSet<String>>) {
let entries = match read_dir(paths::agents_data_dir()) {
Ok(entries) => entries,
Err(err) => {
warn!("Failed to scan agents dir for removed-agent hooks: {err}");
return;
}
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if bundled_files.contains_key(name) {
continue;
}
// A symlinked agent dir is user-arranged: never follow it into a
// hooks tree that lives somewhere else.
if !entry
.path()
.symlink_metadata()
.is_ok_and(|metadata| metadata.is_dir())
{
continue;
}
let hooks_dir = entry.path().join("hooks");
if !hooks_dir
.join(builtin_manifest::BUILTIN_MANIFEST_FILE)
.is_file()
{
continue;
}
info!(
"Removing hooks shipped by removed built-in agent '{name}' in {}",
hooks_dir.display()
);
if let Err(err) =
builtin_manifest::reconcile_builtin_dir(&hooks_dir, &BTreeSet::new(), &BTreeSet::new())
{
warn!("Failed to reconcile builtin hooks for removed agent '{name}': {err}");
continue;
}
// remove_dir only succeeds on an empty directory, so a user file
// left behind keeps the directory in place.
let _ = fs::remove_dir(&hooks_dir);
}
}
#[derive(Debug, Clone)]
pub struct Agent {
name: String,
@@ -64,27 +178,32 @@ pub struct Agent {
}
impl Agent {
pub fn install_builtin_agents(force: bool) -> Result<()> {
pub fn install_builtin_agents(mode: InstallMode) -> Result<()> {
info!(
"Installing built-in agents in {}",
paths::agents_data_dir().display()
);
let mut sticky = StickyMode::None;
let mut written_hooks: HashMap<String, BTreeSet<String>> = HashMap::new();
for file in AgentAssets::iter() {
debug!("Processing agent file: {}", file.as_ref());
let embedded_file = AgentAssets::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded agent file: {}", file.as_ref()))?;
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
let content = std::str::from_utf8(&embedded_file.data)
.expect("bundled agent asset is not valid UTF-8");
let file_path = paths::agents_data_dir().join(file.as_ref());
let file_extension = file_path
.extension()
.and_then(OsStr::to_str)
.map(|s| s.to_lowercase());
#[cfg_attr(not(unix), expect(unused))]
let is_script = matches!(file_extension.as_deref(), Some("sh") | Some("py"));
if file_path.exists() && !force {
if file_path.exists()
&& !conflict::should_replace_existing(
&file_path,
content,
"agents",
mode,
&mut sticky,
)?
{
debug!(
"Agent file already exists, skipping: {}",
file_path.display()
@@ -96,11 +215,12 @@ impl Agent {
info!("Creating agent file: {}", file_path.display());
let mut agent_file = File::create(&file_path)?;
agent_file.write_all(content.as_bytes())?;
#[cfg(unix)]
if is_script {
use std::{fs, os::unix::fs::PermissionsExt};
fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755))?;
set_executable_bit_if_script(&file_path)?;
if let Some((agent, hook)) = parse_direct_hook_path(file.as_ref()) {
written_hooks
.entry(agent.to_string())
.or_default()
.insert(hook.to_string());
}
}
@@ -134,6 +254,53 @@ impl Agent {
}
}
// Hook filenames are open-ended (unlike AGENT_DEFINITION_FILES), so
// each bundled agent's hooks/ directory reconciles through its
// builtin manifest: only files the installer previously shipped are
// removal candidates, and user-created files in the same directory
// are never touched. Only direct children of hooks/ are tracked;
// nested shipped files install but are not reconciled.
for (agent, files) in &bundled_files {
let shipped: BTreeSet<String> = files
.iter()
.filter_map(|rest| direct_hook_name(rest))
.map(str::to_string)
.collect();
let written = written_hooks.remove(agent.as_str()).unwrap_or_default();
let hooks_dir = paths::agents_data_dir().join(agent).join("hooks");
if let Err(err) =
builtin_manifest::reconcile_builtin_dir(&hooks_dir, &shipped, &written)
{
warn!("Failed to reconcile builtin hooks for agent '{agent}': {err}");
}
}
sweep_removed_agent_hooks(&bundled_files);
Ok(())
}
pub fn install_builtin_agent_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> {
let mut shipped: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
for file in AgentAssets::iter() {
let Some((agent, rest)) = file.as_ref().split_once('/') else {
continue;
};
let hooks = shipped.entry(agent.to_string()).or_default();
let Some(hook) = direct_hook_name(rest) else {
continue;
};
let embedded = AgentAssets::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded agent file: {}", file.as_ref()))?;
let content = std::str::from_utf8(&embedded.data)
.expect("bundled agent hook asset is not valid UTF-8");
hooks.push((hook.to_string(), content.to_string()));
}
for (agent, files) in &shipped {
install_and_reconcile_agent_hooks(agent, files, mode, sticky)?;
}
Ok(())
}
@@ -189,6 +356,13 @@ impl Agent {
}
};
let rag_sync_hooks = RagSyncHooks::resolve_for_agent(
&app.hooks,
&agent_config.global_hooks,
&agent_config.hooks,
name,
);
let rag = if rag_path.exists() {
let key = RagKey::Agent(name.to_string());
let app_clone = app.clone();
@@ -221,6 +395,7 @@ impl Agent {
let app_clone = app.clone();
let rag_path_clone = rag_path.clone();
let abort = abort_signal.clone();
let sync_hooks = rag_sync_hooks.clone();
let rag = app_state
.rag_cache
.load_with(key, || async move {
@@ -231,6 +406,7 @@ impl Agent {
&document_paths,
abort,
true,
sync_hooks,
)
.await
})
@@ -254,6 +430,7 @@ impl Agent {
&loaders,
info_flag,
abort_signal.clone(),
&rag_sync_hooks,
)
.await?
}
@@ -639,6 +816,14 @@ impl Agent {
self.config.compression_model.as_deref()
}
pub fn hooks(&self) -> &HooksMap {
&self.config.hooks
}
pub fn global_hooks(&self) -> &[String] {
&self.config.global_hooks
}
pub fn is_dynamic_instructions(&self) -> bool {
self.config.dynamic_instructions
}
@@ -848,6 +1033,10 @@ pub struct AgentConfig {
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)]
pub global_tools: Vec<String>,
#[serde(default)]
pub hooks: HooksMap,
#[serde(default)]
pub global_hooks: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -917,6 +1106,8 @@ impl AgentConfig {
reasoning_effort: graph.reasoning_effort.clone(),
description: graph.description.clone(),
global_tools: graph.global_tools.clone(),
hooks: graph.hooks.clone(),
global_hooks: graph.global_hooks.clone(),
mcp_servers: graph.mcp_servers.clone(),
mcp_tools: graph.mcp_tools.clone(),
skills_enabled: graph.skills_enabled,
@@ -960,10 +1151,21 @@ impl AgentConfig {
if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) {
self.reasoning_effort = v;
}
if let Ok(v) = env::var(with_prefix("global_tools"))
&& let Ok(v) = serde_json::from_str(&v)
{
self.global_tools = v;
if let Ok(v) = env::var(with_prefix("global_tools")) {
match serde_json::from_str(&v) {
Ok(v) => self.global_tools = v,
Err(err) => {
debug!("Ignoring malformed global_tools env override for agent '{name}': {err}")
}
}
}
if let Ok(v) = env::var(with_prefix("global_hooks")) {
match serde_json::from_str(&v) {
Ok(v) => self.global_hooks = v,
Err(err) => {
debug!("Ignoring malformed global_hooks env override for agent '{name}': {err}")
}
}
}
if let Ok(v) = env::var(with_prefix("mcp_servers"))
&& let Ok(v) = serde_json::from_str(&v)
@@ -1111,6 +1313,7 @@ async fn init_graph_rags(
loaders: &HashMap<String, String>,
info_flag: bool,
abort_signal: AbortSignal,
sync_hooks: &RagSyncHooks,
) -> Result<HashMap<String, Arc<Rag>>> {
let mut rags = HashMap::new();
if info_flag {
@@ -1187,6 +1390,7 @@ async fn init_graph_rags(
let path_clone = rag_path.clone();
let name_clone = node_id.clone();
let abort = abort_signal.clone();
let sync_hooks = sync_hooks.clone();
app_state
.rag_cache
.load_with(key, || async move {
@@ -1197,6 +1401,7 @@ async fn init_graph_rags(
&document_paths,
&config,
abort,
sync_hooks,
)
.await
})
@@ -1294,6 +1499,17 @@ pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>
#[cfg(test)]
mod tests {
use super::*;
use crate::testing;
#[test]
fn parse_direct_hook_path_accepts_only_direct_hook_children() {
assert_eq!(parse_direct_hook_path("x/hooks/a.sh"), Some(("x", "a.sh")));
assert_eq!(parse_direct_hook_path("x/hooks/nested/a.sh"), None);
assert_eq!(parse_direct_hook_path("hooks/a.sh"), None);
assert_eq!(parse_direct_hook_path("x/hooks/"), None);
assert_eq!(parse_direct_hook_path("x/config.yaml"), None);
assert_eq!(parse_direct_hook_path("config.yaml"), None);
}
#[test]
fn agent_config_parses_from_yaml() {
@@ -1336,6 +1552,119 @@ variables:
assert_eq!(config.variables[0].name, "username");
}
#[test]
fn agent_config_hooks_and_global_hooks_round_trip() {
let yaml = "\
name: hooked
instructions: hi
hooks:
tool.started:
- name: notify
command: ./hooks/notify.sh
global_hooks:
- tool.started.notify
";
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.hooks["tool.started"][0].name, "notify");
assert_eq!(config.hooks["tool.started"][0].command, "./hooks/notify.sh");
assert_eq!(config.global_hooks, vec!["tool.started.notify"]);
let serialized = serde_yaml::to_string(&config).unwrap();
let reparsed: AgentConfig = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(reparsed.hooks, config.hooks);
assert_eq!(reparsed.global_hooks, config.global_hooks);
}
#[test]
fn load_envs_overrides_global_hooks() {
let yaml = "name: hooks-env-probe\ninstructions: hi\nglobal_hooks:\n - initial.hook\n";
let mut config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
let env_name = normalize_env_name("hooks-env-probe_global_hooks");
let prev = env::var_os(&env_name);
unsafe {
env::set_var(
&env_name,
r#"["tool.started.notify","turn.completed.webhook"]"#,
)
};
config.load_envs(&AppConfig::default());
assert_eq!(
config.global_hooks,
vec!["tool.started.notify", "turn.completed.webhook"]
);
unsafe { env::set_var(&env_name, "not json") };
config.load_envs(&AppConfig::default());
assert_eq!(
config.global_hooks,
vec!["tool.started.notify", "turn.completed.webhook"]
);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
#[serial_test::serial]
fn load_envs_ignores_malformed_global_tools_and_logs_debug() {
testing::install_log_collector();
let name = "gt-malformed-envtest";
let yaml = format!("name: {name}\ninstructions: hi\nglobal_tools:\n - keep_tool.sh\n");
let mut config: AgentConfig = serde_yaml::from_str(&yaml).unwrap();
let env_name = normalize_env_name(&format!("{name}_global_tools"));
let _guard = testing::EnvVarGuard::set(&env_name, "{not-json");
config.load_envs(&AppConfig::default());
assert_eq!(
config.global_tools,
vec!["keep_tool.sh"],
"a malformed override must leave the existing value untouched"
);
let debugs = testing::debug_snapshot();
assert!(
debugs.iter().any(|message| {
message.contains("Ignoring malformed global_tools env override")
&& message.contains(name)
}),
"expected a debug log for the malformed override: {debugs:?}"
);
}
#[test]
fn from_graph_carries_hooks_and_global_hooks() {
let yaml = "\
name: g
start: e
hooks:
turn.completed:
- name: webhook
command: curl -s https://example.com/hook
global_hooks:
- turn.completed.webhook
nodes:
e:
id: e
type: end
output: done
";
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let config = AgentConfig::from_graph("g", &graph);
assert_eq!(config.hooks, graph.hooks);
assert_eq!(config.global_hooks, graph.global_hooks);
assert_eq!(config.hooks["turn.completed"][0].name, "webhook");
assert_eq!(config.global_hooks, vec!["turn.completed.webhook"]);
}
#[test]
fn agent_config_defaults() {
let yaml = "name: minimal\ninstructions: hi\n";
@@ -1350,6 +1679,8 @@ variables:
assert_eq!(config.escalation_timeout, 0);
assert!(config.mcp_servers.is_empty());
assert!(config.global_tools.is_empty());
assert!(config.hooks.is_empty());
assert!(config.global_hooks.is_empty());
assert!(config.conversation_starters.is_empty());
assert!(config.variables.is_empty());
assert!(config.model_id.is_none());
@@ -1731,4 +2062,164 @@ nodes: {}
);
assert_eq!(output, expected);
}
use crate::testing::TestConfigDirGuard;
fn fixture(entries: &[(&str, &str)]) -> Vec<(String, String)> {
entries
.iter()
.map(|(name, content)| (name.to_string(), content.to_string()))
.collect()
}
#[test]
#[serial_test::serial]
fn agent_hooks_prompt_mode_asks_per_conflict_and_honors_sticky() {
use crate::config::conflict::prompt_script;
let _guard = TestConfigDirGuard::new("agent-hooks-prompt");
let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]);
let dir = paths::agents_data_dir().join("probe").join("hooks");
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "local a").unwrap();
fs::write(dir.join("b.sh"), "local b").unwrap();
// First conflict answered per-file, second by the sticky replace-all.
let script = prompt_script::install(&["replace-all"]);
let mut sticky = StickyMode::None;
install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky)
.unwrap();
assert_eq!(prompt_script::prompts_asked(), 1);
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
assert_eq!(read_to_string(dir.join("b.sh")).unwrap(), "new content\n");
assert_eq!(sticky, StickyMode::ReplaceAll);
assert_eq!(
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"a.sh\nb.sh\n"
);
drop(script);
// A sticky mode carried in from an earlier location suppresses all
// prompting: `--install-builtins hooks` shares one sticky scope
// across the global, role, and agent hook locations.
fs::write(dir.join("a.sh"), "local again").unwrap();
let script = prompt_script::install(&[]);
let mut sticky = StickyMode::KeepAll;
install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky)
.unwrap();
assert_eq!(prompt_script::prompts_asked(), 0);
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "local again");
drop(script);
}
#[test]
#[serial_test::serial]
fn agent_hooks_reconcile_adds_updates_and_drops_within_a_shipped_agent() {
let _guard = TestConfigDirGuard::new("agent-hooks-reconcile");
let dir = paths::agents_data_dir().join("probe").join("hooks");
install_and_reconcile_agent_hooks(
"probe",
&fixture(&[("keep.sh", "v1\n"), ("drop.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
install_and_reconcile_agent_hooks(
"probe",
&fixture(&[("keep.sh", "v2\n"), ("added.sh", "#!/bin/sh\n")]),
InstallMode::Force,
&mut StickyMode::None,
)
.unwrap();
assert_eq!(
read_to_string(dir.join("keep.sh")).unwrap(),
"v2\n",
"a still-shipped hook updates under Force"
);
assert_eq!(
read_to_string(dir.join("added.sh")).unwrap(),
"#!/bin/sh\n",
"a newly shipped hook is added"
);
assert!(
!dir.join("drop.sh").exists(),
"a dropped hook reconciles away"
);
assert_eq!(
read_to_string(dir.join("user.sh")).unwrap(),
"user-owned",
"user files in the same dir survive"
);
assert_eq!(
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"added.sh\nkeep.sh\n"
);
}
#[test]
#[serial_test::serial]
fn install_builtin_agent_hooks_reconciles_hookless_bundled_agents() {
let _guard = TestConfigDirGuard::new("agent-hooks-hookless");
let agent = AgentAssets::iter()
.filter_map(|file| {
file.as_ref()
.split_once('/')
.map(|(agent, _)| agent.to_string())
})
.find(|agent| {
!AgentAssets::iter().any(|file| {
parse_direct_hook_path(file.as_ref())
.is_some_and(|(hook_agent, _)| hook_agent == agent)
})
})
.expect("at least one bundled agent ships no hooks");
let dir = paths::agents_data_dir().join(&agent).join("hooks");
// A manifest-owned hook left over from a release that shipped it.
install_and_reconcile_agent_hooks(
&agent,
&fixture(&[("stale.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
Agent::install_builtin_agent_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
assert!(
!dir.join("stale.sh").exists(),
"a still-bundled agent with no hook assets must reconcile its hooks dir"
);
assert_eq!(read_to_string(dir.join("user.sh")).unwrap(), "user-owned");
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn sweep_removed_agent_hooks_never_follows_symlinked_agent_dirs() {
let guard = TestConfigDirGuard::new("agent-sweep-symlink");
let real = guard.path.join("agent-elsewhere");
let hooks = real.join("hooks");
create_dir_all(&hooks).unwrap();
fs::write(
hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE),
"shipped.sh\n",
)
.unwrap();
fs::write(hooks.join("shipped.sh"), "stale").unwrap();
create_dir_all(paths::agents_data_dir()).unwrap();
std::os::unix::fs::symlink(&real, paths::agents_data_dir().join("linked")).unwrap();
sweep_removed_agent_hooks(&HashMap::new());
assert!(
hooks.join("shipped.sh").exists(),
"the sweep must never follow a symlinked agent dir"
);
assert!(hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists());
}
}
+122 -1
View File
@@ -1,4 +1,5 @@
use crate::client::{ClientConfig, Model, ModelType, list_models};
use crate::hooks::HooksMap;
use crate::render::{MarkdownRender, RenderOptions};
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, drain_stale_tty_input, get_env_name};
@@ -52,6 +53,9 @@ pub struct AppConfig {
pub enabled_mcp_servers: Option<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)]
pub hooks: HooksMap,
pub auto_continue: bool,
pub max_auto_continues: usize,
pub inject_todo_instructions: bool,
@@ -67,6 +71,7 @@ pub struct AppConfig {
pub compression_threshold: usize,
pub compression_keep_last: usize,
pub compression_model: Option<String>,
pub compression_safety_valve: f32,
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>,
@@ -143,6 +148,8 @@ impl Default for AppConfig {
enabled_mcp_servers: None,
mcp_tools: None,
hooks: Default::default(),
auto_continue: false,
max_auto_continues: 10,
inject_todo_instructions: true,
@@ -158,6 +165,7 @@ impl Default for AppConfig {
compression_threshold: 4000,
compression_keep_last: 0,
compression_model: None,
compression_safety_valve: 0.8,
summarization_prompt: None,
summary_context_prompt: None,
max_tool_result_chars: None,
@@ -204,6 +212,18 @@ impl Default for AppConfig {
impl AppConfig {
pub fn from_config(config: super::Config) -> Result<Self> {
Self::from_config_impl(config, true)
}
/// Like [`Self::from_config`], but tolerates a config with no resolvable
/// model: `model_id` stays empty instead of failing with "No available
/// model". For inspection readouts (--info, --list-*, --mcp-list,
/// --list-secrets), which render model fields as absent/default.
pub fn from_config_lenient(config: super::Config) -> Result<Self> {
Self::from_config_impl(config, false)
}
fn from_config_impl(config: super::Config, require_model: bool) -> Result<Self> {
let mut app_config = Self {
model_id: config.model_id,
temperature: config.temperature,
@@ -235,6 +255,8 @@ impl AppConfig {
enabled_mcp_servers: config.enabled_mcp_servers,
mcp_tools: config.mcp_tools,
hooks: config.hooks,
auto_continue: config.auto_continue,
max_auto_continues: config.max_auto_continues,
inject_todo_instructions: config.inject_todo_instructions,
@@ -250,6 +272,7 @@ impl AppConfig {
compression_threshold: config.compression_threshold,
compression_keep_last: config.compression_keep_last,
compression_model: config.compression_model,
compression_safety_valve: config.compression_safety_valve,
summarization_prompt: config.summarization_prompt,
summary_context_prompt: config.summary_context_prompt,
max_tool_result_chars: config.max_tool_result_chars,
@@ -298,7 +321,13 @@ impl AppConfig {
}
app_config.setup_document_loaders();
app_config.setup_user_agent();
app_config.resolve_model()?;
if require_model {
app_config.resolve_model()?;
} else {
// Model-less is fine here: model_id stays empty and inspection
// callers substitute Model::default().
let _ = app_config.resolve_model();
}
app_config.validate_reasoning_effort()?;
Ok(app_config)
}
@@ -572,6 +601,15 @@ impl AppConfig {
self.enabled_mcp_servers = v.map(|raw| super::csv_to_vec(&raw));
}
if let Ok(v) = env::var(get_env_name("hooks")) {
match serde_json::from_str(&v) {
Ok(v) => self.hooks = v,
Err(err) => {
debug!("Ignoring malformed hooks env override: {err}")
}
}
}
if let Some(v) = super::read_env_value::<String>(&get_env_name("repl_prelude")) {
self.repl_prelude = v;
}
@@ -788,6 +826,77 @@ mod tests {
assert_eq!(app.clients.len(), 1);
}
#[test]
fn from_config_copies_hooks() {
let mut hooks = HooksMap::new();
hooks.insert(
"turn.completed".to_string(),
vec![crate::hooks::HookDef {
name: "notify".to_string(),
command: "./hooks/notify.sh".to_string(),
}],
);
let cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
hooks: hooks.clone(),
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.hooks, hooks);
}
#[test]
fn from_config_defaults_hooks_to_empty() {
let cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert!(app.hooks.is_empty());
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_hooks() {
let env_name = get_env_name("hooks");
let prev = env::var_os(&env_name);
let mut hooks = HooksMap::new();
hooks.insert(
"turn.completed".to_string(),
vec![crate::hooks::HookDef {
name: "notify".to_string(),
command: "./hooks/notify.sh".to_string(),
}],
);
let mut app = AppConfig::default();
unsafe { env::set_var(&env_name, serde_json::to_string(&hooks).unwrap()) };
app.load_envs();
assert_eq!(app.hooks, hooks);
unsafe { env::set_var(&env_name, "[not json") };
app.load_envs();
assert_eq!(app.hooks, hooks);
unsafe { env::remove_var(&env_name) };
app.load_envs();
assert_eq!(app.hooks, hooks);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
fn from_config_copies_mapping_fields() {
let mut cfg = Config {
@@ -1181,6 +1290,18 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn from_config_errors_when_no_model_available() {
let result = AppConfig::from_config(Config::default());
assert!(result.is_err());
}
#[test]
fn from_config_lenient_tolerates_missing_model() {
let app = AppConfig::from_config_lenient(Config::default()).unwrap();
assert!(app.model_id.is_empty());
}
#[test]
fn resolve_model_keeps_explicit_model_id() {
let mut app = AppConfig {
+6 -1
View File
@@ -43,6 +43,7 @@ impl AppState {
log_path: Option<PathBuf>,
start_mcp_servers: bool,
lenient_vault: bool,
inspection: bool,
abort_signal: AbortSignal,
) -> Result<Self> {
let vault = if lenient_vault {
@@ -74,7 +75,11 @@ impl AppState {
}
}
let mut functions = Functions::init(config.visible_tools.as_deref())?;
let mut functions = if inspection {
Functions::default()
} else {
Functions::init(config.visible_tools.as_deref())?
};
if !mcp_registry.is_empty() && config.mcp_server_support {
functions.append_mcp_meta_functions(mcp_registry.server_features());
}
+398
View File
@@ -0,0 +1,398 @@
//! Per-directory tracking of files the builtin installer shipped, so a later
//! release that stops shipping an asset can remove the stale copy without
//! ever touching user-created files that live in the same directory.
//!
//! Unlike agent definition files, hook filenames are open-ended: no closed
//! candidate list can distinguish "previously bundled" from "user-authored".
//! The manifest records exactly what the installer shipped; anything absent
//! from it is user-owned and untouchable.
use crate::config::ensure_parent_exists;
use crate::function::write_file_atomic;
use anyhow::{Context, Result};
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
pub(crate) const BUILTIN_MANIFEST_FILE: &str = ".builtin-manifest";
pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
name.trim_end_matches(['.', ' '])
.to_uppercase()
.eq_ignore_ascii_case(BUILTIN_MANIFEST_FILE)
}
/// NTFS 8.3 short names (`BUILTI~1`, `builti~1.sh`) can alias any long
/// filename on the same volume, including the manifest itself, so an entry
/// shaped like one is never accepted: it could direct a deletion at a file
/// the manifest never named.
fn is_ntfs_short_name_alias(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or(name);
let Some((base, digits)) = stem.rsplit_once('~') else {
return false;
};
!base.is_empty()
&& base.len() <= 8
&& base.chars().all(|c| c.is_ascii_alphanumeric() || c == '~')
&& !digits.is_empty()
&& digits.chars().all(|c| c.is_ascii_digit())
}
/// Reconciles `dir` against the currently shipped set of builtin filenames:
/// a file is removed ONLY if it appears in the previous manifest AND is
/// absent from `shipped`. The manifest is then rewritten to the names this
/// installer actually owns: files written this run (`written`), plus shipped
/// names it already owned, plus stale files whose removal failed (kept so a
/// later run retries). A shipped name the installer declined to write (e.g., a
/// pre-existing user file with a colliding name) is never claimed.
///
/// Failure modes degrade toward keeping files: a missing or unreadable
/// manifest yields no deletions, and entries that are not plain filenames
/// are ignored.
pub(crate) fn reconcile_builtin_dir(
dir: &Path,
shipped: &BTreeSet<String>,
written: &BTreeSet<String>,
) -> Result<()> {
let previous = read_manifest(dir);
let mut retained: BTreeSet<String> = written.clone();
retained.extend(shipped.intersection(&previous).cloned());
for name in previous.difference(shipped) {
let path = dir.join(name);
if !path.is_file() {
continue;
}
info!(
"Removing stale builtin file no longer shipped: {}",
path.display()
);
if let Err(err) = fs::remove_file(&path) {
warn!(
"Failed to remove stale builtin file {}: {err}",
path.display()
);
retained.insert(name.clone());
}
}
write_manifest(dir, &retained)
}
fn read_manifest(dir: &Path) -> BTreeSet<String> {
let path = dir.join(BUILTIN_MANIFEST_FILE);
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
debug!(
"No readable builtin manifest at {} ({err}); skipping stale-file removal",
path.display()
);
return BTreeSet::new();
}
};
content
.lines()
.map(str::trim)
.filter(|line| {
if line.is_empty() || is_builtin_manifest_name(line) {
return false;
}
if !is_plain_file_name(line) || is_ntfs_short_name_alias(line) {
debug!(
"Ignoring suspicious builtin manifest entry in {}: {line:?}",
path.display()
);
return false;
}
true
})
.map(str::to_string)
.collect()
}
/// Manifest entries name direct children of the manifest's directory and
/// nothing else; anything path-like is rejected so a corrupted or crafted
/// manifest can never direct a deletion outside that directory. The same
/// component rules the bundle store applies to recorded paths are reused so
/// Windows drive prefixes (`C:x`), ADS colons, trailing dots/spaces, and
/// reserved device names are rejected too.
fn is_plain_file_name(name: &str) -> bool {
!name.contains(['/', '\\'])
&& name != "."
&& name != ".."
&& super::install_remote::is_safe_component(name)
}
fn write_manifest(dir: &Path, names: &BTreeSet<String>) -> Result<()> {
let path = dir.join(BUILTIN_MANIFEST_FILE);
if names.is_empty() {
if path.is_file() {
fs::remove_file(&path)
.with_context(|| format!("failed to remove builtin manifest {}", path.display()))?;
}
return Ok(());
}
let mut content = names.iter().cloned().collect::<Vec<_>>().join("\n");
content.push('\n');
ensure_parent_exists(&path)?;
write_file_atomic(&path, &content, None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils;
use std::path::PathBuf;
fn fresh_dir(label: &str) -> PathBuf {
let dir = utils::temp_file(label, "");
fs::create_dir_all(&dir).unwrap();
dir
}
fn shipped(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|n| n.to_string()).collect()
}
#[test]
fn removes_only_manifest_listed_files_absent_from_shipped() {
let dir = fresh_dir("builtin-manifest-stale-");
fs::write(dir.join(BUILTIN_MANIFEST_FILE), "old.sh\nkept.sh\n").unwrap();
fs::write(dir.join("old.sh"), "stale").unwrap();
fs::write(dir.join("kept.sh"), "current").unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["kept.sh"]), &shipped(&[])).unwrap();
assert!(!dir.join("old.sh").exists(), "stale bundled file removed");
assert!(dir.join("kept.sh").exists(), "still-shipped file kept");
assert!(dir.join("user.sh").exists(), "unlisted user file untouched");
assert_eq!(
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
"kept.sh\n"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_manifest_deletes_nothing_and_writes_shipped_set() {
let dir = fresh_dir("builtin-manifest-missing-");
fs::write(dir.join("user.sh"), "user-owned").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["new.sh"]), &shipped(&["new.sh"])).unwrap();
assert!(dir.join("user.sh").exists());
assert_eq!(
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
"new.sh\n"
);
let _ = fs::remove_dir_all(&dir);
}
/// A shipped name the installer skipped (pre-existing user file with a
/// colliding name) is never claimed by the manifest, so a later release
/// dropping that hook can never delete the user's file.
#[test]
fn skipped_preexisting_user_file_is_never_claimed() {
let dir = fresh_dir("builtin-manifest-unclaimed-");
fs::write(dir.join("user.sh"), "user-owned").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["user.sh"]), &shipped(&[])).unwrap();
assert!(
!dir.join(BUILTIN_MANIFEST_FILE).exists(),
"nothing was written this run and nothing was owned before"
);
reconcile_builtin_dir(&dir, &shipped(&[]), &shipped(&[])).unwrap();
assert!(
dir.join("user.sh").exists(),
"the unclaimed user file survives the hook being dropped"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn suspicious_manifest_entries_never_direct_deletions() {
let dir = fresh_dir("builtin-manifest-suspicious-");
let outside = fresh_dir("builtin-manifest-outside-");
let escape = format!(
"../{}/victim.sh",
outside.file_name().unwrap().to_string_lossy()
);
fs::write(
dir.join(BUILTIN_MANIFEST_FILE),
format!("{escape}\nsub/nested.sh\nC:victim.sh\ntrailing.\n..\n.\n\n"),
)
.unwrap();
fs::write(outside.join("victim.sh"), "keep").unwrap();
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("sub/nested.sh"), "keep").unwrap();
reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap();
assert!(outside.join("victim.sh").exists());
assert!(dir.join("sub/nested.sh").exists());
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&outside);
}
#[test]
fn unreadable_manifest_deletes_nothing() {
let dir = fresh_dir("builtin-manifest-unreadable-");
fs::write(dir.join(BUILTIN_MANIFEST_FILE), [0xff, 0xfe, 0x00]).unwrap();
fs::write(dir.join("old.sh"), "keep").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["new.sh"]), &shipped(&["new.sh"])).unwrap();
assert!(
dir.join("old.sh").exists(),
"a bad manifest must fail safe toward keeping files"
);
assert_eq!(
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
"new.sh\n",
"manifest self-heals to the current shipped set"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn empty_shipped_set_removes_manifest_and_creates_nothing() {
let dir = fresh_dir("builtin-manifest-empty-");
fs::write(dir.join(BUILTIN_MANIFEST_FILE), "old.sh\n").unwrap();
fs::write(dir.join("old.sh"), "stale").unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap();
assert!(!dir.join("old.sh").exists());
assert!(dir.join("user.sh").exists());
assert!(!dir.join(BUILTIN_MANIFEST_FILE).exists());
let absent = dir.join("never-created");
reconcile_builtin_dir(&absent, &BTreeSet::new(), &BTreeSet::new()).unwrap();
assert!(!absent.exists(), "no directory is created for an empty set");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn manifest_listing_itself_never_deletes_it() {
let dir = fresh_dir("builtin-manifest-self-");
fs::write(
dir.join(BUILTIN_MANIFEST_FILE),
format!("{BUILTIN_MANIFEST_FILE}\n.BUILTIN-MANIFEST\nkept.sh\n"),
)
.unwrap();
fs::write(dir.join("kept.sh"), "current").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["kept.sh"]), &shipped(&[])).unwrap();
assert!(dir.join(BUILTIN_MANIFEST_FILE).exists());
assert!(dir.join("kept.sh").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn manifest_name_matcher_covers_filesystem_aliases() {
assert!(is_builtin_manifest_name(".builtin-manifest"));
assert!(is_builtin_manifest_name(".BUILTIN-MANIFEST"));
assert!(is_builtin_manifest_name(".Builtin-Manifest."));
assert!(is_builtin_manifest_name(".builtin-manifest "));
assert!(is_builtin_manifest_name(".builtin-manifeſt"));
assert!(!is_builtin_manifest_name("builtin-manifest"));
assert!(!is_builtin_manifest_name(".builtin-manifesto"));
assert!(!is_builtin_manifest_name("notify.sh"));
}
#[test]
fn ntfs_short_name_aliases_are_detected() {
assert!(is_ntfs_short_name_alias("BUILTI~1"));
assert!(is_ntfs_short_name_alias("builti~1.sh"));
assert!(is_ntfs_short_name_alias("NOTIFY~12"));
assert!(is_ntfs_short_name_alias("A~1"));
assert!(!is_ntfs_short_name_alias("notify.sh"));
assert!(!is_ntfs_short_name_alias("my~hook.sh"));
assert!(!is_ntfs_short_name_alias("~1"));
assert!(!is_ntfs_short_name_alias("way-too-long~1"));
assert!(!is_ntfs_short_name_alias(".builtin-manifest"));
}
/// This fixture is unrealizable on NTFS: `.builtin-manifest` gets an
/// auto-generated 8.3 short name of exactly this shape (`BUILTI~1`), so
/// writing `dir/BUILTI~1` opens the manifest through its alias and
/// clobbers it instead of creating a distinct file — the precise
/// collision [`is_ntfs_short_name_alias`] defends against. Only
/// filesystems without 8.3 aliasing can host alias-shaped names as real,
/// distinct files; Windows coverage lives in
/// `ntfs_short_name_entries_never_accepted_on_windows` below.
#[cfg(unix)]
#[test]
fn ntfs_short_name_entries_never_direct_deletions() {
let dir = fresh_dir("builtin-manifest-shortname-");
fs::write(dir.join(BUILTIN_MANIFEST_FILE), "BUILTI~1\nbuilti~1.sh\n").unwrap();
fs::write(dir.join("BUILTI~1"), "keep").unwrap();
fs::write(dir.join("builti~1.sh"), "keep").unwrap();
reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap();
assert!(
dir.join("BUILTI~1").exists() && dir.join("builti~1.sh").exists(),
"8.3-alias-shaped manifest entries must never be removal candidates"
);
let _ = fs::remove_dir_all(&dir);
}
/// Windows counterpart of `ntfs_short_name_entries_never_direct_deletions`:
/// alias-shaped files cannot exist as distinct fixtures here, so the guard
/// invariant is proven through the manifest filter instead. Only entries
/// accepted into the previous-manifest set can become removal candidates
/// (`previous.difference(shipped)`) or be claimed as owned
/// (`shipped ∩ previous`); listing the alias names in `shipped` makes the
/// second path observable in the rewritten manifest — a broken guard
/// would claim them. Where the volume generates 8.3 short names, the
/// self-collision that makes the unix fixture unrealizable is also
/// asserted directly; where generation is disabled, the alias simply
/// never materializes and the invariant assertions stand on their own.
#[cfg(windows)]
#[test]
fn ntfs_short_name_entries_never_accepted_on_windows() {
let dir = fresh_dir("builtin-manifest-shortname-win-");
fs::write(
dir.join(BUILTIN_MANIFEST_FILE),
"BUILTI~1\nbuilti~1.sh\nkept.sh\n",
)
.unwrap();
fs::write(dir.join("kept.sh"), "current").unwrap();
let alias = dir.join("BUILTI~1");
if alias.exists() {
assert_eq!(
fs::read_to_string(&alias).unwrap(),
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
"the 8.3 alias opens the manifest itself, not a distinct file"
);
}
reconcile_builtin_dir(
&dir,
&shipped(&["BUILTI~1", "builti~1.sh", "kept.sh"]),
&shipped(&[]),
)
.unwrap();
assert!(dir.join("kept.sh").exists(), "real shipped file untouched");
assert!(
dir.join(BUILTIN_MANIFEST_FILE).exists(),
"manifest survives reconciliation"
);
assert_eq!(
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
"kept.sh\n",
"alias-shaped entries are never accepted: neither removal candidates nor claimed"
);
let _ = fs::remove_dir_all(&dir);
}
}
+7
View File
@@ -1183,6 +1183,13 @@ mod tests {
assert_eq!(resolved.name, "y/mcp_config");
assert_eq!(resolved.qualified_from.as_deref(), Some("mcp_config"));
let resolved = store
.resolve_bundle_name("https://github.com/z/hooks", None)
.unwrap();
assert_eq!(resolved.name, "z/hooks");
assert_eq!(resolved.qualified_from.as_deref(), Some("hooks"));
}
#[test]
+364
View File
@@ -0,0 +1,364 @@
//! Shared keep/replace conflict resolution for installers that must not
//! silently clobber locally modified files. The bundle installer and the
//! builtin hook installers all funnel per-file conflicts through
//! [`resolve_conflict`], so the prompt wording and the sticky
//! keep-all/replace-all semantics are identical everywhere, including when
//! one sticky scope spans several install locations in a single run.
use crate::utils::IS_STDOUT_TERMINAL;
use anyhow::{Context, Result, bail};
use inquire::Select;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InstallMode {
Skip,
Force,
Prompt,
}
/// A `keep-all`/`replace-all` answer outlives the file it was given for:
/// the caller threads one value through every subsequent conflict in the
/// same run.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum StickyMode {
None,
KeepAll,
ReplaceAll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConflictAction {
Keep,
Replace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NonInteractive {
Bail,
Keep,
}
pub(crate) fn should_replace_existing(
dst: &Path,
content: &str,
category: &str,
mode: InstallMode,
sticky: &mut StickyMode,
) -> Result<bool> {
match mode {
InstallMode::Skip => Ok(false),
InstallMode::Force => Ok(true),
InstallMode::Prompt => {
if fs::read(dst).is_ok_and(|local| local == content.as_bytes()) {
return Ok(false);
}
match resolve_conflict(dst, category, sticky, NonInteractive::Keep)? {
ConflictAction::Keep => Ok(false),
ConflictAction::Replace => Ok(true),
}
}
}
}
pub(crate) fn resolve_conflict(
dst: &Path,
category: &str,
sticky: &mut StickyMode,
non_interactive: NonInteractive,
) -> Result<ConflictAction> {
match *sticky {
StickyMode::KeepAll => return Ok(ConflictAction::Keep),
StickyMode::ReplaceAll => return Ok(ConflictAction::Replace),
StickyMode::None => {}
}
if !is_interactive() {
match non_interactive {
NonInteractive::Keep => return Ok(ConflictAction::Keep),
NonInteractive::Bail => bail!(
"Refusing to overwrite local file {} non-interactively. \
Re-run in a terminal, with --install-force (installs), \
or with --yes (updates).",
dst.display()
),
}
}
let prompt = format!("Conflict at {} (category: {category})", dst.display());
let choice = select_choice(&prompt)?;
match choice.as_str() {
"keep" => Ok(ConflictAction::Keep),
"replace" => Ok(ConflictAction::Replace),
"keep-all" => {
*sticky = StickyMode::KeepAll;
Ok(ConflictAction::Keep)
}
"replace-all" => {
*sticky = StickyMode::ReplaceAll;
Ok(ConflictAction::Replace)
}
"abort" => bail!("Install aborted by user at conflict resolution."),
_ => unreachable!("inquire::Select returned an unexpected option"),
}
}
fn is_interactive() -> bool {
#[cfg(test)]
if let Some(forced) = prompt_script::forced_tty() {
return forced;
}
*IS_STDOUT_TERMINAL
}
fn select_choice(prompt: &str) -> Result<String> {
#[cfg(test)]
if prompt_script::active() {
return Ok(prompt_script::next_answer(prompt));
}
Select::new(
prompt,
vec!["keep", "replace", "keep-all", "replace-all", "abort"],
)
.prompt()
.map(str::to_string)
.with_context(|| "failed to read conflict choice")
}
#[cfg(test)]
pub(crate) mod prompt_script {
//! Scripted stand-in for the interactive conflict prompt.
//! `inquire::Select` cannot run under the test harness, so while a guard
//! is installed the resolver takes its terminal answer from the guard and
//! serves each prompt from a queue of scripted answers, counting every
//! prompt asked. A forced-terminal guard with an empty queue pins the
//! "never prompts" invariant: any prompt panics and the counter stays 0.
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
const INACTIVE: usize = 0;
const TTY: usize = 1;
const NON_TTY: usize = 2;
static STATE: AtomicUsize = AtomicUsize::new(INACTIVE);
static ASKED: AtomicUsize = AtomicUsize::new(0);
// A test that panics while holding the queue poisons its mutex; every
// lock site recovers via `into_inner` so later tests keep working.
static ANSWERS: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// Installs the script with a forced terminal until the guard drops.
/// The script is process-global, so tests using it must serialize
/// (`#[serial]`). Answers are consumed front to back; a prompt beyond
/// the scripted answers panics.
#[must_use]
pub fn install(answers: &[&str]) -> ScriptGuard {
install_with_state(TTY, answers)
}
/// Installs the script with the terminal forced absent, for pinning the
/// non-interactive behavior regardless of where the tests actually run.
#[must_use]
pub fn install_non_interactive() -> ScriptGuard {
install_with_state(NON_TTY, &[])
}
fn install_with_state(state: usize, answers: &[&str]) -> ScriptGuard {
*ANSWERS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
answers.iter().map(|answer| answer.to_string()).collect();
ASKED.store(0, Ordering::SeqCst);
STATE.store(state, Ordering::SeqCst);
ScriptGuard
}
pub struct ScriptGuard;
impl Drop for ScriptGuard {
fn drop(&mut self) {
STATE.store(INACTIVE, Ordering::SeqCst);
ANSWERS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clear();
}
}
/// Number of prompts asked since the script was installed.
pub fn prompts_asked() -> usize {
ASKED.load(Ordering::SeqCst)
}
pub(super) fn active() -> bool {
STATE.load(Ordering::SeqCst) != INACTIVE
}
pub(super) fn forced_tty() -> Option<bool> {
match STATE.load(Ordering::SeqCst) {
TTY => Some(true),
NON_TTY => Some(false),
_ => None,
}
}
pub(super) fn next_answer(prompt: &str) -> String {
ASKED.fetch_add(1, Ordering::SeqCst);
let mut answers = ANSWERS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert!(
!answers.is_empty(),
"conflict prompt asked with no scripted answer left: {prompt}"
);
answers.remove(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::path::PathBuf;
fn dst() -> PathBuf {
PathBuf::from("/tmp/coyote-conflict-tests/example.yaml")
}
#[test]
fn sticky_modes_short_circuit_without_prompting() {
let mut sticky = StickyMode::KeepAll;
assert!(matches!(
resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap(),
ConflictAction::Keep
));
let mut sticky = StickyMode::ReplaceAll;
assert!(matches!(
resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap(),
ConflictAction::Replace
));
}
#[test]
#[serial]
fn non_interactive_bail_keeps_the_bundle_error_shape() {
let _script = prompt_script::install_non_interactive();
let mut sticky = StickyMode::None;
let err =
resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap_err();
assert!(
err.to_string().contains("Refusing to overwrite local file"),
"got: {err}"
);
assert!(err.to_string().contains("example.yaml"), "got: {err}");
assert_eq!(prompt_script::prompts_asked(), 0);
}
#[test]
#[serial]
fn non_interactive_keep_resolves_without_error_or_prompt() {
let _script = prompt_script::install_non_interactive();
let mut sticky = StickyMode::None;
let action = resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap();
assert!(matches!(action, ConflictAction::Keep));
assert_eq!(sticky, StickyMode::None);
assert_eq!(prompt_script::prompts_asked(), 0);
}
#[test]
#[serial]
fn scripted_answers_drive_actions_and_sticky_transitions() {
let _script = prompt_script::install(&["keep", "replace", "replace-all"]);
let mut sticky = StickyMode::None;
assert!(matches!(
resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(),
ConflictAction::Keep
));
assert_eq!(sticky, StickyMode::None);
assert!(matches!(
resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(),
ConflictAction::Replace
));
assert_eq!(sticky, StickyMode::None);
assert!(matches!(
resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(),
ConflictAction::Replace
));
assert_eq!(sticky, StickyMode::ReplaceAll);
// The sticky answer now short-circuits: no further prompt is asked.
assert!(matches!(
resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(),
ConflictAction::Replace
));
assert_eq!(prompt_script::prompts_asked(), 3);
}
#[test]
#[serial]
fn abort_answer_bails_with_the_bundle_abort_message() {
let _script = prompt_script::install(&["abort"]);
let mut sticky = StickyMode::None;
let err = resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap_err();
assert!(
err.to_string()
.contains("Install aborted by user at conflict resolution."),
"got: {err}"
);
}
#[test]
#[serial]
fn should_replace_existing_skips_identical_content_without_prompting() {
let _script = prompt_script::install(&[]);
let dir = crate::utils::temp_file("conflict-identical-", "");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("hook.sh");
fs::write(&path, "same content").unwrap();
let mut sticky = StickyMode::None;
let replace = should_replace_existing(
&path,
"same content",
"hooks",
InstallMode::Prompt,
&mut sticky,
)
.unwrap();
let _ = fs::remove_dir_all(&dir);
assert!(!replace);
assert_eq!(prompt_script::prompts_asked(), 0);
}
#[test]
#[serial]
fn should_replace_existing_never_prompts_in_skip_or_force_mode() {
let _script = prompt_script::install(&[]);
let dir = crate::utils::temp_file("conflict-modes-", "");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("hook.sh");
fs::write(&path, "local edit").unwrap();
let mut sticky = StickyMode::None;
let skip =
should_replace_existing(&path, "shipped", "hooks", InstallMode::Skip, &mut sticky);
let force =
should_replace_existing(&path, "shipped", "hooks", InstallMode::Force, &mut sticky);
let _ = fs::remove_dir_all(&dir);
assert!(!skip.unwrap());
assert!(force.unwrap());
assert_eq!(prompt_script::prompts_asked(), 0);
}
}
+114
View File
@@ -68,6 +68,34 @@ impl Input {
})
}
/// An input carrying only `text`: no session history, no RAG, and no
/// function declarations. For auxiliary LLM calls (such as session
/// summarization) whose requests must not grow with the session.
pub fn detached_text(ctx: &RequestContext, text: &str) -> Self {
let mut role = Role::new("", "");
role.set_model(ctx.current_model().clone());
Self {
app_config: Arc::clone(&ctx.app.config),
stream_enabled: ctx.app.config.stream,
session: None,
rag: None,
functions: None,
text: text.to_string(),
raw: (text.to_string(), vec![]),
patched_text: None,
last_reply: None,
continue_output: None,
regenerate: false,
medias: Default::default(),
data_urls: Default::default(),
tool_calls: None,
role,
rag_name: None,
with_session: false,
with_agent: false,
}
}
pub async fn from_files(
ctx: &RequestContext,
raw_text: &str,
@@ -329,6 +357,14 @@ impl Input {
}
}
/// Re-captures `ctx.session` in place of the snapshot taken at
/// construction. Session compression mutates the ctx's session, not the
/// snapshot, so an input that outlives a compression must refresh before
/// building its next request or it re-sends the pre-compression history.
pub fn refresh_session(&mut self, ctx: &RequestContext) {
self.session = ctx.session.clone();
}
pub fn session_mut<'a>(&self, session: &'a mut Option<Session>) -> Option<&'a mut Session> {
if self.with_session {
session.as_mut()
@@ -660,6 +696,60 @@ mod tests {
assert!(!with_session);
}
#[test]
fn detached_text_input_carries_no_session_history_or_functions() {
let mut ctx = create_test_ctx();
ctx.session = Some(Session::default());
let earlier = Input::from_str(&ctx, "earlier question", None).unwrap();
ctx.session
.as_mut()
.unwrap()
.add_message(&earlier, "earlier answer")
.unwrap();
let detached = Input::detached_text(&ctx, "summarize the above");
let messages = detached.build_messages().unwrap();
assert_eq!(messages.len(), 1, "must contain only the detached text");
assert!(messages[0].role.is_user());
assert_eq!(messages[0].content.to_text(), "summarize the above");
assert!(detached.declared_function_names().is_empty());
assert!(!detached.with_session());
assert!(!detached.with_agent());
}
#[test]
fn refresh_session_rebuilds_from_compressed_session() {
let mut ctx = create_test_ctx();
ctx.session = Some(Session::default());
for i in 0..4 {
let question =
Input::from_str(&ctx, &format!("question {i}: {}", "x".repeat(500)), None).unwrap();
ctx.session
.as_mut()
.unwrap()
.add_message(&question, &"y".repeat(500))
.unwrap();
}
let mut input = Input::from_str(&ctx, "retry me", None).unwrap();
let stale = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
ctx.session.as_mut().unwrap().compress("recap".into(), 1);
assert_eq!(
serde_json::to_string(&input.build_messages().unwrap()).unwrap(),
stale,
"the snapshot captured at construction must be untouched by compression"
);
input.refresh_session(&ctx);
let refreshed = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
assert!(
refreshed.len() < stale.len(),
"a refreshed input must build a strictly smaller request after compression"
);
}
#[test]
fn resolve_paths_detects_last_reply_syntax() {
let loaders = HashMap::new();
@@ -1023,6 +1113,7 @@ mod tests {
assert!(!tool_calls.sequence);
assert_eq!(tool_calls.tool_results.len(), 1);
assert!(tool_calls.tool_results[0].text.is_none());
assert!(tool_calls.round_starts.is_empty());
}
#[test]
@@ -1044,6 +1135,29 @@ mod tests {
tool_calls.tool_results[1].text,
Some("second text".to_string())
);
assert_eq!(tool_calls.round_starts, vec![1]);
}
/// Regression: rounds where the model emitted only tool calls (no
/// narration text) carry no text marker, so `round_starts` is the only
/// record of the round boundary — it must be set on every merge.
#[test]
fn merge_tool_results_records_round_starts_for_textless_rounds() {
let ctx = create_test_ctx();
let input = Input::from_str(&ctx, "test", None)
.unwrap()
.merge_tool_results(String::new(), vec![tool_result("id-1", "ok")])
.merge_tool_results(
String::new(),
vec![tool_result("id-2", "ok2"), tool_result("id-3", "ok3")],
)
.merge_tool_results(String::new(), vec![tool_result("id-4", "ok4")]);
let tool_calls = input.tool_calls().as_ref().unwrap();
assert!(tool_calls.sequence);
assert_eq!(tool_calls.tool_results.len(), 4);
assert!(tool_calls.tool_results.iter().all(|t| t.text.is_none()));
assert_eq!(tool_calls.round_starts, vec![1, 3]);
}
#[test]
+343 -85
View File
@@ -2,9 +2,11 @@ use super::bundles::{
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes,
hash_file,
};
use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths};
#[cfg(not(windows))]
use crate::function::Language;
use crate::config::builtin_manifest::is_builtin_manifest_name;
use crate::config::conflict::{ConflictAction, NonInteractive, StickyMode, resolve_conflict};
use crate::config::{
AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths, set_executable_bit_if_script,
};
use crate::mcp::{McpServer, McpServersConfig};
use crate::utils;
use crate::utils::IS_STDOUT_TERMINAL;
@@ -32,7 +34,7 @@ pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool)
if layout.is_empty() {
println!(
"No recognized assets found in {git_url}. Expected one or more of: \
agents/, roles/, skills/, macros/, functions/tools/, mcp.json"
agents/, roles/, skills/, macros/, functions/tools/, hooks/, mcp.json"
);
return Ok(());
}
@@ -805,7 +807,7 @@ fn is_safe_relative_path(path: &str) -> bool {
/// Rejects names Windows refuses or silently rewrites (alternate data stream
/// colons, reserved device names, trailing dots or spaces) so a recorded path
/// denotes the same regular file on every platform.
fn is_safe_component(name: &str) -> bool {
pub(crate) fn is_safe_component(name: &str) -> bool {
!name.contains(':')
&& !name.ends_with('.')
&& !name.ends_with(' ')
@@ -1270,6 +1272,7 @@ struct RemoteLayout {
roles: Option<PathBuf>,
skills: Option<PathBuf>,
macros: Option<PathBuf>,
hooks: Option<PathBuf>,
functions_tools: Option<PathBuf>,
mcp_json: Option<PathBuf>,
manifest: Option<BundleManifest>,
@@ -1282,6 +1285,7 @@ impl RemoteLayout {
&& self.roles.is_none()
&& self.skills.is_none()
&& self.macros.is_none()
&& self.hooks.is_none()
&& self.functions_tools.is_none()
&& self.mcp_json.is_none()
}
@@ -1313,6 +1317,11 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
layout.macros = Some(macros);
}
let hooks = root.join("hooks");
if hooks.is_dir() {
layout.hooks = Some(hooks);
}
let root_mcp = root.join("mcp.json");
if root_mcp.is_file() {
layout.mcp_json = Some(root_mcp);
@@ -1540,6 +1549,10 @@ fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> Remo
functions_tools: layout.functions_tools.take(),
..base
},
InstallFilter::Hooks => RemoteLayout {
hooks: layout.hooks.take(),
..base
},
InstallFilter::McpConfig => RemoteLayout {
mcp_json: layout.mcp_json.take(),
..base
@@ -1591,6 +1604,7 @@ enum TopCategory {
Roles,
Skills,
Macros,
Hooks,
FunctionsTools,
}
@@ -1601,6 +1615,7 @@ impl TopCategory {
TopCategory::Roles => "roles",
TopCategory::Skills => "skills",
TopCategory::Macros => "macros",
TopCategory::Hooks => "hooks",
TopCategory::FunctionsTools => "functions/tools",
}
}
@@ -1619,6 +1634,8 @@ enum PlannedKind {
struct PlannedFile {
src: PathBuf,
dst: PathBuf,
/// Path relative to the category's source directory in the bundle.
rel: PathBuf,
kind: PlannedKind,
top_category: TopCategory,
}
@@ -1660,6 +1677,9 @@ fn plan_changes(layout: &RemoteLayout) -> Result<InstallPlan> {
&mut files,
)?;
}
if let Some(src_dir) = &layout.hooks {
plan_dir_into(src_dir, &paths::hooks_dir(), TopCategory::Hooks, &mut files)?;
}
if let Some(src_dir) = &layout.functions_tools {
plan_dir_into(
src_dir,
@@ -1686,7 +1706,28 @@ fn plan_dir_into(
for src in walk_files(src_dir)? {
let rel = src
.strip_prefix(src_dir)
.expect("walk_files only returns paths under src_dir");
.expect("walk_files only returns paths under src_dir")
.to_path_buf();
// The builtin installer's per-directory manifests drive stale-file
// deletion; letting a bundle install one would hand it control over
// which local files the next builtin reconcile removes. Every path
// component is checked (a bundle-shipped DIRECTORY of that name would
// permanently occupy the manifest path and disable reconciliation),
// under the filesystem-alias-aware match of is_builtin_manifest_name.
let is_builtin_manifest = rel.components().any(|c| match c.as_os_str().to_str() {
Some(name) => is_builtin_manifest_name(name),
// A non-UTF-8 name can never equal the ASCII manifest constant.
None => false,
});
if is_builtin_manifest {
log::warn!(
"Ignoring bundle file {} ({}): the name is reserved for the builtin installer",
rel.display(),
category.label()
);
continue;
}
if category == TopCategory::Skills {
let skill_name = rel
@@ -1707,11 +1748,12 @@ fn plan_dir_into(
})?;
}
let dst = dst_dir.join(rel);
let dst = dst_dir.join(&rel);
let kind = classify_file(&src, &dst)?;
out.push(PlannedFile {
src,
dst,
rel,
kind,
top_category: category,
});
@@ -1790,6 +1832,7 @@ fn print_plan_summary(plan: &InstallPlan) {
TopCategory::Roles,
TopCategory::Skills,
TopCategory::Macros,
TopCategory::Hooks,
TopCategory::FunctionsTools,
] {
let new_ = count_kind(plan, cat, PlannedKind::New);
@@ -1808,6 +1851,37 @@ fn print_plan_summary(plan: &InstallPlan) {
println!("{line}");
}
}
for line in hook_script_lines(plan) {
println!("{line}");
}
}
/// Bundle-shipped hook scripts execute on the user's machine once wired into
/// config, so every planned file landing in a hook location is called out
/// individually. Visibility only: installing them needs no extra approval.
fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
plan.files
.iter()
.filter(|planned| is_hook_location(planned))
.map(|planned| {
let rel = planned.rel.to_string_lossy().replace('\\', "/");
format!(
"bundle defines hook script: {}/{rel}",
planned.top_category.label()
)
})
.collect()
}
fn is_hook_location(planned: &PlannedFile) -> bool {
let components: Vec<_> = planned.rel.components().collect();
match planned.top_category {
TopCategory::Hooks => true,
TopCategory::Agents => components.len() == 3 && components[1].as_os_str() == "hooks",
TopCategory::Roles => components.len() == 2 && components[0].as_os_str() == "hooks",
_ => false,
}
}
fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize {
@@ -1817,18 +1891,6 @@ fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize
.count()
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum StickyMode {
None,
KeepAll,
ReplaceAll,
}
enum ConflictAction {
Keep,
Replace,
}
#[derive(Debug)]
struct ApplyReport {
new_count: usize,
@@ -1868,7 +1930,12 @@ fn apply_plan(
record_written_file(store, bundle, planned, FileAction::Replaced)?;
report.refreshed_count += 1;
}
PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? {
PlannedKind::Conflict => match resolve_conflict(
&planned.dst,
planned.top_category.label(),
&mut sticky,
NonInteractive::Bail,
)? {
ConflictAction::Keep => report.kept_count += 1,
ConflictAction::Replace => {
write_file(&planned.src, &planned.dst)?;
@@ -1968,50 +2035,6 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo
store.record_mcp_servers(bundle, entries)
}
fn resolve_conflict(planned: &PlannedFile, sticky: &mut StickyMode) -> Result<ConflictAction> {
match *sticky {
StickyMode::KeepAll => return Ok(ConflictAction::Keep),
StickyMode::ReplaceAll => return Ok(ConflictAction::Replace),
StickyMode::None => {}
}
if !*IS_STDOUT_TERMINAL {
bail!(
"Refusing to overwrite local file {} non-interactively. \
Re-run in a terminal, with --install-force (installs), \
or with --yes (updates).",
planned.dst.display()
);
}
let prompt = format!(
"Conflict at {} (category: {})",
planned.dst.display(),
planned.top_category.label()
);
let choice = Select::new(
&prompt,
vec!["keep", "replace", "keep-all", "replace-all", "abort"],
)
.prompt()
.with_context(|| "failed to read conflict choice")?;
match choice {
"keep" => Ok(ConflictAction::Keep),
"replace" => Ok(ConflictAction::Replace),
"keep-all" => {
*sticky = StickyMode::KeepAll;
Ok(ConflictAction::Keep)
}
"replace-all" => {
*sticky = StickyMode::ReplaceAll;
Ok(ConflictAction::Replace)
}
"abort" => bail!("Install aborted by user at conflict resolution."),
_ => unreachable!("inquire::Select returned an unexpected option"),
}
}
fn write_file(src: &Path, dst: &Path) -> Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)
@@ -2023,26 +2046,6 @@ fn write_file(src: &Path, dst: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_executable_bit_if_script(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let Some(ext) = path.extension().and_then(OsStr::to_str) else {
return Ok(());
};
if Language::from_extension(ext) == Language::Unsupported {
return Ok(());
}
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.with_context(|| format!("chmod {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable_bit_if_script(_path: &Path) -> Result<()> {
Ok(())
}
#[derive(Debug)]
struct McpMergeReport {
added: Vec<String>,
@@ -2349,6 +2352,8 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::utils::get_env_name;
use serial_test::serial;
@@ -2551,6 +2556,7 @@ mod tests {
roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
macros: Some(PathBuf::from("m")),
hooks: Some(PathBuf::from("h")),
functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
@@ -2560,6 +2566,7 @@ mod tests {
assert!(out.agents.is_some() && out.roles.is_some() && out.skills.is_some());
assert!(out.macros.is_some() && out.functions_tools.is_some() && out.mcp_json.is_some());
assert!(out.hooks.is_some());
}
#[test]
@@ -2638,6 +2645,37 @@ mod tests {
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
}
#[test]
fn apply_filter_hooks_keeps_only_hooks() {
let l = RemoteLayout {
agents: Some(PathBuf::from("a")),
roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
macros: Some(PathBuf::from("m")),
hooks: Some(PathBuf::from("h")),
functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
};
let out = apply_filter(l, Some(InstallFilter::Hooks));
assert_eq!(out.hooks, Some(PathBuf::from("h")));
assert!(!out.is_empty());
assert!(out.agents.is_none() && out.roles.is_none() && out.skills.is_none());
assert!(out.macros.is_none() && out.functions_tools.is_none() && out.mcp_json.is_none());
}
#[test]
fn remote_layout_with_only_hooks_is_not_empty() {
let l = RemoteLayout {
hooks: Some(PathBuf::from("h")),
..RemoteLayout::default()
};
assert!(!l.is_empty());
}
#[test]
fn walk_files_skips_dot_git_and_collects_regular_files() {
let root = fresh_temp_dir("walk-test-");
@@ -2684,6 +2722,7 @@ mod tests {
fs::create_dir_all(root.join("roles")).unwrap();
fs::create_dir_all(root.join("skills")).unwrap();
fs::create_dir_all(root.join("macros")).unwrap();
fs::create_dir_all(root.join("hooks")).unwrap();
fs::create_dir_all(root.join("functions/tools")).unwrap();
touch(&root.join("functions/mcp.json"));
touch(&root.join("README.md"));
@@ -2693,11 +2732,29 @@ mod tests {
assert!(layout.roles.is_some());
assert!(layout.skills.is_some());
assert!(layout.macros.is_some());
assert!(layout.hooks.is_some());
assert!(layout.functions_tools.is_some());
assert!(layout.mcp_json.is_some());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_finds_hooks_only() {
let root = fresh_temp_dir("scan-hooks-only-");
fs::create_dir_all(root.join("hooks")).unwrap();
touch(&root.join("hooks/notify.sh"));
let layout = scan_remote_layout(&root).unwrap();
assert_eq!(layout.hooks, Some(root.join("hooks")));
assert!(layout.agents.is_none());
assert!(layout.roles.is_none());
assert!(layout.macros.is_none());
assert!(layout.functions_tools.is_none());
assert!(layout.mcp_json.is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_finds_root_mcp_json() {
let root = fresh_temp_dir("scan-root-mcp-test-");
@@ -2752,6 +2809,76 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn plan_dir_into_skips_bundle_shipped_builtin_manifests() {
let root = fresh_temp_dir("plan-manifest-skip-");
let src_dir = root.join("hooks");
touch(&src_dir.join("notify.sh"));
touch(&src_dir.join(BUILTIN_MANIFEST_FILE));
touch(&src_dir.join(format!("sub/{BUILTIN_MANIFEST_FILE}")));
// Case and trailing-dot variants alias the real manifest path on
// case-insensitive filesystems; they live in separate subdirs so this
// fixture also builds on such filesystems without name collisions.
touch(&src_dir.join("sub2/.BUILTIN-MANIFEST"));
touch(&src_dir.join("sub3/.Builtin-Manifest."));
// A DIRECTORY of the manifest name would occupy the manifest path and
// disable reconciliation; a Unicode case-fold alias (U+017F ſ → s)
// aliases it on case-insensitive APFS/NTFS volumes.
touch(&src_dir.join("sub4/.builtin-manifest/payload.sh"));
touch(&src_dir.join("sub5/.builtin-manifeſt"));
let dst_dir = root.join("dst");
let mut files = Vec::new();
plan_dir_into(&src_dir, &dst_dir, TopCategory::Hooks, &mut files).unwrap();
let rels: Vec<&Path> = files.iter().map(|p| p.rel.as_path()).collect();
assert_eq!(
rels,
vec![Path::new("notify.sh")],
"a bundle must never install a builtin manifest: it would hand the \
bundle control over builtin stale-file deletion"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn hook_script_lines_cover_every_hooks_dir_and_nothing_else() {
let file = |rel: &str, cat: TopCategory| PlannedFile {
src: PathBuf::from("src").join(rel),
dst: PathBuf::from("dst").join(rel),
rel: PathBuf::from(rel),
kind: PlannedKind::New,
top_category: cat,
};
let plan = InstallPlan {
files: vec![
file("notify.sh", TopCategory::Hooks),
file("x/hooks/pre.sh", TopCategory::Agents),
file("hooks/role-hook.sh", TopCategory::Roles),
file("x/config.yaml", TopCategory::Agents),
file("reviewer.md", TopCategory::Roles),
file("hello.yaml", TopCategory::Macros),
file("x/hooks/nested/deep.sh", TopCategory::Agents),
file("x/sub/hooks/pre.sh", TopCategory::Agents),
file("sub/hooks/extra.sh", TopCategory::Roles),
file("my-skill/hooks/tool.sh", TopCategory::Skills),
file("my-macro/hooks/tool.sh", TopCategory::Macros),
],
mcp_json: None,
};
let lines = hook_script_lines(&plan);
assert_eq!(
lines,
vec![
"bundle defines hook script: hooks/notify.sh",
"bundle defines hook script: agents/x/hooks/pre.sh",
"bundle defines hook script: roles/hooks/role-hook.sh",
]
);
}
#[test]
fn classify_file_new_when_dst_missing() {
let dir = fresh_temp_dir("classify-new-");
@@ -3420,6 +3547,91 @@ mod tests {
let _ = fs::remove_dir_all(&src_root);
}
fn hooks_fixture_bundle(label: &str) -> (PathBuf, PathBuf) {
let src_root = fresh_temp_dir(label);
let repo = src_root.join("hooky");
write_src(&repo, "hooks/notify-me.sh", "#!/bin/sh\necho hi\n");
write_src(&repo, "roles/hooks/role-hook.sh", "#!/bin/sh\n");
write_src(&repo, "agents/x/hooks/pre.sh", "#!/bin/sh\n");
write_src(&repo, "macros/hello.yaml", "name: hello\n");
init_bundle_repo(&repo);
(src_root, repo)
}
#[cfg(unix)]
fn assert_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path).unwrap().permissions().mode();
assert_ne!(
mode & 0o111,
0,
"{} must carry the executable bit",
path.display()
);
}
#[test]
#[serial]
fn install_remote_installs_all_hooks_dirs_and_records_hooks_category() {
let _guard = TestVaultConfigGuard::new("prov-hooks-dirs");
let (src_root, repo) = hooks_fixture_bundle("prov-hooks-dirs-src-");
install_remote(repo.to_str().unwrap(), None, false).unwrap();
let top = paths::hooks_dir().join("notify-me.sh");
let role = paths::roles_dir().join("hooks/role-hook.sh");
let agent = paths::agents_data_dir().join("x/hooks/pre.sh");
assert!(top.is_file(), "top-level hooks/ installs to hooks_dir");
assert!(role.is_file(), "roles/hooks/ installs via the roles walk");
assert!(
agent.is_file(),
"agents/<name>/hooks/ installs via the agents walk"
);
#[cfg(unix)]
{
assert_executable(&top);
assert_executable(&role);
assert_executable(&agent);
}
let store = BundleStore::load().unwrap();
let record = store.get("hooky").unwrap();
let category_of = |path: &str| {
record
.files
.iter()
.find(|f| f.path == path)
.unwrap_or_else(|| panic!("no provenance row for {path}"))
.category
.clone()
};
assert_eq!(category_of("hooks/notify-me.sh"), "hooks");
assert_eq!(category_of("roles/hooks/role-hook.sh"), "roles");
assert_eq!(category_of("agents/x/hooks/pre.sh"), "agents");
let _ = fs::remove_dir_all(&src_root);
}
#[test]
#[serial]
fn install_remote_filter_hooks_restricts_to_top_level_hooks_dir() {
let _guard = TestVaultConfigGuard::new("prov-hooks-filter");
let (src_root, repo) = hooks_fixture_bundle("prov-hooks-filter-src-");
install_remote(repo.to_str().unwrap(), Some(InstallFilter::Hooks), false).unwrap();
assert!(paths::hooks_dir().join("notify-me.sh").is_file());
assert!(!paths::roles_dir().join("hooks/role-hook.sh").exists());
assert!(!paths::agents_data_dir().join("x/hooks/pre.sh").exists());
assert!(!paths::macros_dir().join("hello.yaml").exists());
let store = BundleStore::load().unwrap();
let record = store.get("hooky").unwrap();
assert_eq!(record.files.len(), 1);
assert_eq!(record.files[0].path, "hooks/notify-me.sh");
assert_eq!(record.files[0].category, "hooks");
let _ = fs::remove_dir_all(&src_root);
}
#[test]
#[serial]
fn install_remote_without_manifest_uses_repo_slug_and_short_sha_version() {
@@ -3467,12 +3679,14 @@ mod tests {
PlannedFile {
src: src_new,
dst: dst_new.clone(),
rel: PathBuf::from("new.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Macros,
},
PlannedFile {
src: src_conflict,
dst: dst_conflict.clone(),
rel: PathBuf::from("conflict.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
},
@@ -3525,6 +3739,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3656,12 +3871,50 @@ mod tests {
}
let _guard = TestVaultConfigGuard::new("prov-builtin");
crate::config::install_assets(crate::config::AssetCategory::Macros).unwrap();
config::install_assets(config::AssetCategory::Macros).unwrap();
assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
assert!(!paths::installed_bundles_file().exists());
}
#[test]
#[serial]
fn builtin_hooks_category_installs_example_scripts() {
if *IS_STDOUT_TERMINAL {
eprintln!(
"Skipping builtin_hooks_category_installs_example_scripts: requires non-TTY stdout"
);
return;
}
let _guard = TestVaultConfigGuard::new("prov-hooks");
let hooks_key = get_env_name("hooks_dir");
let previous_hooks = env::var_os(&hooks_key);
let hooks_dir = fresh_temp_dir("prov-hooks-dir-");
unsafe {
env::set_var(&hooks_key, &hooks_dir);
}
// Capture outcomes before restoring the env var so a failed assertion
// cannot leave the hooks-dir override pointing at a deleted temp dir.
let result = config::install_assets(AssetCategory::Hooks);
let installed = (
hooks_dir.join("notify.sh").is_file(),
hooks_dir.join("log-events.sh").is_file(),
);
unsafe {
match previous_hooks {
Some(v) => env::set_var(&hooks_key, v),
None => env::remove_var(&hooks_key),
}
}
let _ = fs::remove_dir_all(&hooks_dir);
result.unwrap();
assert!(installed.0, "notify.sh must be installed");
assert!(installed.1, "log-events.sh must be installed");
}
#[test]
#[serial]
fn update_silently_refreshes_owned_unmodified_files() {
@@ -3798,6 +4051,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3844,6 +4098,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3923,6 +4178,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("shared.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -4195,6 +4451,7 @@ mod tests {
files: vec![PlannedFile {
src: dir.join("bundle-src/agents/foo/graph.yaml"),
dst: dir.join("agents/foo/graph.yaml"),
rel: PathBuf::from("foo/graph.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Agents,
}],
@@ -4237,6 +4494,7 @@ mod tests {
files: vec![PlannedFile {
src: dir.join("bundle-src/agents/foo/graph.yaml"),
dst: dir.join("agents/foo/graph.yaml"),
rel: PathBuf::from("foo/graph.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Agents,
}],
+19 -55
View File
@@ -83,12 +83,17 @@ pub async fn macro_execute(
.bootstrap_tools(app.as_ref(), true, abort_signal.clone())
.await?;
for step in &macro_value.steps {
let command = Macro::interpolate_command(step, &variables);
println!(">> {}", multiline_text(&command));
run_repl_command(&mut macro_ctx, abort_signal.clone(), &command).await?;
let result = async {
for step in &macro_value.steps {
let command = Macro::interpolate_command(step, &variables);
println!(">> {}", multiline_text(&command));
run_repl_command(&mut macro_ctx, abort_signal.clone(), &command).await?;
}
Ok(())
}
Ok(())
.await;
macro_ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
result
}
struct MacroModeGuard<'a> {
@@ -167,7 +172,8 @@ impl Macro {
debug!("Processing macro file: {}", file.as_ref());
let embedded_file = MacroAssets::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded macro file: {}", file.as_ref()))?;
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
let content = std::str::from_utf8(&embedded_file.data)
.expect("bundled macro asset is not valid UTF-8");
let file_path = paths::macros_dir().join(file.as_ref());
if file_path.exists() && !force {
@@ -350,56 +356,14 @@ fn default_true() -> bool {
mod tests {
use super::*;
use crate::config::{AppState, Session, WorkingMode};
use crate::testing::TestConfigDirGuard;
use crate::utils::{create_abort_signal, get_env_name};
use serial_test::serial;
use std::fs::{create_dir_all, remove_dir_all, write};
use std::future::Future;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{env, str};
struct TestConfigDirGuard {
key: String,
previous: Option<std::ffi::OsString>,
path: PathBuf,
}
impl TestConfigDirGuard {
fn new() -> Self {
let key = get_env_name("config_dir");
let previous = env::var_os(&key);
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = env::temp_dir().join(format!("coyote-macros-tests-{unique}"));
create_dir_all(&path).unwrap();
unsafe {
env::set_var(&key, &path);
}
Self {
key,
previous,
path,
}
}
}
impl Drop for TestConfigDirGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
unsafe {
env::set_var(&self.key, previous);
}
} else {
unsafe {
env::remove_var(&self.key);
}
}
let _ = remove_dir_all(&self.path);
}
}
fn test_ctx() -> RequestContext {
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
}
@@ -974,7 +938,7 @@ variables:
#[test]
#[serial]
fn non_isolated_steps_run_on_live_ctx_and_mutations_persist() {
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file(
"live-macro",
"isolated: false\nsteps:\n - \".set temperature 0.42\"\n",
@@ -1006,7 +970,7 @@ variables:
#[test]
#[serial]
fn non_isolated_step_failure_aborts_and_restores_flag_and_mode() {
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file(
"fail-macro",
"isolated: false\nsteps:\n - \".set temperature 0.9\"\n - \".update\"\n - \".set temperature 0.1\"\n",
@@ -1057,7 +1021,7 @@ variables:
#[test]
#[serial]
fn non_isolated_macro_step_invoking_macro_is_rejected() {
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file(
"outer-macro",
"isolated: false\nsteps:\n - \".inner-macro\"\n",
@@ -1093,7 +1057,7 @@ variables:
#[test]
#[serial]
fn isolated_macro_step_runs_non_isolated_macro_inline_on_fork() {
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file("iso-outer-macro", "steps:\n - \".inner-macro\"\n");
write_macro_file(
"inner-macro",
@@ -1122,7 +1086,7 @@ variables:
#[test]
#[serial]
fn isolated_macro_still_forks_and_leaves_live_ctx_untouched() {
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file("iso-macro", "steps:\n - \".set temperature 0.77\"\n");
let mut ctx = test_ctx();
ctx.session = Some(Session::default());
@@ -1152,7 +1116,7 @@ variables:
#[test]
#[serial]
fn guard_restores_prior_flag_values_after_inline_run() {
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file(
"inner-macro",
"isolated: false\nsteps:\n - \".set temperature 0.11\"\n",
+255 -36
View File
@@ -1,5 +1,6 @@
use crate::hooks::McpServerHooks;
use crate::mcp::{
ConnectedServer, JsonField, McpAuthRequired, McpServer, McpTransportType,
ConnectedServer, HttpAuth, JsonField, McpAuthRequired, McpServer, McpTransportType,
is_auth_required_error, resolve_http_auth, spawn_mcp_server,
};
@@ -80,11 +81,6 @@ pub struct McpFactory {
}
impl McpFactory {
pub fn try_get_active(&self, key: &McpServerKey) -> Option<Arc<ConnectedServer>> {
let map = self.active.lock();
map.get(key).and_then(|weak| weak.upgrade())
}
pub fn insert_active(&self, key: McpServerKey, handle: &Arc<ConnectedServer>) {
let mut map = self.active.lock();
map.insert(key, Arc::downgrade(handle));
@@ -95,34 +91,88 @@ impl McpFactory {
name: &str,
spec: &McpServer,
log_path: Option<&Path>,
hooks: &McpServerHooks,
) -> Result<Arc<ConnectedServer>> {
let key = McpServerKey::from_spec(name, spec);
if let Some(existing) = self.try_get_active(&key) {
return Ok(existing);
}
let (auth, auth_reason) = resolve_http_auth(name, spec).await;
let handle = spawn_mcp_server(spec, log_path, auth).await.map_err(|e| {
if is_auth_required_error(&e) {
e.context(McpAuthRequired {
server: name.to_string(),
reason: auth_reason,
})
} else {
e
let reconnect = {
let map = self.active.lock();
if let Some(existing) = map.get(&key).and_then(|weak| weak.upgrade()) {
return Ok(existing);
}
})?;
map.contains_key(&key)
};
let transport = transport_label(&spec.transport_type);
let handle = match spawn_server(name, spec, log_path).await {
Ok(handle) => handle,
Err(e) => {
hooks.fire_failed(name, transport, &e, is_auth_required_error(&e));
return Err(e);
}
};
self.insert_active(key, &handle);
hooks.fire_connected(name, transport, reconnect);
Ok(handle)
}
}
fn transport_label(transport: &McpTransportType) -> &'static str {
match transport {
McpTransportType::Stdio => "stdio",
McpTransportType::Http => "http",
McpTransportType::Sse => "sse",
}
}
async fn spawn_server(
name: &str,
spec: &McpServer,
log_path: Option<&Path>,
) -> Result<Arc<ConnectedServer>> {
let (auth, auth_reason) = resolve_http_auth(name, spec).await;
spawn_transport(spec, log_path, auth).await.map_err(|e| {
if is_auth_required_error(&e) {
e.context(McpAuthRequired {
server: name.to_string(),
reason: auth_reason,
})
} else {
e
}
})
}
async fn spawn_transport(
spec: &McpServer,
log_path: Option<&Path>,
auth: HttpAuth,
) -> Result<Arc<ConnectedServer>> {
#[cfg(test)]
if let Some(result) = STUB_SPAWNS.lock().pop() {
return result;
}
spawn_mcp_server(spec, log_path, auth).await
}
/// Injected spawn outcomes, popped instead of spawning a real transport:
/// nothing in a test environment speaks MCP over stdio or HTTP, so the
/// connect path is exercised with fixture handles. Empty means spawn for
/// real.
#[cfg(test)]
static STUB_SPAWNS: Mutex<Vec<Result<Arc<ConnectedServer>>>> = Mutex::new(Vec::new());
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_fixtures::FixtureServer;
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
use crate::hooks::{HookDef, HooksMap, test_sink};
use crate::mcp::{JsonField, McpServer, McpTransportType};
use anyhow::anyhow;
use indexmap::IndexMap;
use rmcp::ServiceExt;
use serial_test::serial;
use std::collections::HashMap;
fn stdio_spec(
@@ -299,26 +349,195 @@ mod tests {
}
}
#[test]
fn factory_try_get_active_returns_none_when_empty() {
let factory = McpFactory::default();
let spec = stdio_spec("cmd", None, None);
let key = McpServerKey::from_spec("s", &spec);
assert!(factory.try_get_active(&key).is_none());
}
#[test]
fn factory_try_get_active_returns_none_for_unknown_key() {
let factory = McpFactory::default();
let spec = stdio_spec("cmd", None, None);
let key = McpServerKey::from_spec("s", &spec);
assert!(factory.try_get_active(&key).is_none());
}
#[test]
fn factory_default_has_empty_active_map() {
let factory = McpFactory::default();
let map = factory.active.lock();
assert!(map.is_empty());
}
/// Clears injected spawn outcomes on drop so a panicking test cannot
/// leak stubs into later tests.
struct StubSpawnsGuard;
impl StubSpawnsGuard {
fn push(result: Result<Arc<ConnectedServer>>) -> Self {
STUB_SPAWNS.lock().push(result);
StubSpawnsGuard
}
}
impl Drop for StubSpawnsGuard {
fn drop(&mut self) {
STUB_SPAWNS.lock().clear();
}
}
/// An in-process client/server pair; the returned server half must stay
/// alive for the client handle to keep working.
async fn fixture_handle() -> (Arc<ConnectedServer>, impl Sized) {
let (client_io, server_io) = tokio::io::duplex(4096);
let (server, client) = tokio::join!(
FixtureServer::default().serve(server_io),
().serve(client_io)
);
(Arc::new(client.unwrap()), server.unwrap())
}
fn ctx_with_mcp_hooks(marker: &str) -> RequestContext {
let mut hooks_map = HooksMap::default();
for event in ["mcp.server.connected", "mcp.server.failed"] {
hooks_map.insert(
event.to_string(),
vec![HookDef {
name: format!("{marker}-{event}"),
command: "true".to_string(),
}],
);
}
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks: hooks_map,
..Default::default()
});
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn mcp_captures(marker: &str) -> Vec<test_sink::Capture> {
test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name.starts_with(marker))
.collect()
}
#[tokio::test]
#[serial]
async fn acquire_fires_connected_only_on_real_spawns_and_marks_reconnects() {
let _sink = test_sink::install();
let marker = "mcp-spawn-k7d";
let ctx = ctx_with_mcp_hooks(marker);
let hooks = McpServerHooks::resolve(&ctx);
let factory = McpFactory::default();
let spec = stdio_spec("fixture-server-cmd", None, None);
let (first, _first_server) = fixture_handle().await;
let stub = StubSpawnsGuard::push(Ok(first.clone()));
let connected = factory.acquire("srv", &spec, None, &hooks).await.unwrap();
drop(stub);
assert!(Arc::ptr_eq(&connected, &first));
let captures = mcp_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
let capture = &captures[0];
assert_eq!(capture.hook_name, format!("{marker}-mcp.server.connected"));
assert_eq!(
capture.envs.get("COYOTE_MCP_SERVER").map(String::as_str),
Some("srv")
);
assert_eq!(
capture.envs.get("COYOTE_MCP_TRANSPORT").map(String::as_str),
Some("stdio")
);
assert!(
!capture.envs.contains_key("COYOTE_MCP_RECONNECT"),
"a first connect must not be marked as a reconnect"
);
let reused = factory.acquire("srv", &spec, None, &hooks).await.unwrap();
assert!(Arc::ptr_eq(&reused, &first));
assert_eq!(
mcp_captures(marker).len(),
1,
"reusing a live server must fire nothing"
);
// Every holder dropped: the weak in the factory dies, and the next
// acquire is a real spawn again — now marked as a reconnect.
drop(connected);
drop(reused);
drop(first);
let (second, _second_server) = fixture_handle().await;
let _stub = StubSpawnsGuard::push(Ok(second.clone()));
let respawned = factory.acquire("srv", &spec, None, &hooks).await.unwrap();
assert!(Arc::ptr_eq(&respawned, &second));
let captures = mcp_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(
captures[1]
.envs
.get("COYOTE_MCP_RECONNECT")
.map(String::as_str),
Some("true")
);
}
#[tokio::test]
#[serial]
async fn acquire_spawn_failure_fires_failed_with_the_error() {
let _sink = test_sink::install();
let marker = "mcp-fail-v2q";
let ctx = ctx_with_mcp_hooks(marker);
let hooks = McpServerHooks::resolve(&ctx);
let factory = McpFactory::default();
// No stub: the nonexistent binary drives the real spawn-error path.
let spec = stdio_spec("coyote-nonexistent-mcp-binary-v2q", None, None);
let result = factory.acquire("bad-srv", &spec, None, &hooks).await;
assert!(result.is_err());
let captures = mcp_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
let capture = &captures[0];
assert_eq!(capture.hook_name, format!("{marker}-mcp.server.failed"));
assert_eq!(
capture.envs.get("COYOTE_MCP_SERVER").map(String::as_str),
Some("bad-srv")
);
assert_eq!(
capture.envs.get("COYOTE_MCP_TRANSPORT").map(String::as_str),
Some("stdio")
);
assert!(
capture
.envs
.get("COYOTE_ERROR")
.is_some_and(|error| !error.is_empty())
);
assert!(
!capture.envs.contains_key("COYOTE_MCP_AUTH_REQUIRED"),
"a plain spawn failure must not be flagged as auth-required"
);
}
#[tokio::test]
#[serial]
async fn acquire_auth_failure_sets_the_auth_required_flag() {
let _sink = test_sink::install();
let marker = "mcp-auth-h4n";
let ctx = ctx_with_mcp_hooks(marker);
let hooks = McpServerHooks::resolve(&ctx);
let factory = McpFactory::default();
let spec = stdio_spec("oauth-cmd", None, None);
let _stub = StubSpawnsGuard::push(Err(anyhow!("Auth required: no stored token")));
let err = factory
.acquire("oauth-srv", &spec, None, &hooks)
.await
.unwrap_err();
assert!(
err.downcast_ref::<McpAuthRequired>().is_some(),
"the auth error must be wrapped in McpAuthRequired context"
);
let captures = mcp_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
let capture = &captures[0];
assert_eq!(capture.hook_name, format!("{marker}-mcp.server.failed"));
assert_eq!(
capture
.envs
.get("COYOTE_MCP_AUTH_REQUIRED")
.map(String::as_str),
Some("true")
);
}
}
+321 -25
View File
@@ -1,7 +1,9 @@
mod agent;
mod app_config;
mod app_state;
pub(crate) mod builtin_manifest;
mod bundles;
pub(crate) mod conflict;
mod input;
mod install_remote;
pub(crate) mod instructions;
@@ -36,6 +38,7 @@ pub use self::app_config::AppConfig;
pub use self::app_state::AppState;
pub(crate) use self::bundles::installed_bundle_names;
pub use self::bundles::list_installed_bundles;
use self::conflict::{InstallMode, StickyMode};
pub use self::input::Input;
pub use self::install_remote::{
DEFAULT_GIT_HOST, install_or_update, install_or_update_from_repl_args, uninstall_bundle,
@@ -55,7 +58,7 @@ pub use self::request_context::{
pub use self::role::{
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
};
pub use self::session::Session;
pub use self::session::{Session, SessionScope, labeled_session_names, session_scope_dirs};
#[allow(unused_imports)]
pub use self::skill::Skill;
#[allow(unused_imports)]
@@ -75,12 +78,14 @@ use crate::client::{
oauth, set_client_models_config,
};
use crate::function::{FunctionDeclaration, Functions};
use crate::hooks::HooksMap;
use crate::rag::Rag;
use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::utils::*;
pub use macros::macro_execute;
use crate::config::macros::Macro;
use crate::hooks;
use crate::vault::{
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice,
};
@@ -162,6 +167,7 @@ const AGENT_GRAPH_FILE_NAME: &str = "graph.yaml";
const ROLES_DIR_NAME: &str = "roles";
const SKILLS_DIR_NAME: &str = "skills";
const MACROS_DIR_NAME: &str = "macros";
const HOOKS_DIR_NAME: &str = "hooks";
const ENV_FILE_NAME: &str = ".env";
const MESSAGES_FILE_NAME: &str = "messages.md";
const SESSIONS_DIR_NAME: &str = "sessions";
@@ -177,7 +183,7 @@ const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
pub(crate) const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
@@ -262,6 +268,9 @@ pub struct Config {
pub enabled_mcp_servers: Option<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)]
pub hooks: HooksMap,
pub auto_continue: bool,
pub max_auto_continues: usize,
pub inject_todo_instructions: bool,
@@ -277,6 +286,7 @@ pub struct Config {
pub compression_threshold: usize,
pub compression_keep_last: usize,
pub compression_model: Option<String>,
pub compression_safety_valve: f32,
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>,
@@ -350,6 +360,8 @@ impl Default for Config {
enabled_mcp_servers: None,
mcp_tools: None,
hooks: Default::default(),
auto_continue: false,
max_auto_continues: 10,
inject_todo_instructions: true,
@@ -365,6 +377,7 @@ impl Default for Config {
compression_threshold: 4000,
compression_keep_last: 0,
compression_model: None,
compression_safety_valve: 0.8,
summarization_prompt: None,
summary_context_prompt: None,
max_tool_result_chars: None,
@@ -408,10 +421,13 @@ impl Default for Config {
}
pub fn install_builtins() -> Result<()> {
hooks::sweep_stale_payload_files();
Functions::install_builtin_global_tools(false)?;
Agent::install_builtin_agents(false)?;
Agent::install_builtin_agents(InstallMode::Skip)?;
Macro::install_macros(false)?;
Skill::install_builtin_skills(false)?;
hooks::install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None)?;
Role::install_builtin_role_hooks(InstallMode::Skip, &mut StickyMode::None)?;
Ok(())
}
@@ -421,12 +437,20 @@ pub enum AssetCategory {
Macros,
Functions,
Skills,
Hooks,
#[value(name = "mcp-config", alias = "mcp_config")]
McpConfig,
}
impl AssetCategory {
pub const NAMES: [&'static str; 5] = ["agents", "macros", "functions", "skills", "mcp-config"];
pub const NAMES: [&'static str; 6] = [
"agents",
"macros",
"functions",
"skills",
"hooks",
"mcp-config",
];
pub fn parse(name: &str) -> Option<Self> {
match name {
@@ -434,6 +458,7 @@ impl AssetCategory {
"macros" => Some(Self::Macros),
"functions" => Some(Self::Functions),
"skills" => Some(Self::Skills),
"hooks" => Some(Self::Hooks),
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
_ => None,
}
@@ -453,17 +478,19 @@ pub enum InstallFilter {
Skills,
Macros,
Functions,
Hooks,
#[value(name = "mcp-config", alias = "mcp_config")]
McpConfig,
}
impl InstallFilter {
pub const NAMES: [&'static str; 6] = [
pub const NAMES: [&'static str; 7] = [
"agents",
"roles",
"skills",
"macros",
"functions",
"hooks",
"mcp-config",
];
@@ -474,6 +501,7 @@ impl InstallFilter {
"skills" => Some(Self::Skills),
"macros" => Some(Self::Macros),
"functions" => Some(Self::Functions),
"hooks" => Some(Self::Hooks),
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
_ => None,
}
@@ -486,6 +514,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
AssetCategory::Macros => ("macros", paths::macros_dir()),
AssetCategory::Functions => ("functions", paths::functions_dir()),
AssetCategory::Skills => ("skills", paths::skills_dir()),
AssetCategory::Hooks => ("hooks", paths::hooks_dir()),
AssetCategory::McpConfig => ("MCP config", paths::mcp_config_file()),
};
@@ -495,10 +524,16 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
}
match category {
AssetCategory::Agents => Agent::install_builtin_agents(true)?,
AssetCategory::Agents => Agent::install_builtin_agents(InstallMode::Force)?,
AssetCategory::Macros => Macro::install_macros(true)?,
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
AssetCategory::Hooks => {
let mut sticky = StickyMode::None;
hooks::install_builtin_hooks(InstallMode::Prompt, &mut sticky)?;
Role::install_builtin_role_hooks(InstallMode::Prompt, &mut sticky)?;
Agent::install_builtin_agent_hooks(InstallMode::Prompt, &mut sticky)?;
}
AssetCategory::McpConfig => Functions::install_mcp_config()?,
}
@@ -519,6 +554,14 @@ fn confirm_asset_overwrite(category: AssetCategory, label: &str, target: &Path)
references) are left untouched.",
target.display()
),
AssetCategory::Hooks => format!(
"Refreshing bundled hooks installs any missing bundled hook \
scripts in {}, the roles hooks directory, and each bundled \
agent's hooks directory. Where a local copy differs from the \
bundled version you are asked per file whether to keep or \
replace it; non-interactive runs keep local files.",
target.display()
),
_ => format!(
"Reinstalling bundled {label} overwrites every bundled {label} in \
{} with this build's packaged versions. Local changes to bundled \
@@ -540,10 +583,6 @@ pub fn default_sessions_dir() -> PathBuf {
}
}
pub fn list_sessions() -> Vec<String> {
list_file_names(default_sessions_dir(), ".yaml")
}
/// Refreshes the models override. With a URL, mirrors a full models.yaml from
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
/// APIs merged with the embedded quirks.
@@ -704,11 +743,17 @@ pub fn maybe_spawn_models_refresh(app: &AppConfig) {
}
impl Config {
pub async fn load_with_interpolation(info_flag: bool) -> Result<Self> {
pub async fn load_with_interpolation(
skip_interpolation: bool,
inspection: bool,
) -> Result<Self> {
let config_path = paths::config_file();
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
if !config_path.exists() {
if inspection {
return Ok(Self::default());
}
create_config_file(&config_path).await?;
}
@@ -723,6 +768,9 @@ impl Config {
{
Some(v) => (Self::load_dynamic(&v)?, String::new()),
None => {
if inspection {
return Ok(Self::default());
}
if *IS_STDOUT_TERMINAL {
create_config_file(&config_path).await?;
}
@@ -733,7 +781,7 @@ impl Config {
Self::load_from_file(&config_path)?
};
if info_flag {
if skip_interpolation {
return Ok(config);
}
@@ -1095,6 +1143,32 @@ pub(crate) fn ensure_parent_exists(path: &Path) -> Result<()> {
Ok(())
}
/// The one exec-bit policy for every hook/agent installer: a file is
/// executable iff `Language::from_extension` recognizes its extension.
/// Users may keep non-executable support files in hooks directories, so
/// installers must write plain and then apply this predicate — never
/// blanket-0755.
#[cfg(unix)]
pub(crate) fn set_executable_bit_if_script(path: &Path) -> Result<()> {
use crate::function::Language;
use std::os::unix::fs::PermissionsExt;
let Some(ext) = path.extension().and_then(std::ffi::OsStr::to_str) else {
return Ok(());
};
if Language::from_extension(ext) == Language::Unsupported {
return Ok(());
}
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.with_context(|| format!("chmod {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
pub(crate) fn set_executable_bit_if_script(_path: &Path) -> Result<()> {
Ok(())
}
fn read_env_value<T>(key: &str) -> Option<Option<T>>
where
T: std::str::FromStr,
@@ -1303,6 +1377,7 @@ clients:
assert!(cfg.mcp_server_support);
assert_eq!(cfg.compression_threshold, 4000);
assert!(cfg.compression_model.is_none());
assert_eq!(cfg.compression_safety_valve, 0.8);
assert_eq!(cfg.rag_top_k, 5);
assert!(cfg.save_shell_history);
assert_eq!(cfg.keybindings, "emacs");
@@ -1343,6 +1418,7 @@ clients:
assert!(cfg.mapping_tools.is_empty());
assert!(cfg.document_loaders.is_empty());
assert_eq!(cfg.compression_threshold, 4000);
assert_eq!(cfg.compression_safety_valve, 0.8);
assert!(cfg.theme.is_none());
assert_eq!(cfg.clients.len(), 1);
}
@@ -1393,6 +1469,59 @@ clients:
assert!(!CONFIG_TEMPLATE.contains("escalation_timeout"));
}
#[test]
fn config_template_carries_hooks_block_without_global_hooks() {
// `global_hooks` is a per-agent whitelist; the global template must never grow it.
assert!(CONFIG_TEMPLATE.contains("\nhooks:"));
assert!(!CONFIG_TEMPLATE.contains("global_hooks"));
}
#[test]
fn config_template_renders_with_empty_hooks() {
let clients = json!([{ "type": "openai", "api_key": "sk-test" }]);
let rendered = render_config_template("openai:gpt-4o", None, &clients).unwrap();
assert!(rendered.contains("\nhooks:"));
assert!(!rendered.contains("global_hooks"));
let cfg = Config::load_from_str(&rendered).unwrap();
assert!(cfg.hooks.is_empty());
}
#[test]
fn config_parses_hooks_map_preserving_order() {
let yaml = "\
hooks:
tool.started:
- name: notify
command: ./hooks/notify.sh
- name: audit
command: ./hooks/audit.sh
turn.completed:
- name: webhook
command: curl -s https://example.com/hook
";
let cfg: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
cfg.hooks.keys().collect::<Vec<_>>(),
vec!["tool.started", "turn.completed"]
);
assert_eq!(cfg.hooks["tool.started"].len(), 2);
assert_eq!(cfg.hooks["tool.started"][0].name, "notify");
assert_eq!(cfg.hooks["tool.started"][0].command, "./hooks/notify.sh");
assert_eq!(cfg.hooks["turn.completed"][0].name, "webhook");
}
#[test]
fn config_without_hooks_parses_unchanged() {
let cfg: Config = serde_yaml::from_str("model: openai:gpt-4o").unwrap();
assert!(cfg.hooks.is_empty());
assert_eq!(cfg.model_id, "openai:gpt-4o");
}
#[test]
fn config_enabled_macros_empty_string_is_some_empty() {
let cfg: Config = serde_yaml::from_str("enabled_macros: \"\"").unwrap();
@@ -1515,12 +1644,12 @@ clients:
use std::fs;
use std::time;
let unique = time::SystemTime::now()
let unique = SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
fs::create_dir_all(&tmp_dir).unwrap();
let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
create_dir_all(&tmp_dir).unwrap();
let config_path = tmp_dir.join("config.yaml");
fs::write(
@@ -1530,25 +1659,25 @@ clients:
.unwrap();
let config_env = get_env_name("config_file");
let prev_config = std::env::var_os(&config_env);
let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG);
let prev_config = env::var_os(&config_env);
let prev_sandbox = env::var_os(SANDBOX_ENV_FLAG);
unsafe {
std::env::set_var(&config_env, &config_path);
std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1");
env::set_var(&config_env, &config_path);
env::set_var(SANDBOX_ENV_FLAG, "1");
}
let result = Config::load_with_interpolation(false).await;
let result = Config::load_with_interpolation(false, false).await;
let (_, raw) = Config::load_from_file(&config_path).unwrap();
unsafe {
match prev_config {
Some(v) => std::env::set_var(&config_env, v),
None => std::env::remove_var(&config_env),
Some(v) => env::set_var(&config_env, v),
None => env::remove_var(&config_env),
}
match prev_sandbox {
Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v),
None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG),
Some(v) => env::set_var(SANDBOX_ENV_FLAG, v),
None => env::remove_var(SANDBOX_ENV_FLAG),
}
}
let _ = fs::remove_dir_all(&tmp_dir);
@@ -1562,6 +1691,60 @@ clients:
);
}
#[tokio::test]
#[serial_test::serial]
async fn info_flag_with_missing_config_skips_wizard_and_writes_nothing() {
let unique = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = env::temp_dir().join(format!("coyote-info-bypass-{unique}"));
create_dir_all(&tmp_dir).unwrap();
let _config_dir = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &tmp_dir);
let _config_file = crate::testing::EnvVarGuard::unset(get_env_name("config_file"));
let _sandbox = crate::testing::EnvVarGuard::unset(SANDBOX_ENV_FLAG);
let _provider = crate::testing::EnvVarGuard::unset(get_env_name("provider"));
let _platform = crate::testing::EnvVarGuard::unset(get_env_name("platform"));
let result = Config::load_with_interpolation(true, true).await;
let leftover: Vec<_> = read_dir(&tmp_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
let _ = fs::remove_dir_all(&tmp_dir);
result.expect("inspection flags must load a default config without a config file");
assert!(
leftover.is_empty(),
"inspection flags must not write anything: {leftover:?}"
);
}
#[tokio::test]
#[serial_test::serial]
async fn sandbox_info_flag_with_missing_config_writes_nothing() {
let unique = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-info-bypass-{unique}"));
create_dir_all(&tmp_dir).unwrap();
let config_path = tmp_dir.join("config.yaml");
let _config_file =
crate::testing::EnvVarGuard::set(get_env_name("config_file"), &config_path);
let _sandbox = crate::testing::EnvVarGuard::set(SANDBOX_ENV_FLAG, "1");
let result = Config::load_with_interpolation(true, true).await;
let created = config_path.exists();
let _ = fs::remove_dir_all(&tmp_dir);
result.expect("inspection flags must load a default config in sandbox mode too");
assert!(
!created,
"inspection flags must not create the sandbox config file"
);
}
#[test]
fn unloadable_override_is_stale() {
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
@@ -1641,7 +1824,7 @@ clients:
let written = write_models_override(vec![entry()]).unwrap();
assert_eq!(written, path);
let mut tmp_name = path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", std::process::id()));
tmp_name.push(format!(".{}.tmp", process::id()));
assert!(!PathBuf::from(tmp_name).exists());
let loaded = paths::local_models_override().unwrap();
assert_eq!(loaded.len(), 1);
@@ -1659,4 +1842,117 @@ clients:
std::panic::resume_unwind(panic);
}
}
use crate::testing::TestConfigDirGuard;
#[test]
#[serial_test::serial]
fn install_assets_hooks_shares_one_sticky_scope_across_all_three_locations() {
use crate::config::conflict::prompt_script;
let _guard = TestConfigDirGuard::new("hooks-three-locations");
let notify = paths::hooks_dir().join("notify.sh");
let role_dir = paths::roles_dir().join("hooks");
let agent_dir = paths::agents_data_dir().join("probe").join("hooks");
let shipped = vec![("hook.sh".to_string(), "shipped\n".to_string())];
// Drives the exact three installers `install_assets(Hooks)` runs,
// threading one sticky scope through all of them.
let refresh = |sticky: &mut StickyMode| {
hooks::install_builtin_hooks(InstallMode::Prompt, sticky).unwrap();
role::install_and_reconcile_role_hooks(
&role_dir,
&shipped,
InstallMode::Prompt,
sticky,
)
.unwrap();
agent::install_and_reconcile_agent_hooks(
"probe",
&shipped,
InstallMode::Prompt,
sticky,
)
.unwrap();
};
let modify_all = || {
fs::write(&notify, "local global").unwrap();
fs::write(role_dir.join("hook.sh"), "local role").unwrap();
fs::write(agent_dir.join("hook.sh"), "local agent").unwrap();
};
let script = prompt_script::install(&[]);
refresh(&mut StickyMode::None);
assert_eq!(
prompt_script::prompts_asked(),
0,
"a clean install asks nothing"
);
drop(script);
// One replace-all at the first (global) conflict must silently
// replace the role and agent conflicts too.
modify_all();
let script = prompt_script::install(&["replace-all"]);
let mut sticky = StickyMode::None;
refresh(&mut sticky);
assert_eq!(prompt_script::prompts_asked(), 1);
assert_eq!(sticky, StickyMode::ReplaceAll);
assert!(
read_to_string(&notify)
.unwrap()
.starts_with("#!/usr/bin/env bash")
);
assert_eq!(
read_to_string(role_dir.join("hook.sh")).unwrap(),
"shipped\n"
);
assert_eq!(
read_to_string(agent_dir.join("hook.sh")).unwrap(),
"shipped\n"
);
drop(script);
// And one keep-all must keep every location's local edit.
modify_all();
let script = prompt_script::install(&["keep-all"]);
let mut sticky = StickyMode::None;
refresh(&mut sticky);
assert_eq!(prompt_script::prompts_asked(), 1);
assert_eq!(sticky, StickyMode::KeepAll);
assert_eq!(read_to_string(&notify).unwrap(), "local global");
assert_eq!(
read_to_string(role_dir.join("hook.sh")).unwrap(),
"local role"
);
assert_eq!(
read_to_string(agent_dir.join("hook.sh")).unwrap(),
"local agent"
);
drop(script);
}
#[test]
#[serial_test::serial]
fn install_builtins_never_prompts_and_keeps_local_hook_edits() {
use crate::config::conflict::prompt_script;
let _guard = TestConfigDirGuard::new("install-builtins-startup");
install_builtins().unwrap();
let notify = paths::hooks_dir().join("notify.sh");
fs::write(&notify, "# local edit").unwrap();
// A forced terminal with no scripted answers: any prompt would panic
// and the counter would move.
let script = prompt_script::install(&[]);
install_builtins().unwrap();
assert_eq!(prompt_script::prompts_asked(), 0);
assert_eq!(read_to_string(&notify).unwrap(), "# local edit");
drop(script);
let _script = prompt_script::install_non_interactive();
install_builtins().unwrap();
assert_eq!(prompt_script::prompts_asked(), 0);
assert_eq!(read_to_string(&notify).unwrap(), "# local edit");
}
}
+64 -2
View File
@@ -2,10 +2,10 @@ use super::role::Role;
use super::{
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, INSTALLED_BUNDLES_FILE_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_NAME, INSTALLED_BUNDLES_FILE_NAME,
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
SBX_MIXIN_KITS_DIR_NAME, SESSIONS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
};
use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME;
@@ -184,6 +184,13 @@ pub fn role_file(name: &str) -> PathBuf {
roles_dir().join(format!("{name}.md"))
}
pub fn hooks_dir() -> PathBuf {
match env::var(get_env_name("hooks_dir")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(HOOKS_DIR_NAME),
}
}
pub fn skills_dir() -> PathBuf {
match env::var(get_env_name("skills_dir")) {
Ok(value) => PathBuf::from(value),
@@ -222,6 +229,17 @@ pub fn workspace_macros_dir() -> PathBuf {
workspace_config_dir().join(MACROS_DIR_NAME)
}
pub fn workspace_sessions_dir() -> PathBuf {
workspace_config_dir().join(SESSIONS_DIR_NAME)
}
pub fn workspace_agent_sessions_dir(name: &str) -> PathBuf {
workspace_config_dir()
.join(AGENTS_DIR_NAME)
.join(name)
.join(SESSIONS_DIR_NAME)
}
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
}
@@ -325,6 +343,10 @@ pub fn agent_data_dir(name: &str) -> PathBuf {
}
}
pub fn agent_sessions_dir(name: &str) -> PathBuf {
agent_data_dir(name).join(SESSIONS_DIR_NAME)
}
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
}
@@ -847,6 +869,21 @@ mod tests {
);
});
}
#[test]
#[serial]
fn workspace_session_dirs_live_under_workspace_config_dir() {
with_workspace_dir(|_, ws_dir| {
assert_eq!(workspace_sessions_dir(), ws_dir.join(SESSIONS_DIR_NAME));
assert_eq!(
workspace_agent_sessions_dir("helper"),
ws_dir
.join(AGENTS_DIR_NAME)
.join("helper")
.join(SESSIONS_DIR_NAME)
);
});
}
}
mod user_mcp_resolution {
@@ -947,6 +984,31 @@ mod tests {
}
}
#[test]
#[serial_test::serial]
fn hooks_dir_reflects_env_var_state() {
let env_name = get_env_name("hooks_dir");
let prev = env::var_os(&env_name);
unsafe {
env::remove_var(&env_name);
}
assert_eq!(hooks_dir(), config_dir().join("hooks"));
let probe = PathBuf::from("/tmp/coyote-hooks-dir-probe");
unsafe {
env::set_var(&env_name, &probe);
}
assert_eq!(hooks_dir(), probe);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
fn list_skills_skips_invalid_directory_names() {
let unique = time::SystemTime::now()
+58
View File
@@ -47,3 +47,61 @@ impl RagCache {
Ok(arc)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use crate::hooks::test_sink;
use crate::rag::RagData;
use anyhow::anyhow;
use serial_test::serial;
use std::time::{SystemTime, UNIX_EPOCH};
/// A cache hit must never reach the loader: the loader closure is where
/// a real RAG build (and its `rag.sync.*` hook dispatch) happens, so
/// returning the cached entry is what keeps warm-cache lookups silent.
#[tokio::test]
#[serial]
async fn cache_hit_returns_the_cached_rag_without_running_the_loader() {
let _sink = test_sink::install();
test_sink::drain();
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("coyote-rag-cache-hit-{unique}"));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("kb.yaml");
let data = RagData {
driver: "yaml".to_string(),
// The only embedding model resolvable in tests; see
// `seed_model_registries` in the request_context tests.
embedding_model: "test-seeded:test-embedder".to_string(),
chunk_size: 1000,
chunk_overlap: 100,
top_k: 5,
..Default::default()
};
std::fs::write(&path, serde_yaml::to_string(&data).unwrap()).unwrap();
let rag = Arc::new(Rag::load(&AppConfig::default(), "kb", &path).await.unwrap());
let _ = std::fs::remove_dir_all(&dir);
let cache = RagCache::default();
let key = RagKey::Named("kb".to_string());
cache.insert(key.clone(), &rag);
let loaded = cache
.load_with(key, || async {
Err(anyhow!("a cache hit must not rebuild"))
})
.await
.unwrap();
assert!(Arc::ptr_eq(&loaded, &rag));
assert!(
test_sink::drain().is_empty(),
"a cache hit must dispatch no hooks"
);
}
}
File diff suppressed because it is too large. Load diff
+337 -1
View File
@@ -1,12 +1,18 @@
use super::*;
use crate::client::{Message, MessageContent, MessageRole, Model};
use crate::config::builtin_manifest;
use crate::config::conflict::{self, InstallMode, StickyMode};
use crate::function::write_file_atomic;
use crate::hooks::HooksMap;
use anyhow::Result;
use fancy_regex::Regex;
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::LazyLock;
pub const SHELL_ROLE: &str = "shell";
@@ -99,6 +105,8 @@ pub struct Role {
skill_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
memory: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
hooks: Option<HooksMap>,
#[serde(skip)]
model: Model,
@@ -161,6 +169,7 @@ impl Role {
role.skill_instructions = value.as_str().map(|v| v.to_string())
}
"memory" => role.memory = value.as_bool(),
"hooks" => role.hooks = parse_hooks_map(value),
_ => (),
}
}
@@ -171,7 +180,8 @@ impl Role {
pub fn builtin(name: &str) -> Result<Self> {
let content = RolesAsset::get(&format!("{name}.md"))
.ok_or_else(|| anyhow!("Unknown role `{name}`"))?;
let content = unsafe { std::str::from_utf8_unchecked(&content.data) };
let content =
std::str::from_utf8(&content.data).expect("bundled role asset is not valid UTF-8");
Ok(Role::new(name, content))
}
@@ -181,6 +191,27 @@ impl Role {
.collect()
}
/// Installs builtin role hook scripts into `<roles_dir>/hooks/` and
/// reconciles ones a release stopped shipping. This is the only builtin
/// install machinery roles have: general role installation deliberately
/// does not exist (builtin roles load straight from the embed).
pub fn install_builtin_role_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> {
let shipped: Vec<(String, String)> = RolesAsset::iter()
.filter_map(|file| {
let name = file.as_ref().strip_prefix("hooks/")?;
if name.is_empty() || name.contains('/') {
return None;
}
let embedded = RolesAsset::get(file.as_ref())?;
let content = std::str::from_utf8(&embedded.data)
.expect("bundled role hook asset is not valid UTF-8");
Some((name.to_string(), content.to_string()))
})
.collect();
install_and_reconcile_role_hooks(&paths::roles_dir().join("hooks"), &shipped, mode, sticky)
}
pub fn has_args(&self) -> bool {
self.name.contains('#')
}
@@ -248,6 +279,10 @@ impl Role {
if let Some(memory) = self.memory {
metadata.push(format!("memory: {memory}"));
}
if let Some(hooks) = &self.hooks {
let inline = serde_json::to_string(hooks).unwrap_or_else(|_| "{}".to_string());
metadata.push(format!("hooks: {inline}"));
}
if metadata.is_empty() {
format!("{}\n", self.prompt)
} else if self.prompt.is_empty() {
@@ -380,6 +415,10 @@ impl Role {
self.memory
}
pub fn hooks(&self) -> Option<&HooksMap> {
self.hooks.as_ref()
}
pub fn skills_enabled(&self) -> Option<bool> {
self.skills_enabled
}
@@ -544,6 +583,16 @@ fn parse_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
Some(mcp_tools)
}
fn parse_hooks_map(value: &Value) -> Option<HooksMap> {
match serde_json::from_value(value.clone()) {
Ok(map) => Some(map),
Err(err) => {
debug!("Ignoring malformed hooks configuration: {err}");
None
}
}
}
fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
let mut text = prompt;
let mut search_input = true;
@@ -592,6 +641,50 @@ fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
(prompt, vec![])
}
/// Only direct children of the hooks/ directory are supported: they are the
/// only shape the builtin manifest tracks, so nested asset paths are skipped
/// by the caller rather than installed unreconciled.
pub(crate) fn install_and_reconcile_role_hooks(
dir: &Path,
shipped: &[(String, String)],
mode: InstallMode,
sticky: &mut StickyMode,
) -> Result<()> {
if !shipped.is_empty() {
info!("Installing built-in role hooks in {}", dir.display());
}
let mut written = BTreeSet::new();
for (name, content) in shipped {
let path = dir.join(name);
if path.exists()
&& !conflict::should_replace_existing(&path, content, "roles", mode, sticky)?
{
debug!(
"Role hook file already exists, skipping: {}",
path.display()
);
continue;
}
ensure_parent_exists(&path)?;
info!("Creating role hook file: {}", path.display());
write_file_atomic(&path, content, None)?;
set_executable_bit_if_script(&path)?;
written.insert(name.clone());
}
let names: BTreeSet<String> = shipped.iter().map(|(name, _)| name.clone()).collect();
// Reconciliation is best-effort housekeeping: a failure here must not
// abort startup (install_builtins), matching the agent-side policy.
if let Err(err) = builtin_manifest::reconcile_builtin_dir(dir, &names, &written) {
warn!(
"Failed to reconcile builtin role hooks in {}: {err}",
dir.display()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -641,6 +734,42 @@ mod tests {
);
}
#[test]
fn role_hooks_round_trip_through_export() {
let content = "---\nhooks:\n turn.completed:\n - name: notify\n command: ./hooks/notify.sh\n---\nPrompt";
let role = Role::new("test", content);
let hooks = role.hooks().expect("hooks should be parsed");
assert_eq!(hooks["turn.completed"][0].name, "notify");
assert_eq!(hooks["turn.completed"][0].command, "./hooks/notify.sh");
let exported = role.export();
assert!(exported.contains("hooks:"));
let reparsed = Role::new("test", &exported);
assert_eq!(reparsed.hooks(), role.hooks());
assert_eq!(reparsed.prompt(), "Prompt");
}
#[test]
fn role_without_hooks_has_none_and_export_omits_key() {
let role = Role::new("test", "---\nmemory: true\n---\nPrompt");
assert!(role.hooks().is_none());
assert!(!role.export().contains("hooks:"));
}
#[test]
fn role_malformed_hooks_degrades_to_none() {
let content = "---\nhooks: [not, a, map]\n---\nPrompt";
let role = Role::new("test", content);
assert!(role.hooks().is_none());
assert_eq!(role.prompt(), "Prompt");
}
#[test]
fn role_new_no_metadata_has_none_fields() {
let role = Role::new("test", "Just a prompt");
@@ -909,4 +1038,211 @@ Input 1
assert_eq!(parse_structure_prompt(prompt), (prompt, vec![]));
}
fn fixture(entries: &[(&str, &str)]) -> Vec<(String, String)> {
entries
.iter()
.map(|(name, content)| (name.to_string(), content.to_string()))
.collect()
}
fn hooks_fixture_dir(label: &str) -> PathBuf {
let dir = temp_file(label, "");
create_dir_all(&dir).unwrap();
dir.join("hooks")
}
#[test]
fn role_hooks_install_writes_scripts_and_manifest() {
let dir = hooks_fixture_dir("role-hooks-install-");
install_and_reconcile_role_hooks(
&dir,
&fixture(&[("a.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n");
assert_eq!(
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"a.sh\n"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(dir.join("a.sh")).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755, "role hook scripts install executable");
let manifest_mode = fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
.unwrap()
.permissions()
.mode();
assert_eq!(
manifest_mode & 0o111,
0,
"the manifest must never be executable"
);
}
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
/// The installer writes plain and then applies the shared exec-bit
/// predicate: users may keep non-executable support files in hooks
/// directories, so only recognized script extensions get 0755.
#[cfg(unix)]
#[test]
fn role_hooks_install_leaves_non_script_files_non_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = hooks_fixture_dir("role-hooks-nonscript-");
install_and_reconcile_role_hooks(
&dir,
&fixture(&[("README.md", "docs\n"), ("run.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap();
let readme_mode = fs::metadata(dir.join("README.md"))
.unwrap()
.permissions()
.mode();
assert_eq!(
readme_mode & 0o111,
0,
"a non-script file in a hooks dir must stay non-executable"
);
let script_mode = fs::metadata(dir.join("run.sh"))
.unwrap()
.permissions()
.mode();
assert_eq!(script_mode & 0o777, 0o755);
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_install_overwrites_only_with_force() {
let dir = hooks_fixture_dir("role-hooks-force-");
let shipped = fixture(&[("a.sh", "new content\n")]);
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Skip, &mut StickyMode::None)
.unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL");
install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Force, &mut StickyMode::None)
.unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
#[serial_test::serial]
fn role_hooks_prompt_mode_asks_per_conflict_and_honors_sticky() {
use crate::config::conflict::prompt_script;
let dir = hooks_fixture_dir("role-hooks-prompt-");
let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]);
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "local a").unwrap();
fs::write(dir.join("b.sh"), "local b").unwrap();
// First conflict answered per-file, second by the sticky replace-all.
let script = prompt_script::install(&["replace-all"]);
let mut sticky = StickyMode::None;
install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Prompt, &mut sticky).unwrap();
assert_eq!(prompt_script::prompts_asked(), 1);
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
assert_eq!(read_to_string(dir.join("b.sh")).unwrap(), "new content\n");
assert_eq!(sticky, StickyMode::ReplaceAll);
drop(script);
// A sticky mode carried in from an earlier location suppresses all
// prompting: `--install-builtins hooks` shares one sticky scope
// across the global, role, and agent hook locations.
fs::write(dir.join("a.sh"), "local again").unwrap();
let script = prompt_script::install(&[]);
let mut sticky = StickyMode::KeepAll;
install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Prompt, &mut sticky).unwrap();
assert_eq!(prompt_script::prompts_asked(), 0);
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "local again");
drop(script);
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_reconcile_removes_dropped_hook_and_keeps_user_files() {
let dir = hooks_fixture_dir("role-hooks-reconcile-");
install_and_reconcile_role_hooks(
&dir,
&fixture(&[("keep.sh", "#!/bin/sh\n"), ("drop.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(
&dir,
&fixture(&[("keep.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap();
assert!(
!dir.join("drop.sh").exists(),
"dropped builtin hook removed"
);
assert!(dir.join("keep.sh").exists());
assert!(dir.join("user.sh").exists(), "user file survives reconcile");
assert_eq!(
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"keep.sh\n"
);
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_never_claim_a_preexisting_user_file() {
let dir = hooks_fixture_dir("role-hooks-unclaimed-");
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(
&dir,
&fixture(&[("a.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap();
assert!(
!dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists(),
"a skipped pre-existing file must not be claimed by the manifest"
);
install_and_reconcile_role_hooks(&dir, &[], InstallMode::Skip, &mut StickyMode::None)
.unwrap();
assert_eq!(
read_to_string(dir.join("a.sh")).unwrap(),
"user-owned",
"the user file survives the hook being dropped from the embed"
);
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_empty_shipped_set_creates_nothing() {
let dir = hooks_fixture_dir("role-hooks-empty-");
install_and_reconcile_role_hooks(&dir, &[], InstallMode::Skip, &mut StickyMode::None)
.unwrap();
assert!(!dir.exists(), "an empty shipped set must not create hooks/");
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
}
+392 -12
View File
@@ -3,6 +3,7 @@ use super::todo::TodoList;
use super::*;
use crate::client::{Message, MessageContent, MessageRole, TokenUsage};
use crate::hooks::HooksMap;
use crate::render::MarkdownRender;
use anyhow::{Context, Result, bail};
@@ -11,8 +12,10 @@ use inquire::{Confirm, Text, validator::Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::fs::{read_to_string, write};
use std::path::Path;
use std::fmt;
use std::fs::{OpenOptions, read_to_string, remove_file, rename};
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
@@ -28,6 +31,62 @@ fn cost_is_zero(v: &f64) -> bool {
*v == 0.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SessionScope {
#[default]
Global,
Workspace,
}
impl fmt::Display for SessionScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SessionScope::Global => write!(f, "global"),
SessionScope::Workspace => write!(f, "workspace"),
}
}
}
pub fn session_scope_dirs(agent: Option<&str>) -> (PathBuf, PathBuf) {
match agent {
Some(agent) => (
paths::workspace_agent_sessions_dir(agent),
paths::agent_sessions_dir(agent),
),
None => (paths::workspace_sessions_dir(), default_sessions_dir()),
}
}
/// Session names across both scope dirs, workspace first, deduped by name so
/// a workspace session shadows a global one with the same name.
pub fn labeled_session_names(
workspace_dir: &Path,
global_dir: &Path,
) -> Vec<(String, SessionScope)> {
let mut values = Vec::new();
let mut seen = HashSet::new();
for (dir, scope) in [
(workspace_dir, SessionScope::Workspace),
(global_dir, SessionScope::Global),
] {
for name in list_file_names(dir, ".yaml") {
if seen.insert(name.clone()) {
values.push((name, scope));
}
}
}
values
}
pub fn merged_session_names(workspace_dir: &Path, global_dir: &Path) -> Vec<String> {
let mut names: Vec<String> = labeled_session_names(workspace_dir, global_dir)
.into_iter()
.map(|(name, _)| name)
.collect();
names.sort_unstable();
names
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Session {
#[serde(rename(serialize = "model", deserialize = "model"))]
@@ -113,10 +172,14 @@ pub struct Session {
#[serde(skip)]
role_prompt: String,
#[serde(skip)]
role_hooks: Option<HooksMap>,
#[serde(skip)]
name: String,
#[serde(skip)]
path: Option<String>,
#[serde(skip)]
scope: SessionScope,
#[serde(skip)]
dirty: bool,
#[serde(skip)]
save_session_this_time: bool,
@@ -170,6 +233,7 @@ impl Session {
app: &AppConfig,
name: &str,
path: &Path,
scope: SessionScope,
) -> Result<Self> {
let content = read_to_string(path)
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
@@ -177,6 +241,7 @@ impl Session {
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
session.scope = scope;
if let Some(autoname) = name.strip_prefix("_/") {
session.name = TEMP_SESSION_NAME.to_string();
@@ -193,6 +258,7 @@ impl Session {
&& let Ok(role) = ctx.retrieve_role(app, role_name)
{
session.role_prompt = role.prompt().to_string();
session.role_hooks = role.hooks().cloned();
}
session.update_tokens();
@@ -246,6 +312,14 @@ impl Session {
self.name = name;
}
pub fn scope(&self) -> SessionScope {
self.scope
}
pub fn set_scope(&mut self, scope: SessionScope) {
self.scope = scope;
}
pub fn clear_autoname(&mut self) {
self.autoname = None;
}
@@ -254,6 +328,13 @@ impl Session {
self.role_name.as_deref()
}
/// Hooks of the role this session holds. `set_role` snapshots them
/// because only the frontmatter-stripped role prompt is persisted;
/// `load_from_ctx` restores them by re-reading the role file on resume.
pub fn role_hooks(&self) -> Option<&HooksMap> {
self.role_hooks.as_ref()
}
pub fn dirty(&self) -> bool {
self.dirty
}
@@ -302,6 +383,7 @@ impl Session {
pub fn export(&self) -> Result<String> {
let mut data = json!({
"path": self.path,
"scope": self.scope.to_string(),
"model": self.model().id(),
});
if let Some(temperature) = self.temperature() {
@@ -392,6 +474,7 @@ impl Session {
if let Some(path) = &self.path {
items.push(("path", path.to_string()));
}
items.push(("scope", self.scope.to_string()));
if let Some(autoname) = self.autoname() {
items.push(("autoname", autoname.to_string()));
@@ -561,6 +644,7 @@ impl Session {
self.model = role.model().clone();
self.role_name = convert_option_string(role.name());
self.role_prompt = role.prompt().to_string();
self.role_hooks = role.hooks().cloned();
self.dirty = true;
self.update_tokens();
}
@@ -568,11 +652,13 @@ impl Session {
pub fn clear_role(&mut self) {
self.role_name = None;
self.role_prompt.clear();
self.role_hooks = None;
}
pub fn sync_agent(&mut self, agent: &Agent) {
self.role_name = None;
self.role_prompt = agent.interpolated_instructions();
self.role_hooks = None;
self.agent_variables = agent.variables().clone();
self.agent_instructions = self.role_prompt.clone();
if let Some(threshold) = agent.compression_threshold() {
@@ -705,17 +791,50 @@ impl Session {
self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1)
}
/// Estimated tokens of the messages that compression would actually fold
/// away: everything except the leading system message (whose content is
/// carried into the summary message) and the `keep_last` tail (kept verbatim).
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
/// Whether the prompt is close enough to the model's hard input limit
/// that compression must run regardless of how `compression_threshold`
/// is configured. Sizes the prompt as the larger of the API-reported
/// measurement from the last request that reported usage and the local
/// estimate, because the estimate can run well below the provider's
/// real count.
pub fn safety_valve_triggered(
&self,
valve: f32,
keep_last: usize,
last_prompt_tokens: Option<usize>,
) -> bool {
if self.compressing || !valve.is_finite() || valve <= 0.0 {
return false;
}
let Some(max_input_tokens) = self.model().max_input_tokens() else {
return false;
};
// With nothing to fold away, compressing is a no-op; firing anyway
// would spin compress attempts while a single in-flight turn holds
// all the tokens.
if self.reclaimable_tokens(keep_last) == 0 {
return false;
}
last_prompt_tokens.unwrap_or(0).max(self.tokens())
>= (valve * max_input_tokens as f32) as usize
}
/// The messages that compression would actually fold away: everything
/// except the leading system message (whose content is carried into the
/// summary message) and the `keep_last` tail (kept verbatim).
pub fn foldable_messages(&self, keep_last: usize) -> &[Message] {
let start = usize::from(
self.messages
.first()
.is_some_and(|v| v.role == MessageRole::System),
);
let end = self.messages.len().saturating_sub(keep_last).max(start);
self.model().total_tokens(&self.messages[start..end])
&self.messages[start..end]
}
/// Estimated tokens of the messages compression would fold away.
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
self.model().total_tokens(self.foldable_messages(keep_last))
}
pub fn compressing(&self) -> bool {
@@ -839,17 +958,52 @@ impl Session {
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
ensure_parent_exists(session_path)?;
self.path = Some(session_path.display().to_string());
let content = serde_yaml::to_string(&self)
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
write(session_path, content).with_context(|| {
// Write through a sibling temp file and rename it over the target so a
// symlink planted at the session path is replaced, never followed. The
// temp file is opened with create_new so a symlink planted at the temp
// path is not followed either; a stale entry there is removed and the
// open retried once.
let mut tmp_name = session_path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", process::id()));
let tmp_path = PathBuf::from(tmp_name);
let write_context = || {
format!(
"Failed to write session '{}' to '{}'",
self.name,
session_path.display()
)
})?;
};
let open_tmp = || {
OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
};
let mut tmp_file = match open_tmp() {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
remove_file(&tmp_path).with_context(write_context)?;
open_tmp().with_context(write_context)?
}
Err(error) => return Err(error).with_context(write_context),
};
if let Err(error) = tmp_file
.write_all(content.as_bytes())
.and_then(|_| tmp_file.flush())
{
drop(tmp_file);
let _ = remove_file(&tmp_path);
return Err(error).with_context(write_context);
}
drop(tmp_file);
if let Err(error) = rename(&tmp_path, session_path) {
let _ = remove_file(&tmp_path);
return Err(error).with_context(write_context);
}
self.path = Some(session_path.display().to_string());
if is_repl {
println!("✓ Saved the session to '{}'.", session_path.display());
@@ -1095,7 +1249,7 @@ impl AutoName {
mod tests {
use super::*;
use crate::client::{
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, TokenUsage,
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, ModelData, TokenUsage,
};
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
use crate::function::{Functions, ToolCall, ToolResult};
@@ -1111,6 +1265,25 @@ mod tests {
assert!(!session.dirty());
}
#[test]
fn set_role_captures_and_clear_role_drops_role_hooks() {
let content = "---\nhooks:\n turn.completed:\n - name: role-probe\n command: role-cmd\n---\nPrompt";
let mut session = Session::default();
session.set_role(Role::new("hooked", content));
let hooks = session.role_hooks().expect("set_role must snapshot hooks");
assert_eq!(hooks["turn.completed"][0].name, "role-probe");
session.clear_role();
assert!(session.role_hooks().is_none());
session.set_role(Role::new("plain", "No frontmatter"));
assert!(
session.role_hooks().is_none(),
"a role without hooks must not leave a stale snapshot"
);
}
fn push_interrupted_turn(session: &mut Session) {
session.messages.push(Message::new(
MessageRole::User,
@@ -1233,6 +1406,47 @@ mod tests {
assert!(!yaml.contains("token_usage"));
}
#[test]
fn session_role_hooks_is_not_serialized() {
let content = "---\nhooks:\n turn.completed:\n - name: role-probe\n command: role-cmd\n---\nPrompt";
let mut session = Session::default();
session.set_role(Role::new("hooked", content));
assert!(session.role_hooks().is_some());
let yaml = serde_yaml::to_string(&session).unwrap();
assert!(
!yaml.contains("role_hooks"),
"the role-hooks snapshot must never reach session YAML"
);
assert!(
!yaml.contains("role-cmd"),
"no hook command text may leak into session YAML under any key"
);
}
#[test]
fn session_scope_is_not_serialized() {
let mut session = Session::default();
session.set_scope(SessionScope::Workspace);
let yaml = serde_yaml::to_string(&session).unwrap();
assert!(!yaml.contains("scope"));
}
#[test]
fn session_deserialization_ignores_injected_role_hooks() {
// A session file is attacker-writable data: a `role_hooks:` key
// crafted into it must never deserialize into executable hook
// config. Hooks are only ever re-read from the role file itself.
let yaml = "model: ''\nmessages: []\nrole_hooks:\n session.started:\n - name: injected\n command: touch /tmp/pwned\n";
let session: Session = serde_yaml::from_str(yaml).unwrap();
assert!(
session.role_hooks().is_none(),
"injected role_hooks in session YAML must be ignored"
);
}
#[test]
fn session_cost_accumulates_and_survives_yaml_round_trip() {
let mut session = Session::default();
@@ -1259,8 +1473,10 @@ mod tests {
fn session_export_includes_real_usage_only_when_reported() {
let empty = Session::default().export().unwrap();
assert!(!empty.contains("real_input_tokens"));
assert!(empty.contains("scope: global"));
let mut session = Session::default();
session.set_scope(SessionScope::Workspace);
session.accumulate_token_usage(&TokenUsage {
input_tokens: Some(5),
output_tokens: Some(7),
@@ -1268,12 +1484,97 @@ mod tests {
cache_read_input_tokens: Some(3),
});
let exported = session.export().unwrap();
assert!(exported.contains("scope: workspace"));
assert!(exported.contains("real_input_tokens: 5"));
assert!(exported.contains("real_output_tokens: 7"));
assert!(exported.contains("cache_read_tokens: 3"));
assert!(!exported.contains("cache_creation_tokens"));
}
fn rendered_lines(session: &Session) -> Vec<String> {
let options = crate::render::RenderOptions {
raw_markdown: true,
..Default::default()
};
let mut render = MarkdownRender::init(options).unwrap();
session
.render(&mut render, &None)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn session_render_reports_scope_line() {
let mut session = Session::default();
assert!(
rendered_lines(&session)
.iter()
.any(|line| line.starts_with("scope") && line.ends_with("global"))
);
session.set_scope(SessionScope::Workspace);
assert!(
rendered_lines(&session)
.iter()
.any(|line| line.starts_with("scope") && line.ends_with("workspace"))
);
}
#[cfg(unix)]
#[test]
fn session_save_replaces_a_symlink_instead_of_following_it() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("coyote-session-save-symlink-{unique}"));
let sessions_dir = dir.join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
let target = dir.join("target.txt");
std::fs::write(&target, "untouched").unwrap();
let link = sessions_dir.join("link.yaml");
std::os::unix::fs::symlink(&target, &link).unwrap();
let mut session = Session::default();
session.save("link", &link, false).unwrap();
assert_eq!(read_to_string(&target).unwrap(), "untouched");
assert!(link.symlink_metadata().unwrap().is_file());
assert!(read_to_string(&link).unwrap().contains("model:"));
assert_eq!(session.name(), "link");
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn session_save_does_not_follow_a_symlink_planted_at_the_temp_path() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("coyote-session-save-tmp-symlink-{unique}"));
let sessions_dir = dir.join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
let target = dir.join("target.txt");
std::fs::write(&target, "untouched").unwrap();
let destination = sessions_dir.join("notes.yaml");
let mut tmp_name = destination.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", process::id()));
let tmp_path = PathBuf::from(tmp_name);
std::os::unix::fs::symlink(&target, &tmp_path).unwrap();
let mut session = Session::default();
session.save("notes", &destination, false).unwrap();
assert_eq!(read_to_string(&target).unwrap(), "untouched");
assert!(destination.symlink_metadata().unwrap().is_file());
assert!(read_to_string(&destination).unwrap().contains("model:"));
assert!(!tmp_path.exists(), "temp file must not be left behind");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn session_todo_state_survives_yaml_round_trip() {
let mut session = Session::default();
@@ -1514,6 +1815,85 @@ mod tests {
assert!(session.needs_compression(100, 1));
}
/// A session with a system message, a large user message, and a small
/// assistant reply, on a model with the given input limit. With
/// `keep_last = 1` the user message is reclaimable; with `keep_last = 2`
/// nothing is.
fn valve_session(max_input_tokens: Option<usize>) -> Session {
let mut session = Session::default();
let mut data = ModelData::new("valve-model");
data.max_input_tokens = max_input_tokens;
session.set_model(Model::from_config("provider", &[data]).remove(0));
session.messages.push(Message::new(
MessageRole::System,
MessageContent::Text("x".repeat(400)),
));
session.messages.push(Message::new(
MessageRole::User,
MessageContent::Text("y".repeat(4000)),
));
session.messages.push(Message::new(
MessageRole::Assistant,
MessageContent::Text("ok".to_string()),
));
session.update_tokens();
session
}
#[test]
fn session_safety_valve_fires_from_estimate() {
let session = valve_session(Some(1000));
assert!(session.tokens() >= 800);
assert!(session.safety_valve_triggered(0.8, 1, None));
}
#[test]
fn session_safety_valve_fires_from_real_usage_over_estimate() {
let session = valve_session(Some(1_000_000));
assert!(!session.safety_valve_triggered(0.8, 1, None));
assert!(!session.safety_valve_triggered(0.8, 1, Some(700_000)));
assert!(session.safety_valve_triggered(0.8, 1, Some(800_000)));
}
#[test]
fn session_safety_valve_disabled_when_zero() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(0.0, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_disabled_when_nan() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(f32::NAN, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_respects_compressing() {
let mut session = valve_session(Some(1000));
session.set_compressing(true);
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_requires_reclaimable_tokens() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(0.8, 2, Some(1_000_000)));
assert!(session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_requires_model_max_input_tokens() {
let session = valve_session(None);
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_set_compressing_flag() {
let mut session = Session::default();
+2 -1
View File
@@ -100,7 +100,8 @@ impl Skill {
let embedded_file = SkillsAsset::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded skill file: {}", file.as_ref()))?;
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
let content = std::str::from_utf8(&embedded_file.data)
.expect("bundled skill asset is not valid UTF-8");
let file_path = paths::skills_dir().join(file.as_ref());
if file_path.exists() && !force {
+7 -7
View File
@@ -918,7 +918,7 @@ mod tests {
use super::*;
use crate::config::mcp_tool_policy::LayerSource;
use crate::function::ToolCall;
use crate::testing::{install_warn_collector, warn_messages};
use crate::testing::{install_log_collector, warn_snapshot};
use std::sync::atomic::Ordering;
#[test]
@@ -1083,7 +1083,7 @@ mod tests {
#[tokio::test]
async fn catalog_items_warns_when_resource_listing_fails() {
install_warn_collector();
install_log_collector();
let fixture = FixtureServer {
resources_capability: true,
fail_resource_listings: true,
@@ -1093,7 +1093,7 @@ mod tests {
runtime.catalog_items("fixture").await.unwrap();
let messages = warn_messages().lock().unwrap();
let messages = warn_snapshot();
assert!(
messages
.iter()
@@ -1547,7 +1547,7 @@ mod tests {
#[tokio::test]
async fn prompt_catalog_degrades_when_one_server_fails() {
install_warn_collector();
install_log_collector();
let healthy = FixtureServer {
prompts_capability: true,
..Default::default()
@@ -1564,7 +1564,7 @@ mod tests {
assert_eq!(items.len(), 1);
assert_eq!(items[0].server, "fixture");
let messages = warn_messages().lock().unwrap();
let messages = warn_snapshot();
assert!(
messages
.iter()
@@ -1794,7 +1794,7 @@ mod tests {
#[tokio::test]
async fn catalog_items_warns_on_dead_context_patterns() {
install_warn_collector();
install_log_collector();
let (mut runtime, _server) = fixture_runtime(FixtureServer::default()).await;
let mut filter = single_layer_filter(LayerSource::Global, &["*"]);
filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]);
@@ -1802,7 +1802,7 @@ mod tests {
runtime.catalog_items("fixture").await.unwrap();
let messages = warn_messages().lock().unwrap();
let messages = warn_snapshot();
assert!(
messages.iter().any(|msg| msg.contains("'zzz_*'")
&& msg.contains("session (.set)")
+361 -1
View File
@@ -6,6 +6,7 @@ use crate::config::{
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
list_agents_with_descriptions, load_agent_variables,
};
use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
use crate::supervisor::notification::agent_notification;
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
@@ -15,6 +16,7 @@ use crate::graph;
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
use hooks::McpServerHooks;
use indexmap::IndexMap;
use log::{debug, warn};
use parking_lot::RwLock;
@@ -960,10 +962,11 @@ async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[Stri
})
.unwrap_or_default();
let mcp_hooks = McpServerHooks::resolve(ctx);
for (id, spec) in server_specs {
let handle = app
.mcp_factory
.acquire(&id, &spec, app.mcp_log_path.as_deref())
.acquire(&id, &spec, app.mcp_log_path.as_deref(), &mcp_hooks)
.await?;
ctx.tool_scope.mcp_runtime.insert(id, handle);
}
@@ -996,6 +999,60 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
Ok(())
}
/// Pre-resolved `agent.completed`/`agent.interrupted`/`agent.failed`
/// snapshots for a spawned agent's result arms, captured from the child
/// context before the spawn task takes ownership of it. Resolving against
/// the child keeps global hooks gated by the child's own whitelist, the
/// same gate every other event fired from the child's context goes through.
struct SpawnResultHooks {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
interrupted: Vec<ResolvedHook>,
session_name: Option<String>,
agent_id: String,
agent_name: String,
}
impl SpawnResultHooks {
fn resolve(child_ctx: &RequestContext, agent_id: &str, agent_name: &str) -> Self {
Self {
completed: child_ctx.resolved_hooks(HookEvent::AgentCompleted),
failed: child_ctx.resolved_hooks(HookEvent::AgentFailed),
interrupted: child_ctx.resolved_hooks(HookEvent::AgentInterrupted),
session_name: child_ctx
.session
.as_ref()
.map(|session| session.name().to_string()),
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
}
}
fn fire(self, error: Option<&str>, child_abort: &AbortSignal) {
let mut extras = vec![
("COYOTE_AGENT_ID", self.agent_id),
("COYOTE_AGENT_NAME", self.agent_name.clone()),
];
let (event, resolved) = match error {
None => (HookEvent::AgentCompleted, self.completed),
Some(_) if child_abort.aborted_ctrlc() => {
(HookEvent::AgentInterrupted, self.interrupted)
}
Some(error) => {
extras.push(("COYOTE_AGENT_ERROR", error.to_string()));
(HookEvent::AgentFailed, self.failed)
}
};
hooks::fire_resolved(
event,
resolved,
hooks::base_envs_parts(event, self.session_name.as_deref(), Some(&self.agent_name)),
&extras,
None,
);
}
}
async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let agent_name = args
.get("agent")
@@ -1149,9 +1206,23 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
debug!("Spawning child agent '{agent_name}' as '{agent_id}'");
hooks::fire(
HookEvent::AgentStarted,
&child_ctx,
&[
("COYOTE_AGENT_ID", agent_id.clone()),
("COYOTE_AGENT_NAME", agent_name.clone()),
],
None,
);
// Resolved before the spawn: the result arms outlive the child context
// the snapshots come from.
let result_hooks = SpawnResultHooks::resolve(&child_ctx, &agent_id, &agent_name);
let spawn_agent_id = agent_id.clone();
let spawn_agent_name = agent_name.clone();
let spawn_abort = child_abort.clone();
let hook_abort = child_abort.clone();
let spawn_notifications = Arc::clone(&ctx.notification_queue);
let child_supervisor = child_ctx.supervisor.clone();
@@ -1173,6 +1244,10 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
},
};
let success = agent_result.exit_status == AgentExitStatus::Completed;
match &agent_result.exit_status {
AgentExitStatus::Completed => result_hooks.fire(None, &hook_abort),
AgentExitStatus::Failed(e) => result_hooks.fire(Some(e), &hook_abort),
}
spawn_notifications.push(agent_notification(
&agent_result.id,
&agent_result.agent_name,
@@ -1703,6 +1778,18 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result<Val
let from_agent = request.from_agent_name.clone();
let question = request.question.clone();
let _ = request.reply_tx.send(reply.to_string());
hooks::fire(
HookEvent::EscalationAnswered,
ctx,
&[
("COYOTE_ESCALATION_ID", escalation_id.to_string()),
("COYOTE_ESCALATION_FROM_AGENT_ID", request.from_agent_id),
("COYOTE_ESCALATION_FROM_AGENT_NAME", from_agent.clone()),
("COYOTE_ESCALATION_QUESTION", question.clone()),
("COYOTE_ESCALATION_REPLY", reply.to_string()),
],
None,
);
Ok(json!({
"status": "ok",
"message": format!("Reply sent to agent '{from_agent}' for escalation '{escalation_id}'"),
@@ -2154,6 +2241,206 @@ mod tests {
.unwrap();
}
/// A graph agent whose graph.yaml wires `agent.*` hooks at its top
/// level. With `failing`, the start node references a script that does
/// not exist, so the child run fails at runtime — after the spawn seam
/// has already fired `agent.started`.
fn write_graph_agent_with_hooks(agent_name: &str, marker: &str, failing: bool) {
let agent_dir = paths::agent_data_dir(agent_name);
create_dir_all(&agent_dir).unwrap();
let (start, nodes) = if failing {
(
"work",
" work:\n type: script\n script: missing.sh\n next: done\n done:\n type: end\n output: done\n",
)
} else {
("done", " done:\n type: end\n output: done\n")
};
write(
agent_dir.join("graph.yaml"),
format!(
"name: {agent_name}\n\
start: {start}\n\
settings:\n\
\x20 validate_before_run: false\n\
hooks:\n\
\x20 agent.started:\n\
\x20 - name: {marker}_started\n\
\x20 command: \"true\"\n\
\x20 agent.completed:\n\
\x20 - name: {marker}_completed\n\
\x20 command: \"true\"\n\
\x20 agent.interrupted:\n\
\x20 - name: {marker}_interrupted\n\
\x20 command: \"true\"\n\
\x20 agent.failed:\n\
\x20 - name: {marker}_failed\n\
\x20 command: \"true\"\n\
nodes:\n\
{nodes}"
),
)
.unwrap();
}
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == name)
.collect()
}
/// Spawns an agent through the supervisor seam and awaits its spawn
/// task, returning the spawn id. The result arms fire their hooks
/// before that task returns, so the sink is complete afterwards.
fn spawn_and_collect(agent_name: &str) -> String {
run_async(async {
let mut ctx = ctx_with_supervisor(4, 3);
let spawned = handle_spawn(&mut ctx, &json!({"agent": agent_name, "prompt": "hi"}))
.await
.unwrap();
assert_eq!(spawned["status"], "ok", "{spawned}");
let id = spawned["id"].as_str().unwrap().to_string();
let handle = ctx
.supervisor
.as_ref()
.unwrap()
.write()
.take(&id)
.expect("spawned agent must be registered");
handle
.join_handle
.await
.expect("spawn task must not panic")
.expect("spawn task must return an AgentResult");
id
})
}
#[test]
#[serial]
fn spawned_agent_success_fires_started_and_completed_with_matching_id() {
let _guard = TestConfigDirGuard::new();
let _sink = hooks::test_sink::install();
let agent_name = unique_agent_name("test_hook_spawn_ok");
write_graph_agent_with_hooks(&agent_name, "t037_spawn_ok", false);
let id = spawn_and_collect(&agent_name);
let started = hook_captures_named("t037_spawn_ok_started");
assert_eq!(started.len(), 1, "{started:?}");
assert_eq!(started[0].envs["COYOTE_EVENT"], "agent.started");
assert_eq!(started[0].envs["COYOTE_AGENT_ID"], id);
assert_eq!(started[0].envs["COYOTE_AGENT_NAME"], agent_name);
let completed = hook_captures_named("t037_spawn_ok_completed");
assert_eq!(completed.len(), 1, "{completed:?}");
assert_eq!(completed[0].envs["COYOTE_EVENT"], "agent.completed");
assert_eq!(completed[0].envs["COYOTE_AGENT_ID"], id);
assert_eq!(completed[0].envs["COYOTE_AGENT_NAME"], agent_name);
assert!(hook_captures_named("t037_spawn_ok_failed").is_empty());
}
#[test]
#[serial]
fn spawned_agent_failure_fires_started_and_failed_with_matching_id() {
let _guard = TestConfigDirGuard::new();
let _sink = hooks::test_sink::install();
let agent_name = unique_agent_name("test_hook_spawn_fail");
write_graph_agent_with_hooks(&agent_name, "t037_spawn_fail", true);
let id = spawn_and_collect(&agent_name);
let started = hook_captures_named("t037_spawn_fail_started");
assert_eq!(started.len(), 1, "{started:?}");
assert_eq!(started[0].envs["COYOTE_AGENT_ID"], id);
let failed = hook_captures_named("t037_spawn_fail_failed");
assert_eq!(failed.len(), 1, "{failed:?}");
assert_eq!(failed[0].envs["COYOTE_EVENT"], "agent.failed");
assert_eq!(failed[0].envs["COYOTE_AGENT_ID"], id);
assert!(
!failed[0].envs["COYOTE_AGENT_ERROR"].is_empty(),
"a failed spawn must carry its error"
);
assert!(hook_captures_named("t037_spawn_fail_completed").is_empty());
}
#[test]
#[serial]
fn cancelled_spawned_agent_fires_interrupted_not_failed() {
let _guard = TestConfigDirGuard::new();
let _sink = hooks::test_sink::install();
let agent_name = unique_agent_name("test_hook_spawn_cancel");
write_graph_agent_with_hooks(&agent_name, "t049_spawn_cancel", true);
let id = run_async(async {
let mut ctx = ctx_with_supervisor(4, 3);
let spawned = handle_spawn(&mut ctx, &json!({"agent": agent_name, "prompt": "hi"}))
.await
.unwrap();
assert_eq!(spawned["status"], "ok", "{spawned}");
let id = spawned["id"].as_str().unwrap().to_string();
let handle = ctx
.supervisor
.as_ref()
.unwrap()
.write()
.take(&id)
.expect("spawned agent must be registered");
// The `agent__cancel` signal, fired before the spawn task runs:
// on this current-thread runtime the task only progresses once
// awaited, so its failure arm observes the ctrl-c deterministically.
handle.abort_signal.set_ctrlc();
handle
.join_handle
.await
.expect("spawn task must not panic")
.expect("spawn task must return an AgentResult");
id
});
let interrupted = hook_captures_named("t049_spawn_cancel_interrupted");
assert_eq!(interrupted.len(), 1, "{interrupted:?}");
assert_eq!(interrupted[0].envs["COYOTE_EVENT"], "agent.interrupted");
assert_eq!(interrupted[0].envs["COYOTE_AGENT_ID"], id);
assert_eq!(interrupted[0].envs["COYOTE_AGENT_NAME"], agent_name);
assert!(
!interrupted[0].envs.contains_key("COYOTE_AGENT_ERROR"),
"an interruption is not a failure and carries no error env"
);
assert!(hook_captures_named("t049_spawn_cancel_failed").is_empty());
assert!(hook_captures_named("t049_spawn_cancel_completed").is_empty());
}
#[test]
fn agent_event_base_envs_carry_optional_names_only_when_present() {
let envs = hooks::base_envs_parts(HookEvent::AgentCompleted, None, None);
let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect();
assert!(keys.contains(&"COYOTE_EVENT"));
assert!(keys.contains(&"COYOTE_CONFIG_DIR"));
assert!(keys.contains(&"COYOTE_EVENT_TIMESTAMP"));
assert!(!keys.contains(&"COYOTE_SESSION_ID"));
assert!(!keys.contains(&"COYOTE_AGENT_NAME"));
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed")
);
let envs = hooks::base_envs_parts(HookEvent::AgentFailed, Some("sess"), Some("bot"));
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess")
);
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_AGENT_NAME" && value == "bot")
);
}
fn run_agent_for_graph_error(
agent_name: &str,
graph_inputs: Option<HashMap<String, Value>>,
@@ -3107,6 +3394,79 @@ mod tests {
assert!(result.is_err());
}
/// `escalation.answered` belongs to the successful take -> reply arm
/// alone: exactly once per escalation, and never from the failure arms
/// (unknown id, already-taken id).
#[test]
#[serial]
fn reply_escalation_fires_answered_once_and_never_on_failure_arms() {
let _sink = hooks::test_sink::install();
let marker = "t049_answered_once";
let mut hooks_map = hooks::HooksMap::default();
hooks_map.insert(
"escalation.answered".to_string(),
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
);
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks: hooks_map,
..Default::default()
});
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
let queue = Arc::new(EscalationQueue::new());
let (tx, rx) = tokio::sync::oneshot::channel();
queue.submit(EscalationRequest {
id: "esc_once".into(),
from_agent_id: "a1".into(),
from_agent_name: "explore".into(),
question: "[input] What do?".into(),
options: None,
reply_tx: tx,
});
ctx.escalation_queue = Some(queue);
// Unknown id: an error result, and no event.
let miss = handle_reply_escalation(
&mut ctx,
&json!({"escalation_id": "missing", "reply": "nope"}),
)
.unwrap();
assert_eq!(miss["status"], "error");
assert!(
hook_captures_named(marker).is_empty(),
"a failed reply must not fire escalation.answered"
);
// The successful take -> reply arm fires it exactly once.
let ok = handle_reply_escalation(
&mut ctx,
&json!({"escalation_id": "esc_once", "reply": "do X"}),
)
.unwrap();
assert_eq!(ok["status"], "ok");
assert_eq!(rx.blocking_recv().unwrap(), "do X");
let answered = hook_captures_named(marker);
assert_eq!(answered.len(), 1, "{answered:?}");
assert_eq!(answered[0].envs["COYOTE_ESCALATION_ID"], "esc_once");
assert_eq!(answered[0].envs["COYOTE_ESCALATION_REPLY"], "do X");
// Replying again to the same id hits the already-taken arm: still one.
let again = handle_reply_escalation(
&mut ctx,
&json!({"escalation_id": "esc_once", "reply": "again"}),
)
.unwrap();
assert_eq!(again["status"], "error");
assert_eq!(
hook_captures_named(marker).len(),
1,
"a second reply must not re-fire escalation.answered"
);
}
#[test]
fn handle_task_create_simple() {
let mut ctx = ctx_with_supervisor(4, 3);
+270
View File
@@ -9,6 +9,7 @@ use crate::config::{
McpRuntime, RequestContext, effective_max_concurrent_jobs, jobs_enabled, paths,
};
use crate::graph;
use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_PROMPT_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX,
@@ -53,6 +54,75 @@ pub fn is_agent_task(supervisor: Option<&Arc<RwLock<Supervisor>>>, id: &str) ->
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
}
struct JobHookSnapshot {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
session_name: Option<String>,
agent_name: Option<String>,
job_id: String,
tool: String,
}
impl JobHookSnapshot {
fn resolve(ctx: &RequestContext, job_id: &str, tool: &str) -> Self {
Self {
completed: ctx.resolved_hooks(HookEvent::JobCompleted),
failed: ctx.resolved_hooks(HookEvent::JobFailed),
session_name: ctx
.session
.as_ref()
.map(|session| session.name().to_string()),
agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()),
job_id: job_id.to_string(),
tool: tool.to_string(),
}
}
/// Fires `job.completed` or `job.failed`. `fire_resolved` callers supply
/// their own base envs; the event name and timestamp are computed here so
/// they reflect the actual outcome rather than the moment the snapshot
/// was taken.
fn fire_terminal(self, success: bool, error: Option<String>) {
let (event, resolved) = if success {
(HookEvent::JobCompleted, self.completed)
} else {
(HookEvent::JobFailed, self.failed)
};
if resolved.is_empty() {
return;
}
let base_envs = hooks::base_envs_parts(
event,
self.session_name.as_deref(),
self.agent_name.as_deref(),
);
let mut extras = vec![
("COYOTE_JOB_ID", self.job_id),
("COYOTE_TOOL_NAME", self.tool),
];
if let Some(error) = error {
extras.push(("COYOTE_JOB_ERROR", error));
}
hooks::fire_resolved(event, resolved, base_envs, &extras, None);
}
}
fn fire_job_started(ctx: &RequestContext, job_id: &str, tool: &str) {
hooks::fire(
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.to_string()),
("COYOTE_TOOL_NAME", tool.to_string()),
],
None,
);
}
pub struct RingBuf {
buf: Vec<u8>,
capacity: usize,
@@ -470,15 +540,19 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let task_notifications = Arc::clone(&ctx.notification_queue);
let notify_id = job_id.clone();
let notify_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
fire_job_started(ctx, &job_id, &tool);
tokio::spawn(async move {
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
let success = result.is_ok();
let error = result.as_ref().err().map(|err| err.to_string());
task_state.lock().status = if success {
JobStatus::Completed
} else {
JobStatus::Failed
};
task_notifications.push(job_notification(&notify_id, &notify_tool, success));
hook_snapshot.fire_terminal(success, error);
result
})
} else {
@@ -488,9 +562,18 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let task_notifications = Arc::clone(&ctx.notification_queue);
let notify_id = job_id.clone();
let notify_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
fire_job_started(ctx, &job_id, &tool);
tokio::spawn(async move {
let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await;
let success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0));
let error = (!success).then(|| match &result {
Err(err) => err.to_string(),
Ok(job_result) => match job_result.exit_code {
Some(code) => format!("exited with code {code}"),
None => "terminated without an exit code".to_string(),
},
});
let mut job_state = task_state.lock();
job_state.pgid = None;
job_state.status = if success {
@@ -502,6 +585,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
drop(job_state);
task_notifications.push(job_notification(&notify_id, &notify_tool, success));
hook_snapshot.fire_terminal(success, error);
result
})
};
@@ -3119,4 +3203,190 @@ mod tests {
);
assert!(ctx.supervisor.is_none(), "no job may be spawned");
}
fn job_hooks_map(marker: &str) -> hooks::HooksMap {
["job.started", "job.completed", "job.failed"]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
)
})
.collect()
}
fn job_hooked_ctx(marker: &str) -> RequestContext {
let app = app_state_with_config(|config| {
config.hooks = job_hooks_map(marker);
});
RequestContext::new(app, WorkingMode::Cmd)
}
fn job_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn job_hooks_fire_started_and_completed() {
run_async(async {
let marker = "job-hooks-ok-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("echo".into());
let _guard = hooks::test_sink::install();
let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
.await
.unwrap();
assert_eq!(started["status"], "ok");
let job_id = started["job_id"].as_str().unwrap().to_string();
// `job.started` is dispatched before `handle_start` returns.
let captures = job_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
assert_eq!(
captures[0].envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "completed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let completed = &captures[1];
assert_eq!(completed.event, HookEvent::JobCompleted);
assert_eq!(
completed.envs.get("COYOTE_EVENT").map(String::as_str),
Some("job.completed")
);
assert_eq!(
completed.envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
completed.envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
assert!(completed.envs.contains_key("COYOTE_CONFIG_DIR"));
assert!(completed.envs.contains_key("COYOTE_EVENT_TIMESTAMP"));
assert!(!completed.envs.contains_key("COYOTE_SESSION_ID"));
assert!(!completed.envs.contains_key("COYOTE_JOB_ERROR"));
});
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn job_hooks_fire_failed_with_job_error() {
run_async(async {
let marker = "job-hooks-fail-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("false".into());
let _guard = hooks::test_sink::install();
let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
.await
.unwrap();
let job_id = started["job_id"].as_str().unwrap().to_string();
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "failed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::JobFailed);
assert_eq!(
failed.envs.get("COYOTE_EVENT").map(String::as_str),
Some("job.failed")
);
assert_eq!(
failed.envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
failed.envs.get("COYOTE_JOB_ERROR").map(String::as_str),
Some("exited with code 1")
);
});
}
#[test]
#[serial_test::serial]
fn job_hooks_fire_for_mcp_jobs() {
run_async(async {
let marker = "job-hooks-mcp-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names
.insert("mcp_invoke_fixture".into());
let fixture = FixtureServer::default();
let (mut runtime, _server) = fixture_runtime(fixture).await;
let mut filter = ToolFilter::default();
filter.push_layer(LayerSource::Global, &[]);
runtime.tool_filters.insert("fixture".to_string(), filter);
ctx.tool_scope.mcp_runtime = runtime;
let _guard = hooks::test_sink::install();
let started = handle_start(
&mut ctx,
&json!({"tool": "mcp_invoke_fixture", "arguments": {"tool": "dup"}}),
)
.await
.unwrap();
let job_id = started["job_id"].as_str().unwrap().to_string();
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "failed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("mcp_invoke_fixture")
);
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::JobFailed);
assert!(
failed
.envs
.get("COYOTE_JOB_ERROR")
.unwrap()
.contains("dup not found"),
"{failed:?}"
);
});
}
#[test]
#[serial_test::serial]
fn rejected_job_start_fires_no_hooks() {
let marker = "job-hooks-rejected-x7x";
let mut ctx = job_hooked_ctx(marker);
let _guard = hooks::test_sink::install();
let result = run_async(handle_start(
&mut ctx,
&json!({"tool": "undeclared_tool", "arguments": {}}),
))
.unwrap();
assert_eq!(result["status"], "error");
assert!(job_captures(marker).is_empty());
}
}
+395 -19
View File
@@ -17,6 +17,7 @@ use crate::{
use crate::config::ensure_parent_exists;
use crate::config::paths;
use crate::hooks::{self, HookEvent};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_META_FUNCTION_PREFIXES, MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
@@ -1539,6 +1540,22 @@ impl ToolCall {
}
pub async fn eval(&self, ctx: &mut RequestContext) -> Result<Value> {
let result = self.eval_inner(ctx).await;
if let Err(err) = &result {
hooks::fire(
HookEvent::ToolFailed,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
("COYOTE_TOOL_ERROR", err.to_string()),
],
None,
);
}
result
}
async fn eval_inner(&self, ctx: &mut RequestContext) -> Result<Value> {
let agent = ctx.agent.clone();
let functions = ctx.tool_scope.functions.clone();
let current_depth = ctx.current_depth;
@@ -1566,7 +1583,8 @@ impl ToolCall {
);
};
cmd_args.push(json_data.to_string());
let args_json = json_data.to_string();
cmd_args.push(args_json.clone());
if (*IS_STDOUT_TERMINAL || *SHOW_TOOL_CALLS)
&& current_depth == 0
@@ -1575,6 +1593,14 @@ impl ToolCall {
println!("{}", format_call_log(&cmd_name, &cmd_args, &json_data));
}
hooks::fire(
HookEvent::ToolStarted,
ctx,
&[("COYOTE_TOOL_NAME", self.name.clone())],
Some(args_json.clone()),
);
let tool_started_at = Instant::now();
let output = match cmd_name.as_str() {
_ if cmd_name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX) => {
Self::search_mcp_tools(ctx, &cmd_name, &json_data)
@@ -1699,6 +1725,38 @@ impl ToolCall {
},
};
match output.get("tool_call_error") {
Some(error) => {
let error = error
.as_str()
.map(str::to_string)
.unwrap_or_else(|| error.to_string());
hooks::fire(
HookEvent::ToolFailed,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
("COYOTE_TOOL_ERROR", error),
],
Some(args_json),
);
}
None => {
hooks::fire(
HookEvent::ToolCompleted,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
(
"COYOTE_TOOL_DURATION_MS",
tool_started_at.elapsed().as_millis().to_string(),
),
],
Some(args_json),
);
}
}
Ok(output)
}
@@ -2518,13 +2576,16 @@ fn polyfill_cmd_name<T: AsRef<Path>>(cmd_name: &str, bin_dir: &[T]) -> String {
cmd_name
}
// Polling tools are expected to repeat; recording them would also let them
// break up detection of a real loop in the calls they interleave with.
const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 4] = [
// Polling tools are expected to repeat with identical arguments (status probes,
// list views, inbox checks); recording them would also let them break up
// detection of a real loop in the calls they interleave with.
const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 6] = [
"job__check",
"job__list",
"agent__check",
"agent__list_running",
"agent__task_list",
"agent__check_inbox",
];
fn is_loop_tracker_exempt(name: &str) -> bool {
@@ -2633,7 +2694,7 @@ impl ToolCallTracker {
}
}
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::Value) -> String {
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &Value) -> String {
if *NO_COLOR {
return format!("Call {cmd_name} {}", cmd_args.join(" "));
}
@@ -2652,8 +2713,8 @@ fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::
)
}
fn format_json_colored_keys(value: &serde_json::Value) -> String {
let serde_json::Value::Object(map) = value else {
fn format_json_colored_keys(value: &Value) -> String {
let Value::Object(map) = value else {
return dimmed_text(&value.to_string());
};
if map.is_empty() {
@@ -3328,10 +3389,12 @@ mod tests {
"job__list",
"agent__check",
"agent__list_running",
"agent__task_list",
"agent__check_inbox",
]
.into_iter()
.collect();
assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 4);
assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 6);
assert_eq!(actual, expected);
}
@@ -3368,6 +3431,29 @@ mod tests {
assert!(tracker.check_loop(&x).is_some());
}
/// Zero-argument polls (`agent__check_inbox`, `agent__task_list`) are
/// byte-identical on every call, so the protocol-mandated "check your inbox
/// after each round" cadence must never read as a loop — even when the
/// polls are back-to-back and well past the repeat threshold.
#[test]
fn tracker_zero_arg_polls_never_trip_even_when_consecutive() {
for name in ["agent__check_inbox", "agent__task_list"] {
let mut tracker = ToolCallTracker::default();
let poll = call_with_args(name, json!({}));
for _ in 0..10 {
assert!(
tracker.check_loop(&poll).is_none(),
"{name} must never trip the loop tracker"
);
tracker.record_call(poll.clone());
}
assert!(
tracker.last_calls.is_empty(),
"{name} must not be recorded in the call history"
);
}
}
#[test]
fn tracker_non_exempt_behavior_unchanged() {
let mut tracker = ToolCallTracker::default();
@@ -3400,16 +3486,15 @@ mod tests {
#[test]
fn bundled_bash_tools_generate_declarations() {
let tools_dir =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
let tools_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
let mut checked = Vec::new();
for entry in std::fs::read_dir(&tools_dir).unwrap() {
for entry in fs::read_dir(&tools_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(OsStr::to_str) != Some("sh") {
continue;
}
let name = path.file_stem().unwrap().to_string_lossy().to_string();
let src = std::fs::read_to_string(&path).unwrap();
let src = fs::read_to_string(&path).unwrap();
let (_, declarations) = bash::build_bash_tool(&src, &name)
.unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}"));
assert!(
@@ -4587,7 +4672,7 @@ mod tests {
.unwrap()
.expect("nonzero exit must return an error payload");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let json: Value = serde_json::from_str(&result).unwrap();
assert!(
json["tool_call_error"]
.as_str()
@@ -4616,7 +4701,7 @@ mod tests {
.unwrap()
.expect("nonzero exit must return an error payload");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let json: Value = serde_json::from_str(&result).unwrap();
assert!(
json["tool_call_error"]
.as_str()
@@ -4655,7 +4740,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(elapsed < Duration::from_secs(1), "took {elapsed:?}");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let json: Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["tool_call_error"], "Tool call 'bash' aborted");
}
@@ -4737,6 +4822,24 @@ mod tests {
fn built_shims_resolve_paths_at_runtime_after_relocation() {
use std::os::unix::fs::PermissionsExt;
// A shim written moments ago can still be open for writing somewhere
// in the process (schedule-dependent), making exec fail with
// ETXTBSY; retry briefly instead of flaking.
fn output_with_etxtbsy_retry(command: &mut Command) -> io::Result<process::Output> {
let mut attempts = 0;
loop {
match command.output() {
Err(err)
if err.kind() == io::ErrorKind::ExecutableFileBusy && attempts < 10 =>
{
attempts += 1;
thread::sleep(Duration::from_millis(50));
}
ret => return ret,
}
}
}
let root = temp_file("-shim-relocate-", "");
let home_a = root.join("home-a");
let config_a = home_a.join("coyote");
@@ -4800,14 +4903,14 @@ mod tests {
return;
}
let output = process::Command::new(&moved_shim)
let mut command = Command::new(&moved_shim);
command
.arg("{}")
.env_remove(&config_env)
.env_remove(&functions_env)
.env_remove("LLM_OUTPUT")
.env_remove("LLM_TOOL_DATA_FILE")
.output()
.unwrap();
.env_remove("LLM_TOOL_DATA_FILE");
let output = output_with_etxtbsy_retry(&mut command).unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
@@ -5243,4 +5346,277 @@ mod tests {
assert!(err.starts_with(expected), "{name}: {err}");
}
}
fn tool_hooks_map(marker: &str) -> hooks::HooksMap {
["tool.started", "tool.completed", "tool.failed"]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
)
})
.collect()
}
fn ctx_with_tool_hooks(marker: &str, update: impl FnOnce(&mut AppConfig)) -> RequestContext {
let mut app = AppState::test_default();
let mut config = AppConfig {
hooks: tool_hooks_map(marker),
..Default::default()
};
update(&mut config);
app.config = Arc::new(config);
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn tool_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
}
#[test]
#[serial]
fn eval_fires_started_and_completed_for_builtin_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-builtin-ok-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |config| {
config.function_calling_support = true;
config.auto_continue = true;
});
ctx.tool_scope.functions.append_todo_functions();
let _guard = hooks::test_sink::install();
let output =
run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap();
assert_eq!(output["status"], "ok");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("todo__init")
);
assert_eq!(captures[0].payload.as_deref(), Some(r#"{"goal":"g"}"#));
assert_eq!(captures[0].cwd, paths::config_dir());
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
assert!(captures[1].envs.contains_key("COYOTE_TOOL_DURATION_MS"));
assert_eq!(captures[1].payload.as_deref(), Some(r#"{"goal":"g"}"#));
}
#[test]
#[serial]
fn eval_fires_started_and_completed_for_mcp_meta_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-mcp-ok-x7x";
run_async(async {
let mut result = CallToolResult::success(vec![ContentBlock::text("hi")]);
result.structured_content = Some(json!({"ok": true}));
let fixture = FixtureServer {
tool_result: Some(result),
..Default::default()
};
let (runtime, _server) = fixture_runtime(fixture).await;
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope.mcp_runtime = runtime;
ctx.tool_scope
.functions
.append_mcp_meta_functions(vec![tools_only("fixture")]);
let _guard = hooks::test_sink::install();
let output = call_with_args("mcp_invoke_fixture", json!({"tool": "dup"}))
.eval(&mut ctx)
.await
.unwrap();
assert_eq!(output["isError"], false);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("mcp_invoke_fixture")
);
assert_eq!(captures[0].payload.as_deref(), Some(r#"{"tool":"dup"}"#));
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
});
}
#[cfg(unix)]
#[test]
#[serial]
fn eval_fires_started_and_completed_for_external_command_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-external-ok-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope
.functions
.append_declaration(FunctionDeclaration {
name: "echo".to_string(),
description: String::new(),
parameters: JsonSchema::default(),
agent: false,
});
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("echo", json!({"x": 1})).eval(&mut ctx)).unwrap();
assert_eq!(output, Value::Null);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
}
#[cfg(unix)]
#[test]
#[serial]
fn eval_fires_failed_for_external_command_nonzero_exit() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-external-fail-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope
.functions
.append_declaration(FunctionDeclaration {
name: "false".to_string(),
description: String::new(),
parameters: JsonSchema::default(),
agent: false,
});
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("false", json!({})).eval(&mut ctx)).unwrap();
assert!(output["tool_call_error"].is_string());
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.unwrap()
.contains("exited with code 1")
);
assert!(!captures[1].envs.contains_key("COYOTE_TOOL_DURATION_MS"));
}
#[test]
#[serial]
fn eval_fires_failed_on_error_shaped_output_with_identical_result() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-error-shape-x7x";
// Default config leaves todo tools disabled, so the handler bails
// and eval shapes the failure as a `tool_call_error` output.
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
hooked.tool_scope.functions.append_todo_functions();
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
plain.tool_scope.functions.append_todo_functions();
let call = call_with_args("todo__done", json!({}));
let _guard = hooks::test_sink::install();
let hooked_output = run_async(call.eval(&mut hooked)).unwrap();
let plain_output = run_async(call.eval(&mut plain)).unwrap();
assert_eq!(
hooked_output.to_string(),
plain_output.to_string(),
"hooks must not change the tool result"
);
let error = hooked_output["tool_call_error"].as_str().unwrap();
assert!(error.starts_with("Todo tool failed:"), "{error}");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert_eq!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.map(String::as_str),
Some(error)
);
}
#[test]
#[serial]
fn eval_fires_hooks_with_exact_user_prefixed_tool_name() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-user-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope.functions.append_user_interaction_functions();
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("user__confirm", json!({})).eval(&mut ctx)).unwrap();
// Without a question the user handler errors before prompting, so the
// error-shaped output must fire started + failed with the exact name.
let error = output["tool_call_error"].as_str().unwrap();
assert!(error.starts_with("User interaction failed"), "{error}");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("user__confirm")
);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert_eq!(
captures[1].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("user__confirm")
);
assert_eq!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.map(String::as_str),
Some(error)
);
}
#[test]
#[serial]
fn eval_err_fires_failed_without_started_and_identical_error() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-eval-err-x7x";
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
let call = call_with_args("nonexistent_tool", json!({}));
let _guard = hooks::test_sink::install();
let hooked_err = run_async(call.eval(&mut hooked)).unwrap_err();
let plain_err = run_async(call.eval(&mut plain)).unwrap_err();
assert_eq!(
hooked_err.to_string(),
plain_err.to_string(),
"hooks must not change the error"
);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolFailed);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("nonexistent_tool")
);
assert!(
captures[0]
.envs
.get("COYOTE_TOOL_ERROR")
.unwrap()
.contains("Unexpected call")
);
assert!(captures[0].payload.is_none());
}
}
+34
View File
@@ -126,6 +126,16 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
if !ctx.app.config.function_calling_support {
bail!("Cannot use todo tools: function calling is disabled.");
}
if ctx.in_graph_llm_node {
return Ok(json!({
"error": "todo tools are unavailable inside graph llm nodes: they drive chat turn \
loops (auto_continue), and llm nodes have no turn loop. Node \
continuation is owned by the node loop, max_iterations, and graph \
edges. For driven multi-step work, use an agent node whose agent sets \
auto_continue: true."
}));
}
let auto_config = ctx.auto_continue_config();
if !auto_config.enabled {
bail!(
@@ -245,4 +255,28 @@ mod tests {
assert!(error.contains("requires an active session"));
assert!(ctx.auto_continue_paused.is_none());
}
#[test]
fn graph_llm_node_returns_teaching_error() {
let mut app_state = AppState::test_default();
app_state.config = Arc::new(AppConfig {
auto_continue: true,
..AppConfig::default()
});
let mut ctx = RequestContext::new(Arc::new(app_state), WorkingMode::Cmd);
ctx.in_graph_llm_node = true;
let result = handle_todo_tool(&mut ctx, "todo__init", &json!({"goal": "g"})).unwrap();
let error = result["error"].as_str().unwrap();
assert!(error.contains("graph llm nodes"));
assert!(
ctx.todo_list.is_default(),
"the guard must fire before any todo state mutation (goal included)"
);
ctx.in_graph_llm_node = false;
let result = handle_todo_tool(&mut ctx, "todo__init", &json!({"goal": "g"})).unwrap();
assert_eq!(result["status"], "ok");
}
}
+178 -2
View File
@@ -1,5 +1,6 @@
use super::{FunctionDeclaration, JsonSchema};
use crate::config::RequestContext;
use crate::hooks::{self, HookEvent};
use crate::supervisor::escalation::{EscalationRequest, new_escalation_id};
use crate::utils::{ACP_SERVER, HEADLESS, queue_acp_permission};
@@ -278,22 +279,37 @@ async fn handle_escalated(ctx: &RequestContext, action: &str, args: &Value) -> R
let escalation_id = new_escalation_id();
let (tx, rx) = oneshot::channel();
let question = format!("[{action}] {question}");
let request = EscalationRequest {
id: escalation_id.clone(),
from_agent_id,
from_agent_id: from_agent_id.clone(),
from_agent_name: from_agent_name.clone(),
question: format!("[{action}] {question}"),
question: question.clone(),
options,
reply_tx: tx,
};
root_queue.submit(request);
hooks::fire(
HookEvent::EscalationRaised,
ctx,
&[
("COYOTE_ESCALATION_ID", escalation_id),
("COYOTE_ESCALATION_FROM_AGENT_ID", from_agent_id),
("COYOTE_ESCALATION_FROM_AGENT_NAME", from_agent_name),
("COYOTE_ESCALATION_QUESTION", question),
],
None,
);
await_escalation_reply(rx, timeout_secs).await
}
/// Waits for the parent's reply. `timeout_secs == 0` waits indefinitely; a
/// dropped sender (the parent cancelled the request) resolves either way.
/// Both fallback arms resolve without touching the escalation queue, so
/// neither fires an escalation event: the request was never answered.
async fn await_escalation_reply(rx: oneshot::Receiver<String>, timeout_secs: u64) -> Result<Value> {
let reply = if timeout_secs == 0 {
rx.await
@@ -346,8 +362,14 @@ fn parse_options(args: &Value) -> Result<Vec<String>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AppState, WorkingMode};
use crate::function::agents::handle_agent_tool;
use crate::hooks::{HookDef, HooksMap, test_sink};
use crate::supervisor::escalation::EscalationQueue;
use serial_test::serial;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[test]
fn headless_select_returns_structured_json() {
@@ -374,6 +396,160 @@ mod tests {
assert_eq!(DEFAULT_ESCALATION_TIMEOUT_SECS, 0);
}
fn ctx_with_escalation_hooks(marker: &str) -> RequestContext {
let mut hooks = HooksMap::default();
for event in ["escalation.raised", "escalation.answered"] {
hooks.insert(
event.to_string(),
vec![HookDef {
name: format!("{marker}-{event}"),
command: "true".to_string(),
}],
);
}
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks,
..Default::default()
});
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn marker_captures(marker: &str, event: &str) -> Vec<test_sink::Capture> {
test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == format!("{marker}-{event}"))
.collect()
}
/// `escalation.raised` (child seam) and `escalation.answered` (parent
/// seam) must describe the same origin agent for one escalation id: the
/// answered envs come from the taken queue request, never from the
/// replying context.
#[tokio::test]
#[serial]
async fn escalation_raised_and_answered_agree_on_origin_envs() {
let _sink = test_sink::install();
let marker = "esc-parity-k4w";
let queue = Arc::new(EscalationQueue::new());
let mut child_ctx = ctx_with_escalation_hooks(marker);
child_ctx.escalation_queue = Some(Arc::clone(&queue));
child_ctx.self_agent_id = Some("esc-child-1".to_string());
let mut parent_ctx = ctx_with_escalation_hooks(marker);
parent_ctx.escalation_queue = Some(Arc::clone(&queue));
let raise_args = json!({ "question": "which db?" });
let raise = handle_escalated(&child_ctx, "input", &raise_args);
let reply = async {
// Bounded poll: on the current-thread runtime the raise future
// submits before its first await, so this resolves immediately.
let mut escalation_id = None;
for _ in 0..1000 {
if let Some(entry) = queue.pending_summary().first() {
escalation_id = entry["escalation_id"].as_str().map(str::to_string);
break;
}
tokio::task::yield_now().await;
}
let escalation_id = escalation_id.expect("escalation must reach the queue");
handle_agent_tool(
&mut parent_ctx,
"agent__reply_escalation",
&json!({ "escalation_id": escalation_id, "reply": "postgres" }),
)
.await
.unwrap()
};
let (raised_result, replied) = tokio::join!(raise, reply);
assert_eq!(raised_result.unwrap()["answer"], "postgres");
assert_eq!(replied["status"], "ok");
let raised = marker_captures(marker, "escalation.raised");
let answered = marker_captures(marker, "escalation.answered");
assert_eq!(raised.len(), 1, "{raised:?}");
assert_eq!(answered.len(), 1, "{answered:?}");
for key in [
"COYOTE_ESCALATION_ID",
"COYOTE_ESCALATION_FROM_AGENT_ID",
"COYOTE_ESCALATION_FROM_AGENT_NAME",
"COYOTE_ESCALATION_QUESTION",
] {
assert!(raised[0].envs.contains_key(key), "{key} missing on raised");
assert_eq!(raised[0].envs.get(key), answered[0].envs.get(key), "{key}");
}
assert_eq!(
raised[0]
.envs
.get("COYOTE_ESCALATION_FROM_AGENT_ID")
.map(String::as_str),
Some("esc-child-1")
);
assert_eq!(
raised[0]
.envs
.get("COYOTE_ESCALATION_QUESTION")
.map(String::as_str),
Some("[input] which db?")
);
assert_eq!(
answered[0]
.envs
.get("COYOTE_ESCALATION_REPLY")
.map(String::as_str),
Some("postgres")
);
assert!(!raised[0].envs.contains_key("COYOTE_ESCALATION_REPLY"));
}
/// `COYOTE_ESCALATION_QUESTION` rides through the standard env
/// truncation: a huge question is trimmed to 2048 bytes on the raised
/// seam, never passed through whole.
#[tokio::test]
#[serial]
async fn escalation_question_env_is_truncated_to_2048_bytes() {
let _sink = test_sink::install();
let marker = "esc-trunc-p9j";
let queue = Arc::new(EscalationQueue::new());
let mut child_ctx = ctx_with_escalation_hooks(marker);
child_ctx.escalation_queue = Some(Arc::clone(&queue));
child_ctx.self_agent_id = Some("esc-child-trunc".to_string());
let raise_args = json!({ "question": "q".repeat(3000) });
let raise = handle_escalated(&child_ctx, "input", &raise_args);
let reply = async {
// Bounded poll: on the current-thread runtime the raise future
// submits before its first await, so this resolves immediately.
let mut request = None;
for _ in 0..1000 {
if let Some(entry) = queue.pending_summary().first() {
let id = entry["escalation_id"].as_str().unwrap().to_string();
request = queue.take(&id);
break;
}
tokio::task::yield_now().await;
}
let request = request.expect("escalation must reach the queue");
let _ = request.reply_tx.send("ok".to_string());
};
let (raised_result, ()) = tokio::join!(raise, reply);
assert_eq!(raised_result.unwrap()["answer"], "ok");
let raised = marker_captures(marker, "escalation.raised");
assert_eq!(raised.len(), 1, "{raised:?}");
let question = raised[0]
.envs
.get("COYOTE_ESCALATION_QUESTION")
.expect("QUESTION env must be present on escalation.raised");
assert_eq!(
question.len(),
2048,
"QUESTION env must be 2048-byte truncated"
);
assert!(question.starts_with("[input] qqq"));
}
#[tokio::test(start_paused = true)]
async fn zero_timeout_waits_indefinitely_for_the_reply() {
let (tx, rx) = oneshot::channel();
+133 -14
View File
@@ -6,6 +6,7 @@ use super::types::AgentNode;
use super::wall_clock;
use crate::config::RequestContext;
use crate::function::agents::run_agent_for_graph;
use crate::hooks::{self, HookEvent};
use crate::supervisor::mailbox::{Inbox, PeerRegistry};
use crate::utils::create_abort_signal;
use anyhow::{Context, Error, Result, anyhow};
@@ -35,6 +36,11 @@ impl AgentNodeExecutor {
parent_ctx: &mut RequestContext,
retire_peer_on_return: bool,
) -> Result<AgentExecutionOutcome> {
let agent_hook_id = parent_ctx
.peer_assignment
.as_ref()
.map(|(id, _)| id.clone())
.unwrap_or_else(|| node_id.to_string());
let result = run(
node_id,
node,
@@ -43,6 +49,34 @@ impl AgentNodeExecutor {
retire_peer_on_return,
)
.await;
if let Err(e) = &result {
if parent_ctx
.session_abort
.as_ref()
.is_some_and(|signal| signal.aborted_ctrlc())
{
hooks::fire(
HookEvent::AgentInterrupted,
parent_ctx,
&[
("COYOTE_AGENT_ID", agent_hook_id),
("COYOTE_AGENT_NAME", node.agent.clone()),
],
None,
);
} else {
hooks::fire(
HookEvent::AgentFailed,
parent_ctx,
&[
("COYOTE_AGENT_ID", agent_hook_id),
("COYOTE_AGENT_NAME", node.agent.clone()),
("COYOTE_AGENT_ERROR", format!("{e:#}")),
],
None,
);
}
}
outcome_from(node_id, node, state_manager, result)
}
}
@@ -320,10 +354,12 @@ fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, outpu
mod tests {
use super::super::types::AgentNode;
use super::*;
use crate::config::{AppState, WorkingMode, default_max_agent_depth};
use crate::config::{AppConfig, AppState, WorkingMode, default_max_agent_depth};
use crate::hooks::{HookDef, HooksMap, test_sink};
use crate::supervisor::mailbox::{Inbox, PeerRegistry, graph_agent_id};
use crate::testing::{install_warn_collector, warn_messages};
use crate::testing::{install_log_collector, warn_snapshot};
use serde_json::json;
use serial_test::serial;
use std::collections::HashMap;
use std::sync::Arc;
@@ -398,6 +434,95 @@ mod tests {
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
}
fn ctx_with_agent_hooks(marker: &str) -> RequestContext {
let mut hooks = HooksMap::default();
for event in ["agent.interrupted", "agent.failed"] {
hooks.insert(
event.to_string(),
vec![HookDef {
name: format!("{marker}-{event}"),
command: "true".to_string(),
}],
);
}
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks,
..Default::default()
});
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
/// A fired session signal (the user's interrupt) reclassifies a failed
/// agent node: `agent.interrupted` fires instead of `agent.failed`.
#[tokio::test]
#[serial]
async fn execute_failure_with_fired_session_abort_fires_agent_interrupted() {
let _sink = test_sink::install();
let marker = "graph-agent-int-z2f";
let mut ctx = ctx_with_agent_hooks(marker);
// Past max depth the node fails before touching agent config on disk.
ctx.current_depth = default_max_agent_depth();
let session = create_abort_signal();
session.set_ctrlc();
ctx.session_abort = Some(session);
let node = node_with("hi", None);
let mut state = manager_with(&[]);
AgentNodeExecutor::execute("test_node", &node, &mut state, &mut ctx, false)
.await
.expect_err("agent past max depth should fail");
let captures: Vec<_> = test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name.starts_with(marker))
.collect();
assert_eq!(captures.len(), 1, "{captures:?}");
assert_eq!(captures[0].hook_name, format!("{marker}-agent.interrupted"));
assert_eq!(
captures[0]
.envs
.get("COYOTE_AGENT_NAME")
.map(String::as_str),
Some("test_agent")
);
assert!(
!captures[0].envs.contains_key("COYOTE_AGENT_ERROR"),
"an interruption is not a failure and carries no error env"
);
}
/// The inverse gate: a failure whose session signal never fired stays
/// `agent.failed` with its error env — the presence of a signal alone
/// must not reclassify a real failure as an interruption.
#[tokio::test]
#[serial]
async fn execute_failure_without_fired_session_abort_fires_agent_failed() {
let _sink = test_sink::install();
let marker = "graph-agent-fail-inv-x7q";
let mut ctx = ctx_with_agent_hooks(marker);
// Past max depth the node fails before touching agent config on disk.
ctx.current_depth = default_max_agent_depth();
ctx.session_abort = Some(create_abort_signal());
let node = node_with("hi", None);
let mut state = manager_with(&[]);
AgentNodeExecutor::execute("test_node", &node, &mut state, &mut ctx, false)
.await
.expect_err("agent past max depth should fail");
let captures: Vec<_> = test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name.starts_with(marker))
.collect();
assert_eq!(captures.len(), 1, "{captures:?}");
assert_eq!(captures[0].hook_name, format!("{marker}-agent.failed"));
assert!(
captures[0].envs.contains_key("COYOTE_AGENT_ERROR"),
"a real failure keeps its error env"
);
}
fn retryable_node(max_attempts: u32) -> AgentNode {
let mut node = node_with("hi", None);
node.max_attempts = max_attempts;
@@ -658,7 +783,7 @@ mod tests {
#[tokio::test]
async fn retry_transient_warns_once_per_retried_attempt_with_the_context_chain() {
install_warn_collector();
install_log_collector();
let mut ctx = plain_ctx();
let node = retryable_node(3);
let mut attempts = 0u32;
@@ -682,12 +807,9 @@ mod tests {
.expect_err("all attempts fail");
assert_eq!(attempts, 3);
let warns: Vec<String> = warn_messages()
.lock()
.unwrap()
.iter()
let warns: Vec<String> = warn_snapshot()
.into_iter()
.filter(|m| m.contains("'warn_capture'"))
.cloned()
.collect();
assert_eq!(
warns.len(),
@@ -843,7 +965,7 @@ mod tests {
#[test]
fn outcome_from_fallback_warn_names_the_node_and_the_fallback_target() {
install_warn_collector();
install_log_collector();
let mut node = node_with("hi", None);
node.fallback = Some("recover".into());
let mut state = manager_with(&[]);
@@ -857,12 +979,9 @@ mod tests {
.unwrap();
assert_eq!(outcome, AgentExecutionOutcome::FellBack("recover".into()));
let warns: Vec<String> = warn_messages()
.lock()
.unwrap()
.iter()
let warns: Vec<String> = warn_snapshot()
.into_iter()
.filter(|m| m.contains("'fallback_warn_capture'"))
.cloned()
.collect();
assert_eq!(warns.len(), 1, "exactly one fallback warn: {warns:?}");
assert!(
+468 -49
View File
@@ -1,6 +1,6 @@
use super::agent::{AgentExecutionOutcome, AgentNodeExecutor};
use super::llm::{LlmExecutionOutcome, LlmNodeExecutor};
use super::logging::{GraphLogger, narrate_node_complete, narrate_node_failed};
use super::logging::{GraphLogger, narrate_node_complete, narrate_node_failed, node_type_label};
use super::map::MapNodeExecutor;
use super::rag::RagNodeExecutor;
use super::script::ScriptExecutor;
@@ -11,6 +11,7 @@ use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor};
use super::validator::{AgentValidationContext, GraphValidator};
use super::wall_clock;
use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext};
use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt};
use anyhow::{Context, Result, anyhow, bail};
@@ -234,6 +235,16 @@ impl GraphExecutor {
})?
.clone();
logger.node_start(&node, in_super_step);
let node_hooks = NodeHookEmitter::resolve(ctx, node_id, node_type_label(&node));
hooks::fire(
HookEvent::GraphNodeStarted,
ctx,
&[
("COYOTE_NODE_ID", node_id.clone()),
("COYOTE_NODE_TYPE", node_hooks.node_type.to_string()),
],
None,
);
let branch_state = state.fork_for_branch_state();
let mut branch_ctx = ctx.fork_for_branch();
let mut peer_id: Option<String> = None;
@@ -281,6 +292,7 @@ impl GraphExecutor {
"aborted",
in_super_step,
);
node_hooks.failed(Duration::default(), "aborted");
return (
current.clone(),
branch_state,
@@ -315,6 +327,7 @@ impl GraphExecutor {
route.as_deref(),
in_super_step,
);
node_hooks.completed(elapsed, route.as_deref());
}
Ok(StepResult::End(_)) => {
narrate_node_complete(
@@ -324,6 +337,7 @@ impl GraphExecutor {
Some("END"),
in_super_step,
);
node_hooks.completed(elapsed, Some("END"));
}
Err(e) => {
narrate_node_failed(
@@ -333,6 +347,7 @@ impl GraphExecutor {
&e.to_string(),
in_super_step,
);
node_hooks.failed(elapsed, &e.to_string());
}
}
(current, state, result, elapsed)
@@ -498,6 +513,67 @@ fn provision_frontier_peers(
(Some(registry), assignments)
}
struct NodeHookEmitter {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
session_name: Option<String>,
agent_name: Option<String>,
node_id: String,
node_type: &'static str,
}
impl NodeHookEmitter {
fn resolve(ctx: &RequestContext, node_id: &str, node_type: &'static str) -> Self {
Self {
completed: ctx.resolved_hooks(HookEvent::GraphNodeCompleted),
failed: ctx.resolved_hooks(HookEvent::GraphNodeFailed),
session_name: ctx
.session
.as_ref()
.map(|session| session.name().to_string()),
agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()),
node_id: node_id.to_string(),
node_type,
}
}
fn extras(&self, elapsed: Duration) -> Vec<(&'static str, String)> {
vec![
("COYOTE_NODE_ID", self.node_id.clone()),
("COYOTE_NODE_TYPE", self.node_type.to_string()),
("COYOTE_NODE_DURATION_MS", elapsed.as_millis().to_string()),
]
}
fn dispatch(&self, event: HookEvent, resolved: &[ResolvedHook], extras: &[(&str, String)]) {
hooks::fire_resolved(
event,
resolved.to_vec(),
hooks::base_envs_parts(
event,
self.session_name.as_deref(),
self.agent_name.as_deref(),
),
extras,
None,
);
}
fn completed(&self, elapsed: Duration, route: Option<&str>) {
let mut extras = self.extras(elapsed);
if let Some(route) = route {
extras.push(("COYOTE_NODE_ROUTE", route.to_string()));
}
self.dispatch(HookEvent::GraphNodeCompleted, &self.completed, &extras);
}
fn failed(&self, elapsed: Duration, error: &str) {
let mut extras = self.extras(elapsed);
extras.push(("COYOTE_NODE_ERROR", error.to_string()));
self.dispatch(HookEvent::GraphNodeFailed, &self.failed, &extras);
}
}
pub(super) struct TaskCancelGuard(Vec<AbortHandle>);
impl TaskCancelGuard {
@@ -928,6 +1004,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,
@@ -1048,24 +1126,50 @@ mod tests {
mod integration_tests {
use super::*;
use crate::config::paths;
use crate::config::{AppState, WorkingMode};
use crate::config::{Agent, AgentConfig, AppConfig, AppState, WorkingMode};
#[cfg(unix)]
use crate::function::jobs::RingBuf;
use crate::hooks::HookDef;
#[cfg(unix)]
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor, notification};
use crate::utils::{create_abort_signal, get_env_name, temp_file};
use crate::utils::{create_abort_signal, temp_file};
use parking_lot::Mutex;
use serial_test::serial;
use std::env;
use std::fs;
#[cfg(unix)]
use std::mem;
use std::time::{SystemTime, UNIX_EPOCH};
fn cmd_available(name: &str) -> bool {
which::which(name).is_ok()
}
fn hooks_config(entries: &[(&str, &str)]) -> hooks::HooksMap {
let mut map = hooks::HooksMap::new();
for (event, name) in entries {
map.entry((*event).to_string()).or_default().push(HookDef {
name: (*name).to_string(),
command: "true".to_string(),
});
}
map
}
fn ctx_with_global_hooks(hooks: hooks::HooksMap) -> RequestContext {
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks,
..Default::default()
});
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == name)
.collect()
}
struct TestWorkspace {
dir: PathBuf,
}
@@ -1161,7 +1265,7 @@ nodes:
eprintln!("skipping: bash or python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-executor-tests");
materialize_probe_agent(0.0);
let ws = TestWorkspace::new();
ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n");
@@ -1208,6 +1312,361 @@ nodes:
assert_eq!(result, "held|held");
}
/// An agent node that fails but declares a fallback must surface the
/// failure to observers as `agent.failed`, fired at the point of
/// failure, even though the fallback then converts it into a normal
/// node completion that routes to the fallback target — so the same
/// node also yields `graph.node.completed`, never `graph.node.failed`.
#[tokio::test]
async fn agent_node_failure_with_fallback_fires_agent_failed_and_node_completed() {
let _sink = hooks::test_sink::install();
let mut ctx = ctx_with_global_hooks(hooks_config(&[
("agent.failed", "t037_fb_agent_failed"),
("graph.node.completed", "t037_fb_node_completed"),
("graph.node.failed", "t037_fb_node_failed"),
]));
// Forces the agent node to fail before it ever loads the (absent)
// agent from disk, keeping the test hermetic.
ctx.current_depth = crate::config::default_max_agent_depth();
let yaml = r#"
name: t
settings:
validate_before_run: false
start: worker
nodes:
worker:
type: agent
agent: missing_agent
prompt: p
fallback: recover
next: done
recover:
type: end
output: recovered
done:
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, ".")
.execute(&mut ctx, abort)
.await
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
assert_eq!(result, "recovered");
let agent_failed = hook_captures_named("t037_fb_agent_failed");
assert_eq!(agent_failed.len(), 1, "{agent_failed:?}");
assert_eq!(agent_failed[0].envs["COYOTE_EVENT"], "agent.failed");
assert_eq!(agent_failed[0].envs["COYOTE_AGENT_ID"], "worker");
assert_eq!(agent_failed[0].envs["COYOTE_AGENT_NAME"], "missing_agent");
assert!(
agent_failed[0].envs["COYOTE_AGENT_ERROR"].contains("Max agent depth exceeded"),
"{:?}",
agent_failed[0].envs
);
let node_completed = hook_captures_named("t037_fb_node_completed");
let worker_completed: Vec<_> = node_completed
.iter()
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == "worker")
.collect();
assert_eq!(worker_completed.len(), 1, "{node_completed:?}");
assert_eq!(worker_completed[0].envs["COYOTE_NODE_TYPE"], "agent");
assert_eq!(worker_completed[0].envs["COYOTE_NODE_ROUTE"], "recover");
let node_failed = hook_captures_named("t037_fb_node_failed");
assert!(
node_failed
.iter()
.all(|capture| capture.envs["COYOTE_NODE_ID"] != "worker"),
"a node that continues via fallback must not fire graph.node.failed: {node_failed:?}"
);
}
/// Two branches running in the same super-step each carry their own
/// node metadata, captured task-locally before the branch was spawned;
/// the distinct routes prove one branch's metadata never leaks into a
/// sibling's events.
#[tokio::test]
async fn parallel_super_step_branches_fire_per_node_metadata() {
if !cmd_available("bash") {
eprintln!("skipping: bash not available");
return;
}
let _sink = hooks::test_sink::install();
let ws = TestWorkspace::new();
for script in [
"dispatcher.sh",
"worker_a.sh",
"worker_b.sh",
"sink_a.sh",
"sink_b.sh",
] {
ws.write_script(script, "#!/bin/bash\necho '{}'\n");
}
let mut ctx = ctx_with_global_hooks(hooks_config(&[
("graph.node.started", "t037_par_started"),
("graph.node.completed", "t037_par_completed"),
]));
let yaml = r#"
name: t
start: dispatcher
nodes:
dispatcher:
type: script
script: dispatcher.sh
state_updates: {}
next: [worker_a, worker_b]
worker_a:
type: script
script: worker_a.sh
state_updates: {}
next: sink_a
worker_b:
type: script
script: worker_b.sh
state_updates: {}
next: sink_b
sink_a:
type: script
script: sink_a.sh
state_updates: {}
next: join
sink_b:
type: script
script: sink_b.sh
state_updates: {}
next: join
join:
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, &ws.dir)
.execute(&mut ctx, abort)
.await
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
assert_eq!(result, "done");
let started = hook_captures_named("t037_par_started");
for node_id in [
"dispatcher",
"worker_a",
"worker_b",
"sink_a",
"sink_b",
"join",
] {
assert_eq!(
started
.iter()
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == node_id)
.count(),
1,
"exactly one graph.node.started for '{node_id}': {started:?}"
);
}
let completed = hook_captures_named("t037_par_completed");
let completed_route = |node_id: &str| -> String {
let matches: Vec<_> = completed
.iter()
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == node_id)
.collect();
assert_eq!(
matches.len(),
1,
"exactly one graph.node.completed for '{node_id}': {completed:?}"
);
let expected_type = if node_id == "join" { "end" } else { "script" };
assert_eq!(matches[0].envs["COYOTE_NODE_TYPE"], expected_type);
let duration = &matches[0].envs["COYOTE_NODE_DURATION_MS"];
let _: u128 = duration
.parse()
.unwrap_or_else(|_| panic!("non-numeric duration for '{node_id}': {duration}"));
matches[0].envs["COYOTE_NODE_ROUTE"].clone()
};
assert_eq!(completed_route("dispatcher"), "worker_a, worker_b");
assert_eq!(completed_route("worker_a"), "sink_a");
assert_eq!(completed_route("worker_b"), "sink_b");
assert_eq!(completed_route("sink_a"), "join");
assert_eq!(completed_route("sink_b"), "join");
assert_eq!(completed_route("join"), "END");
}
/// A graph agent's hooks come from graph.yaml's top level: they resolve
/// through the agent scope end-to-end, while a global hook on the same
/// event stays gated off because the agent whitelisted nothing.
#[tokio::test]
async fn graph_agent_hooks_resolve_from_graph_yaml_top_level() {
let _sink = hooks::test_sink::install();
let mut ctx =
ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")]));
let yaml = r#"
name: hooked_graph
settings:
validate_before_run: false
start: done
hooks:
graph.node.started:
- name: t037_gy_started
command: "true"
graph.node.completed:
- name: t037_gy_completed
command: "true"
nodes:
done:
type: end
output: ok
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
ctx.agent = Some(Agent::test_new(AgentConfig::from_graph(
"hooked_graph",
&graph,
)));
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, ".")
.execute(&mut ctx, abort)
.await
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
assert_eq!(result, "ok");
let started = hook_captures_named("t037_gy_started");
assert_eq!(started.len(), 1, "{started:?}");
assert_eq!(started[0].envs["COYOTE_EVENT"], "graph.node.started");
assert_eq!(started[0].envs["COYOTE_NODE_ID"], "done");
assert_eq!(started[0].envs["COYOTE_NODE_TYPE"], "end");
assert_eq!(started[0].envs["COYOTE_AGENT_NAME"], "hooked_graph");
let completed = hook_captures_named("t037_gy_completed");
assert_eq!(completed.len(), 1, "{completed:?}");
assert_eq!(completed[0].envs["COYOTE_NODE_ID"], "done");
assert_eq!(completed[0].envs["COYOTE_NODE_ROUTE"], "END");
assert_eq!(completed[0].envs["COYOTE_AGENT_NAME"], "hooked_graph");
assert!(
hook_captures_named("t037_gy_global").is_empty(),
"an agent context must not run global hooks it did not whitelist"
);
}
/// A node that genuinely fails — no fallback to absorb the error — fires
/// `graph.node.failed` with the full payload observers rely on: the node's
/// id and type, a numeric duration, and the error text, even though the
/// failure then propagates out of the executor.
#[tokio::test]
async fn genuine_node_failure_fires_graph_node_failed_with_payload() {
let _sink = hooks::test_sink::install();
let mut ctx = ctx_with_global_hooks(hooks_config(&[(
"graph.node.failed",
"t037_gf_node_failed",
)]));
// Forces the agent node to fail before it ever loads the (absent)
// agent from disk, keeping the test hermetic.
ctx.current_depth = crate::config::default_max_agent_depth();
let yaml = r#"
name: t
settings:
validate_before_run: false
start: worker
nodes:
worker:
type: agent
agent: missing_agent
prompt: p
next: done
done:
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, ".")
.execute(&mut ctx, abort)
.await;
let error = result.expect_err("a node failure without a fallback must propagate");
let failed = hook_captures_named("t037_gf_node_failed");
assert_eq!(failed.len(), 1, "{failed:?}");
let envs = &failed[0].envs;
assert_eq!(envs["COYOTE_EVENT"], "graph.node.failed");
assert_eq!(envs["COYOTE_NODE_ID"], "worker");
assert_eq!(envs["COYOTE_NODE_TYPE"], "agent");
let duration = &envs["COYOTE_NODE_DURATION_MS"];
let _: u128 = duration
.parse()
.unwrap_or_else(|_| panic!("non-numeric duration: {duration}"));
assert!(!envs["COYOTE_NODE_ERROR"].is_empty(), "{envs:?}");
assert!(
format!("{error:#}").contains("Max agent depth exceeded"),
"unexpected executor error: {error:#}"
);
}
/// A malformed hook definition reaching the executor seam through
/// graph.yaml's top level — an entry whose command is blank — degrades
/// safely: the node still runs, the malformed hook is skipped with a
/// debug log, and a well-formed sibling on the same event still fires.
#[tokio::test]
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
crate::testing::install_log_collector();
let _sink = hooks::test_sink::install();
let mut ctx = make_ctx();
let yaml = r#"
name: hooked_graph
settings:
validate_before_run: false
start: done
hooks:
graph.node.completed:
- name: t037_mal_empty
command: " "
- name: t037_mal_valid
command: "true"
nodes:
done:
type: end
output: ok
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
ctx.agent = Some(Agent::test_new(AgentConfig::from_graph(
"hooked_graph",
&graph,
)));
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, ".")
.execute(&mut ctx, abort)
.await
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
assert_eq!(result, "ok");
assert!(
hook_captures_named("t037_mal_empty").is_empty(),
"a blank-command hook must be skipped, not dispatched"
);
let valid = hook_captures_named("t037_mal_valid");
assert_eq!(valid.len(), 1, "{valid:?}");
assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done");
let debugs = crate::testing::debug_snapshot();
assert!(
debugs
.iter()
.any(|message| message.contains("t037_mal_empty")
&& message.contains("empty command")),
"expected a debug log for the skipped hook: {debugs:?}"
);
}
/// Declared variables are seeded into state at run start, so the agent
/// node's strict prompt interpolation of `{{foo}}` succeeds instead of
/// bailing with "not found in state". `ctx.agent` is None here, so each
@@ -1220,7 +1679,7 @@ nodes:
eprintln!("skipping: python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-executor-tests");
materialize_probe_agent(0.0);
let ws = TestWorkspace::new();
@@ -1903,47 +2362,7 @@ nodes:
assert_eq!(events[0].event, "job_completed");
}
struct TestConfigDirGuard {
key: String,
previous: Option<std::ffi::OsString>,
path: PathBuf,
}
impl TestConfigDirGuard {
fn new() -> Self {
let key = get_env_name("config_dir");
let previous = env::var_os(&key);
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = env::temp_dir().join(format!("coyote-graph-executor-tests-{unique}"));
fs::create_dir_all(&path).unwrap();
unsafe {
env::set_var(&key, &path);
}
Self {
key,
previous,
path,
}
}
}
impl Drop for TestConfigDirGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
unsafe {
env::set_var(&self.key, previous);
}
} else {
unsafe {
env::remove_var(&self.key);
}
}
let _ = fs::remove_dir_all(&self.path);
}
}
use crate::testing::TestConfigDirGuard;
const PROBE_AGENT: &str = "timeout-probe";
@@ -2193,7 +2612,7 @@ nodes:
eprintln!("skipping: bash or python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-executor-tests");
materialize_probe_agent(5.0);
let ws = TestWorkspace::new();
// The abort must land after the worker tasks exist (their retirement
+101 -9
View File
@@ -12,6 +12,7 @@ use crate::function::ToolResult;
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
use crate::function::jobs::reap_jobs;
use crate::function::skill::skill_function_declarations;
use crate::function::todo::TODO_FUNCTION_PREFIX;
use crate::utils::AbortSignal;
use anyhow::{Context, Error, Result, anyhow, bail};
use log::warn;
@@ -38,7 +39,15 @@ impl LlmNodeExecutor {
parent_ctx: &mut RequestContext,
abort: &AbortSignal,
) -> Result<LlmExecutionOutcome> {
let result = run(node_id, node, state_manager, parent_ctx, abort).await;
let result = run(
node_id,
node,
state_manager,
parent_ctx,
abort,
&mut default_completion_runner(),
)
.await;
finish(node, state_manager, parent_ctx, result, abort).await
}
}
@@ -94,12 +103,15 @@ fn outcome_from(
}
}
/// The runner is a parameter for the same reason `finish` is split out:
/// tests drive the node scope without a model.
async fn run(
node_id: &str,
node: &LlmNode,
state_manager: &mut StateManager,
parent_ctx: &mut RequestContext,
abort: &AbortSignal,
runner: &mut CompletionRunner<'_>,
) -> Result<String> {
let mut instructions: Option<String> = match &node.instructions {
Some(s) => Some(
@@ -202,14 +214,11 @@ async fn run(
node.mcp_tools.clone().map(|map| (node_id.to_string(), map)),
);
parent_ctx.refresh_mcp_tool_filters();
let result = bounded_run(
node,
&prompt,
parent_ctx,
abort,
&mut default_completion_runner(),
)
.await;
// Inside the node, user__/todo__ builtins require explicit opt-in via the
// node's `tools:` list; mem::replace so nested execution restores cleanly.
let saved_in_node = std::mem::replace(&mut parent_ctx.in_graph_llm_node, true);
let result = bounded_run(node, &prompt, parent_ctx, abort, runner).await;
parent_ctx.in_graph_llm_node = saved_in_node;
parent_ctx.role = saved_role;
let node_jobs =
std::mem::replace(&mut parent_ctx.node_job_scope, saved_job_scope).unwrap_or_default();
@@ -477,6 +486,14 @@ fn validate_tools_subset(
.map(|d| d.name.as_str())
.collect();
for name in regular {
if name.starts_with(TODO_FUNCTION_PREFIX) {
bail!(
"llm node cannot enable '{name}': todo tools drive chat turn loops \
(auto_continue), which llm nodes don't have. Node continuation is \
owned by the node loop, max_iterations, and graph edges. For driven \
multi-step work, use an agent node whose agent sets auto_continue: true."
);
}
if !known.contains(name.as_str()) {
let mut avail: Vec<&str> = known.iter().copied().collect();
avail.sort();
@@ -786,6 +803,24 @@ mod tests {
assert!(mcp.is_empty());
}
#[test]
fn validate_tools_subset_rejects_todo_tools() {
let mut ctx = plain_ctx();
let mut agent = Agent::test_new(AgentConfig {
auto_continue: true,
..AgentConfig::default()
});
agent.functions_mut().append_todo_functions();
ctx.agent = Some(agent);
let err = validate_tools_subset(&["todo__init".to_string()], &[], &ctx).unwrap_err();
assert!(
err.to_string().contains("todo tools drive chat turn loops"),
"expected the todo teaching error, got: {err}"
);
}
#[test]
fn zero_timeout_resolves_to_no_bound() {
assert!(Some(0u64).and_then(wall_clock).is_none());
@@ -1205,4 +1240,61 @@ mod tests {
"{captured}"
);
}
#[tokio::test]
async fn node_scope_flag_is_set_during_the_run_and_restored_on_ok() {
let node = node_with(None);
let mut state = manager_with(&[]);
let mut ctx = plain_ctx();
ctx.agent = Some(Agent::test_new(AgentConfig::default()));
let abort = create_abort_signal();
let out = run(
"n",
&node,
&mut state,
&mut ctx,
&abort,
&mut completion_runner(|_input, ctx, _abort| {
assert!(
ctx.in_graph_llm_node,
"flag must be set while the node runs"
);
boxed_completion(async { Ok(("done".to_string(), vec![])) })
}),
)
.await
.unwrap();
assert_eq!(out, "done");
assert!(!ctx.in_graph_llm_node, "flag must not outlive the node");
}
#[tokio::test]
async fn node_scope_flag_restores_prior_value_when_the_run_fails() {
let node = node_with(None);
let mut state = manager_with(&[]);
let mut ctx = plain_ctx();
ctx.agent = Some(Agent::test_new(AgentConfig::default()));
ctx.in_graph_llm_node = true;
let abort = create_abort_signal();
run(
"n",
&node,
&mut state,
&mut ctx,
&abort,
&mut completion_runner(|_input, _ctx, _abort| {
boxed_completion(async { Err(anyhow!("boom")) })
}),
)
.await
.expect_err("the runner failure must surface");
assert!(
ctx.in_graph_llm_node,
"a nested run must restore the outer scope's flag"
);
}
}
+11 -51
View File
@@ -497,6 +497,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,
@@ -744,14 +746,12 @@ mod chain_tests {
use super::*;
use crate::config::paths;
use crate::config::{AppState, Role, WorkingMode};
use crate::utils::{AbortSignal, create_abort_signal, get_env_name, temp_file};
use crate::utils::{AbortSignal, create_abort_signal, temp_file};
use indexmap::IndexMap;
use serde_json::json;
use serial_test::serial;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn cmd_available(name: &str) -> bool {
which::which(name).is_ok()
@@ -1737,47 +1737,7 @@ nodes:
}
}
struct TestConfigDirGuard {
key: String,
previous: Option<std::ffi::OsString>,
path: PathBuf,
}
impl TestConfigDirGuard {
fn new() -> Self {
let key = get_env_name("config_dir");
let previous = env::var_os(&key);
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = env::temp_dir().join(format!("coyote-graph-map-tests-{unique}"));
fs::create_dir_all(&path).unwrap();
unsafe {
env::set_var(&key, &path);
}
Self {
key,
previous,
path,
}
}
}
impl Drop for TestConfigDirGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
unsafe {
env::set_var(&self.key, previous);
}
} else {
unsafe {
env::remove_var(&self.key);
}
}
let _ = fs::remove_dir_all(&self.path);
}
}
use crate::testing::TestConfigDirGuard;
const PROBE_AGENT: &str = "timeout-probe";
@@ -2029,7 +1989,7 @@ nodes:
eprintln!("skipping: python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(0.0);
let h = Harness::new(&flagged_probe_graph(None));
let peers = manual_peer("worker");
@@ -2055,7 +2015,7 @@ nodes:
eprintln!("skipping: python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(0.0);
let ws = TestWorkspace::new();
let yaml = format!(
@@ -2105,7 +2065,7 @@ nodes:
eprintln!("skipping: python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(5.0);
let h = Harness::new(&flagged_probe_graph(Some(1)));
let (registry, assignments) = h.provision("worker", 2);
@@ -2142,7 +2102,7 @@ nodes:
eprintln!("skipping: python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(1.5);
let h = Harness::new(&flagged_probe_graph(Some(0)));
let peers = manual_peer("worker");
@@ -2165,7 +2125,7 @@ nodes:
eprintln!("skipping: python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(0.0);
let h = Harness::new(&format!(
r#"
@@ -2270,7 +2230,7 @@ nodes:
eprintln!("skipping: python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_echo_inputs_agent();
let parent = echo_inputs_parent_graph(" inputs:\n width: \"{{n}}\"\n");
@@ -2289,7 +2249,7 @@ nodes:
eprintln!("skipping: python3 not available");
return;
}
let _guard = TestConfigDirGuard::new();
let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_echo_inputs_agent();
let parent = echo_inputs_parent_graph("");
+53
View File
@@ -1,4 +1,5 @@
use crate::config::AgentVariable;
use crate::hooks::HooksMap;
use anyhow::Result;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
@@ -43,6 +44,12 @@ pub struct Graph {
#[serde(default)]
pub global_tools: Vec<String>,
#[serde(default)]
pub hooks: HooksMap,
#[serde(default)]
pub global_hooks: Vec<String>,
#[serde(default)]
pub mcp_servers: Vec<String>,
@@ -611,6 +618,52 @@ nodes:
}
}
#[test]
fn graph_hooks_and_global_hooks_parse_and_round_trip() {
let yaml = r#"
name: g
start: e
hooks:
tool.started:
- name: notify
command: ./hooks/notify.sh
global_hooks:
- tool.started.notify
nodes:
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert_eq!(graph.hooks["tool.started"][0].name, "notify");
assert_eq!(graph.hooks["tool.started"][0].command, "./hooks/notify.sh");
assert_eq!(graph.global_hooks, vec!["tool.started.notify"]);
let reparsed: Graph =
serde_yaml::from_str(&serde_yaml::to_string(&graph).unwrap()).unwrap();
assert_eq!(reparsed.hooks, graph.hooks);
assert_eq!(reparsed.global_hooks, graph.global_hooks);
}
#[test]
fn graph_without_hooks_keys_parses_empty() {
let yaml = r#"
name: g
start: e
nodes:
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert!(graph.hooks.is_empty());
assert!(graph.global_hooks.is_empty());
}
#[test]
fn agent_node_teammates_parses_and_defaults_false() {
let yaml = r#"
+196
View File
@@ -3,6 +3,8 @@ use super::types::{ConcurrencyCap, Graph, NextTargets, Node, NodeType};
use crate::client::{Model, ModelType};
use crate::config;
use crate::config::{Agent, AppConfig, paths};
use crate::function::todo::TODO_FUNCTION_PREFIX;
use crate::function::user_interaction::USER_FUNCTION_PREFIX;
use crate::rag::{GraphRagConfig, RagData};
use anyhow::{Result, bail};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
@@ -179,6 +181,8 @@ impl GraphValidator {
self.validate_max_attempts(graph, &mut result);
self.validate_fallback_capture(graph, &mut result);
self.validate_output_schema_properties(graph, &mut result);
self.validate_structured_output_user_tools(graph, &mut result);
self.validate_llm_node_todo_tools(graph, &mut result);
self.validate_max_concurrency(graph, &mut result);
self.validate_max_concurrency_template(graph, &mut result);
self.validate_orchestration_limits(graph, &mut result);
@@ -458,6 +462,51 @@ impl GraphValidator {
}
}
fn validate_structured_output_user_tools(&self, graph: &Graph, result: &mut ValidationResult) {
for (node_id, node) in &graph.nodes {
let NodeType::Llm(llm) = &node.node_type else {
continue;
};
if llm.output_schema.is_none() {
continue;
}
let exposes_user_tools = llm.tools.as_ref().is_some_and(|tools| {
tools
.iter()
.any(|t| t.trim().starts_with(USER_FUNCTION_PREFIX))
});
if exposes_user_tools {
result.warning(ValidationError::with_node(
node_id,
"structured-output llm node exposes interactive user tools (`user__*`); \
in headless runs a user__ call blocks the node as an escalation",
));
}
}
}
fn validate_llm_node_todo_tools(&self, graph: &Graph, result: &mut ValidationResult) {
for (node_id, node) in &graph.nodes {
let NodeType::Llm(llm) = &node.node_type else {
continue;
};
let exposes_todo_tools = llm.tools.as_ref().is_some_and(|tools| {
tools
.iter()
.any(|t| t.trim().starts_with(TODO_FUNCTION_PREFIX))
});
if exposes_todo_tools {
result.error(ValidationError::with_node(
node_id,
"llm node exposes todo tools (`todo__*`): todo tools drive chat turn \
loops (auto_continue), which llm nodes don't have. Node continuation \
is owned by the node loop, max_iterations, and graph edges. For driven \
multi-step work, use an agent node whose agent sets `auto_continue: true`",
));
}
}
}
fn validate_node_references(&self, graph: &Graph, result: &mut ValidationResult) {
for (node_id, node) in &graph.nodes {
for (target, label) in declared_targets(node) {
@@ -1410,6 +1459,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,
@@ -4685,6 +4736,151 @@ mod tests {
);
}
#[test]
fn structured_output_node_with_user_tools_warns() {
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
if let NodeType::Llm(ref mut n) = node.node_type {
n.tools = Some(vec!["user__select".into()]);
}
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
let result = validator().validate(&graph);
assert!(
result
.warnings
.iter()
.any(|e| e.node_id.as_deref() == Some("l")
&& e.message.contains("interactive user tools")),
"expected user-tool warning: {:?}",
result.warnings
);
}
#[test]
fn structured_output_node_with_all_tools_does_not_warn() {
// `all` is not a valid llm-node tools entry (validate_tools_subset
// rejects it at run()), so this advisory stays silent about it.
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
if let NodeType::Llm(ref mut n) = node.node_type {
n.tools = Some(vec!["all".into()]);
}
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
let result = validator().validate(&graph);
assert!(
!result
.warnings
.iter()
.any(|e| e.message.contains("interactive user tools")),
"unexpected user-tool warning: {:?}",
result.warnings
);
}
#[test]
fn structured_output_node_without_user_tools_does_not_warn() {
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
if let NodeType::Llm(ref mut n) = node.node_type {
n.tools = Some(vec![]);
}
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
let result = validator().validate(&graph);
assert!(
!result
.warnings
.iter()
.any(|e| e.message.contains("interactive user tools")),
"unexpected user-tool warning: {:?}",
result.warnings
);
}
#[test]
fn non_structured_node_with_user_tools_does_not_warn() {
let mut node = llm_node("l", None, Some("end"));
if let NodeType::Llm(ref mut n) = node.node_type {
n.tools = Some(vec!["user__select".into()]);
}
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
let result = validator().validate(&graph);
assert!(
!result
.warnings
.iter()
.any(|e| e.message.contains("interactive user tools")),
"unexpected user-tool warning: {:?}",
result.warnings
);
}
#[test]
fn structured_output_node_with_todo_tools_errors() {
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
if let NodeType::Llm(ref mut n) = node.node_type {
n.tools = Some(vec!["todo__init".into()]);
}
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
let result = validator().validate(&graph);
assert!(
result
.errors
.iter()
.any(|e| e.node_id.as_deref() == Some("l")
&& e.message.contains("todo tools drive chat turn loops")),
"expected todo-tool error: {:?}",
result.errors
);
}
#[test]
fn non_structured_node_with_todo_tools_errors() {
let mut node = llm_node("l", None, Some("end"));
if let NodeType::Llm(ref mut n) = node.node_type {
n.tools = Some(vec!["todo__pause".into()]);
}
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
let result = validator().validate(&graph);
assert!(
result
.errors
.iter()
.any(|e| e.node_id.as_deref() == Some("l")
&& e.message.contains("todo tools drive chat turn loops")),
"expected todo-tool error: {:?}",
result.errors
);
}
#[test]
fn llm_node_without_todo_tools_does_not_error() {
let mut node = llm_node("l", None, Some("end"));
if let NodeType::Llm(ref mut n) = node.node_type {
n.tools = Some(vec!["user__select".into()]);
}
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
let result = validator().validate(&graph);
assert!(
!result
.errors
.iter()
.any(|e| e.message.contains("todo tools drive chat turn loops")),
"unexpected todo-tool error: {:?}",
result.errors
);
}
#[test]
fn fallback_with_output_schema_and_empty_state_updates_warns() {
let mut node = llm_with_output_schema("l", &["summary"], Some("end"));
+3103
View File
File diff suppressed because it is too large. Load diff
+165 -43
View File
@@ -4,6 +4,7 @@ mod client;
mod config;
mod function;
mod graph;
mod hooks;
mod rag;
mod render;
mod repl;
@@ -22,7 +23,8 @@ extern crate log;
use crate::cli::Cli;
use crate::client::{
ModelType, call_chat_completions, call_chat_completions_streaming, catalog, list_models, oauth,
Client, ModelType, call_chat_completions, call_chat_completions_streaming, catalog,
list_models, oauth,
};
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{
@@ -35,7 +37,7 @@ use crate::config::{memory, paths};
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
use crate::mcp::McpServersConfig;
use crate::render::{prompt_theme, render_error};
use crate::repl::Repl;
use crate::repl::{EXIT_HOOK_DRAIN_TIMEOUT, Repl, TurnBracket};
use crate::utils::*;
use crate::vault::{Vault, interpolate_secrets};
use anyhow::{Context, Result, anyhow, bail};
@@ -56,8 +58,37 @@ use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::{env, fs, process, sync::Arc};
#[tokio::main]
async fn main() -> Result<()> {
const MAIN_STACK_SIZE: usize = 16 * 1024 * 1024;
/// Bootstraps the async entry point on a dedicated thread with an explicit
/// large stack instead of `#[tokio::main]`, which would poll the root
/// future on the (small, non-configurable) OS main thread. Everything else
/// matches the macro expansion: same multi-thread runtime flavor, same
/// `enable_all()` driver set (so `tokio::signal::ctrl_c()` keeps working),
/// same `block_on` of the async body, and the `Result` propagates
/// unchanged. Worker threads get the same headroom because spawned tasks
/// (graph nodes, subagents) embed the same class of giant state machine.
/// The thread is named "main" and panics are re-raised on the real main
/// thread without re-invoking the panic hook, keeping panic output and the
/// exit code identical to the direct arrangement.
fn main() -> Result<()> {
std::thread::Builder::new()
.name("main".into())
.stack_size(MAIN_STACK_SIZE)
.spawn(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(MAIN_STACK_SIZE)
.build()
.expect("failed to build the tokio runtime")
.block_on(async_main())
})
.expect("failed to spawn the main bootstrap thread")
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic))
}
async fn async_main() -> Result<()> {
load_env_file()?;
CompleteEnv::with_factory(Cli::command).complete();
let cli = Cli::parse();
@@ -111,6 +142,15 @@ async fn main() -> Result<()> {
|| cli.list_macros
|| cli.list_skills
|| cli.list_sessions;
let readout_flag = (cli.info
|| cli.list_models
|| cli.list_roles
|| cli.list_agents
|| cli.list_rags
|| cli.list_macros
|| cli.list_skills
|| cli.list_sessions)
&& !cli.sync_models;
let vault_flags = cli.add_secret.is_some()
|| cli.get_secret.is_some()
|| cli.update_secret.is_some()
@@ -138,7 +178,18 @@ async fn main() -> Result<()> {
return config::list_installed_bundles();
}
install_builtins()?;
let mcp_inspect =
cli.mcp_list && cli.mcp_get.is_none() && cli.mcp_remove.is_none() && cli.mcp_add.is_none();
let vault_inspect = cli.list_secrets
&& cli.add_secret.is_none()
&& cli.get_secret.is_none()
&& cli.update_secret.is_none()
&& cli.delete_secret.is_none();
let inspection_only =
(readout_flag || vault_inspect || mcp_inspect) && cli.agent.is_none() && cli.role.is_none();
if !inspection_only {
install_builtins()?;
}
if let Some(category) = cli.install_builtins {
return config::install_assets(category);
@@ -162,7 +213,7 @@ async fn main() -> Result<()> {
}
if let Some(client_arg) = &cli.authenticate {
let cfg = Config::load_with_interpolation(true).await?;
let cfg = Config::load_with_interpolation(true, false).await?;
let app_config = AppConfig::from_config(cfg)?;
let (client_name, provider) =
resolve_oauth_client(client_arg.as_deref(), &app_config.clients)?;
@@ -171,7 +222,7 @@ async fn main() -> Result<()> {
}
if let Some(server_name) = &cli.auth_mcp {
let cfg = Config::load_with_interpolation(true).await?;
let cfg = Config::load_with_interpolation(true, false).await?;
let app_config = AppConfig::from_config(cfg)?;
let vault = Vault::init(&app_config)?;
let mcp_path = paths::mcp_config_file();
@@ -223,9 +274,17 @@ async fn main() -> Result<()> {
let mcp_action =
cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some();
if mcp_action {
let cfg = Config::load_with_interpolation(true).await?;
let app_config = AppConfig::from_config(cfg)?;
let vault = Vault::init(&app_config)?;
let cfg = Config::load_with_interpolation(true, mcp_inspect).await?;
let app_config = if mcp_inspect {
AppConfig::from_config_lenient(cfg)?
} else {
AppConfig::from_config(cfg)?
};
let vault = if mcp_inspect {
Vault::init_lenient(&app_config)
} else {
Vault::init(&app_config)?
};
mcp::manage::handle(&cli, &vault)?;
@@ -233,16 +292,24 @@ async fn main() -> Result<()> {
}
if vault_flags {
let cfg = Config::load_with_interpolation(true).await?;
let app_config = AppConfig::from_config(cfg)?;
let cfg = Config::load_with_interpolation(true, vault_inspect).await?;
let app_config = if vault_inspect {
AppConfig::from_config_lenient(cfg)?
} else {
AppConfig::from_config(cfg)?
};
let vault = Vault::init(&app_config)?;
return Vault::handle_vault_flags(cli, &vault);
}
let abort_signal = create_abort_signal();
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
let cfg = Config::load_with_interpolation(info_flag).await?;
let mut app_config = AppConfig::from_config(cfg)?;
let cfg = Config::load_with_interpolation(info_flag, readout_flag).await?;
let mut app_config = if readout_flag {
AppConfig::from_config_lenient(cfg)?
} else {
AppConfig::from_config(cfg)?
};
if cli.no_workspace_mcp {
app_config.no_workspace_mcp = true;
}
@@ -256,14 +323,17 @@ async fn main() -> Result<()> {
log_path,
start_mcp_servers,
info_flag,
inspection_only,
abort_signal.clone(),
)
.await?,
);
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
let app_config = Arc::clone(&ctx.app.config);
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
.await?;
if !inspection_only {
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
.await?;
}
{
let app = &*ctx.app.config;
@@ -304,9 +374,12 @@ async fn run(
}
if cli.list_models {
for model in list_models(ctx.app.config.as_ref(), ModelType::Chat) {
println!("{}", model.id());
}
let models = list_models(ctx.app.config.as_ref(), ModelType::Chat)
.iter()
.map(|model| model.id())
.collect::<Vec<_>>()
.join("\n");
println!("{models}");
return Ok(());
}
if cli.list_roles {
@@ -386,6 +459,13 @@ async fn run(
let app = Arc::clone(&ctx.app.config);
ctx.use_agent(app.as_ref(), agent, session, abort_signal.clone())
.await?;
// The top-level agent bracket does NOT open here: the inspection
// flags below (--list-sessions, --info, --rebuild-rag, --init-*)
// return early without running the agent, and an open bracket on
// those paths would leak `agent.started` with no terminal event.
// It opens at the dispatch sites further down, where a run is
// actually committed, including the --macro arm, which executes
// rather than inspects.
} else {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
if let Some(prompt) = &cli.temp_role {
@@ -518,13 +598,24 @@ async fn run(
}
}
if let Some(name) = &cli.macro_name {
macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await?;
return Ok(());
ctx.top_level_agent_started();
let result = macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await;
ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
return result;
}
if cli.execute && !is_repl {
let input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await?;
return Ok(());
// Dispatch committed: open the top-level agent bracket only now,
// past every inspection-flag early return above.
ctx.top_level_agent_started();
let result = async {
let input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await
}
.await;
ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
return result;
}
{
@@ -542,11 +633,23 @@ async fn run(
return acp::run_acp_server(ctx, abort_signal).await;
}
// Dispatch committed (headless directive or REPL): open the top-level
// agent bracket only now, past every inspection-flag early return
// above. The ACP server stays unbracketed deliberately. The headless
// arm closes the bracket below; the REPL closes it at teardown (or on
// an interactive agent switch).
ctx.top_level_agent_started();
match is_repl {
false => {
let mut input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
input.use_embeddings(abort_signal.clone()).await?;
start_directive(&mut ctx, input, cli.code, abort_signal).await
let result = async {
let mut input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
input.use_embeddings(abort_signal.clone()).await?;
start_directive(&mut ctx, input, cli.code, abort_signal.clone()).await
}
.await;
ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
result
}
true => {
if !*IS_STDOUT_TERMINAL {
@@ -563,6 +666,17 @@ async fn start_directive(
input: Input,
code_mode: bool,
abort_signal: AbortSignal,
) -> Result<()> {
let bracket = TurnBracket::enter(ctx);
let result = start_directive_inner(ctx, input, code_mode, abort_signal.clone()).await;
bracket.finish(ctx, &abort_signal, result)
}
async fn start_directive_inner(
ctx: &mut RequestContext,
input: Input,
code_mode: bool,
abort_signal: AbortSignal,
) -> Result<()> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
ctx.session_abort = Some(abort_signal.clone());
@@ -638,22 +752,10 @@ async fn shell_execute(
abort_signal: AbortSignal,
) -> Result<()> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
let client = input.create_client()?;
ctx.before_chat_completion(&input)?;
let (eval_str, _) = call_chat_completions(
&input,
false,
true,
client.as_ref(),
ctx,
abort_signal.clone(),
)
.await?;
ctx.after_chat_completion(app.as_ref(), &input, &eval_str, &[])?;
if eval_str.is_empty() {
bail!("No command generated");
}
// Each revision round is its own turn: the recursive 'r' call re-enters.
let bracket = TurnBracket::enter(ctx);
let result = shell_execute_turn(ctx, &input, abort_signal.clone()).await;
let (client, eval_str) = bracket.finish(ctx, &abort_signal, result)?;
if app.dry_run {
app.print_markdown(&eval_str)?;
return Ok(());
@@ -679,6 +781,8 @@ async fn shell_execute(
if code == 0 && app.save_shell_history {
let _ = append_to_shell_history(&shell.name, &eval_str, code);
}
ctx.top_level_agent_finished(None, None);
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
process::exit(code);
}
'r' => {
@@ -726,6 +830,24 @@ async fn shell_execute(
Ok(())
}
async fn shell_execute_turn(
ctx: &mut RequestContext,
input: &Input,
abort_signal: AbortSignal,
) -> Result<(Box<dyn Client>, String)> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
let client = input.create_client()?;
ctx.before_chat_completion(input)?;
let (eval_str, _) =
call_chat_completions(input, false, true, client.as_ref(), ctx, abort_signal).await?;
ctx.after_chat_completion(app.as_ref(), input, &eval_str, &[])?;
if eval_str.is_empty() {
bail!("No command generated");
}
Ok((client, eval_str))
}
async fn create_input(
ctx: &RequestContext,
text: Option<String>,
+388 -12
View File
@@ -13,6 +13,7 @@ mod splitter;
use self::graph::{KnowledgeGraph, extract_entities};
use self::provider::RagProvider;
use self::providers::{DuckDbProvider, QdrantProvider, YamlProvider};
use crate::hooks::RagSyncHooks;
use crate::sandbox::mcp_credentials;
use crate::vault::{Vault, interpolate_secrets};
@@ -144,6 +145,7 @@ impl Rag {
doc_paths: &[String],
config: &RagInitConfig,
abort_signal: AbortSignal,
sync_hooks: RagSyncHooks,
) -> Result<Self> {
if doc_paths.is_empty() {
bail!("Cannot build RAG knowledge base '{name}' with no documents");
@@ -154,15 +156,27 @@ impl Rag {
let mut rag = Self::create(app, name, save_path, data).await?;
let loaders = app.document_loaders.clone();
let (spinner, spinner_rx) = Spinner::create("");
abortable_run_with_spinner_rx(
sync_hooks.fire_started(&rag.name, &rag.path);
let synced = abortable_run_with_spinner_rx(
rag.sync_documents(doc_paths, true, false, loaders, Some(spinner)),
spinner_rx,
abort_signal,
)
.await?;
if rag.save()? {
println!("✓ Saved RAG to '{}'.", save_path.display());
.await;
if let Err(err) = synced {
sync_hooks.fire_failed(&rag.name, &rag.path, &err);
return Err(err);
}
match rag.save() {
Ok(true) => println!("✓ Saved RAG to '{}'.", save_path.display()),
Ok(false) => {}
Err(err) => {
sync_hooks.fire_failed(&rag.name, &rag.path, &err);
return Err(err);
}
}
sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count());
Ok(rag)
}
@@ -261,6 +275,7 @@ impl Rag {
doc_paths: &[String],
abort_signal: AbortSignal,
prompt_for_driver: bool,
sync_hooks: RagSyncHooks,
) -> Result<Self> {
if !*IS_STDOUT_TERMINAL {
bail!("Failed to init rag in non-interactive mode");
@@ -305,15 +320,27 @@ impl Rag {
};
let loaders = app.document_loaders.clone();
let (spinner, spinner_rx) = Spinner::create("");
abortable_run_with_spinner_rx(
sync_hooks.fire_started(&rag.name, &rag.path);
let synced = abortable_run_with_spinner_rx(
rag.sync_documents(&paths, true, false, loaders, Some(spinner)),
spinner_rx,
abort_signal,
)
.await?;
if rag.save()? {
println!("✓ Saved RAG to '{}'.", save_path.display());
.await;
if let Err(err) = synced {
sync_hooks.fire_failed(&rag.name, &rag.path, &err);
return Err(err);
}
match rag.save() {
Ok(true) => println!("✓ Saved RAG to '{}'.", save_path.display()),
Ok(false) => {}
Err(err) => {
sync_hooks.fire_failed(&rag.name, &rag.path, &err);
return Err(err);
}
}
sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count());
Ok(rag)
}
@@ -636,10 +663,12 @@ impl Rag {
force_reingest: bool,
app: &AppConfig,
abort_signal: AbortSignal,
sync_hooks: RagSyncHooks,
) -> Result<()> {
let loaders = app.document_loaders.clone();
let (spinner, spinner_rx) = Spinner::create("");
abortable_run_with_spinner_rx(
sync_hooks.fire_started(&self.name, &self.path);
let synced = abortable_run_with_spinner_rx(
self.sync_documents(
document_paths,
refresh,
@@ -650,10 +679,21 @@ impl Rag {
spinner_rx,
abort_signal,
)
.await?;
if self.save()? {
println!("✓ Saved rag to '{}'.", self.path);
.await;
if let Err(err) = synced {
sync_hooks.fire_failed(&self.name, &self.path, &err);
return Err(err);
}
match self.save() {
Ok(true) => println!("✓ Saved rag to '{}'.", self.path),
Ok(false) => {}
Err(err) => {
sync_hooks.fire_failed(&self.name, &self.path, &err);
return Err(err);
}
}
sync_hooks.fire_completed(&self.name, &self.path, self.file_count());
Ok(())
}
@@ -2280,6 +2320,8 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::{HookDef, HooksMap, test_sink};
use serial_test::serial;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -2313,6 +2355,340 @@ mod tests {
}
}
fn rag_hooks_ctx(marker: &str) -> RequestContext {
let mut hooks_map = HooksMap::default();
for event in ["rag.sync.started", "rag.sync.completed", "rag.sync.failed"] {
hooks_map.insert(
event.to_string(),
vec![HookDef {
name: format!("{marker}-{event}"),
command: "true".to_string(),
}],
);
}
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks: hooks_map,
..Default::default()
});
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn rag_captures(marker: &str) -> Vec<test_sink::Capture> {
test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name.starts_with(marker))
.collect()
}
fn buildable_data() -> RagData {
RagData {
driver: "yaml".to_string(),
// The only embedding model resolvable in tests: the model
// registry is a process-global OnceLock pre-seeded by
// `seed_model_registries` in the request_context tests.
embedding_model: "test-seeded:test-embedder".to_string(),
chunk_size: 1000,
chunk_overlap: 100,
top_k: 5,
..Default::default()
}
}
/// An AppConfig whose client list can construct the "test-seeded"
/// embeddings client: init_with_config instantiates the client even
/// when a sync has nothing to embed.
fn seeded_client_app() -> AppConfig {
let mut client = ClientConfig::default();
if let ClientConfig::OpenAIConfig(config) = &mut client {
config.name = Some("test-seeded".to_string());
let mut embedder = ModelData::new("test-embedder");
embedder.model_type = "embedding".to_string();
config.models = vec![embedder];
}
AppConfig {
clients: vec![client],
..Default::default()
}
}
fn init_config() -> RagInitConfig {
RagInitConfig {
embedding_model: Some("test-seeded:test-embedder".to_string()),
chunk_size: Some(1000),
chunk_overlap: Some(100),
..Default::default()
}
}
fn yaml_rag(name: &str, path: &Path, data: RagData) -> Rag {
Rag {
app_config: Arc::new(AppConfig::default()),
name: name.to_string(),
path: path.display().to_string(),
embedding_model: Model::new("openai", "text-embedding-3-small"),
bm25: data.build_bm25(),
provider: Box::new(YamlProvider::from_data(&data)),
node_to_docs: IndexMap::new(),
data,
last_sources: RwLock::new(None),
}
}
#[tokio::test]
#[serial]
async fn refresh_document_paths_fires_started_then_completed_with_doc_count() {
let _sink = test_sink::install();
let marker = "rag-sync-ok-v5k";
let dir = TempDir::new("sync-hooks-ok");
let doc = dir.path.join("doc.txt");
fs::write(&doc, "hello rag hooks").unwrap();
let doc_path = doc.display().to_string();
// The prepopulated file entry must use the exact string
// sync_documents resolves, so the sync sees an unchanged corpus and
// never needs an embeddings client.
let loaders = HashMap::new();
let (_, _, _, _, local_paths) = resolve_paths(&loaders, std::slice::from_ref(&doc_path))
.await
.unwrap();
let resolved_path = local_paths.iter().next().unwrap().clone();
let mut data = buildable_data();
data.next_file_id = 1;
data.files.insert(
0,
RagFile {
hash: sha256("hello rag hooks"),
path: resolved_path,
documents: vec![],
},
);
let rag_path = dir.path.join("kb.yaml");
let rag_path_display = rag_path.display().to_string();
let mut rag = yaml_rag("kb", &rag_path, data);
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
rag.refresh_document_paths(
&[doc_path],
false,
false,
&AppConfig::default(),
create_abort_signal(),
sync_hooks,
)
.await
.unwrap();
let captures = rag_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
assert_eq!(
captures[0].envs.get("COYOTE_RAG_NAME").map(String::as_str),
Some("kb")
);
assert_eq!(
captures[0].envs.get("COYOTE_RAG_PATH").map(String::as_str),
Some(rag_path_display.as_str())
);
assert_eq!(
captures[1].hook_name,
format!("{marker}-rag.sync.completed")
);
assert_eq!(
captures[1]
.envs
.get("COYOTE_RAG_DOC_COUNT")
.map(String::as_str),
Some("1")
);
}
#[tokio::test]
#[serial]
async fn refresh_document_paths_fires_started_then_failed_on_sync_error() {
let _sink = test_sink::install();
let marker = "rag-sync-fail-b8m";
let dir = TempDir::new("sync-hooks-fail");
let missing = dir.path.join("missing.txt").display().to_string();
let rag_path = dir.path.join("kb.yaml");
let mut rag = yaml_rag("kb", &rag_path, buildable_data());
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
let result = rag
.refresh_document_paths(
&[missing],
false,
false,
&AppConfig::default(),
create_abort_signal(),
sync_hooks,
)
.await;
assert!(result.is_err(), "syncing a nonexistent document must fail");
let captures = rag_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed"));
assert_eq!(
captures[1].envs.get("COYOTE_RAG_NAME").map(String::as_str),
Some("kb")
);
assert!(
captures[1]
.envs
.get("COYOTE_ERROR")
.is_some_and(|error| !error.is_empty())
);
}
#[tokio::test]
#[serial]
async fn init_with_config_fires_started_then_completed_after_save() {
let _sink = test_sink::install();
let marker = "rag-init-ok-q7t";
let dir = TempDir::new("init-hooks-ok");
// An empty document syncs without embedding anything, so the build
// needs no live embeddings endpoint.
let doc = dir.path.join("doc.txt");
fs::write(&doc, "").unwrap();
let save_path = dir.path.join("kb.yaml");
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
let rag = Rag::init_with_config(
&seeded_client_app(),
"kb-init",
&save_path,
&[doc.display().to_string()],
&init_config(),
create_abort_signal(),
sync_hooks,
)
.await
.unwrap();
assert_eq!(rag.file_count(), 1);
assert!(
save_path.exists(),
"completed must only fire after the knowledge base was saved"
);
let captures = rag_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
assert_eq!(
captures[1].hook_name,
format!("{marker}-rag.sync.completed")
);
assert_eq!(
captures[1]
.envs
.get("COYOTE_RAG_DOC_COUNT")
.map(String::as_str),
Some("1")
);
}
#[tokio::test]
#[serial]
async fn init_with_config_fires_failed_when_save_fails() {
let _sink = test_sink::install();
let marker = "rag-init-save-fail-w2j";
let dir = TempDir::new("init-hooks-save-fail");
let doc = dir.path.join("doc.txt");
fs::write(&doc, "").unwrap();
// A regular file where save() expects a parent directory makes the
// save fail after a successful sync.
let blocker = dir.path.join("blocker");
fs::write(&blocker, "not a directory").unwrap();
let save_path = blocker.join("kb.yaml");
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
let result = Rag::init_with_config(
&seeded_client_app(),
"kb-init",
&save_path,
&[doc.display().to_string()],
&init_config(),
create_abort_signal(),
sync_hooks,
)
.await;
assert!(
result.is_err(),
"an unwritable save path must fail the init"
);
let captures = rag_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed"));
assert!(
captures[1]
.envs
.get("COYOTE_ERROR")
.is_some_and(|error| !error.is_empty())
);
}
// The user-abort arm in abortable_run_with_spinner_rx only exists when
// stdout is a terminal, so a unit test cannot reach it; its
// bail!("Aborted.") surfaces through the same `synced` Err arm this
// sync error drives.
#[tokio::test]
#[serial]
async fn init_with_config_fires_failed_on_sync_error() {
let _sink = test_sink::install();
let marker = "rag-init-fail-d4n";
let dir = TempDir::new("init-hooks-fail");
let missing = dir.path.join("missing.txt").display().to_string();
let save_path = dir.path.join("kb.yaml");
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
let result = Rag::init_with_config(
&seeded_client_app(),
"kb-init",
&save_path,
&[missing],
&init_config(),
create_abort_signal(),
sync_hooks,
)
.await;
assert!(result.is_err(), "syncing a nonexistent document must fail");
assert!(!save_path.exists());
let captures = rag_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed"));
assert!(
captures[1]
.envs
.get("COYOTE_ERROR")
.is_some_and(|error| !error.is_empty())
);
}
#[tokio::test]
#[serial]
async fn pure_load_fires_no_rag_sync_events() {
let _sink = test_sink::install();
test_sink::drain();
let dir = TempDir::new("sync-hooks-load");
let path = dir.path.join("kb.yaml");
fs::write(&path, serde_yaml::to_string(&buildable_data()).unwrap()).unwrap();
let rag = Rag::load(&AppConfig::default(), "kb", &path).await.unwrap();
assert_eq!(rag.file_count(), 0);
let captures = test_sink::drain();
assert!(
captures.is_empty(),
"a pure load must dispatch no hooks: {captures:?}"
);
}
#[test]
fn attached_ragdata_serializes_driver_and_attached() {
let mut data = RagData {
+1399 -64
View File
File diff suppressed because it is too large. Load diff
+118
View File
@@ -197,9 +197,23 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
) {
out.push(read_mixin(path)?);
}
for agent_dir in subdirs_of(&paths::agents_data_dir()) {
for path in collect_mixins(&agent_dir.join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
}
for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
for path in collect_mixins(&paths::hooks_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
if let Ok(cwd) = env::current_dir()
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
@@ -940,6 +954,110 @@ network:
let _ = fs::remove_dir_all(&root);
}
#[test]
#[serial_test::serial]
fn discover_scans_roles_and_role_hooks_mixins() {
use crate::utils::get_env_name;
let root = unique_root("discover-roles");
let roles = root.join("roles");
fs::create_dir_all(roles.join("hooks")).unwrap();
let role_mixin = roles.join("review.sbx-mixin.yaml");
let hook_mixin = roles.join("hooks").join("notify.sbx-mixin.yaml");
fs::write(&role_mixin, "kind: mixin\n").unwrap();
fs::write(&hook_mixin, "kind: mixin\n").unwrap();
// The builtin installer's hidden manifest lives in the same dir but
// is not a mixin and must never be picked up by the flat scan.
let manifest = roles
.join("hooks")
.join(crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE);
fs::write(&manifest, "notify.sh\n").unwrap();
let config_env = get_env_name("config_dir");
let roles_env = get_env_name("roles_dir");
let prev_config = env::var_os(&config_env);
let prev_roles = env::var_os(&roles_env);
unsafe {
env::set_var(&config_env, &root);
env::set_var(&roles_env, &roles);
}
let discovered = discover();
unsafe {
match prev_config {
Some(v) => env::set_var(&config_env, v),
None => env::remove_var(&config_env),
}
match prev_roles {
Some(v) => env::set_var(&roles_env, v),
None => env::remove_var(&roles_env),
}
}
let found: Vec<PathBuf> = discovered
.unwrap()
.into_iter()
.map(|mixin| mixin.path)
.collect();
assert!(found.contains(&role_mixin), "missing role mixin: {found:?}");
assert!(
found.contains(&hook_mixin),
"missing role hook mixin: {found:?}"
);
assert!(
!found.contains(&manifest),
"builtin manifest must not be scanned as a mixin: {found:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
#[serial_test::serial]
fn discover_scans_global_and_agent_hooks_mixins_but_never_the_manifest() {
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
use crate::utils::get_env_name;
let root = unique_root("discover-hooks-dirs");
let hooks = root.join("hooks");
let agent_hooks = root.join("agents").join("researcher").join("hooks");
fs::create_dir_all(&hooks).unwrap();
fs::create_dir_all(&agent_hooks).unwrap();
let global_mixin = hooks.join("notify.sbx-mixin.yaml");
let agent_mixin = agent_hooks.join("guard.sbx-mixin.yaml");
fs::write(&global_mixin, "kind: mixin\n").unwrap();
fs::write(&agent_mixin, "kind: mixin\n").unwrap();
// The builtin installer's hidden manifest lives in both hooks dirs
// but is not a mixin and must never be picked up.
fs::write(hooks.join(BUILTIN_MANIFEST_FILE), "notify.sh\n").unwrap();
fs::write(agent_hooks.join(BUILTIN_MANIFEST_FILE), "guard.sh\n").unwrap();
let _config = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &root);
let found: Vec<PathBuf> = discover()
.unwrap()
.into_iter()
.map(|mixin| mixin.path)
.collect();
assert!(
found.contains(&global_mixin),
"missing global hooks mixin: {found:?}"
);
assert!(
found.contains(&agent_mixin),
"missing agent hooks mixin: {found:?}"
);
assert!(
found.iter().all(|path| path
.file_name()
.is_none_or(|name| name != BUILTIN_MANIFEST_FILE)),
"builtin manifest must not be scanned as a mixin: {found:?}"
);
let _ = fs::remove_dir_all(&root);
}
/// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
/// existed, nothing scanned that shape and attaching a Qdrant RAG from
+90
View File
@@ -190,6 +190,17 @@ impl Supervisor {
.count()
}
pub fn has_active_tasks(&self) -> bool {
self.effective_active_count() > 0
|| self.active_job_count() > 0
|| self.agents().any(|agent| {
agent
.child_supervisor
.as_ref()
.is_some_and(|child_sup| child_sup.read().has_active_tasks())
})
}
pub fn max_concurrent(&self) -> usize {
self.max_concurrent
}
@@ -449,6 +460,85 @@ mod tests {
assert!(result.unwrap_err().to_string().contains("at capacity"));
}
#[test]
fn has_active_tasks_false_when_empty_or_all_finished() {
let mut sup = Supervisor::new(4, 3);
assert!(!sup.has_active_tasks());
// make_handle drops its runtime, aborting the task, so the
// registered agent is already finished.
sup.register(make_handle("a1", "explore", 1)).unwrap();
assert!(!sup.has_active_tasks());
}
#[test]
fn has_active_tasks_true_with_running_agent() {
// Keep the runtime alive so the task is never polled and the agent
// counts as running.
let rt = Builder::new_current_thread().enable_all().build().unwrap();
let join_handle = rt.spawn(async {
Ok::<AgentResult, Error>(AgentResult {
id: "done".into(),
agent_name: "test".into(),
output: "result".into(),
exit_status: AgentExitStatus::Completed,
})
});
let running_handle = AgentHandle {
id: "a1".to_string(),
agent_name: "explore".to_string(),
depth: 1,
inbox: Arc::new(Inbox::new()),
abort_signal: create_abort_signal(),
join_handle,
child_supervisor: None,
};
let mut sup = Supervisor::new(4, 3);
sup.register(running_handle).unwrap();
assert!(sup.has_active_tasks());
}
#[test]
fn has_active_tasks_true_with_running_job() {
let mut sup = Supervisor::new(4, 3).with_max_concurrent_jobs(1);
sup.register(make_job("j1", create_abort_signal())).unwrap();
assert!(sup.has_active_tasks());
}
#[test]
fn has_active_tasks_true_with_only_grandchild_activity() {
// Keep the runtime alive so the grandchild task is never polled and
// counts as running.
let rt = Builder::new_current_thread().enable_all().build().unwrap();
let join_handle = rt.spawn(async {
Ok::<AgentResult, Error>(AgentResult {
id: "done".into(),
agent_name: "test".into(),
output: "result".into(),
exit_status: AgentExitStatus::Completed,
})
});
let grandchild = AgentHandle {
id: "g1".to_string(),
agent_name: "worker".to_string(),
depth: 2,
inbox: Arc::new(Inbox::new()),
abort_signal: create_abort_signal(),
join_handle,
child_supervisor: None,
};
let mut child_sup = Supervisor::new(4, 3);
child_sup.register(grandchild).unwrap();
// make_handle's direct task is already finished, so the only live
// work sits one supervisor level down.
let mut parent_handle = make_handle("a1", "explore", 1);
parent_handle.child_supervisor = Some(Arc::new(RwLock::new(child_sup)));
let mut sup = Supervisor::new(4, 3);
sup.register(parent_handle).unwrap();
assert!(sup.has_active_tasks());
}
#[test]
fn supervisor_register_rejects_exceeding_depth() {
let mut sup = Supervisor::new(4, 2);
+171 -14
View File
@@ -1,29 +1,94 @@
//! Test-only helpers shared across modules' unit tests.
use crate::config::WORKSPACE_COYOTE_DIR_NAME;
use crate::utils;
use log::{Level, LevelFilter, Log, Metadata, Record};
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::sync::{Mutex, Once, OnceLock};
struct WarnCollector;
struct TestLogCollector;
static WARN_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
static DEBUG_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
/// Every warn-level message captured since the collector was installed. The
/// buffer is process-global and shared by all tests, so assert by filtering
/// for a marker unique to the test rather than on the whole buffer.
/// Debug capture is restricted to the modules whose tests assert on debug
/// output so the buffer is not flooded by the rest of the crate. Matching is
/// module-boundary-aware: only these exact modules or their `::` submodules
/// qualify, so a sibling like `hooks_registry` would not.
const DEBUG_TARGET_PREFIXES: [&str; 2] = [
concat!(env!("CARGO_CRATE_NAME"), "::hooks"),
concat!(env!("CARGO_CRATE_NAME"), "::config::agent"),
];
fn captures_warn(metadata: &Metadata) -> bool {
metadata.level() <= Level::Warn
}
fn captures_module_debug(metadata: &Metadata) -> bool {
if metadata.level() != Level::Debug {
return false;
}
let target = metadata.target();
DEBUG_TARGET_PREFIXES.iter().any(|prefix| {
target == *prefix
|| target
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with("::"))
})
}
/// Every warn- or error-level message captured since the collector was
/// installed. The buffer is process-global and shared by all tests, so assert
/// by filtering for a marker unique to the test rather than on the whole
/// buffer.
pub(crate) fn warn_messages() -> &'static Mutex<Vec<String>> {
WARN_MESSAGES.get_or_init(Mutex::default)
}
impl Log for WarnCollector {
/// Every debug-level message from the [`DEBUG_TARGET_PREFIXES`] modules
/// captured since the collector was installed. Same marker-filtering
/// discipline as [`warn_messages`].
pub(crate) fn debug_messages() -> &'static Mutex<Vec<String>> {
DEBUG_MESSAGES.get_or_init(Mutex::default)
}
/// A copy of everything in [`warn_messages`], recovering from poisoning the
/// same way the collector itself does.
pub(crate) fn warn_snapshot() -> Vec<String> {
snapshot_of(warn_messages())
}
/// A copy of everything in [`debug_messages`], recovering from poisoning the
/// same way the collector itself does.
pub(crate) fn debug_snapshot() -> Vec<String> {
snapshot_of(debug_messages())
}
fn snapshot_of(buffer: &Mutex<Vec<String>>) -> Vec<String> {
buffer
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
impl Log for TestLogCollector {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= Level::Warn
captures_warn(metadata) || captures_module_debug(metadata)
}
// A test that panics while holding a buffer poisons its mutex; the logger
// recovers via `into_inner` so every later test can still log.
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
if captures_warn(record.metadata()) {
warn_messages()
.lock()
.unwrap()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(record.args().to_string());
} else if captures_module_debug(record.metadata()) {
debug_messages()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(record.args().to_string());
}
}
@@ -31,13 +96,105 @@ impl Log for WarnCollector {
fn flush(&self) {}
}
/// Installs the process-wide warn collector. `log::set_logger` accepts one
/// logger per process, so every test that captures warns must install through
/// this shared entry point.
pub(crate) fn install_warn_collector() {
/// Installs the process-wide log collector capturing warn-level messages and
/// debug messages from the [`DEBUG_TARGET_PREFIXES`] modules.
/// `log::set_logger` accepts one logger per process, so every test that
/// captures either stream must install through this shared entry point. The
/// max level is Debug so the debug capture sees its records; warn capture is
/// unaffected.
pub(crate) fn install_log_collector() {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
log::set_logger(&WarnCollector).expect("no other logger should be installed");
log::set_max_level(LevelFilter::Warn);
log::set_logger(&TestLogCollector).expect("no other logger should be installed");
log::set_max_level(LevelFilter::Debug);
});
}
/// Sets (or removes) an environment variable for the guard's lifetime and
/// restores the previous value on drop, including on panic, so a failing
/// assertion cannot leak the override into subsequent tests. Tests using it
/// must serialize (`#[serial]`) — the process environment is global.
pub(crate) struct EnvVarGuard {
key: String,
previous: Option<OsString>,
}
impl EnvVarGuard {
pub(crate) fn set(key: impl Into<String>, value: impl AsRef<OsStr>) -> Self {
let key = key.into();
let previous = std::env::var_os(&key);
unsafe {
std::env::set_var(&key, value);
}
Self { key, previous }
}
pub(crate) fn unset(key: impl Into<String>) -> Self {
let key = key.into();
let previous = std::env::var_os(&key);
unsafe {
std::env::remove_var(&key);
}
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(previous) => std::env::set_var(&self.key, previous),
None => std::env::remove_var(&self.key),
}
}
}
}
/// Points the config dir and the workspace config dir at fresh temp
/// directories for the guard's lifetime and removes them on drop, including
/// on panic, so the developer's real `./.coyote/` never leaks into a test.
/// The sessions-dir override is cleared too, so Global-scope session writes
/// land under the temp config dir rather than the developer's real one.
/// Tests using it must serialize (`#[serial]`) — the env vars are
/// process-global.
pub(crate) struct TestConfigDirGuard {
_env: EnvVarGuard,
_workspace_env: EnvVarGuard,
_sessions_env: EnvVarGuard,
pub(crate) path: PathBuf,
workspace_path: PathBuf,
}
impl TestConfigDirGuard {
pub(crate) fn new(label: &str) -> Self {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
std::fs::create_dir_all(&path).unwrap();
let workspace_path = std::env::temp_dir()
.join(format!("coyote-{label}-ws-{unique}"))
.join(WORKSPACE_COYOTE_DIR_NAME);
std::fs::create_dir_all(&workspace_path).unwrap();
Self {
_env: EnvVarGuard::set(utils::get_env_name("config_dir"), &path),
_workspace_env: EnvVarGuard::set(
utils::get_env_name("workspace_config_dir"),
&workspace_path,
),
_sessions_env: EnvVarGuard::unset(utils::get_env_name("sessions_dir")),
path,
workspace_path,
}
}
}
impl Drop for TestConfigDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
if let Some(root) = self.workspace_path.parent() {
let _ = std::fs::remove_dir_all(root);
}
}
}
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more