Compare commits

77 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 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 9e811c088f merge: absorb main (model catalog refresh) into feat/PLAN-hooks-v2 2026-09-17 10:17:38 -06: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
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
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 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
82 changed files with 10898 additions and 1732 deletions

No files matched your search

+6
View File
@@ -1 +1,7 @@
assets/config-template.yaml text eol=lf 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
+3 -2
View File
@@ -78,8 +78,9 @@ act -W .github/workflows/release.yml --input_type bump=minor
## Authorship Policy ## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT, All code in this repository is written and reviewed by humans, or AI-generated where explicitly disclosed.
Claude, etc.) is not permitted unless explicitly disclosed and approved. 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. Submissions must certify that the contributor understands and can maintain the code they submit.
+6 -1
View File
@@ -82,6 +82,8 @@ RUN set -euo pipefail; \
chown 1000:1000 /home/agent/.cargo/bin/coyote; \ chown 1000:1000 /home/agent/.cargo/bin/coyote; \
rm -rf "$TMPDIR" rm -rf "$TMPDIR"
COPY --chmod=0755 scripts/docker-entrypoint.sh /usr/local/bin/coyote-entrypoint
FROM scratch FROM scratch
ARG COYOTE_VERSION ARG COYOTE_VERSION
@@ -107,4 +109,7 @@ WORKDIR /home/agent/workspace
USER 1000 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. * [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). * [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. * [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. * [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. * [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. * [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 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 ### Scripts
#### Linux/MacOS (`bash`) #### 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 You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
+22 -2
View File
@@ -205,9 +205,26 @@ nodes:
confirm the change makes the criterion true, confirm the test targets confirm the change makes the criterion true, confirm the test targets
BEHAVIOR (not implementation echo), use ast_grep for structural claims. BEHAVIOR (not implementation echo), use ast_grep for structural claims.
Watch for gamed evidence: a test weakened/deleted so it passes, hardcoded 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. 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 Execution criteria ("tests pass", "lint clean", "build succeeds") are
judged ONLY against the recorded verification runs in your prompt — you judged ONLY against the recorded verification runs in your prompt — you
have no execution tools BY DESIGN and must never claim to have run have no execution tools BY DESIGN and must never claim to have run
@@ -297,7 +314,10 @@ nodes:
The checklist (each hit becomes one complaint with kind, file:line The checklist (each hit becomes one complaint with kind, file:line
location, the violation, and the fix): location, the violation, and the fix):
- **Absence** — something the PLAN requires that maps to no criterion and - **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 - **Scope drift / out-of-scope** — the diff touches what the plan's scope
notes say not to touch, or quietly does extra unplanned work. notes say not to touch, or quietly does extra unplanned work.
- **Interface/approach substitution** — the plan named an interface, - **Interface/approach substitution** — the plan named an interface,
+16 -4
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 min(PER_COMMAND_TIMEOUT_SECS, remaining budget); commands the deadline
leaves no budget for are recorded as {"cmd", "skipped": "deadline"} entries, leaves no budget for are recorded as {"cmd", "skipped": "deadline"} entries,
so the in-script handling stays authoritative. The env var 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 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 its own session (start_new_session) and a timeout SIGKILLs the whole process
@@ -51,14 +53,20 @@ GRAPH_STATE_FILE, so an inherited live state file would silently win.
import json import json
import os import os
import signal import signal
import fcntl
import subprocess import subprocess
import sys import sys
import time import time
try:
import fcntl
except ImportError: # non-POSIX (Windows): advisory flock unavailable
fcntl = None
PER_COMMAND_TIMEOUT_SECS = 900 PER_COMMAND_TIMEOUT_SECS = 900
TOTAL_DEADLINE_SECS = float(os.environ.get("ADVERSARY_RUN_CHECKS_DEADLINE_SECS") or 3300) TOTAL_DEADLINE_SECS = float(os.environ.get("ADVERSARY_RUN_CHECKS_DEADLINE_SECS") or 3300)
STACK_LOCK_PATH = os.path.expanduser("~/.cache/coyote/review-stack.lock") 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) STACK_LOCK_WAIT_SECS = float(os.environ.get("ADVERSARY_STACK_LOCK_WAIT_SECS") or 1200)
@@ -67,7 +75,11 @@ def acquire_stack_lock():
well-known path; the probe is instructed to flock the same file around 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 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 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 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: try:
os.makedirs(os.path.dirname(STACK_LOCK_PATH), exist_ok=True) os.makedirs(os.path.dirname(STACK_LOCK_PATH), exist_ok=True)
fd = os.open(STACK_LOCK_PATH, os.O_CREAT | os.O_RDWR) fd = os.open(STACK_LOCK_PATH, os.O_CREAT | os.O_RDWR)
+17 -3
View File
@@ -6,7 +6,7 @@ description: |
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and 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 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. lives on disk in a plans directory, so runs survive context compression.
version: 2.5.0 version: 2.8.0
agent_session: temp agent_session: temp
auto_continue: true auto_continue: true
max_auto_continues: 100 max_auto_continues: 100
@@ -18,6 +18,7 @@ spawnable_agents:
- oracle - oracle
- explore - explore
- librarian - librarian
- deep-research
- adversary - adversary
- probe - probe
- review-gauntlet - review-gauntlet
@@ -180,7 +181,9 @@ instructions: |
<label>; authoritative sources preferred; return a distilled, deduplicated checklist"), put the <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 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, 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 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 {{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
@@ -373,6 +376,12 @@ instructions: |
`## Review round N @ <full head SHA>` (lane table, verdicts, what changed since the `## 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 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. 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: - **Spawn `adversary`** with the diff base and the criteria pasted in:
``` ```
@@ -587,7 +596,8 @@ instructions: |
|-------|---------| |-------|---------|
| `sisyphus` | Implement ONE task's code in {{project_dir}} (its own explore/coder/verify/review loop). One per task. | | `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. | | `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. | | `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. | | `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). | | `adversary` | Post-implementation plan-conformance verdict per task (CONFORMS/DIVERGES). |
@@ -622,6 +632,10 @@ instructions: |
an unprobeable consumer-facing change is a plan gap to fix, not a checkbox to skip. 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 - Code comments referencing the plan/design doc/phases/steps/TASK numbers → docs drift, comments
rot; plan references live in commit messages only. 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; - 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. `.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 - Dropping a Sisyphus-reported follow-up (not logged in the task's log.md, not in the PR's
+5 -3
View File
@@ -39,7 +39,7 @@ settings:
initial_state: initial_state:
project_dir: '' project_dir: ''
fix_attempts: 0 fix_attempts: 0
max_fix_attempts: 3 max_fix_attempts: 5
fix_instructions: '' fix_instructions: ''
build_output: '' build_output: ''
tests_output: '' tests_output: ''
@@ -222,7 +222,8 @@ nodes:
3. While reading it, note the repo's comment register per 3. While reading it, note the repo's comment register per
`comment-discipline` (self-documenting / api-documented / `comment-discipline` (self-documenting / api-documented /
comment-heavy) and write comments to match. When the signal is 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 4. If the change touches boundaries, error paths, jobs, or state
transitions, also note the logging register per transitions, also note the logging register per
`logging-discipline` (logger, message style, payload vs IDs, `logging-discipline` (logger, message style, payload vs IDs,
@@ -323,7 +324,8 @@ nodes:
- Suppressed errors (as any, @ts-ignore, #[allow(...)] on unfamiliar - Suppressed errors (as any, @ts-ignore, #[allow(...)] on unfamiliar
lints, empty catch blocks) lints, empty catch blocks)
- Dishonest naming (get_X that mutates, returns wrong type, etc.) - 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 - AI slop (filler prose, multi-paragraph docstrings, defensive
handling of impossible cases) 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` `plan -> parallel research -> vet -> critique -> synthesize -> verify -> approve`
pipeline. 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 This agent is also the **canonical reference for the Coyote graph
system**: it exercises every node type (`script`, `llm`, `rag`, `map`, system**: it exercises every node type (`script`, `llm`, `rag`, `map`,
`agent`, `input`, `approval`, `end`) and both static fan-out and `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 delegates the final write-up to a focused sub-agent, checks that the
cited sources are reachable, and gates the result behind human cited sources are reachable, and gates the result behind human
approval. A reviewer's free-form feedback at the approval step feeds 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 This is the canonical Coyote graph-agent reference: it exercises every
node type (script, llm, rag, map, agent, input, approval, end) and 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?' - 'How does HTTP/3 differ from HTTP/2?'
- 'Summarize recent advances in solid-state battery chemistry' - '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: settings:
max_loop_iterations: 40 max_loop_iterations: 120
log_state_snapshots: false log_state_snapshots: false
validate_before_run: true validate_before_run: true
max_concurrency: 4 max_concurrency: 4
@@ -142,23 +155,11 @@ nodes:
research_one_question: research_one_question:
id: research_one_question id: research_one_question
type: llm type: agent
instructions: | agent: librarian
You are a web research assistant. Investigate the SINGLE question # The leaf research primitive: triage -> doc+OSS search -> cited
given to you using your tools: search the web, fetch and read # synthesis. `inputs:` overlays the librarian graph's initial state,
pages, and search arXiv for academic sources. # pinning research_depth to deep for every sub-question lane.
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.
prompt: | prompt: |
Research question: {{question}} Research question: {{question}}
@@ -166,16 +167,13 @@ nodes:
{{local_context}} {{local_context}}
{{research_feedback}} {{research_feedback}}
tools: inputs:
- web_search_coyote research_depth: deep
- fetch_url_via_curl timeout: 1900
- search_arxiv
- mcp:ddg-search
max_iterations: 10
max_attempts: 2 max_attempts: 2
state_updates: state_updates:
finding: '{{output}}' 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 # 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. # is branch-local: a map branch's fallback must stay inside the branch.
fallback: question_fault fallback: question_fault
@@ -312,6 +310,13 @@ nodes:
id: verify_sources id: verify_sources
type: script type: script
script: scripts/verify_sources.py 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 next: approve
approve: approve:
@@ -348,6 +353,15 @@ nodes:
type: end type: end
output: '{{report}}{{pipeline_notes}}' output: '{{report}}{{pipeline_notes}}'
end_autonomous:
id: end_autonomous
type: end
output: |
{{report}}
----
{{source_check}}{{pipeline_notes}}
end_rejected: end_rejected:
id: end_rejected id: end_rejected
type: end 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 Reached ONLY via research_one_question's `fallback:` route inside the
`research_each_question` map branch (branch-local — a map branch's fallback `research_each_question` map branch (branch-local — a map branch's fallback
must stay inside the branch subgraph). Grounded in engine behavior: on must stay inside the branch subgraph). Grounded in engine behavior: on
llm-node failure the engine writes "LLM node …failed: <chain>" into branch failure the engine writes its failure text into `finding` via the
`finding` via the node's state_updates BEFORE routing to the fallback (on node's state_updates BEFORE routing to the fallback — "Agent node failed:
success the same key holds the researched findings, which never start with <chain>" now that the branch spawns a librarian agent, "LLM node …failed:
the "LLM node" prefix). This node normalizes the failure text into a <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 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 as the lane's `finding`, so one dead research lane never sinks the whole
map. combine_findings later lifts PIPELINE-FAULT findings into map. combine_findings later lifts PIPELINE-FAULT findings into
@@ -53,7 +54,7 @@ def main():
state = load_state() state = load_state()
raw = state.get("finding") raw = state.get("finding")
detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else "" 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" detail = "the research lane died without recording a failure detail"
if len(detail) > MAX_DETAIL_CHARS: if len(detail) > MAX_DETAIL_CHARS:
detail = detail[: MAX_DETAIL_CHARS - 1] + "…" detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
@@ -2,7 +2,8 @@
"""Reflexion gate for deep-research. """Reflexion gate for deep-research.
Runs after `critique` has reviewed the current research findings. If the 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 loops back to `research` with the critique attached as
`research_feedback`, so the retry is informed rather than a blind `research_feedback`, so the retry is informed rather than a blind
re-run. Otherwise it proceeds to `synthesize`. re-run. Otherwise it proceeds to `synthesize`.
@@ -26,9 +27,11 @@ import json
import os import os
import re import re
# Automated revision passes allowed. `research` runs at most # Default automated revision passes when the `reflexion_budget` variable
# MAX_REFLEXION_REVISIONS + 1 times per user pass. Bump to allow more. # is absent or malformed. `research` runs at most budget + 1 times per
MAX_REFLEXION_REVISIONS = 2 # 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(): def load_state():
@@ -69,8 +72,9 @@ def main():
critique = state.get("critique") or "" critique = state.get("critique") or ""
verdict = parse_verdict(critique) verdict = parse_verdict(critique)
attempts = as_int(state.get("research_attempts")) 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 = ( feedback = (
"A reviewer judged the previous research pass incomplete. " "A reviewer judged the previous research pass incomplete. "
"Address every point in the critique below:\n\n" + critique "Address every point in the critique below:\n\n" + critique
+1 -1
View File
@@ -174,7 +174,7 @@ nodes:
- fs_read - fs_read
- fs_cat - fs_cat
- fs_grep - fs_grep
max_iterations: 14 max_iterations: 28
max_attempts: 2 max_attempts: 2
state_updates: state_updates:
verdict: '{{output}}' verdict: '{{output}}'
+43 -17
View File
@@ -6,13 +6,16 @@ description: |
preamble. The "external grep" sibling of explore (which handles preamble. The "external grep" sibling of explore (which handles
internal/codebase grep). Designed to be fanned out 1-3 in parallel by internal/codebase grep). Designed to be fanned out 1-3 in parallel by
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are 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' version: '1.0'
global_tools: global_tools:
- web_search_coyote.sh - web_search_coyote.sh
- fetch_url_via_curl.sh - fetch_url_via_curl.sh
- search_arxiv.sh
mcp_servers: mcp_servers:
- ddg-search - ddg-search
@@ -26,11 +29,14 @@ variables:
- name: project_dir - name: project_dir
description: Project directory for context (unused in MVP but reserved for future iterations). description: Project directory for context (unused in MVP but reserved for future iterations).
default: '.' 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: settings:
max_loop_iterations: 12 max_loop_iterations: 12
log_state_snapshots: true log_state_snapshots: true
timeout: 600 timeout: 1800
initial_state: initial_state:
language_ecosystem: 'general' language_ecosystem: 'general'
@@ -117,7 +123,7 @@ nodes:
search: search:
id: search id: search
type: llm 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 skills_enabled: true
enabled_skills: enabled_skills:
- ai-slop-remover - ai-slop-remover
@@ -129,7 +135,10 @@ nodes:
Priority order: Priority order:
1. Official documentation - PRIORITIZE the hinted doc domains when 1. Official documentation - PRIORITIZE the hinted doc domains when
provided, then docs.X.org / readthedocs.io / MDN / vendor docs 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 3. Credible secondary references (PEPs, official blog posts) - only
if 1-2 are sparse if 1-2 are sparse
@@ -142,14 +151,19 @@ nodes:
## Search budget and fail-fast rules ## Search budget and fail-fast rules
You have a HARD BUDGET of 3 search calls total. After 3 calls, stop Your budget is set by the `Research depth` line in the prompt:
calling tools and produce your final answer with whatever you have. - 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, 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 or rate-limit warnings: that counts as a used call. Do not retry the
same query - either rephrase OR give up. 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 NO_AUTHORITATIVE_SOURCES_FOUND
Reason: <one line> Reason: <one line>
@@ -168,9 +182,11 @@ nodes:
URL: <full url> 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: | prompt: |
Research topic: {{initial_prompt}} Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Triage hints: Triage hints:
- Language/ecosystem: {{language_ecosystem}} - Language/ecosystem: {{language_ecosystem}}
@@ -183,8 +199,9 @@ nodes:
pathlib` style queries). pathlib` style queries).
tools: tools:
- mcp:ddg-search - mcp:ddg-search
- search_arxiv
- web_search_coyote - web_search_coyote
max_iterations: 15 max_iterations: 30
state_updates: state_updates:
search_output: '{{output}}' search_output: '{{output}}'
fallback: synthesize fallback: synthesize
@@ -193,7 +210,7 @@ nodes:
search_oss: search_oss:
id: search_oss id: search_oss
type: llm 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 skills_enabled: true
enabled_skills: enabled_skills:
- ai-slop-remover - ai-slop-remover
@@ -219,8 +236,12 @@ nodes:
## Search budget and fail-fast rules ## Search budget and fail-fast rules
HARD BUDGET: 8 tool calls total. After 8 calls, stop and output what Your budget is set by the `Research depth` line in the prompt:
you have - even one or two examples is fine. - 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: If you find no production examples, output exactly:
@@ -240,10 +261,12 @@ nodes:
REPO: ... 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. demonstrates the pattern (not just the repo root) when possible.
prompt: | prompt: |
Research topic: {{initial_prompt}} Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Triage hints: Triage hints:
- Language/ecosystem: {{language_ecosystem}} - Language/ecosystem: {{language_ecosystem}}
@@ -254,7 +277,7 @@ nodes:
Filter to {{language_ecosystem}} repositories when the API allows. Filter to {{language_ecosystem}} repositories when the API allows.
tools: tools:
- mcp:personal-github - mcp:personal-github
max_iterations: 15 max_iterations: 30
state_updates: state_updates:
oss_output: '{{output}}' oss_output: '{{output}}'
fallback: synthesize fallback: synthesize
@@ -308,8 +331,10 @@ nodes:
5. If a URL is dead, returns garbage, or is off-topic, note it 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. under "Sources skipped" at the end and move on. Do not retry.
Budget: max 8 fetches total (across both source lists). Skip Fetch budget, set by the `Research depth` line in the prompt:
aggressively. - 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 ## Output format
@@ -332,6 +357,7 @@ nodes:
No flattery, no preamble. Start with `## Findings`. No flattery, no preamble. Start with `## Findings`.
prompt: | prompt: |
Research topic: {{initial_prompt}} Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Documentation sources (from doc search branch): Documentation sources (from doc search branch):
{{search_output}} {{search_output}}
@@ -340,7 +366,7 @@ nodes:
{{oss_output}} {{oss_output}}
tools: tools:
- fetch_url_via_curl - fetch_url_via_curl
max_iterations: 20 max_iterations: 45
state_updates: state_updates:
findings: '{{output}}' findings: '{{output}}'
fallback: final_format fallback: final_format
+9 -1
View File
@@ -120,7 +120,15 @@ instructions: |
update — never delete or silence it). update — never delete or silence it).
4. **Clean, local, isolated.** Fresh ephemeral state, mocked externals, no dependence on 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 — 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 5. **Classify every failure** as BUG / EXPECTED-CHANGE / ENV per the skill table. The verdict
depends on the classification being honest. depends on the classification being honest.
6. **Committed tests are the deliverable** alongside the verdict: write them where the repo's 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)"] parse -. "fallback" .-> pfault["parse_fault<br/>(script: PIPELINE-FAULT marker)"]
pfault --> gate pfault --> gate
signals --> select["select_lanes<br/>(llm: ADDITIVE-only judgment)"] 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)"] select -. "fallback" .-> dlanes["default_lanes<br/>(script: deterministic degraded<br/>selection — wider, never narrower)"]
dlanes --> build dlanes --> build
build --> mcr["map: code-review"] & madv["map: adversary"] & msec["map: security"] & mpb["map: probe"] 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: Local-run recipe / usage suites:
<how to boot the service locally + where existing suites live — enables the probe lane> <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\"]"} --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 (`'[]'` ⇒ "none declared"); a declaration that is not a JSON array of
strings executes nothing and records a `PIPELINE-FAULT`, which the strings executes nothing and records a `PIPELINE-FAULT`, which the
adversary lane reports as DIVERGES and the gate as BLOCKED. adversary lane reports as DIVERGES and the gate as BLOCKED.
- Signals failures degrade gracefully: if git can't compute the diff, lane - Signals failures degrade WIDER: if git can't compute the diff, the surface
selection falls back to caller context + forced lanes, and the report notes is unknown — the security lane is added (and probe, when a local-run recipe
it. The auth-path regex deliberately over-fires (`auth(?!or)` matches 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 auth/authn/authz/oauth but not "author") — the safe direction, since signals
only ever ADD the security lane. only ever ADD the security lane.
- Requires the `code-reviewer`, `adversary`, `security-reviewer`, and `probe` - Requires the `code-reviewer`, `adversary`, `security-reviewer`, and `probe`
+11 -6
View File
@@ -107,10 +107,14 @@ nodes:
You extract the facts of a review request into structured fields. You extract the facts of a review request into structured fields.
EXTRACTION ONLY — do not decide which review lanes should run. EXTRACTION ONLY — do not decide which review lanes should run.
- `diff_spec`: the git diff specification the caller named — a ref - `diff_spec`: the git diff specification the caller named, as a BARE
range ("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal revision token usable verbatim in `git diff <spec>` — a ref range
"worktree" for uncommitted changes against HEAD. When the caller ("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
did not name one, use "worktree". "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 - `project_dir_in`: the repository directory the caller named as holding
the change (absolute path preferred); empty string when the caller the change (absolute path preferred); empty string when the caller
named none. named none.
@@ -127,7 +131,8 @@ nodes:
pointers for black-box probing (how to boot the service, where the pointers for black-box probing (how to boot the service, where the
suites live). Empty string when the caller supplied none. suites live). Empty string when the caller supplied none.
- `forced_lanes`: lanes the caller explicitly demanded (verbatim - `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, - `extra_context`: any remaining caller context reviewers should see,
condensed. condensed.
prompt: | prompt: |
@@ -261,7 +266,7 @@ nodes:
build_items: build_items:
id: build_items id: build_items
type: script 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 script: scripts/build_items.py
timeout: 30 timeout: 30
next: [map_code_review, map_adversary, map_security, map_probe] next: [map_code_review, map_adversary, map_security, map_probe]
@@ -1,14 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Deterministic lane builder for the review gauntlet. """Deterministic lane builder for the review gauntlet.
Selection = hard rules (the floor) ∪ LLM additions; forced lanes override Selection = hard rules (the floor) ∪ LLM additions ∪ caller-forced lanes.
everything exactly. Emits one 0-or-1-item list per lane: an empty list makes Forcing is ADDITIVE: it can only widen the computed selection — honoring a
that lane's map node run zero branches (map-over-nothing), which IS the forced list as the exact set once dropped the always-on code-review lane in
skip mechanism — no dynamic routing anywhere. 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 The LLM's add_* flags can only ADD lanes. A flag that is false never
removes a lane the hard rules selected. 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 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 named lanes get an item, stamped with their attempt number, and every other
lane maps over nothing — retry_gate restores their earlier reports. lane maps over nothing — retry_gate restores their earlier reports.
@@ -101,10 +107,6 @@ def main():
(forced.append(canon) if canon else unknown.append(str(lane))) (forced.append(canon) if canon else unknown.append(str(lane)))
reasons = [] reasons = []
if forced:
lanes = set(forced)
reasons.append(f"forced lanes honored exactly: {sorted(lanes)}")
else:
lanes = {"code-review"} lanes = {"code-review"}
reasons.append( reasons.append(
"code-review: always on — the gauntlet is only spawned for non-trivial work" "code-review: always on — the gauntlet is only spawned for non-trivial work"
@@ -128,6 +130,22 @@ def main():
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists" "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: diff signals unavailable — surface unknown, degrading wider"
)
if "probe" not in lanes and text("probe_context"):
lanes.add("probe")
reasons.append(
"probe: diff signals unavailable and a local-run recipe/suite pointer "
"exists — degrading wider"
)
# LLM judgment is additive-only. # LLM judgment is additive-only.
llm_why = text("lane_reasons")[:300] llm_why = text("lane_reasons")[:300]
for flag, lane in ( for flag, lane in (
@@ -152,6 +170,28 @@ def main():
lanes.add(lane) lanes.add(lane)
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}") 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: if unknown:
reasons.append(f"ignored unknown forced lane name(s): {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), probe without a recipe is INCONCLUSIVE by construction),
plus security when the attack-surface signals (auth/deps/exec) or a hardened plus security when the attack-surface signals (auth/deps/exec) or a hardened
posture demand it, unioned with any lanes the caller forced (canonicalized posture demand it, unioned with any lanes the caller forced (canonicalized
with the same aliases build_items uses) — which build_items honors exactly, with the same aliases build_items uses) — which build_items honors
so selection degrades WIDER, never narrower. The degradation note goes into 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 `lanes_degraded`; build_items folds it into lanes_summary so the final
report names the degradation. report names the degradation.
@@ -72,6 +74,11 @@ def main():
or state.get("security_posture") == "hardened" or state.get("security_posture") == "hardened"
): ):
lanes.add("security") 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. # Caller-forced lanes survive degradation — union, never overwrite.
for lane in state.get("forced_lanes") or []: for lane in state.get("forced_lanes") or []:
if canon := ALIASES.get(str(lane).strip().lower()): if canon := ALIASES.get(str(lane).strip().lower()):
@@ -3,9 +3,10 @@
Pure git — no LLM judgment. Every signal is computed from the changed-file 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: 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 on any error the script reports `signals_error` and marks the signals
(false/empty) in place, which biases lane selection toward the caller's unavailable — downstream (build_items / default_lanes) treats that as
context and forced lanes. UNKNOWN surface and degrades WIDER (security on; probe when a recipe
exists), on top of caller context and forced lanes.
""" """
import json import json
@@ -26,6 +27,19 @@ proj = os.path.expanduser(
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "." (state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
) )
spec = (state.get("diff_spec") or "worktree").strip() 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): def git(*args):
@@ -112,13 +126,18 @@ try:
), ),
"docs_only": bool(files) "docs_only": bool(files)
and all(re.search(r"\.(md|rst|txt|adoc)$", f, re.I) for f in 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 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"] = ( out["signals_error"] = (
f"NOTE: diff signals could not be computed ({e}); " f"NOTE: diff signals could not be computed ({what}: {e}); surface UNKNOWN — "
"lane selection falls back to caller context and forced lanes." "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" out["signals_summary"] = "signals unavailable"
+12 -3
View File
@@ -1,6 +1,6 @@
name: sisyphus name: sisyphus
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline 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 agent_session: temp
auto_continue: true auto_continue: true
@@ -11,6 +11,7 @@ can_spawn_agents: true
spawnable_agents: spawnable_agents:
- explore - explore
- librarian - librarian
- deep-research
- coder - coder
- oracle - oracle
- code-reviewer - code-reviewer
@@ -157,7 +158,8 @@ instructions: |
| Agent | Use For | Characteristics | | 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+. | | `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 | | `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 | | `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 | | `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
@@ -575,7 +577,7 @@ instructions: |
When you write or modify files yourself (rather than delegating to coder): 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. - **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 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. - **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.
@@ -709,6 +711,13 @@ instructions: |
description: in-place edits destroy the review history and hide which commits each round 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 actually reviewed. Comments are the append-only review log; the SHA in the title names
exactly what was reviewed. 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 **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 that they record the resolution with `coyote --macro record-ruling` so that class of decision
+2
View File
@@ -186,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_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_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_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 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 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) max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
@@ -276,6 +277,7 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons
# All clients have the following configuration: # All clients have the following configuration:
# - type: xxxx # - type: xxxx
# name: xxxx # Only use it to distinguish clients with the same client type. Optional # 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: # models:
# - name: xxxx # Chat model # - name: xxxx # Chat model
# max_input_tokens: 100000 # max_input_tokens: 100000
+7 -6
View File
@@ -4,7 +4,8 @@
# each one carries. # each one carries.
# #
# Env vars read: # Env vars read:
# COYOTE_HOOK_LOG log file to append to (default: /tmp/coyote-hooks.log) # 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 # COYOTE_* everything coyote sets for the event is dumped verbatim
# #
# Secrets (COYOTE_SECRET_*) are excluded from the snapshot; widening the grep # Secrets (COYOTE_SECRET_*) are excluded from the snapshot; widening the grep
@@ -26,15 +27,15 @@
set -u set -u
log_file="${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}" log_file="${COYOTE_HOOK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log}"
# The default lives in shared /tmp: refuse to append through a symlink someone # COYOTE_HOOK_LOG may point into a shared directory: refuse to append through
# else planted there. Point COYOTE_HOOK_LOG at a private path to avoid the # a symlink someone else planted there.
# shared directory entirely.
[ -L "$log_file" ] && exit 0 [ -L "$log_file" ] && exit 0
umask 077 umask 077
mkdir -p "$(dirname "$log_file")" 2> /dev/null || true
{ {
echo "=== $(date '+%Y-%m-%dT%H:%M:%S%z') ${COYOTE_EVENT:-unknown}" echo "=== $(date -u '+%Y-%m-%dT%H:%M:%SZ') ${COYOTE_EVENT:-unknown}"
env | grep '^COYOTE_' | grep -v '^COYOTE_SECRET_' | sort env | grep '^COYOTE_' | grep -v '^COYOTE_SECRET_' | sort
echo echo
} >> "$log_file" 2> /dev/null || true } >> "$log_file" 2> /dev/null || true
+4 -1
View File
@@ -38,5 +38,8 @@ elif command -v osascript > /dev/null 2>&1; then
else else
# coyote discards hook stdout, so aim the fallback at the controlling # coyote discards hook stdout, so aim the fallback at the controlling
# terminal when one exists; plain stdout keeps direct/manual runs working. # terminal when one exists; plain stdout keeps direct/manual runs working.
{ echo "[$title] $body" > /dev/tty; } 2> /dev/null || echo "[$title] $body" # $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 fi
+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 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 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. 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, - **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 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 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 - **Leaky fixture** — an integration test against a shared/persistent database inserts fixture
rows directly with no paired cleanup (deferred delete/teardown/transaction rollback). The 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. 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 ## 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). 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 ## 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). 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. 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. 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) ## Ground-truth verification (verify, don't pattern-match)
+1102 -376
View File
File diff suppressed because it is too large. Load diff
+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 "$@"
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env bash
# Black-box usage-pattern probe for the coyote hook system.
#
# Verifies, against a REAL built `coyote` binary in a clean/isolated
# COYOTE_CONFIG_DIR (dry_run:true, no network/LLM credentials required):
# A) exactly one turn.started + one turn.completed + one llm.request.completed
# fire per headless run, with correctly scoped env vars (no cross-event
# extras leakage).
# B) a failing/missing-script hook never changes coyote's stdout, stderr, or
# exit code.
# C) `coyote --install-builtins hooks` installs both bundled example scripts
# 0755 into <config_dir>/hooks via the real CLI, and a second invocation
# (force reinstall) overwrites local edits back to the packaged content.
# D) `coyote --agent <bundled-agent> --info` (an inspection flag) fires ZERO
# agent.* hook events.
#
# Not covered here (see src/function/mod.rs tests for tool.* + payload-file
# coverage instead): a live tool.started firing requires a real LLM round
# trip that actually decides to call a tool; dry_run's echo_messages() path
# never invokes tools, so it cannot be exercised via this recipe.
#
# Usage: scripts/usage-probe-hooks.sh
# Requires: a `coyote` binary on PATH or at ./target/debug/coyote or
# ./target/release/coyote (build with `cargo build --bin coyote` first).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="${COYOTE_BIN:-}"
if [ -z "$BIN" ]; then
if [ -x "$REPO_ROOT/target/debug/coyote" ]; then
BIN="$REPO_ROOT/target/debug/coyote"
elif [ -x "$REPO_ROOT/target/release/coyote" ]; then
BIN="$REPO_ROOT/target/release/coyote"
else
echo "No coyote binary found; run 'cargo build --bin coyote' first." >&2
exit 2
fi
fi
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
fail() {
echo "FAIL: $1" >&2
exit 1
}
write_dryrun_config() {
# $1 = config dir, $2..$n = extra YAML fragment lines appended verbatim
local dir="$1"
shift
cat > "$dir/config.yaml" <<'BASE'
model: dryrun:dry-model
dry_run: true
clients:
- type: openai
name: dryrun
auth: none
api_key: 'unused'
models:
- name: dry-model
max_input_tokens: 100000
supports_function_calling: true
save: false
memory: false
stream: false
BASE
if [ "$#" -gt 0 ]; then
printf '%s\n' "$@" >> "$dir/config.yaml"
fi
}
echo "== Scenario A: turn/llm hook cadence and env scoping =="
CFG_A="$WORKDIR/a"
mkdir -p "$CFG_A"
TS_LOG="$WORKDIR/turn-started.log"
TC_LOG="$WORKDIR/turn-completed.log"
LC_LOG="$WORKDIR/llm-completed.log"
write_dryrun_config "$CFG_A" "hooks:" \
" turn.started:" \
" - name: probe" \
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TS_LOG\"" \
" turn.completed:" \
" - name: probe" \
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TC_LOG\"" \
" llm.request.completed:" \
" - name: probe-llm" \
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT PROVIDER=\$COYOTE_LLM_PROVIDER MODEL=\$COYOTE_LLM_MODEL\\\"; } >> $LC_LOG\""
COYOTE_CONFIG_DIR="$CFG_A" "$BIN" --no-stream "say exactly: hi" > /dev/null
sleep 1
[ "$(grep -c '^EVENT=turn.started' "$TS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one turn.started, got: $(cat "$TS_LOG" 2>/dev/null)"
[ "$(grep -c '^EVENT=turn.completed' "$TC_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one turn.completed, got: $(cat "$TC_LOG" 2>/dev/null)"
[ "$(grep -c '^EVENT=llm.request.completed' "$LC_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one llm.request.completed, got: $(cat "$LC_LOG" 2>/dev/null)"
grep -q 'PROVIDER=dryrun MODEL=dryrun:dry-model' "$LC_LOG" \
|| fail "llm.request.completed missing expected COYOTE_LLM_PROVIDER/MODEL: $(cat "$LC_LOG")"
echo "PASS: exactly 1 turn.started + 1 turn.completed + 1 llm.request.completed, correctly scoped"
echo "== Scenario B: failing/missing-script hooks never affect output or exit code =="
CFG_B="$WORKDIR/b"
mkdir -p "$CFG_B"
write_dryrun_config "$CFG_B" "hooks:" \
" turn.completed:" \
" - name: failing" \
" command: /nonexistent/script/does/not/exist" \
" - name: exit1" \
" command: \"exit 1\""
CFG_BASE="$WORKDIR/base"
mkdir -p "$CFG_BASE"
write_dryrun_config "$CFG_BASE"
exit_b=0
COYOTE_CONFIG_DIR="$CFG_B" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_b.txt" 2> "$WORKDIR/err_b.txt" || exit_b=$?
exit_base=0
COYOTE_CONFIG_DIR="$CFG_BASE" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_base.txt" 2> "$WORKDIR/err_base.txt" || exit_base=$?
[ "$exit_b" = "$exit_base" ] || fail "exit code differs: with-hooks=$exit_b baseline=$exit_base"
diff -q "$WORKDIR/out_b.txt" "$WORKDIR/out_base.txt" > /dev/null || fail "stdout differs when a hook fails/is missing"
diff -q "$WORKDIR/err_b.txt" "$WORKDIR/err_base.txt" > /dev/null || fail "stderr differs when a hook fails/is missing"
echo "PASS: failing/missing-script hooks are invisible to stdout/stderr/exit code"
echo "== Scenario C: coyote --install-builtins hooks (real CLI, clean config dir) =="
CFG_C="$WORKDIR/c"
mkdir -p "$CFG_C"
out_install1="$WORKDIR/install1.out"
COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$out_install1" 2>&1
[ -f "$CFG_C/hooks/notify.sh" ] || fail "notify.sh was not installed: $(cat "$out_install1")"
[ -f "$CFG_C/hooks/log-events.sh" ] || fail "log-events.sh was not installed: $(cat "$out_install1")"
grep -q "Reinstalled bundled hooks" "$out_install1" \
|| fail "expected confirmation message, got: $(cat "$out_install1")"
if command -v stat > /dev/null 2>&1; then
mode_notify="$(stat -c '%a' "$CFG_C/hooks/notify.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/notify.sh")"
mode_log="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")"
[ "$mode_notify" = "755" ] || fail "notify.sh mode is $mode_notify, expected 755"
[ "$mode_log" = "755" ] || fail "log-events.sh mode is $mode_log, expected 755"
fi
# Modify a script locally, then re-run --install-builtins hooks: it always
# forces (no confirmation prompt on non-tty stdout), so the local edit must
# be overwritten back to the packaged content.
echo "# LOCALLY MODIFIED" > "$CFG_C/hooks/notify.sh"
COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$WORKDIR/install2.out" 2>&1
grep -q "LOCALLY MODIFIED" "$CFG_C/hooks/notify.sh" \
&& fail "force reinstall did not overwrite the locally modified notify.sh"
grep -q '^#!/usr/bin/env bash' "$CFG_C/hooks/notify.sh" \
|| fail "notify.sh was not restored to the packaged script"
echo "PASS: --install-builtins hooks installs both scripts 0755 and force-overwrites local edits"
echo "== Scenario C2: cold-start auto-bootstrap fills the hooks dir but never forces =="
CFG_C2="$WORKDIR/c2"
mkdir -p "$CFG_C2/hooks"
write_dryrun_config "$CFG_C2"
# Every coyote invocation calls install_builtins() (force=false) before
# dispatching any flag, so a plain run on a config dir with no hooks/ yet
# must auto-populate it...
rmdir "$CFG_C2/hooks" 2>/dev/null || true
COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null
[ -f "$CFG_C2/hooks/notify.sh" ] || fail "cold-start run did not auto-install notify.sh"
# ...but once a file exists, the non-force path must NEVER touch it again.
echo "# LOCALLY MODIFIED" > "$CFG_C2/hooks/notify.sh"
COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null
grep -q "LOCALLY MODIFIED" "$CFG_C2/hooks/notify.sh" \
|| fail "a plain (non --install-builtins) invocation overwrote a locally modified hook script"
echo "PASS: plain invocations auto-bootstrap missing hooks but never force-overwrite existing ones"
echo "== Scenario C3: installed log-events.sh fires end-to-end via the documented relative path =="
# Exercises the exact wiring promised in the script headers:
# command: ./hooks/log-events.sh (resolves against the config dir)
# and the script's own contract: COYOTE_HOOK_LOG override honored, log file
# created 0600, COYOTE_SECRET_* values excluded from the snapshot.
CFG_C3="$WORKDIR/c3"
mkdir -p "$CFG_C3"
HOOK_LOG="$WORKDIR/c3-hook.log"
write_dryrun_config "$CFG_C3" "hooks:" \
" turn.completed:" \
" - name: log-events" \
" command: ./hooks/log-events.sh"
COYOTE_CONFIG_DIR="$CFG_C3" "$BIN" --install-builtins hooks > /dev/null 2>&1
COYOTE_CONFIG_DIR="$CFG_C3" COYOTE_HOOK_LOG="$HOOK_LOG" COYOTE_SECRET_PROBE=hunter2 \
"$BIN" --no-stream "say exactly: hi" > /dev/null
sleep 1
[ -f "$HOOK_LOG" ] || fail "installed log-events.sh did not write to COYOTE_HOOK_LOG via relative-path wiring"
[ "$(grep -c '^=== ' "$HOOK_LOG")" = "1" ] \
|| fail "expected exactly one event block in the hook log, got: $(cat "$HOOK_LOG")"
grep -q 'COYOTE_EVENT=turn.completed' "$HOOK_LOG" \
|| fail "hook log missing COYOTE_EVENT=turn.completed: $(cat "$HOOK_LOG")"
grep -q 'hunter2' "$HOOK_LOG" \
&& fail "COYOTE_SECRET_* value leaked into the hook log"
grep -q 'COYOTE_SECRET_' "$HOOK_LOG" \
&& fail "COYOTE_SECRET_ variable name leaked into the hook log"
if command -v stat > /dev/null 2>&1; then
mode_hook_log="$(stat -c '%a' "$HOOK_LOG" 2>/dev/null || stat -f '%Lp' "$HOOK_LOG")"
[ "$mode_hook_log" = "600" ] || fail "hook log mode is $mode_hook_log, expected 600"
fi
echo "PASS: relative-path wiring fires the installed log-events.sh; log is 0600 with secrets excluded"
echo "== Scenario D: --agent <bundled> --info fires zero agent.* events =="
CFG_D="$WORKDIR/d"
mkdir -p "$CFG_D/agents"
AGENT_LOG="$WORKDIR/agent-events.log"
write_dryrun_config "$CFG_D" "hooks:" \
" agent.started:" \
" - name: probe-agent" \
" command: \"echo AGENT_STARTED >> $AGENT_LOG\"" \
" agent.completed:" \
" - name: probe-agent" \
" command: \"echo AGENT_DONE >> $AGENT_LOG\"" \
" agent.failed:" \
" - name: probe-agent" \
" command: \"echo AGENT_FAILED >> $AGENT_LOG\""
AGENT_NAME="$(ls "$REPO_ROOT/assets/agents" | head -1)"
cp -r "$REPO_ROOT/assets/agents/$AGENT_NAME" "$CFG_D/agents/$AGENT_NAME"
COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --agent "$AGENT_NAME" --info > /dev/null 2>&1
sleep 1
[ -s "$AGENT_LOG" ] && fail "expected zero agent.* events from --agent --info, got: $(cat "$AGENT_LOG")"
echo "PASS: --agent $AGENT_NAME --info fired zero agent.* hook events"
echo
echo "ALL SCENARIOS PASSED"
+135 -18
View File
@@ -1,7 +1,9 @@
use crate::client::{ModelType, list_models}; use crate::client::{ModelType, list_models};
use crate::config::paths; use crate::config::paths;
use crate::config::{AppConfig, Config, list_agents_with_descriptions, list_sessions}; use crate::config::{
use crate::utils::list_file_names; AppConfig, Config, labeled_session_names, list_agents_with_descriptions, sanitize_display_text,
session_scope_dirs,
};
use crate::vault::Vault; use crate::vault::Vault;
use clap_complete::{CompletionCandidate, Shell, generate}; use clap_complete::{CompletionCandidate, Shell, generate};
use clap_complete_nushell::Nushell; 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> { fn load_app_config_for_completion() -> anyhow::Result<AppConfig> {
let h = tokio::runtime::Handle::try_current().ok(); let h = tokio::runtime::Handle::try_current().ok();
let cfg = match h { let cfg = match h {
Some(handle) => { Some(handle) => tokio::task::block_in_place(|| {
tokio::task::block_in_place(|| handle.block_on(Config::load_with_interpolation(true)))? handle.block_on(Config::load_with_interpolation(true, true))
} })?,
None => { None => {
let rt = tokio::runtime::Runtime::new()?; 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) AppConfig::from_config(cfg)
@@ -115,7 +117,11 @@ pub(super) fn bundle_completer(current: &OsStr) -> Vec<CompletionCandidate> {
} }
fn extract_agent_from_args() -> Option<String> { 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; let mut i = 0;
while i < args.len() { while i < args.len() {
let arg = &args[i]; let arg = &args[i];
@@ -134,19 +140,21 @@ fn extract_agent_from_args() -> Option<String> {
} }
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> { 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() { fn session_candidates(cur: &str, agent: Option<&str>) -> Vec<CompletionCandidate> {
let sessions_dir = paths::agent_data_dir(&agent_name).join("sessions"); let (workspace_dir, global_dir) = session_scope_dirs(agent);
list_file_names(sessions_dir, ".yaml") labeled_session_names(&workspace_dir, &global_dir)
} else {
list_sessions()
};
sessions
.into_iter() .into_iter()
.filter(|s| s.starts_with(&*cur)) .filter(|(name, _)| name.starts_with(cur))
.map(CompletionCandidate::new) .map(|(name, scope)| {
CompletionCandidate::new(sanitize_display_text(&name))
.help(Some(scope.to_string().into()))
})
.collect() .collect()
} }
@@ -194,3 +202,112 @@ pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
Err(_) => vec![], 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);
}
}
+3 -1
View File
@@ -4,13 +4,15 @@ use super::*;
use anyhow::Result; use anyhow::Result;
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize, Default)]
pub struct AzureOpenAIConfig { pub struct AzureOpenAIConfig {
pub name: Option<String>, pub name: Option<String>,
pub api_base: Option<String>, pub api_base: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>, pub extra: Option<ExtraConfig>,
} }
+103 -7
View File
@@ -16,7 +16,7 @@ use reqwest::{Client as ReqwestClient, Method, RequestBuilder};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize, Default)]
pub struct BedrockConfig { pub struct BedrockConfig {
pub name: Option<String>, pub name: Option<String>,
pub access_key_id: Option<String>, pub access_key_id: Option<String>,
@@ -25,6 +25,8 @@ pub struct BedrockConfig {
pub session_token: Option<String>, pub session_token: Option<String>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>, pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>, pub extra: Option<ExtraConfig>,
@@ -423,6 +425,7 @@ fn build_chat_completions_body(
tool_results, tool_results,
text, text,
sequence, sequence,
round_starts,
}) => { }) => {
if !sequence { if !sequence {
let mut assistant_parts = vec![]; let mut assistant_parts = vec![];
@@ -472,11 +475,15 @@ fn build_chat_completions_body(
} }
} else { } else {
// One pair per round: Claude can reuse tool_use IDs across API calls. // 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 // Round boundaries are recorded in `round_starts` at merge time.
// rounds where the model emitted only tool calls (no narration) // Splitting per round also keeps the serialized history append-only
// carry no text marker. As a backstop, also split whenever a // across tool-loop iterations, so prompt caching keeps hitting on
// toolUseId would repeat within the current assistant message. // earlier rounds instead of rewriting them every request.
// Converse rejects duplicate toolUseIds in a single message. // 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 messages = vec![];
let mut assistant_parts: Vec<Value> = vec![]; let mut assistant_parts: Vec<Value> = vec![];
let mut user_parts: Vec<Value> = vec![]; let mut user_parts: Vec<Value> = vec![];
@@ -487,7 +494,11 @@ fn build_chat_completions_body(
.id .id
.as_deref() .as_deref()
.is_some_and(|id| chunk_ids.contains(id)); .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( messages.push(
json!({ "role": "assistant", "content": assistant_parts }), json!({ "role": "assistant", "content": assistant_parts }),
); );
@@ -896,6 +907,7 @@ mod tests {
tool_results, tool_results,
text: text.to_string(), text: text.to_string(),
sequence, sequence,
round_starts: vec![],
}), }),
), ),
], ],
@@ -912,6 +924,29 @@ mod tests {
build_body_with(tool_results, "", true) 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 { fn tool_result_content_block(output: Value) -> Value {
let body = build_body(vec![tool_result(output)]); let body = build_body(vec![tool_result(output)]);
body["messages"][2]["content"][0]["toolResult"]["content"][0].clone() body["messages"][2]["content"][0]["toolResult"]["content"][0].clone()
@@ -1067,6 +1102,9 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body); 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] #[test]
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() { fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
let body = build_body(vec![ let body = build_body(vec![
@@ -1081,6 +1119,64 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body); 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] #[test]
fn non_sequence_emits_assistant_user_pair() { fn non_sequence_emits_assistant_user_pair() {
let body = build_body_with( let body = build_body_with(
+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 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 { ModelData {
name: hand.name, name: hand.name,
model_type: hand.model_type, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ProviderQuirks { 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");
}
} }
+106 -14
View File
@@ -15,7 +15,7 @@ use serde_json::{Value, json};
const API_BASE: &str = "https://api.anthropic.com/v1"; const API_BASE: &str = "https://api.anthropic.com/v1";
const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; 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 struct ClaudeConfig {
pub name: Option<String>, pub name: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
@@ -24,6 +24,8 @@ pub struct ClaudeConfig {
pub oauth: Option<Box<OAuthConfig>>, pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>, pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>, pub extra: Option<ExtraConfig>,
@@ -401,6 +403,7 @@ pub fn claude_build_chat_completions_body(
tool_results, tool_results,
text, text,
sequence, sequence,
round_starts,
}) => { }) => {
if !sequence { if !sequence {
let mut assistant_parts = vec![]; let mut assistant_parts = vec![];
@@ -456,14 +459,18 @@ pub fn claude_build_chat_completions_body(
} }
} else { } else {
// One pair per round: Claude can reuse tool_use IDs across API calls. // 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 // Round boundaries are recorded in `round_starts` at merge time.
// rounds where the model emitted only tool calls (no narration) // Splitting per round also keeps the serialized history append-only
// carry no text marker. As a backstop, also split whenever a // across tool-loop iterations, so prefix-based prompt caching keeps
// tool_use ID would repeat within the current assistant message — // hitting on earlier rounds instead of rewriting them every request.
// the API rejects duplicate tool_use IDs in a single message. // 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 messages = vec![];
let mut assistant_parts: Vec<serde_json::Value> = vec![]; let mut assistant_parts: Vec<Value> = vec![];
let mut user_parts: Vec<serde_json::Value> = vec![]; let mut user_parts: Vec<Value> = vec![];
let mut chunk_ids: HashSet<&str> = HashSet::new(); let mut chunk_ids: HashSet<&str> = HashSet::new();
for (index, tool_result) in tool_results.iter().enumerate() { for (index, tool_result) in tool_results.iter().enumerate() {
let id_collision = tool_result let id_collision = tool_result
@@ -471,7 +478,11 @@ pub fn claude_build_chat_completions_body(
.id .id
.as_deref() .as_deref()
.is_some_and(|id| chunk_ids.contains(id)); .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( messages.push(
json!({ "role": "assistant", "content": assistant_parts }), json!({ "role": "assistant", "content": assistant_parts }),
); );
@@ -735,6 +746,7 @@ mod tests {
tool_results, tool_results,
text: text.to_string(), text: text.to_string(),
sequence, sequence,
round_starts: vec![],
}), }),
), ),
], ],
@@ -752,6 +764,30 @@ mod tests {
build_body_with(tool_results, "", true, prompt_cache) 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 { fn multi_turn_data(functions: Option<Vec<FunctionDeclaration>>) -> ChatCompletionsData {
ChatCompletionsData { ChatCompletionsData {
messages: vec![ messages: vec![
@@ -823,6 +859,63 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body); 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] #[test]
fn sequence_splits_on_reused_id_in_textless_round() { fn sequence_splits_on_reused_id_in_textless_round() {
let body = build_body( let body = build_body(
@@ -840,6 +933,9 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body); 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] #[test]
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() { fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
let body = build_body( let body = build_body(
@@ -1028,16 +1124,12 @@ mod tests {
fn claude_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> ClaudeConfig { fn claude_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> ClaudeConfig {
ClaudeConfig { ClaudeConfig {
name: Some(name.into()), name: Some(name.into()),
api_key: None,
api_base: oauth api_base: oauth
.as_ref() .as_ref()
.map(|_| "https://gateway.example/v1".to_string()), .map(|_| "https://gateway.example/v1".to_string()),
auth: auth.map(str::to_string), auth: auth.map(str::to_string),
oauth: oauth.map(Box::new), oauth: oauth.map(Box::new),
models: vec![], ..Default::default()
prompt_cache: None,
patch: None,
extra: None,
} }
} }
+2
View File
@@ -16,6 +16,8 @@ pub struct CohereConfig {
pub api_base: Option<String>, pub api_base: Option<String>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>, pub extra: Option<ExtraConfig>,
} }
+16 -9
View File
@@ -420,6 +420,15 @@ impl TokenUsage {
&& self.cache_read_input_tokens.is_none() && 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) { pub fn accumulate(&mut self, other: &TokenUsage) {
fn add(acc: &mut Option<u64>, value: Option<u64>) { fn add(acc: &mut Option<u64>, value: Option<u64>) {
if let Some(value) = value { if let Some(value) = value {
@@ -613,9 +622,9 @@ fn llm_failed_extras(
} }
extras.push(( extras.push((
"COYOTE_LLM_ERROR", "COYOTE_LLM_ERROR",
// `{:#}` renders the whole context chain, so the root cause survives // The immediate error only, matching COYOTE_TOOL_ERROR; the context
// the `context(...)` wrapping the client trait defaults apply. // chain below it stays out of the env.
err.map(|err| format!("{err:#}")) err.map(|err| err.to_string())
.unwrap_or_else(|| "Aborted.".to_string()), .unwrap_or_else(|| "Aborted.".to_string()),
)); ));
@@ -1896,12 +1905,10 @@ mod tests {
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str), failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
Some("429") Some("429")
); );
assert!( assert_eq!(
failed failed.envs.get("COYOTE_LLM_ERROR").map(String::as_str),
.envs Some("Failed to call chat-completions api"),
.get("COYOTE_LLM_ERROR") "the env must carry the immediate error only, not the context chain"
.unwrap()
.contains("error (status: 429)")
); );
assert!(!failed.envs.contains_key("COYOTE_LLM_DURATION_MS")); assert!(!failed.envs.contains_key("COYOTE_LLM_DURATION_MS"));
} }
+2
View File
@@ -20,6 +20,8 @@ pub struct GeminiConfig {
pub oauth: Option<Box<OAuthConfig>>, pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>, pub extra: Option<ExtraConfig>,
} }
+14 -5
View File
@@ -51,15 +51,24 @@ macro_rules! register_client {
pub fn list_models(local_config: &$config) -> Vec<Model> { pub fn list_models(local_config: &$config) -> Vec<Model> {
let client_name = Self::name(local_config); let client_name = Self::name(local_config);
if local_config.models.is_empty() { let find_catalog = || $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
if let Some(v) = $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
v.provider == $name || v.provider == $name ||
($name == OpenAICompatibleClient::NAME ($name == OpenAICompatibleClient::NAME
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default()) && local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
}) { });
return Model::from_config(client_name, &v.models); if local_config.models.is_empty() {
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 { } else {
Model::from_config(client_name, &local_config.models) Model::from_config(client_name, &local_config.models)
} }
+66
View File
@@ -216,6 +216,16 @@ pub struct MessageContentToolCalls {
pub tool_results: Vec<ToolResult>, pub tool_results: Vec<ToolResult>,
pub text: String, pub text: String,
pub sequence: bool, 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 { impl MessageContentToolCalls {
@@ -224,6 +234,7 @@ impl MessageContentToolCalls {
tool_results, tool_results,
text, text,
sequence: false, sequence: false,
round_starts: vec![],
} }
} }
@@ -233,6 +244,9 @@ impl MessageContentToolCalls {
{ {
first.text = Some(text); 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.tool_results.extend(tool_results);
self.sequence = true; self.sequence = true;
} }
@@ -358,4 +372,56 @@ mod tests {
let restored: Vec<ThinkingBlock> = serde_yaml::from_str(&yaml).unwrap(); let restored: Vec<ThinkingBlock> = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(serde_json::to_value(&restored).unwrap(), json); 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]);
}
} }
+92
View File
@@ -557,4 +557,96 @@ reasoning_levels: [none, low, medium, high]
"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" "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");
}
} }
+4 -23
View File
@@ -1457,13 +1457,9 @@ echo_pkce_in_token_exchange: true
ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig { ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig {
name: Some(name.into()), name: Some(name.into()),
api_base: Some("https://api.example/v1".into()), api_base: Some("https://api.example/v1".into()),
api_key: None,
auth: auth.map(str::to_string), auth: auth.map(str::to_string),
oauth: oauth.map(Box::new), oauth: oauth.map(Box::new),
wire_api: None, ..Default::default()
models: vec![],
patch: None,
extra: None,
}) })
} }
@@ -1474,14 +1470,9 @@ echo_pkce_in_token_exchange: true
) -> ClientConfig { ) -> ClientConfig {
ClientConfig::ClaudeConfig(ClaudeConfig { ClientConfig::ClaudeConfig(ClaudeConfig {
name: Some(name.into()), name: Some(name.into()),
api_key: None,
api_base: None,
auth: auth.map(str::to_string), auth: auth.map(str::to_string),
oauth: oauth.map(Box::new), oauth: oauth.map(Box::new),
models: vec![], ..Default::default()
prompt_cache: None,
patch: None,
extra: None,
}) })
} }
@@ -1492,15 +1483,9 @@ echo_pkce_in_token_exchange: true
) -> ClientConfig { ) -> ClientConfig {
ClientConfig::OpenAIConfig(OpenAIConfig { ClientConfig::OpenAIConfig(OpenAIConfig {
name: Some(name.into()), name: Some(name.into()),
api_key: None,
api_base: None,
organization_id: None,
auth: auth.map(str::to_string), auth: auth.map(str::to_string),
oauth: oauth.map(Box::new), oauth: oauth.map(Box::new),
wire_api: None, ..Default::default()
models: vec![],
patch: None,
extra: None,
}) })
} }
@@ -1511,13 +1496,9 @@ echo_pkce_in_token_exchange: true
) -> ClientConfig { ) -> ClientConfig {
ClientConfig::GeminiConfig(GeminiConfig { ClientConfig::GeminiConfig(GeminiConfig {
name: Some(name.into()), name: Some(name.into()),
api_key: None,
api_base: None,
auth: auth.map(str::to_string), auth: auth.map(str::to_string),
oauth: oauth.map(Box::new), oauth: oauth.map(Box::new),
models: vec![], ..Default::default()
patch: None,
extra: None,
}) })
} }
+36 -7
View File
@@ -24,6 +24,8 @@ pub struct OpenAIConfig {
pub wire_api: Option<WireApi>, pub wire_api: Option<WireApi>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>, pub extra: Option<ExtraConfig>,
} }
@@ -481,6 +483,7 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
tool_results, tool_results,
text, text,
sequence, sequence,
..
}) => { }) => {
// Empty tool_results (reachable via deserialized sessions) must not emit empty tool_calls. // Empty tool_results (reachable via deserialized sessions) must not emit empty tool_calls.
if tool_results.is_empty() { if tool_results.is_empty() {
@@ -681,7 +684,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
MessageContent::ToolCalls(MessageContentToolCalls { MessageContent::ToolCalls(MessageContentToolCalls {
tool_results, tool_results,
text, text,
sequence: _, ..
}) => tool_results }) => tool_results
.into_iter() .into_iter()
.enumerate() .enumerate()
@@ -868,17 +871,22 @@ fn reasoning_thinking_block(item: &Value) -> Option<ThinkingBlock> {
} }
/// OpenAI reports cached input under `input_tokens_details` and has no /// OpenAI reports cached input under `input_tokens_details` and has no
/// cache-creation concept, so that field stays `None`. /// cache-creation concept, so that field stays `None`. The reported
/// `input_tokens` total already includes cached reads, so cached tokens are
/// subtracted here to keep the buckets disjoint like other providers.
fn openai_parse_responses_usage(usage: &Value) -> Option<TokenUsage> { fn openai_parse_responses_usage(usage: &Value) -> Option<TokenUsage> {
if !usage.is_object() { if !usage.is_object() {
return None; return None;
} }
let cached_tokens = usage["input_tokens_details"]["cached_tokens"].as_u64();
Some(TokenUsage { Some(TokenUsage {
input_tokens: usage["input_tokens"].as_u64(), input_tokens: usage["input_tokens"]
.as_u64()
.map(|v| v.saturating_sub(cached_tokens.unwrap_or(0))),
output_tokens: usage["output_tokens"].as_u64(), output_tokens: usage["output_tokens"].as_u64(),
cache_creation_input_tokens: None, cache_creation_input_tokens: None,
cache_read_input_tokens: usage["input_tokens_details"]["cached_tokens"].as_u64(), cache_read_input_tokens: cached_tokens,
}) })
} }
@@ -993,6 +1001,7 @@ mod tests {
tool_results: vec![], tool_results: vec![],
text: "leftover text".to_string(), text: "leftover text".to_string(),
sequence, sequence,
round_starts: vec![],
}), }),
), ),
], ],
@@ -1057,6 +1066,7 @@ mod tests {
tool_results, tool_results,
text: "first round".to_string(), text: "first round".to_string(),
sequence: false, sequence: false,
round_starts: vec![],
}), }),
), ),
], ],
@@ -1264,12 +1274,31 @@ mod tests {
let output = openai_extract_responses(&data).unwrap(); let output = openai_extract_responses(&data).unwrap();
let usage = output.usage.unwrap(); let usage = output.usage.unwrap();
assert_eq!(usage.input_tokens, Some(100)); assert_eq!(usage.input_tokens, Some(40));
assert_eq!(usage.output_tokens, Some(20)); assert_eq!(usage.output_tokens, Some(20));
assert_eq!(usage.cache_creation_input_tokens, None); assert_eq!(usage.cache_creation_input_tokens, None);
assert_eq!(usage.cache_read_input_tokens, Some(60)); assert_eq!(usage.cache_read_input_tokens, Some(60));
} }
#[test]
fn extract_responses_usage_without_cached_details_keeps_input_intact() {
let data = json!({
"output": [
{ "type": "message", "content": [{ "type": "output_text", "text": "answer" }] },
],
"usage": {
"input_tokens": 100,
"output_tokens": 20,
}
});
let output = openai_extract_responses(&data).unwrap();
let usage = output.usage.unwrap();
assert_eq!(usage.input_tokens, Some(100));
assert_eq!(usage.cache_read_input_tokens, None);
}
#[test] #[test]
fn extract_responses_without_usage_leaves_it_none() { fn extract_responses_without_usage_leaves_it_none() {
let data = json!({ let data = json!({
@@ -1354,7 +1383,7 @@ mod tests {
assert!(done); assert!(done);
let (_, _, _, usage) = handler.take(); let (_, _, _, usage) = handler.take();
let usage = usage.unwrap(); let usage = usage.unwrap();
assert_eq!(usage.input_tokens, Some(100)); assert_eq!(usage.input_tokens, Some(40));
assert_eq!(usage.output_tokens, Some(20)); assert_eq!(usage.output_tokens, Some(20));
assert_eq!(usage.cache_creation_input_tokens, None); assert_eq!(usage.cache_creation_input_tokens, None);
assert_eq!(usage.cache_read_input_tokens, Some(60)); assert_eq!(usage.cache_read_input_tokens, Some(60));
@@ -1464,7 +1493,7 @@ mod tests {
let (text, _, _, usage) = handler.take(); let (text, _, _, usage) = handler.take();
assert_eq!(text, "partial answer"); assert_eq!(text, "partial answer");
let usage = usage.expect("usage should be recorded for truncated turns"); let usage = usage.expect("usage should be recorded for truncated turns");
assert_eq!(usage.input_tokens, Some(50)); assert_eq!(usage.input_tokens, Some(25));
assert_eq!(usage.output_tokens, Some(10)); assert_eq!(usage.output_tokens, Some(10));
assert_eq!(usage.cache_read_input_tokens, Some(25)); assert_eq!(usage.cache_read_input_tokens, Some(25));
} }
+4 -6
View File
@@ -9,7 +9,7 @@ use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use oauth::OAuthConfig; use oauth::OAuthConfig;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize, Default)]
pub struct OpenAICompatibleConfig { pub struct OpenAICompatibleConfig {
pub name: Option<String>, pub name: Option<String>,
pub api_base: Option<String>, pub api_base: Option<String>,
@@ -19,6 +19,8 @@ pub struct OpenAICompatibleConfig {
pub wire_api: Option<WireApi>, pub wire_api: Option<WireApi>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>, pub extra: Option<ExtraConfig>,
} }
@@ -274,12 +276,8 @@ mod tests {
name: Some("compat-test".into()), name: Some("compat-test".into()),
api_base: Some("https://compat.example/v1".into()), api_base: Some("https://compat.example/v1".into()),
api_key: Some("sk-test".into()), api_key: Some("sk-test".into()),
auth: None,
oauth: None,
wire_api, wire_api,
models: vec![], ..Default::default()
patch: None,
extra: None,
} }
} }
+3
View File
@@ -18,6 +18,8 @@ pub struct VertexAIConfig {
pub adc_file: Option<String>, pub adc_file: Option<String>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>, pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>, pub extra: Option<ExtraConfig>,
@@ -600,6 +602,7 @@ mod tests {
tool_results: vec![], tool_results: vec![],
text: "leftover text".to_string(), text: "leftover text".to_string(),
sequence: false, sequence: false,
round_starts: vec![],
}), }),
), ),
], ],
+348 -22
View File
@@ -1,6 +1,6 @@
use super::*; use super::*;
use crate::hooks::HooksMap; use crate::hooks::{HooksMap, RagSyncHooks};
use crate::{ use crate::{
client::Model, client::Model,
config::memory, config::memory,
@@ -14,11 +14,13 @@ use crate::{
use super::rag_cache::RagKey; use super::rag_cache::RagKey;
use crate::config::builtin_manifest; use crate::config::builtin_manifest;
use crate::config::conflict::{self, InstallMode, StickyMode};
use crate::config::paths; use crate::config::paths;
use crate::config::prompts::{ use crate::config::prompts::{
DEFAULT_JOB_INSTRUCTIONS, DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_JOB_INSTRUCTIONS, DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS,
DEFAULT_TODO_INSTRUCTIONS, DEFAULT_USER_INTERACTION_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS, DEFAULT_USER_INTERACTION_INSTRUCTIONS,
}; };
use crate::function::write_file_atomic;
use crate::graph::types::RagNode; use crate::graph::types::RagNode;
use crate::graph::{Graph, GraphParser, NodeType}; use crate::graph::{Graph, GraphParser, NodeType};
use crate::mcp::McpServerFeatures; use crate::mcp::McpServerFeatures;
@@ -29,8 +31,8 @@ use fancy_regex::Captures;
use inquire::{Text, validator::Validation}; use inquire::{Text, validator::Validation};
use rust_embed::Embed; use rust_embed::Embed;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeSet; use std::collections::{BTreeMap, BTreeSet};
use std::{env, ffi::OsStr, path::Path}; use std::{env, path::Path};
const DEFAULT_AGENT_NAME: &str = "rag"; const DEFAULT_AGENT_NAME: &str = "rag";
@@ -63,6 +65,101 @@ fn direct_hook_name(rest: &str) -> Option<&str> {
(!name.is_empty() && !name.contains('/')).then_some(name) (!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)] #[derive(Debug, Clone)]
pub struct Agent { pub struct Agent {
name: String, name: String,
@@ -81,28 +178,32 @@ pub struct Agent {
} }
impl Agent { impl Agent {
pub fn install_builtin_agents(force: bool) -> Result<()> { pub fn install_builtin_agents(mode: InstallMode) -> Result<()> {
info!( info!(
"Installing built-in agents in {}", "Installing built-in agents in {}",
paths::agents_data_dir().display() paths::agents_data_dir().display()
); );
let mut sticky = StickyMode::None;
let mut written_hooks: HashMap<String, BTreeSet<String>> = HashMap::new(); let mut written_hooks: HashMap<String, BTreeSet<String>> = HashMap::new();
for file in AgentAssets::iter() { for file in AgentAssets::iter() {
debug!("Processing agent file: {}", file.as_ref()); debug!("Processing agent file: {}", file.as_ref());
let embedded_file = AgentAssets::get(&file) let embedded_file = AgentAssets::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded agent file: {}", file.as_ref()))?; .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_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!( debug!(
"Agent file already exists, skipping: {}", "Agent file already exists, skipping: {}",
file_path.display() file_path.display()
@@ -114,18 +215,13 @@ impl Agent {
info!("Creating agent file: {}", file_path.display()); info!("Creating agent file: {}", file_path.display());
let mut agent_file = File::create(&file_path)?; let mut agent_file = File::create(&file_path)?;
agent_file.write_all(content.as_bytes())?; agent_file.write_all(content.as_bytes())?;
set_executable_bit_if_script(&file_path)?;
if let Some((agent, hook)) = parse_direct_hook_path(file.as_ref()) { if let Some((agent, hook)) = parse_direct_hook_path(file.as_ref()) {
written_hooks written_hooks
.entry(agent.to_string()) .entry(agent.to_string())
.or_default() .or_default()
.insert(hook.to_string()); .insert(hook.to_string());
} }
#[cfg(unix)]
if is_script {
use std::{fs, os::unix::fs::PermissionsExt};
fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755))?;
}
} }
let mut bundled_files: HashMap<String, HashSet<String>> = HashMap::new(); let mut bundled_files: HashMap<String, HashSet<String>> = HashMap::new();
@@ -179,6 +275,32 @@ impl Agent {
} }
} }
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(()) Ok(())
} }
@@ -234,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 rag = if rag_path.exists() {
let key = RagKey::Agent(name.to_string()); let key = RagKey::Agent(name.to_string());
let app_clone = app.clone(); let app_clone = app.clone();
@@ -266,6 +395,7 @@ impl Agent {
let app_clone = app.clone(); let app_clone = app.clone();
let rag_path_clone = rag_path.clone(); let rag_path_clone = rag_path.clone();
let abort = abort_signal.clone(); let abort = abort_signal.clone();
let sync_hooks = rag_sync_hooks.clone();
let rag = app_state let rag = app_state
.rag_cache .rag_cache
.load_with(key, || async move { .load_with(key, || async move {
@@ -276,6 +406,7 @@ impl Agent {
&document_paths, &document_paths,
abort, abort,
true, true,
sync_hooks,
) )
.await .await
}) })
@@ -299,6 +430,7 @@ impl Agent {
&loaders, &loaders,
info_flag, info_flag,
abort_signal.clone(), abort_signal.clone(),
&rag_sync_hooks,
) )
.await? .await?
} }
@@ -1019,10 +1151,13 @@ impl AgentConfig {
if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) { if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) {
self.reasoning_effort = v; self.reasoning_effort = v;
} }
if let Ok(v) = env::var(with_prefix("global_tools")) if let Ok(v) = env::var(with_prefix("global_tools")) {
&& let Ok(v) = serde_json::from_str(&v) match serde_json::from_str(&v) {
{ Ok(v) => self.global_tools = 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")) { if let Ok(v) = env::var(with_prefix("global_hooks")) {
match serde_json::from_str(&v) { match serde_json::from_str(&v) {
@@ -1178,6 +1313,7 @@ async fn init_graph_rags(
loaders: &HashMap<String, String>, loaders: &HashMap<String, String>,
info_flag: bool, info_flag: bool,
abort_signal: AbortSignal, abort_signal: AbortSignal,
sync_hooks: &RagSyncHooks,
) -> Result<HashMap<String, Arc<Rag>>> { ) -> Result<HashMap<String, Arc<Rag>>> {
let mut rags = HashMap::new(); let mut rags = HashMap::new();
if info_flag { if info_flag {
@@ -1254,6 +1390,7 @@ async fn init_graph_rags(
let path_clone = rag_path.clone(); let path_clone = rag_path.clone();
let name_clone = node_id.clone(); let name_clone = node_id.clone();
let abort = abort_signal.clone(); let abort = abort_signal.clone();
let sync_hooks = sync_hooks.clone();
app_state app_state
.rag_cache .rag_cache
.load_with(key, || async move { .load_with(key, || async move {
@@ -1264,6 +1401,7 @@ async fn init_graph_rags(
&document_paths, &document_paths,
&config, &config,
abort, abort,
sync_hooks,
) )
.await .await
}) })
@@ -1361,6 +1499,7 @@ pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::testing;
#[test] #[test]
fn parse_direct_hook_path_accepts_only_direct_hook_children() { fn parse_direct_hook_path_accepts_only_direct_hook_children() {
@@ -1472,6 +1611,33 @@ global_hooks:
} }
} }
#[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] #[test]
fn from_graph_carries_hooks_and_global_hooks() { fn from_graph_carries_hooks_and_global_hooks() {
let yaml = "\ let yaml = "\
@@ -1896,4 +2062,164 @@ nodes: {}
); );
assert_eq!(output, expected); 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());
}
} }
+33
View File
@@ -71,6 +71,7 @@ pub struct AppConfig {
pub compression_threshold: usize, pub compression_threshold: usize,
pub compression_keep_last: usize, pub compression_keep_last: usize,
pub compression_model: Option<String>, pub compression_model: Option<String>,
pub compression_safety_valve: f32,
pub summarization_prompt: Option<String>, pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>, pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>, pub max_tool_result_chars: Option<usize>,
@@ -164,6 +165,7 @@ impl Default for AppConfig {
compression_threshold: 4000, compression_threshold: 4000,
compression_keep_last: 0, compression_keep_last: 0,
compression_model: None, compression_model: None,
compression_safety_valve: 0.8,
summarization_prompt: None, summarization_prompt: None,
summary_context_prompt: None, summary_context_prompt: None,
max_tool_result_chars: None, max_tool_result_chars: None,
@@ -210,6 +212,18 @@ impl Default for AppConfig {
impl AppConfig { impl AppConfig {
pub fn from_config(config: super::Config) -> Result<Self> { 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 { let mut app_config = Self {
model_id: config.model_id, model_id: config.model_id,
temperature: config.temperature, temperature: config.temperature,
@@ -258,6 +272,7 @@ impl AppConfig {
compression_threshold: config.compression_threshold, compression_threshold: config.compression_threshold,
compression_keep_last: config.compression_keep_last, compression_keep_last: config.compression_keep_last,
compression_model: config.compression_model, compression_model: config.compression_model,
compression_safety_valve: config.compression_safety_valve,
summarization_prompt: config.summarization_prompt, summarization_prompt: config.summarization_prompt,
summary_context_prompt: config.summary_context_prompt, summary_context_prompt: config.summary_context_prompt,
max_tool_result_chars: config.max_tool_result_chars, max_tool_result_chars: config.max_tool_result_chars,
@@ -306,7 +321,13 @@ impl AppConfig {
} }
app_config.setup_document_loaders(); app_config.setup_document_loaders();
app_config.setup_user_agent(); app_config.setup_user_agent();
if require_model {
app_config.resolve_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()?; app_config.validate_reasoning_effort()?;
Ok(app_config) Ok(app_config)
} }
@@ -1269,6 +1290,18 @@ mod tests {
assert!(result.is_err()); 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] #[test]
fn resolve_model_keeps_explicit_model_id() { fn resolve_model_keeps_explicit_model_id() {
let mut app = AppConfig { let mut app = AppConfig {
+6 -1
View File
@@ -43,6 +43,7 @@ impl AppState {
log_path: Option<PathBuf>, log_path: Option<PathBuf>,
start_mcp_servers: bool, start_mcp_servers: bool,
lenient_vault: bool, lenient_vault: bool,
inspection: bool,
abort_signal: AbortSignal, abort_signal: AbortSignal,
) -> Result<Self> { ) -> Result<Self> {
let vault = if lenient_vault { 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 { if !mcp_registry.is_empty() && config.mcp_server_support {
functions.append_mcp_meta_functions(mcp_registry.server_features()); functions.append_mcp_meta_functions(mcp_registry.server_features());
} }
+106 -1
View File
@@ -23,6 +23,22 @@ pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
.eq_ignore_ascii_case(BUILTIN_MANIFEST_FILE) .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: /// Reconciles `dir` against the currently shipped set of builtin filenames:
/// a file is removed ONLY if it appears in the previous manifest AND is /// 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 /// absent from `shipped`. The manifest is then rewritten to the names this
@@ -83,7 +99,7 @@ fn read_manifest(dir: &Path) -> BTreeSet<String> {
if line.is_empty() || is_builtin_manifest_name(line) { if line.is_empty() || is_builtin_manifest_name(line) {
return false; return false;
} }
if !is_plain_file_name(line) { if !is_plain_file_name(line) || is_ntfs_short_name_alias(line) {
debug!( debug!(
"Ignoring suspicious builtin manifest entry in {}: {line:?}", "Ignoring suspicious builtin manifest entry in {}: {line:?}",
path.display() path.display()
@@ -290,4 +306,93 @@ mod tests {
assert!(!is_builtin_manifest_name(".builtin-manifesto")); assert!(!is_builtin_manifest_name(".builtin-manifesto"));
assert!(!is_builtin_manifest_name("notify.sh")); 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);
}
} }
+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( pub async fn from_files(
ctx: &RequestContext, ctx: &RequestContext,
raw_text: &str, 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> { pub fn session_mut<'a>(&self, session: &'a mut Option<Session>) -> Option<&'a mut Session> {
if self.with_session { if self.with_session {
session.as_mut() session.as_mut()
@@ -660,6 +696,60 @@ mod tests {
assert!(!with_session); 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] #[test]
fn resolve_paths_detects_last_reply_syntax() { fn resolve_paths_detects_last_reply_syntax() {
let loaders = HashMap::new(); let loaders = HashMap::new();
@@ -1023,6 +1113,7 @@ mod tests {
assert!(!tool_calls.sequence); assert!(!tool_calls.sequence);
assert_eq!(tool_calls.tool_results.len(), 1); assert_eq!(tool_calls.tool_results.len(), 1);
assert!(tool_calls.tool_results[0].text.is_none()); assert!(tool_calls.tool_results[0].text.is_none());
assert!(tool_calls.round_starts.is_empty());
} }
#[test] #[test]
@@ -1044,6 +1135,29 @@ mod tests {
tool_calls.tool_results[1].text, tool_calls.tool_results[1].text,
Some("second text".to_string()) 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] #[test]
+28 -89
View File
@@ -3,9 +3,10 @@ use super::bundles::{
hash_file, hash_file,
}; };
use crate::config::builtin_manifest::is_builtin_manifest_name; use crate::config::builtin_manifest::is_builtin_manifest_name;
use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths}; use crate::config::conflict::{ConflictAction, NonInteractive, StickyMode, resolve_conflict};
#[cfg(not(windows))] use crate::config::{
use crate::function::Language; AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths, set_executable_bit_if_script,
};
use crate::mcp::{McpServer, McpServersConfig}; use crate::mcp::{McpServer, McpServersConfig};
use crate::utils; use crate::utils;
use crate::utils::IS_STDOUT_TERMINAL; use crate::utils::IS_STDOUT_TERMINAL;
@@ -1857,18 +1858,12 @@ fn print_plan_summary(plan: &InstallPlan) {
} }
/// Bundle-shipped hook scripts execute on the user's machine once wired into /// Bundle-shipped hook scripts execute on the user's machine once wired into
/// config, so every planned file landing under a hooks/ directory is called /// config, so every planned file landing in a hook location is called out
/// out individually. Visibility only: installing them needs no extra approval. /// individually. Visibility only: installing them needs no extra approval.
fn hook_script_lines(plan: &InstallPlan) -> Vec<String> { fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
plan.files plan.files
.iter() .iter()
.filter(|planned| { .filter(|planned| is_hook_location(planned))
planned.top_category == TopCategory::Hooks
|| planned
.rel
.parent()
.is_some_and(|parent| parent.components().any(|c| c.as_os_str() == "hooks"))
})
.map(|planned| { .map(|planned| {
let rel = planned.rel.to_string_lossy().replace('\\', "/"); let rel = planned.rel.to_string_lossy().replace('\\', "/");
format!( format!(
@@ -1879,6 +1874,16 @@ fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
.collect() .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 { fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize {
plan.files plan.files
.iter() .iter()
@@ -1886,18 +1891,6 @@ fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize
.count() .count()
} }
#[derive(Debug, Clone, Copy, PartialEq)]
enum StickyMode {
None,
KeepAll,
ReplaceAll,
}
enum ConflictAction {
Keep,
Replace,
}
#[derive(Debug)] #[derive(Debug)]
struct ApplyReport { struct ApplyReport {
new_count: usize, new_count: usize,
@@ -1937,7 +1930,12 @@ fn apply_plan(
record_written_file(store, bundle, planned, FileAction::Replaced)?; record_written_file(store, bundle, planned, FileAction::Replaced)?;
report.refreshed_count += 1; 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::Keep => report.kept_count += 1,
ConflictAction::Replace => { ConflictAction::Replace => {
write_file(&planned.src, &planned.dst)?; write_file(&planned.src, &planned.dst)?;
@@ -2037,50 +2035,6 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo
store.record_mcp_servers(bundle, entries) 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<()> { fn write_file(src: &Path, dst: &Path) -> Result<()> {
if let Some(parent) = dst.parent() { if let Some(parent) = dst.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
@@ -2092,26 +2046,6 @@ fn write_file(src: &Path, dst: &Path) -> Result<()> {
Ok(()) 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)] #[derive(Debug)]
struct McpMergeReport { struct McpMergeReport {
added: Vec<String>, added: Vec<String>,
@@ -2924,6 +2858,11 @@ mod tests {
file("x/config.yaml", TopCategory::Agents), file("x/config.yaml", TopCategory::Agents),
file("reviewer.md", TopCategory::Roles), file("reviewer.md", TopCategory::Roles),
file("hello.yaml", TopCategory::Macros), 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, mcp_json: None,
}; };
+14 -50
View File
@@ -83,6 +83,7 @@ pub async fn macro_execute(
.bootstrap_tools(app.as_ref(), true, abort_signal.clone()) .bootstrap_tools(app.as_ref(), true, abort_signal.clone())
.await?; .await?;
let result = async {
for step in &macro_value.steps { for step in &macro_value.steps {
let command = Macro::interpolate_command(step, &variables); let command = Macro::interpolate_command(step, &variables);
println!(">> {}", multiline_text(&command)); println!(">> {}", multiline_text(&command));
@@ -90,6 +91,10 @@ pub async fn macro_execute(
} }
Ok(()) Ok(())
} }
.await;
macro_ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
result
}
struct MacroModeGuard<'a> { struct MacroModeGuard<'a> {
ctx: &'a mut RequestContext, ctx: &'a mut RequestContext,
@@ -167,7 +172,8 @@ impl Macro {
debug!("Processing macro file: {}", file.as_ref()); debug!("Processing macro file: {}", file.as_ref());
let embedded_file = MacroAssets::get(&file) let embedded_file = MacroAssets::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded macro file: {}", file.as_ref()))?; .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()); let file_path = paths::macros_dir().join(file.as_ref());
if file_path.exists() && !force { if file_path.exists() && !force {
@@ -350,56 +356,14 @@ fn default_true() -> bool {
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{AppState, Session, WorkingMode}; use crate::config::{AppState, Session, WorkingMode};
use crate::testing::TestConfigDirGuard;
use crate::utils::{create_abort_signal, get_env_name}; use crate::utils::{create_abort_signal, get_env_name};
use serial_test::serial; use serial_test::serial;
use std::fs::{create_dir_all, remove_dir_all, write}; use std::fs::{create_dir_all, remove_dir_all, write};
use std::future::Future; use std::future::Future;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use std::{env, str}; 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 { fn test_ctx() -> RequestContext {
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd) RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
} }
@@ -974,7 +938,7 @@ variables:
#[test] #[test]
#[serial] #[serial]
fn non_isolated_steps_run_on_live_ctx_and_mutations_persist() { fn non_isolated_steps_run_on_live_ctx_and_mutations_persist() {
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file( write_macro_file(
"live-macro", "live-macro",
"isolated: false\nsteps:\n - \".set temperature 0.42\"\n", "isolated: false\nsteps:\n - \".set temperature 0.42\"\n",
@@ -1006,7 +970,7 @@ variables:
#[test] #[test]
#[serial] #[serial]
fn non_isolated_step_failure_aborts_and_restores_flag_and_mode() { fn non_isolated_step_failure_aborts_and_restores_flag_and_mode() {
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file( write_macro_file(
"fail-macro", "fail-macro",
"isolated: false\nsteps:\n - \".set temperature 0.9\"\n - \".update\"\n - \".set temperature 0.1\"\n", "isolated: false\nsteps:\n - \".set temperature 0.9\"\n - \".update\"\n - \".set temperature 0.1\"\n",
@@ -1057,7 +1021,7 @@ variables:
#[test] #[test]
#[serial] #[serial]
fn non_isolated_macro_step_invoking_macro_is_rejected() { fn non_isolated_macro_step_invoking_macro_is_rejected() {
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file( write_macro_file(
"outer-macro", "outer-macro",
"isolated: false\nsteps:\n - \".inner-macro\"\n", "isolated: false\nsteps:\n - \".inner-macro\"\n",
@@ -1093,7 +1057,7 @@ variables:
#[test] #[test]
#[serial] #[serial]
fn isolated_macro_step_runs_non_isolated_macro_inline_on_fork() { 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("iso-outer-macro", "steps:\n - \".inner-macro\"\n");
write_macro_file( write_macro_file(
"inner-macro", "inner-macro",
@@ -1122,7 +1086,7 @@ variables:
#[test] #[test]
#[serial] #[serial]
fn isolated_macro_still_forks_and_leaves_live_ctx_untouched() { 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"); write_macro_file("iso-macro", "steps:\n - \".set temperature 0.77\"\n");
let mut ctx = test_ctx(); let mut ctx = test_ctx();
ctx.session = Some(Session::default()); ctx.session = Some(Session::default());
@@ -1152,7 +1116,7 @@ variables:
#[test] #[test]
#[serial] #[serial]
fn guard_restores_prior_flag_values_after_inline_run() { fn guard_restores_prior_flag_values_after_inline_run() {
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("macros-tests");
write_macro_file( write_macro_file(
"inner-macro", "inner-macro",
"isolated: false\nsteps:\n - \".set temperature 0.11\"\n", "isolated: false\nsteps:\n - \".set temperature 0.11\"\n",
+246 -27
View File
@@ -1,5 +1,6 @@
use crate::hooks::McpServerHooks;
use crate::mcp::{ use crate::mcp::{
ConnectedServer, JsonField, McpAuthRequired, McpServer, McpTransportType, ConnectedServer, HttpAuth, JsonField, McpAuthRequired, McpServer, McpTransportType,
is_auth_required_error, resolve_http_auth, spawn_mcp_server, is_auth_required_error, resolve_http_auth, spawn_mcp_server,
}; };
@@ -80,11 +81,6 @@ pub struct McpFactory {
} }
impl 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>) { pub fn insert_active(&self, key: McpServerKey, handle: &Arc<ConnectedServer>) {
let mut map = self.active.lock(); let mut map = self.active.lock();
map.insert(key, Arc::downgrade(handle)); map.insert(key, Arc::downgrade(handle));
@@ -95,15 +91,47 @@ impl McpFactory {
name: &str, name: &str,
spec: &McpServer, spec: &McpServer,
log_path: Option<&Path>, log_path: Option<&Path>,
hooks: &McpServerHooks,
) -> Result<Arc<ConnectedServer>> { ) -> Result<Arc<ConnectedServer>> {
let key = McpServerKey::from_spec(name, spec); let key = McpServerKey::from_spec(name, spec);
if let Some(existing) = self.try_get_active(&key) { let reconnect = {
let map = self.active.lock();
if let Some(existing) = map.get(&key).and_then(|weak| weak.upgrade()) {
return Ok(existing); 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; let (auth, auth_reason) = resolve_http_auth(name, spec).await;
let handle = spawn_mcp_server(spec, log_path, auth).await.map_err(|e| { spawn_transport(spec, log_path, auth).await.map_err(|e| {
if is_auth_required_error(&e) { if is_auth_required_error(&e) {
e.context(McpAuthRequired { e.context(McpAuthRequired {
server: name.to_string(), server: name.to_string(),
@@ -112,17 +140,39 @@ impl McpFactory {
} else { } else {
e e
} }
})?; })
self.insert_active(key, &handle);
Ok(handle)
} }
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 crate::mcp::{JsonField, McpServer, McpTransportType};
use anyhow::anyhow;
use indexmap::IndexMap; use indexmap::IndexMap;
use rmcp::ServiceExt;
use serial_test::serial;
use std::collections::HashMap; use std::collections::HashMap;
fn stdio_spec( 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] #[test]
fn factory_default_has_empty_active_map() { fn factory_default_has_empty_active_map() {
let factory = McpFactory::default(); let factory = McpFactory::default();
let map = factory.active.lock(); let map = factory.active.lock();
assert!(map.is_empty()); 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")
);
}
} }
+244 -26
View File
@@ -3,6 +3,7 @@ mod app_config;
mod app_state; mod app_state;
pub(crate) mod builtin_manifest; pub(crate) mod builtin_manifest;
mod bundles; mod bundles;
pub(crate) mod conflict;
mod input; mod input;
mod install_remote; mod install_remote;
pub(crate) mod instructions; pub(crate) mod instructions;
@@ -37,6 +38,7 @@ pub use self::app_config::AppConfig;
pub use self::app_state::AppState; pub use self::app_state::AppState;
pub(crate) use self::bundles::installed_bundle_names; pub(crate) use self::bundles::installed_bundle_names;
pub use self::bundles::list_installed_bundles; pub use self::bundles::list_installed_bundles;
use self::conflict::{InstallMode, StickyMode};
pub use self::input::Input; pub use self::input::Input;
pub use self::install_remote::{ pub use self::install_remote::{
DEFAULT_GIT_HOST, install_or_update, install_or_update_from_repl_args, uninstall_bundle, DEFAULT_GIT_HOST, install_or_update, install_or_update_from_repl_args, uninstall_bundle,
@@ -56,7 +58,7 @@ pub use self::request_context::{
pub use self::role::{ pub use self::role::{
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_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)] #[allow(unused_imports)]
pub use self::skill::Skill; pub use self::skill::Skill;
#[allow(unused_imports)] #[allow(unused_imports)]
@@ -181,7 +183,7 @@ const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json"; const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; 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_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
@@ -284,6 +286,7 @@ pub struct Config {
pub compression_threshold: usize, pub compression_threshold: usize,
pub compression_keep_last: usize, pub compression_keep_last: usize,
pub compression_model: Option<String>, pub compression_model: Option<String>,
pub compression_safety_valve: f32,
pub summarization_prompt: Option<String>, pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>, pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>, pub max_tool_result_chars: Option<usize>,
@@ -374,6 +377,7 @@ impl Default for Config {
compression_threshold: 4000, compression_threshold: 4000,
compression_keep_last: 0, compression_keep_last: 0,
compression_model: None, compression_model: None,
compression_safety_valve: 0.8,
summarization_prompt: None, summarization_prompt: None,
summary_context_prompt: None, summary_context_prompt: None,
max_tool_result_chars: None, max_tool_result_chars: None,
@@ -417,12 +421,13 @@ impl Default for Config {
} }
pub fn install_builtins() -> Result<()> { pub fn install_builtins() -> Result<()> {
hooks::sweep_stale_payload_files();
Functions::install_builtin_global_tools(false)?; Functions::install_builtin_global_tools(false)?;
Agent::install_builtin_agents(false)?; Agent::install_builtin_agents(InstallMode::Skip)?;
Macro::install_macros(false)?; Macro::install_macros(false)?;
Skill::install_builtin_skills(false)?; Skill::install_builtin_skills(false)?;
hooks::install_builtin_hooks(false)?; hooks::install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None)?;
Role::install_builtin_role_hooks(false)?; Role::install_builtin_role_hooks(InstallMode::Skip, &mut StickyMode::None)?;
Ok(()) Ok(())
} }
@@ -519,11 +524,16 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
} }
match category { match category {
AssetCategory::Agents => Agent::install_builtin_agents(true)?, AssetCategory::Agents => Agent::install_builtin_agents(InstallMode::Force)?,
AssetCategory::Macros => Macro::install_macros(true)?, AssetCategory::Macros => Macro::install_macros(true)?,
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?, AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
AssetCategory::Skills => Skill::install_builtin_skills(true)?, AssetCategory::Skills => Skill::install_builtin_skills(true)?,
AssetCategory::Hooks => hooks::install_builtin_hooks(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()?, AssetCategory::McpConfig => Functions::install_mcp_config()?,
} }
@@ -544,6 +554,14 @@ fn confirm_asset_overwrite(category: AssetCategory, label: &str, target: &Path)
references) are left untouched.", references) are left untouched.",
target.display() 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!( _ => format!(
"Reinstalling bundled {label} overwrites every bundled {label} in \ "Reinstalling bundled {label} overwrites every bundled {label} in \
{} with this build's packaged versions. Local changes to bundled \ {} with this build's packaged versions. Local changes to bundled \
@@ -565,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 /// 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 /// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
/// APIs merged with the embedded quirks. /// APIs merged with the embedded quirks.
@@ -729,11 +743,17 @@ pub fn maybe_spawn_models_refresh(app: &AppConfig) {
} }
impl Config { 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(); let config_path = paths::config_file();
if env::var_os(SANDBOX_ENV_FLAG).is_some() { if env::var_os(SANDBOX_ENV_FLAG).is_some() {
if !config_path.exists() { if !config_path.exists() {
if inspection {
return Ok(Self::default());
}
create_config_file(&config_path).await?; create_config_file(&config_path).await?;
} }
@@ -748,6 +768,9 @@ impl Config {
{ {
Some(v) => (Self::load_dynamic(&v)?, String::new()), Some(v) => (Self::load_dynamic(&v)?, String::new()),
None => { None => {
if inspection {
return Ok(Self::default());
}
if *IS_STDOUT_TERMINAL { if *IS_STDOUT_TERMINAL {
create_config_file(&config_path).await?; create_config_file(&config_path).await?;
} }
@@ -758,7 +781,7 @@ impl Config {
Self::load_from_file(&config_path)? Self::load_from_file(&config_path)?
}; };
if info_flag { if skip_interpolation {
return Ok(config); return Ok(config);
} }
@@ -1120,6 +1143,32 @@ pub(crate) fn ensure_parent_exists(path: &Path) -> Result<()> {
Ok(()) 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>> fn read_env_value<T>(key: &str) -> Option<Option<T>>
where where
T: std::str::FromStr, T: std::str::FromStr,
@@ -1328,6 +1377,7 @@ clients:
assert!(cfg.mcp_server_support); assert!(cfg.mcp_server_support);
assert_eq!(cfg.compression_threshold, 4000); assert_eq!(cfg.compression_threshold, 4000);
assert!(cfg.compression_model.is_none()); assert!(cfg.compression_model.is_none());
assert_eq!(cfg.compression_safety_valve, 0.8);
assert_eq!(cfg.rag_top_k, 5); assert_eq!(cfg.rag_top_k, 5);
assert!(cfg.save_shell_history); assert!(cfg.save_shell_history);
assert_eq!(cfg.keybindings, "emacs"); assert_eq!(cfg.keybindings, "emacs");
@@ -1368,6 +1418,7 @@ clients:
assert!(cfg.mapping_tools.is_empty()); assert!(cfg.mapping_tools.is_empty());
assert!(cfg.document_loaders.is_empty()); assert!(cfg.document_loaders.is_empty());
assert_eq!(cfg.compression_threshold, 4000); assert_eq!(cfg.compression_threshold, 4000);
assert_eq!(cfg.compression_safety_valve, 0.8);
assert!(cfg.theme.is_none()); assert!(cfg.theme.is_none());
assert_eq!(cfg.clients.len(), 1); assert_eq!(cfg.clients.len(), 1);
} }
@@ -1593,12 +1644,12 @@ hooks:
use std::fs; use std::fs;
use std::time; use std::time;
let unique = time::SystemTime::now() let unique = SystemTime::now()
.duration_since(time::UNIX_EPOCH) .duration_since(time::UNIX_EPOCH)
.unwrap() .unwrap()
.as_nanos(); .as_nanos();
let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}")); let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
fs::create_dir_all(&tmp_dir).unwrap(); create_dir_all(&tmp_dir).unwrap();
let config_path = tmp_dir.join("config.yaml"); let config_path = tmp_dir.join("config.yaml");
fs::write( fs::write(
@@ -1608,25 +1659,25 @@ hooks:
.unwrap(); .unwrap();
let config_env = get_env_name("config_file"); let config_env = get_env_name("config_file");
let prev_config = std::env::var_os(&config_env); let prev_config = env::var_os(&config_env);
let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG); let prev_sandbox = env::var_os(SANDBOX_ENV_FLAG);
unsafe { unsafe {
std::env::set_var(&config_env, &config_path); env::set_var(&config_env, &config_path);
std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1"); 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(); let (_, raw) = Config::load_from_file(&config_path).unwrap();
unsafe { unsafe {
match prev_config { match prev_config {
Some(v) => std::env::set_var(&config_env, v), Some(v) => env::set_var(&config_env, v),
None => std::env::remove_var(&config_env), None => env::remove_var(&config_env),
} }
match prev_sandbox { match prev_sandbox {
Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v), Some(v) => env::set_var(SANDBOX_ENV_FLAG, v),
None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG), None => env::remove_var(SANDBOX_ENV_FLAG),
} }
} }
let _ = fs::remove_dir_all(&tmp_dir); let _ = fs::remove_dir_all(&tmp_dir);
@@ -1640,6 +1691,60 @@ hooks:
); );
} }
#[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] #[test]
fn unloadable_override_is_stale() { fn unloadable_override_is_stale() {
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24)); assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
@@ -1719,7 +1824,7 @@ hooks:
let written = write_models_override(vec![entry()]).unwrap(); let written = write_models_override(vec![entry()]).unwrap();
assert_eq!(written, path); assert_eq!(written, path);
let mut tmp_name = path.as_os_str().to_os_string(); 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()); assert!(!PathBuf::from(tmp_name).exists());
let loaded = paths::local_models_override().unwrap(); let loaded = paths::local_models_override().unwrap();
assert_eq!(loaded.len(), 1); assert_eq!(loaded.len(), 1);
@@ -1737,4 +1842,117 @@ hooks:
std::panic::resume_unwind(panic); 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");
}
} }
+31 -1
View File
@@ -5,7 +5,7 @@ use super::{
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_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, 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, 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::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME; use crate::config::REPL_HISTORY_DIR_NAME;
@@ -229,6 +229,17 @@ pub fn workspace_macros_dir() -> PathBuf {
workspace_config_dir().join(MACROS_DIR_NAME) 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> { pub fn workspace_mcp_config_file() -> Option<PathBuf> {
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default()) workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
} }
@@ -332,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 { pub fn agent_graph_file(agent_name: &str) -> PathBuf {
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME) agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
} }
@@ -854,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 { mod user_mcp_resolution {
+58
View File
@@ -47,3 +47,61 @@ impl RagCache {
Ok(arc) 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
+112 -16
View File
@@ -2,6 +2,7 @@ use super::*;
use crate::client::{Message, MessageContent, MessageRole, Model}; use crate::client::{Message, MessageContent, MessageRole, Model};
use crate::config::builtin_manifest; use crate::config::builtin_manifest;
use crate::config::conflict::{self, InstallMode, StickyMode};
use crate::function::write_file_atomic; use crate::function::write_file_atomic;
use crate::hooks::HooksMap; use crate::hooks::HooksMap;
@@ -179,7 +180,8 @@ impl Role {
pub fn builtin(name: &str) -> Result<Self> { pub fn builtin(name: &str) -> Result<Self> {
let content = RolesAsset::get(&format!("{name}.md")) let content = RolesAsset::get(&format!("{name}.md"))
.ok_or_else(|| anyhow!("Unknown role `{name}`"))?; .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)) Ok(Role::new(name, content))
} }
@@ -193,7 +195,7 @@ impl Role {
/// reconciles ones a release stopped shipping. This is the only builtin /// reconciles ones a release stopped shipping. This is the only builtin
/// install machinery roles have: general role installation deliberately /// install machinery roles have: general role installation deliberately
/// does not exist (builtin roles load straight from the embed). /// does not exist (builtin roles load straight from the embed).
pub fn install_builtin_role_hooks(force: bool) -> Result<()> { pub fn install_builtin_role_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> {
let shipped: Vec<(String, String)> = RolesAsset::iter() let shipped: Vec<(String, String)> = RolesAsset::iter()
.filter_map(|file| { .filter_map(|file| {
let name = file.as_ref().strip_prefix("hooks/")?; let name = file.as_ref().strip_prefix("hooks/")?;
@@ -201,12 +203,13 @@ impl Role {
return None; return None;
} }
let embedded = RolesAsset::get(file.as_ref())?; let embedded = RolesAsset::get(file.as_ref())?;
let content = unsafe { std::str::from_utf8_unchecked(&embedded.data) }; 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())) Some((name.to_string(), content.to_string()))
}) })
.collect(); .collect();
install_and_reconcile_role_hooks(&paths::roles_dir().join("hooks"), &shipped, force) install_and_reconcile_role_hooks(&paths::roles_dir().join("hooks"), &shipped, mode, sticky)
} }
pub fn has_args(&self) -> bool { pub fn has_args(&self) -> bool {
@@ -641,10 +644,11 @@ fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
/// Only direct children of the hooks/ directory are supported: they are the /// Only direct children of the hooks/ directory are supported: they are the
/// only shape the builtin manifest tracks, so nested asset paths are skipped /// only shape the builtin manifest tracks, so nested asset paths are skipped
/// by the caller rather than installed unreconciled. /// by the caller rather than installed unreconciled.
fn install_and_reconcile_role_hooks( pub(crate) fn install_and_reconcile_role_hooks(
dir: &Path, dir: &Path,
shipped: &[(String, String)], shipped: &[(String, String)],
force: bool, mode: InstallMode,
sticky: &mut StickyMode,
) -> Result<()> { ) -> Result<()> {
if !shipped.is_empty() { if !shipped.is_empty() {
info!("Installing built-in role hooks in {}", dir.display()); info!("Installing built-in role hooks in {}", dir.display());
@@ -652,7 +656,9 @@ fn install_and_reconcile_role_hooks(
let mut written = BTreeSet::new(); let mut written = BTreeSet::new();
for (name, content) in shipped { for (name, content) in shipped {
let path = dir.join(name); let path = dir.join(name);
if path.exists() && !force { if path.exists()
&& !conflict::should_replace_existing(&path, content, "roles", mode, sticky)?
{
debug!( debug!(
"Role hook file already exists, skipping: {}", "Role hook file already exists, skipping: {}",
path.display() path.display()
@@ -661,7 +667,8 @@ fn install_and_reconcile_role_hooks(
} }
ensure_parent_exists(&path)?; ensure_parent_exists(&path)?;
info!("Creating role hook file: {}", path.display()); info!("Creating role hook file: {}", path.display());
write_file_atomic(&path, content, Some(0o755))?; write_file_atomic(&path, content, None)?;
set_executable_bit_if_script(&path)?;
written.insert(name.clone()); written.insert(name.clone());
} }
@@ -1049,7 +1056,12 @@ Input 1
fn role_hooks_install_writes_scripts_and_manifest() { fn role_hooks_install_writes_scripts_and_manifest() {
let dir = hooks_fixture_dir("role-hooks-install-"); let dir = hooks_fixture_dir("role-hooks-install-");
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false) install_and_reconcile_role_hooks(
&dir,
&fixture(&[("a.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap(); .unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n"); assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n");
@@ -1075,6 +1087,41 @@ Input 1
let _ = fs::remove_dir_all(dir.parent().unwrap()); 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] #[test]
fn role_hooks_install_overwrites_only_with_force() { fn role_hooks_install_overwrites_only_with_force() {
let dir = hooks_fixture_dir("role-hooks-force-"); let dir = hooks_fixture_dir("role-hooks-force-");
@@ -1082,14 +1129,50 @@ Input 1
create_dir_all(&dir).unwrap(); create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "SENTINEL").unwrap(); fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
install_and_reconcile_role_hooks(&dir, &shipped, false).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"); assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL");
install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap(); 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"); assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
let _ = fs::remove_dir_all(dir.parent().unwrap()); 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] #[test]
fn role_hooks_reconcile_removes_dropped_hook_and_keeps_user_files() { fn role_hooks_reconcile_removes_dropped_hook_and_keeps_user_files() {
let dir = hooks_fixture_dir("role-hooks-reconcile-"); let dir = hooks_fixture_dir("role-hooks-reconcile-");
@@ -1097,12 +1180,18 @@ Input 1
install_and_reconcile_role_hooks( install_and_reconcile_role_hooks(
&dir, &dir,
&fixture(&[("keep.sh", "#!/bin/sh\n"), ("drop.sh", "#!/bin/sh\n")]), &fixture(&[("keep.sh", "#!/bin/sh\n"), ("drop.sh", "#!/bin/sh\n")]),
false, InstallMode::Skip,
&mut StickyMode::None,
) )
.unwrap(); .unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap(); fs::write(dir.join("user.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(&dir, &fixture(&[("keep.sh", "#!/bin/sh\n")]), false) install_and_reconcile_role_hooks(
&dir,
&fixture(&[("keep.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap(); .unwrap();
assert!( assert!(
@@ -1124,14 +1213,20 @@ Input 1
create_dir_all(&dir).unwrap(); create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "user-owned").unwrap(); fs::write(dir.join("a.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false) install_and_reconcile_role_hooks(
&dir,
&fixture(&[("a.sh", "#!/bin/sh\n")]),
InstallMode::Skip,
&mut StickyMode::None,
)
.unwrap(); .unwrap();
assert!( assert!(
!dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists(), !dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists(),
"a skipped pre-existing file must not be claimed by the manifest" "a skipped pre-existing file must not be claimed by the manifest"
); );
install_and_reconcile_role_hooks(&dir, &[], false).unwrap(); install_and_reconcile_role_hooks(&dir, &[], InstallMode::Skip, &mut StickyMode::None)
.unwrap();
assert_eq!( assert_eq!(
read_to_string(dir.join("a.sh")).unwrap(), read_to_string(dir.join("a.sh")).unwrap(),
"user-owned", "user-owned",
@@ -1144,7 +1239,8 @@ Input 1
fn role_hooks_empty_shipped_set_creates_nothing() { fn role_hooks_empty_shipped_set_creates_nothing() {
let dir = hooks_fixture_dir("role-hooks-empty-"); let dir = hooks_fixture_dir("role-hooks-empty-");
install_and_reconcile_role_hooks(&dir, &[], false).unwrap(); install_and_reconcile_role_hooks(&dir, &[], InstallMode::Skip, &mut StickyMode::None)
.unwrap();
assert!(!dir.exists(), "an empty shipped set must not create hooks/"); assert!(!dir.exists(), "an empty shipped set must not create hooks/");
let _ = fs::remove_dir_all(dir.parent().unwrap()); let _ = fs::remove_dir_all(dir.parent().unwrap());
+328 -12
View File
@@ -12,8 +12,10 @@ use inquire::{Confirm, Text, validator::Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::{read_to_string, write}; use std::fmt;
use std::path::Path; 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; use std::sync::LazyLock;
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap()); static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
@@ -29,6 +31,62 @@ fn cost_is_zero(v: &f64) -> bool {
*v == 0.0 *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)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Session { pub struct Session {
#[serde(rename(serialize = "model", deserialize = "model"))] #[serde(rename(serialize = "model", deserialize = "model"))]
@@ -120,6 +178,8 @@ pub struct Session {
#[serde(skip)] #[serde(skip)]
path: Option<String>, path: Option<String>,
#[serde(skip)] #[serde(skip)]
scope: SessionScope,
#[serde(skip)]
dirty: bool, dirty: bool,
#[serde(skip)] #[serde(skip)]
save_session_this_time: bool, save_session_this_time: bool,
@@ -173,6 +233,7 @@ impl Session {
app: &AppConfig, app: &AppConfig,
name: &str, name: &str,
path: &Path, path: &Path,
scope: SessionScope,
) -> Result<Self> { ) -> Result<Self> {
let content = read_to_string(path) let content = read_to_string(path)
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?; .with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
@@ -180,6 +241,7 @@ impl Session {
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?; serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?; session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
session.scope = scope;
if let Some(autoname) = name.strip_prefix("_/") { if let Some(autoname) = name.strip_prefix("_/") {
session.name = TEMP_SESSION_NAME.to_string(); session.name = TEMP_SESSION_NAME.to_string();
@@ -250,6 +312,14 @@ impl Session {
self.name = name; 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) { pub fn clear_autoname(&mut self) {
self.autoname = None; self.autoname = None;
} }
@@ -313,6 +383,7 @@ impl Session {
pub fn export(&self) -> Result<String> { pub fn export(&self) -> Result<String> {
let mut data = json!({ let mut data = json!({
"path": self.path, "path": self.path,
"scope": self.scope.to_string(),
"model": self.model().id(), "model": self.model().id(),
}); });
if let Some(temperature) = self.temperature() { if let Some(temperature) = self.temperature() {
@@ -403,6 +474,7 @@ impl Session {
if let Some(path) = &self.path { if let Some(path) = &self.path {
items.push(("path", path.to_string())); items.push(("path", path.to_string()));
} }
items.push(("scope", self.scope.to_string()));
if let Some(autoname) = self.autoname() { if let Some(autoname) = self.autoname() {
items.push(("autoname", autoname.to_string())); items.push(("autoname", autoname.to_string()));
@@ -719,17 +791,50 @@ impl Session {
self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1) self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1)
} }
/// Estimated tokens of the messages that compression would actually fold /// Whether the prompt is close enough to the model's hard input limit
/// away: everything except the leading system message (whose content is /// that compression must run regardless of how `compression_threshold`
/// carried into the summary message) and the `keep_last` tail (kept verbatim). /// is configured. Sizes the prompt as the larger of the API-reported
fn reclaimable_tokens(&self, keep_last: usize) -> usize { /// 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( let start = usize::from(
self.messages self.messages
.first() .first()
.is_some_and(|v| v.role == MessageRole::System), .is_some_and(|v| v.role == MessageRole::System),
); );
let end = self.messages.len().saturating_sub(keep_last).max(start); 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 { pub fn compressing(&self) -> bool {
@@ -853,17 +958,52 @@ impl Session {
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> { pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
ensure_parent_exists(session_path)?; ensure_parent_exists(session_path)?;
self.path = Some(session_path.display().to_string());
let content = serde_yaml::to_string(&self) let content = serde_yaml::to_string(&self)
.with_context(|| format!("Failed to serde session '{}'", self.name))?; .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!( format!(
"Failed to write session '{}' to '{}'", "Failed to write session '{}' to '{}'",
self.name, self.name,
session_path.display() 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 { if is_repl {
println!("✓ Saved the session to '{}'.", session_path.display()); println!("✓ Saved the session to '{}'.", session_path.display());
@@ -1109,7 +1249,7 @@ impl AutoName {
mod tests { mod tests {
use super::*; use super::*;
use crate::client::{ 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::config::{AppConfig, AppState, RequestContext, WorkingMode};
use crate::function::{Functions, ToolCall, ToolResult}; use crate::function::{Functions, ToolCall, ToolResult};
@@ -1284,6 +1424,16 @@ mod tests {
); );
} }
#[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] #[test]
fn session_deserialization_ignores_injected_role_hooks() { fn session_deserialization_ignores_injected_role_hooks() {
// A session file is attacker-writable data: a `role_hooks:` key // A session file is attacker-writable data: a `role_hooks:` key
@@ -1323,8 +1473,10 @@ mod tests {
fn session_export_includes_real_usage_only_when_reported() { fn session_export_includes_real_usage_only_when_reported() {
let empty = Session::default().export().unwrap(); let empty = Session::default().export().unwrap();
assert!(!empty.contains("real_input_tokens")); assert!(!empty.contains("real_input_tokens"));
assert!(empty.contains("scope: global"));
let mut session = Session::default(); let mut session = Session::default();
session.set_scope(SessionScope::Workspace);
session.accumulate_token_usage(&TokenUsage { session.accumulate_token_usage(&TokenUsage {
input_tokens: Some(5), input_tokens: Some(5),
output_tokens: Some(7), output_tokens: Some(7),
@@ -1332,12 +1484,97 @@ mod tests {
cache_read_input_tokens: Some(3), cache_read_input_tokens: Some(3),
}); });
let exported = session.export().unwrap(); let exported = session.export().unwrap();
assert!(exported.contains("scope: workspace"));
assert!(exported.contains("real_input_tokens: 5")); assert!(exported.contains("real_input_tokens: 5"));
assert!(exported.contains("real_output_tokens: 7")); assert!(exported.contains("real_output_tokens: 7"));
assert!(exported.contains("cache_read_tokens: 3")); assert!(exported.contains("cache_read_tokens: 3"));
assert!(!exported.contains("cache_creation_tokens")); 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] #[test]
fn session_todo_state_survives_yaml_round_trip() { fn session_todo_state_survives_yaml_round_trip() {
let mut session = Session::default(); let mut session = Session::default();
@@ -1578,6 +1815,85 @@ mod tests {
assert!(session.needs_compression(100, 1)); 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] #[test]
fn session_set_compressing_flag() { fn session_set_compressing_flag() {
let mut session = Session::default(); let mut session = Session::default();
+2 -1
View File
@@ -100,7 +100,8 @@ impl Skill {
let embedded_file = SkillsAsset::get(&file) let embedded_file = SkillsAsset::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded skill file: {}", file.as_ref()))?; .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()); let file_path = paths::skills_dir().join(file.as_ref());
if file_path.exists() && !force { if file_path.exists() && !force {
+7 -7
View File
@@ -918,7 +918,7 @@ mod tests {
use super::*; use super::*;
use crate::config::mcp_tool_policy::LayerSource; use crate::config::mcp_tool_policy::LayerSource;
use crate::function::ToolCall; 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; use std::sync::atomic::Ordering;
#[test] #[test]
@@ -1083,7 +1083,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn catalog_items_warns_when_resource_listing_fails() { async fn catalog_items_warns_when_resource_listing_fails() {
install_warn_collector(); install_log_collector();
let fixture = FixtureServer { let fixture = FixtureServer {
resources_capability: true, resources_capability: true,
fail_resource_listings: true, fail_resource_listings: true,
@@ -1093,7 +1093,7 @@ mod tests {
runtime.catalog_items("fixture").await.unwrap(); runtime.catalog_items("fixture").await.unwrap();
let messages = warn_messages().lock().unwrap(); let messages = warn_snapshot();
assert!( assert!(
messages messages
.iter() .iter()
@@ -1547,7 +1547,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn prompt_catalog_degrades_when_one_server_fails() { async fn prompt_catalog_degrades_when_one_server_fails() {
install_warn_collector(); install_log_collector();
let healthy = FixtureServer { let healthy = FixtureServer {
prompts_capability: true, prompts_capability: true,
..Default::default() ..Default::default()
@@ -1564,7 +1564,7 @@ mod tests {
assert_eq!(items.len(), 1); assert_eq!(items.len(), 1);
assert_eq!(items[0].server, "fixture"); assert_eq!(items[0].server, "fixture");
let messages = warn_messages().lock().unwrap(); let messages = warn_snapshot();
assert!( assert!(
messages messages
.iter() .iter()
@@ -1794,7 +1794,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn catalog_items_warns_on_dead_context_patterns() { 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 runtime, _server) = fixture_runtime(FixtureServer::default()).await;
let mut filter = single_layer_filter(LayerSource::Global, &["*"]); let mut filter = single_layer_filter(LayerSource::Global, &["*"]);
filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]); filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]);
@@ -1802,7 +1802,7 @@ mod tests {
runtime.catalog_items("fixture").await.unwrap(); runtime.catalog_items("fixture").await.unwrap();
let messages = warn_messages().lock().unwrap(); let messages = warn_snapshot();
assert!( assert!(
messages.iter().any(|msg| msg.contains("'zzz_*'") messages.iter().any(|msg| msg.contains("'zzz_*'")
&& msg.contains("session (.set)") && msg.contains("session (.set)")
+159 -46
View File
@@ -4,7 +4,7 @@ use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::{ use crate::config::{
Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike, Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike,
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled, default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
list_agents_with_descriptions, load_agent_variables, paths, list_agents_with_descriptions, load_agent_variables,
}; };
use crate::hooks::{self, HookEvent, ResolvedHook}; use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id}; use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
@@ -15,7 +15,8 @@ use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_use
use crate::graph; use crate::graph;
use crate::repl::DEFAULT_CONTINUATION_PROMPT; use crate::repl::DEFAULT_CONTINUATION_PROMPT;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use chrono::{SecondsFormat, Utc}; use chrono::Utc;
use hooks::McpServerHooks;
use indexmap::IndexMap; use indexmap::IndexMap;
use log::{debug, warn}; use log::{debug, warn};
use parking_lot::RwLock; use parking_lot::RwLock;
@@ -961,10 +962,11 @@ async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[Stri
}) })
.unwrap_or_default(); .unwrap_or_default();
let mcp_hooks = McpServerHooks::resolve(ctx);
for (id, spec) in server_specs { for (id, spec) in server_specs {
let handle = app let handle = app
.mcp_factory .mcp_factory
.acquire(&id, &spec, app.mcp_log_path.as_deref()) .acquire(&id, &spec, app.mcp_log_path.as_deref(), &mcp_hooks)
.await?; .await?;
ctx.tool_scope.mcp_runtime.insert(id, handle); ctx.tool_scope.mcp_runtime.insert(id, handle);
} }
@@ -997,44 +999,15 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
Ok(()) Ok(())
} }
/// Mirrors the base-env contract `hooks::fire` assembles from a live context, /// Pre-resolved `agent.completed`/`agent.interrupted`/`agent.failed`
/// for detached call sites that dispatch pre-resolved hook snapshots after /// snapshots for a spawned agent's result arms, captured from the child
/// the context they were resolved from is gone. The names are captured by /// context before the spawn task takes ownership of it. Resolving against
/// the caller when it takes the snapshot; the timestamp is taken here, at /// the child keeps global hooks gated by the child's own whitelist, the
/// fire time, so it reflects when the event actually happened. /// same gate every other event fired from the child's context goes through.
pub(crate) fn hook_base_envs(
event: HookEvent,
session_name: Option<&str>,
agent_name: Option<&str>,
) -> Vec<(String, String)> {
let mut envs = vec![
("COYOTE_EVENT".to_string(), event.as_str().to_string()),
(
"COYOTE_CONFIG_DIR".to_string(),
paths::config_dir().display().to_string(),
),
(
"COYOTE_EVENT_TIMESTAMP".to_string(),
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
),
];
if let Some(name) = session_name {
envs.push(("COYOTE_SESSION_ID".to_string(), name.to_string()));
}
if let Some(name) = agent_name {
envs.push(("COYOTE_AGENT_NAME".to_string(), name.to_string()));
}
envs
}
/// Pre-resolved `agent.completed`/`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 { struct SpawnResultHooks {
completed: Vec<ResolvedHook>, completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>, failed: Vec<ResolvedHook>,
interrupted: Vec<ResolvedHook>,
session_name: Option<String>, session_name: Option<String>,
agent_id: String, agent_id: String,
agent_name: String, agent_name: String,
@@ -1045,6 +1018,7 @@ impl SpawnResultHooks {
Self { Self {
completed: child_ctx.resolved_hooks(HookEvent::AgentCompleted), completed: child_ctx.resolved_hooks(HookEvent::AgentCompleted),
failed: child_ctx.resolved_hooks(HookEvent::AgentFailed), failed: child_ctx.resolved_hooks(HookEvent::AgentFailed),
interrupted: child_ctx.resolved_hooks(HookEvent::AgentInterrupted),
session_name: child_ctx session_name: child_ctx
.session .session
.as_ref() .as_ref()
@@ -1054,14 +1028,16 @@ impl SpawnResultHooks {
} }
} }
/// Fires `agent.completed`, or `agent.failed` when `error` is given. fn fire(self, error: Option<&str>, child_abort: &AbortSignal) {
fn fire(self, error: Option<&str>) {
let mut extras = vec![ let mut extras = vec![
("COYOTE_AGENT_ID", self.agent_id), ("COYOTE_AGENT_ID", self.agent_id),
("COYOTE_AGENT_NAME", self.agent_name.clone()), ("COYOTE_AGENT_NAME", self.agent_name.clone()),
]; ];
let (event, resolved) = match error { let (event, resolved) = match error {
None => (HookEvent::AgentCompleted, self.completed), None => (HookEvent::AgentCompleted, self.completed),
Some(_) if child_abort.aborted_ctrlc() => {
(HookEvent::AgentInterrupted, self.interrupted)
}
Some(error) => { Some(error) => {
extras.push(("COYOTE_AGENT_ERROR", error.to_string())); extras.push(("COYOTE_AGENT_ERROR", error.to_string()));
(HookEvent::AgentFailed, self.failed) (HookEvent::AgentFailed, self.failed)
@@ -1070,7 +1046,7 @@ impl SpawnResultHooks {
hooks::fire_resolved( hooks::fire_resolved(
event, event,
resolved, resolved,
hook_base_envs(event, self.session_name.as_deref(), Some(&self.agent_name)), hooks::base_envs_parts(event, self.session_name.as_deref(), Some(&self.agent_name)),
&extras, &extras,
None, None,
); );
@@ -1246,6 +1222,7 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let spawn_agent_id = agent_id.clone(); let spawn_agent_id = agent_id.clone();
let spawn_agent_name = agent_name.clone(); let spawn_agent_name = agent_name.clone();
let spawn_abort = child_abort.clone(); let spawn_abort = child_abort.clone();
let hook_abort = child_abort.clone();
let spawn_notifications = Arc::clone(&ctx.notification_queue); let spawn_notifications = Arc::clone(&ctx.notification_queue);
let child_supervisor = child_ctx.supervisor.clone(); let child_supervisor = child_ctx.supervisor.clone();
@@ -1268,8 +1245,8 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
}; };
let success = agent_result.exit_status == AgentExitStatus::Completed; let success = agent_result.exit_status == AgentExitStatus::Completed;
match &agent_result.exit_status { match &agent_result.exit_status {
AgentExitStatus::Completed => result_hooks.fire(None), AgentExitStatus::Completed => result_hooks.fire(None, &hook_abort),
AgentExitStatus::Failed(e) => result_hooks.fire(Some(e)), AgentExitStatus::Failed(e) => result_hooks.fire(Some(e), &hook_abort),
} }
spawn_notifications.push(agent_notification( spawn_notifications.push(agent_notification(
&agent_result.id, &agent_result.id,
@@ -1801,6 +1778,18 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result<Val
let from_agent = request.from_agent_name.clone(); let from_agent = request.from_agent_name.clone();
let question = request.question.clone(); let question = request.question.clone();
let _ = request.reply_tx.send(reply.to_string()); 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!({ Ok(json!({
"status": "ok", "status": "ok",
"message": format!("Reply sent to agent '{from_agent}' for escalation '{escalation_id}'"), "message": format!("Reply sent to agent '{from_agent}' for escalation '{escalation_id}'"),
@@ -2281,6 +2270,9 @@ mod tests {
\x20 agent.completed:\n\ \x20 agent.completed:\n\
\x20 - name: {marker}_completed\n\ \x20 - name: {marker}_completed\n\
\x20 command: \"true\"\n\ \x20 command: \"true\"\n\
\x20 agent.interrupted:\n\
\x20 - name: {marker}_interrupted\n\
\x20 command: \"true\"\n\
\x20 agent.failed:\n\ \x20 agent.failed:\n\
\x20 - name: {marker}_failed\n\ \x20 - name: {marker}_failed\n\
\x20 command: \"true\"\n\ \x20 command: \"true\"\n\
@@ -2377,8 +2369,56 @@ mod tests {
} }
#[test] #[test]
fn hook_base_envs_carries_optional_names_only_when_present() { #[serial]
let envs = hook_base_envs(HookEvent::AgentCompleted, None, None); 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(); let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect();
assert!(keys.contains(&"COYOTE_EVENT")); assert!(keys.contains(&"COYOTE_EVENT"));
assert!(keys.contains(&"COYOTE_CONFIG_DIR")); assert!(keys.contains(&"COYOTE_CONFIG_DIR"));
@@ -2390,7 +2430,7 @@ mod tests {
.any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed") .any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed")
); );
let envs = hook_base_envs(HookEvent::AgentFailed, Some("sess"), Some("bot")); let envs = hooks::base_envs_parts(HookEvent::AgentFailed, Some("sess"), Some("bot"));
assert!( assert!(
envs.iter() envs.iter()
.any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess") .any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess")
@@ -3354,6 +3394,79 @@ mod tests {
assert!(result.is_err()); 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] #[test]
fn handle_task_create_simple() { fn handle_task_create_simple() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
+14 -18
View File
@@ -111,6 +111,18 @@ impl JobHookSnapshot {
} }
} }
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 { pub struct RingBuf {
buf: Vec<u8>, buf: Vec<u8>,
capacity: usize, capacity: usize,
@@ -529,15 +541,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let notify_id = job_id.clone(); let notify_id = job_id.clone();
let notify_tool = tool.clone(); let notify_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool); let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
hooks::fire( fire_job_started(ctx, &job_id, &tool);
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.clone()),
("COYOTE_TOOL_NAME", tool.clone()),
],
None,
);
tokio::spawn(async move { tokio::spawn(async move {
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await; let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
let success = result.is_ok(); let success = result.is_ok();
@@ -559,15 +563,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let notify_id = job_id.clone(); let notify_id = job_id.clone();
let notify_tool = tool.clone(); let notify_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool); let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
hooks::fire( fire_job_started(ctx, &job_id, &tool);
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.clone()),
("COYOTE_TOOL_NAME", tool.clone()),
],
None,
);
tokio::spawn(async move { tokio::spawn(async move {
let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await; 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 success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0));
+31 -14
View File
@@ -2694,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 { if *NO_COLOR {
return format!("Call {cmd_name} {}", cmd_args.join(" ")); return format!("Call {cmd_name} {}", cmd_args.join(" "));
} }
@@ -2713,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 { fn format_json_colored_keys(value: &Value) -> String {
let serde_json::Value::Object(map) = value else { let Value::Object(map) = value else {
return dimmed_text(&value.to_string()); return dimmed_text(&value.to_string());
}; };
if map.is_empty() { if map.is_empty() {
@@ -3486,16 +3486,15 @@ mod tests {
#[test] #[test]
fn bundled_bash_tools_generate_declarations() { fn bundled_bash_tools_generate_declarations() {
let tools_dir = let tools_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
let mut checked = Vec::new(); 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(); let path = entry.unwrap().path();
if path.extension().and_then(OsStr::to_str) != Some("sh") { if path.extension().and_then(OsStr::to_str) != Some("sh") {
continue; continue;
} }
let name = path.file_stem().unwrap().to_string_lossy().to_string(); 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) let (_, declarations) = bash::build_bash_tool(&src, &name)
.unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}")); .unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}"));
assert!( assert!(
@@ -4673,7 +4672,7 @@ mod tests {
.unwrap() .unwrap()
.expect("nonzero exit must return an error payload"); .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!( assert!(
json["tool_call_error"] json["tool_call_error"]
.as_str() .as_str()
@@ -4702,7 +4701,7 @@ mod tests {
.unwrap() .unwrap()
.expect("nonzero exit must return an error payload"); .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!( assert!(
json["tool_call_error"] json["tool_call_error"]
.as_str() .as_str()
@@ -4741,7 +4740,7 @@ mod tests {
let elapsed = started.elapsed(); let elapsed = started.elapsed();
assert!(elapsed < Duration::from_secs(1), "took {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"); assert_eq!(json["tool_call_error"], "Tool call 'bash' aborted");
} }
@@ -4823,6 +4822,24 @@ mod tests {
fn built_shims_resolve_paths_at_runtime_after_relocation() { fn built_shims_resolve_paths_at_runtime_after_relocation() {
use std::os::unix::fs::PermissionsExt; 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 root = temp_file("-shim-relocate-", "");
let home_a = root.join("home-a"); let home_a = root.join("home-a");
let config_a = home_a.join("coyote"); let config_a = home_a.join("coyote");
@@ -4886,14 +4903,14 @@ mod tests {
return; return;
} }
let output = process::Command::new(&moved_shim) let mut command = Command::new(&moved_shim);
command
.arg("{}") .arg("{}")
.env_remove(&config_env) .env_remove(&config_env)
.env_remove(&functions_env) .env_remove(&functions_env)
.env_remove("LLM_OUTPUT") .env_remove("LLM_OUTPUT")
.env_remove("LLM_TOOL_DATA_FILE") .env_remove("LLM_TOOL_DATA_FILE");
.output() let output = output_with_etxtbsy_retry(&mut command).unwrap();
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
assert!( assert!(
output.status.success(), output.status.success(),
+178 -2
View File
@@ -1,5 +1,6 @@
use super::{FunctionDeclaration, JsonSchema}; use super::{FunctionDeclaration, JsonSchema};
use crate::config::RequestContext; use crate::config::RequestContext;
use crate::hooks::{self, HookEvent};
use crate::supervisor::escalation::{EscalationRequest, new_escalation_id}; use crate::supervisor::escalation::{EscalationRequest, new_escalation_id};
use crate::utils::{ACP_SERVER, HEADLESS, queue_acp_permission}; 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 escalation_id = new_escalation_id();
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
let question = format!("[{action}] {question}");
let request = EscalationRequest { let request = EscalationRequest {
id: escalation_id.clone(), id: escalation_id.clone(),
from_agent_id, from_agent_id: from_agent_id.clone(),
from_agent_name: from_agent_name.clone(), from_agent_name: from_agent_name.clone(),
question: format!("[{action}] {question}"), question: question.clone(),
options, options,
reply_tx: tx, reply_tx: tx,
}; };
root_queue.submit(request); 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 await_escalation_reply(rx, timeout_secs).await
} }
/// Waits for the parent's reply. `timeout_secs == 0` waits indefinitely; a /// Waits for the parent's reply. `timeout_secs == 0` waits indefinitely; a
/// dropped sender (the parent cancelled the request) resolves either way. /// 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> { async fn await_escalation_reply(rx: oneshot::Receiver<String>, timeout_secs: u64) -> Result<Value> {
let reply = if timeout_secs == 0 { let reply = if timeout_secs == 0 {
rx.await rx.await
@@ -346,8 +362,14 @@ fn parse_options(args: &Value) -> Result<Vec<String>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
#[test] #[test]
fn headless_select_returns_structured_json() { fn headless_select_returns_structured_json() {
@@ -374,6 +396,160 @@ mod tests {
assert_eq!(DEFAULT_ESCALATION_TIMEOUT_SECS, 0); 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)] #[tokio::test(start_paused = true)]
async fn zero_timeout_waits_indefinitely_for_the_reply() { async fn zero_timeout_waits_indefinitely_for_the_reply() {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
+115 -14
View File
@@ -50,6 +50,21 @@ impl AgentNodeExecutor {
) )
.await; .await;
if let Err(e) = &result { 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( hooks::fire(
HookEvent::AgentFailed, HookEvent::AgentFailed,
parent_ctx, parent_ctx,
@@ -61,6 +76,7 @@ impl AgentNodeExecutor {
None, None,
); );
} }
}
outcome_from(node_id, node, state_manager, result) outcome_from(node_id, node, state_manager, result)
} }
} }
@@ -338,10 +354,12 @@ fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, outpu
mod tests { mod tests {
use super::super::types::AgentNode; use super::super::types::AgentNode;
use super::*; 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::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 serde_json::json;
use serial_test::serial;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -416,6 +434,95 @@ mod tests {
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd) 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 { fn retryable_node(max_attempts: u32) -> AgentNode {
let mut node = node_with("hi", None); let mut node = node_with("hi", None);
node.max_attempts = max_attempts; node.max_attempts = max_attempts;
@@ -676,7 +783,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn retry_transient_warns_once_per_retried_attempt_with_the_context_chain() { 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 mut ctx = plain_ctx();
let node = retryable_node(3); let node = retryable_node(3);
let mut attempts = 0u32; let mut attempts = 0u32;
@@ -700,12 +807,9 @@ mod tests {
.expect_err("all attempts fail"); .expect_err("all attempts fail");
assert_eq!(attempts, 3); assert_eq!(attempts, 3);
let warns: Vec<String> = warn_messages() let warns: Vec<String> = warn_snapshot()
.lock() .into_iter()
.unwrap()
.iter()
.filter(|m| m.contains("'warn_capture'")) .filter(|m| m.contains("'warn_capture'"))
.cloned()
.collect(); .collect();
assert_eq!( assert_eq!(
warns.len(), warns.len(),
@@ -861,7 +965,7 @@ mod tests {
#[test] #[test]
fn outcome_from_fallback_warn_names_the_node_and_the_fallback_target() { 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); let mut node = node_with("hi", None);
node.fallback = Some("recover".into()); node.fallback = Some("recover".into());
let mut state = manager_with(&[]); let mut state = manager_with(&[]);
@@ -875,12 +979,9 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(outcome, AgentExecutionOutcome::FellBack("recover".into())); assert_eq!(outcome, AgentExecutionOutcome::FellBack("recover".into()));
let warns: Vec<String> = warn_messages() let warns: Vec<String> = warn_snapshot()
.lock() .into_iter()
.unwrap()
.iter()
.filter(|m| m.contains("'fallback_warn_capture'")) .filter(|m| m.contains("'fallback_warn_capture'"))
.cloned()
.collect(); .collect();
assert_eq!(warns.len(), 1, "exactly one fallback warn: {warns:?}"); assert_eq!(warns.len(), 1, "exactly one fallback warn: {warns:?}");
assert!( assert!(
+8 -54
View File
@@ -11,7 +11,6 @@ use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor};
use super::validator::{AgentValidationContext, GraphValidator}; use super::validator::{AgentValidationContext, GraphValidator};
use super::wall_clock; use super::wall_clock;
use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext}; use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext};
use crate::function::agents::hook_base_envs;
use crate::hooks::{self, HookEvent, ResolvedHook}; use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id}; use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt}; use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt};
@@ -550,7 +549,7 @@ impl NodeHookEmitter {
hooks::fire_resolved( hooks::fire_resolved(
event, event,
resolved.to_vec(), resolved.to_vec(),
hook_base_envs( hooks::base_envs_parts(
event, event,
self.session_name.as_deref(), self.session_name.as_deref(),
self.agent_name.as_deref(), self.agent_name.as_deref(),
@@ -1133,14 +1132,12 @@ mod integration_tests {
use crate::hooks::HookDef; use crate::hooks::HookDef;
#[cfg(unix)] #[cfg(unix)]
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor, notification}; 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 parking_lot::Mutex;
use serial_test::serial; use serial_test::serial;
use std::env;
use std::fs; use std::fs;
#[cfg(unix)] #[cfg(unix)]
use std::mem; use std::mem;
use std::time::{SystemTime, UNIX_EPOCH};
fn cmd_available(name: &str) -> bool { fn cmd_available(name: &str) -> bool {
which::which(name).is_ok() which::which(name).is_ok()
@@ -1268,7 +1265,7 @@ nodes:
eprintln!("skipping: bash or python3 not available"); eprintln!("skipping: bash or python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-executor-tests");
materialize_probe_agent(0.0); materialize_probe_agent(0.0);
let ws = TestWorkspace::new(); let ws = TestWorkspace::new();
ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n"); ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n");
@@ -1619,7 +1616,7 @@ nodes:
/// debug log, and a well-formed sibling on the same event still fires. /// debug log, and a well-formed sibling on the same event still fires.
#[tokio::test] #[tokio::test]
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() { async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
crate::testing::install_warn_collector(); crate::testing::install_log_collector();
let _sink = hooks::test_sink::install(); let _sink = hooks::test_sink::install();
let mut ctx = make_ctx(); let mut ctx = make_ctx();
@@ -1660,10 +1657,7 @@ nodes:
assert_eq!(valid.len(), 1, "{valid:?}"); assert_eq!(valid.len(), 1, "{valid:?}");
assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done"); assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done");
let debugs: Vec<String> = crate::testing::debug_messages() let debugs = crate::testing::debug_snapshot();
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
assert!( assert!(
debugs debugs
.iter() .iter()
@@ -1685,7 +1679,7 @@ nodes:
eprintln!("skipping: python3 not available"); eprintln!("skipping: python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-executor-tests");
materialize_probe_agent(0.0); materialize_probe_agent(0.0);
let ws = TestWorkspace::new(); let ws = TestWorkspace::new();
@@ -2368,47 +2362,7 @@ nodes:
assert_eq!(events[0].event, "job_completed"); assert_eq!(events[0].event, "job_completed");
} }
struct TestConfigDirGuard { use crate::testing::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);
}
}
const PROBE_AGENT: &str = "timeout-probe"; const PROBE_AGENT: &str = "timeout-probe";
@@ -2658,7 +2612,7 @@ nodes:
eprintln!("skipping: bash or python3 not available"); eprintln!("skipping: bash or python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-executor-tests");
materialize_probe_agent(5.0); materialize_probe_agent(5.0);
let ws = TestWorkspace::new(); let ws = TestWorkspace::new();
// The abort must land after the worker tasks exist (their retirement // The abort must land after the worker tasks exist (their retirement
+9 -51
View File
@@ -746,14 +746,12 @@ mod chain_tests {
use super::*; use super::*;
use crate::config::paths; use crate::config::paths;
use crate::config::{AppState, Role, WorkingMode}; 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 indexmap::IndexMap;
use serde_json::json; use serde_json::json;
use serial_test::serial; use serial_test::serial;
use std::env;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn cmd_available(name: &str) -> bool { fn cmd_available(name: &str) -> bool {
which::which(name).is_ok() which::which(name).is_ok()
@@ -1739,47 +1737,7 @@ nodes:
} }
} }
struct TestConfigDirGuard { use crate::testing::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);
}
}
const PROBE_AGENT: &str = "timeout-probe"; const PROBE_AGENT: &str = "timeout-probe";
@@ -2031,7 +1989,7 @@ nodes:
eprintln!("skipping: python3 not available"); eprintln!("skipping: python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(0.0); materialize_probe_agent(0.0);
let h = Harness::new(&flagged_probe_graph(None)); let h = Harness::new(&flagged_probe_graph(None));
let peers = manual_peer("worker"); let peers = manual_peer("worker");
@@ -2057,7 +2015,7 @@ nodes:
eprintln!("skipping: python3 not available"); eprintln!("skipping: python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(0.0); materialize_probe_agent(0.0);
let ws = TestWorkspace::new(); let ws = TestWorkspace::new();
let yaml = format!( let yaml = format!(
@@ -2107,7 +2065,7 @@ nodes:
eprintln!("skipping: python3 not available"); eprintln!("skipping: python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(5.0); materialize_probe_agent(5.0);
let h = Harness::new(&flagged_probe_graph(Some(1))); let h = Harness::new(&flagged_probe_graph(Some(1)));
let (registry, assignments) = h.provision("worker", 2); let (registry, assignments) = h.provision("worker", 2);
@@ -2144,7 +2102,7 @@ nodes:
eprintln!("skipping: python3 not available"); eprintln!("skipping: python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(1.5); materialize_probe_agent(1.5);
let h = Harness::new(&flagged_probe_graph(Some(0))); let h = Harness::new(&flagged_probe_graph(Some(0)));
let peers = manual_peer("worker"); let peers = manual_peer("worker");
@@ -2167,7 +2125,7 @@ nodes:
eprintln!("skipping: python3 not available"); eprintln!("skipping: python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_probe_agent(0.0); materialize_probe_agent(0.0);
let h = Harness::new(&format!( let h = Harness::new(&format!(
r#" r#"
@@ -2272,7 +2230,7 @@ nodes:
eprintln!("skipping: python3 not available"); eprintln!("skipping: python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_echo_inputs_agent(); materialize_echo_inputs_agent();
let parent = echo_inputs_parent_graph(" inputs:\n width: \"{{n}}\"\n"); let parent = echo_inputs_parent_graph(" inputs:\n width: \"{{n}}\"\n");
@@ -2291,7 +2249,7 @@ nodes:
eprintln!("skipping: python3 not available"); eprintln!("skipping: python3 not available");
return; return;
} }
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new("graph-map-tests");
materialize_echo_inputs_agent(); materialize_echo_inputs_agent();
let parent = echo_inputs_parent_graph(""); let parent = echo_inputs_parent_graph("");
+1555 -212
View File
File diff suppressed because it is too large. Load diff
+99 -24
View File
@@ -58,8 +58,37 @@ use std::path::PathBuf;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::{env, fs, process, sync::Arc}; use std::{env, fs, process, sync::Arc};
#[tokio::main] const MAIN_STACK_SIZE: usize = 16 * 1024 * 1024;
async fn main() -> Result<()> {
/// 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()?; load_env_file()?;
CompleteEnv::with_factory(Cli::command).complete(); CompleteEnv::with_factory(Cli::command).complete();
let cli = Cli::parse(); let cli = Cli::parse();
@@ -113,6 +142,15 @@ async fn main() -> Result<()> {
|| cli.list_macros || cli.list_macros
|| cli.list_skills || cli.list_skills
|| cli.list_sessions; || 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() let vault_flags = cli.add_secret.is_some()
|| cli.get_secret.is_some() || cli.get_secret.is_some()
|| cli.update_secret.is_some() || cli.update_secret.is_some()
@@ -140,7 +178,18 @@ async fn main() -> Result<()> {
return config::list_installed_bundles(); return config::list_installed_bundles();
} }
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()?; install_builtins()?;
}
if let Some(category) = cli.install_builtins { if let Some(category) = cli.install_builtins {
return config::install_assets(category); return config::install_assets(category);
@@ -164,7 +213,7 @@ async fn main() -> Result<()> {
} }
if let Some(client_arg) = &cli.authenticate { 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 app_config = AppConfig::from_config(cfg)?;
let (client_name, provider) = let (client_name, provider) =
resolve_oauth_client(client_arg.as_deref(), &app_config.clients)?; resolve_oauth_client(client_arg.as_deref(), &app_config.clients)?;
@@ -173,7 +222,7 @@ async fn main() -> Result<()> {
} }
if let Some(server_name) = &cli.auth_mcp { 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 app_config = AppConfig::from_config(cfg)?;
let vault = Vault::init(&app_config)?; let vault = Vault::init(&app_config)?;
let mcp_path = paths::mcp_config_file(); let mcp_path = paths::mcp_config_file();
@@ -225,9 +274,17 @@ async fn main() -> Result<()> {
let mcp_action = let mcp_action =
cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some(); cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some();
if mcp_action { if mcp_action {
let cfg = Config::load_with_interpolation(true).await?; let cfg = Config::load_with_interpolation(true, mcp_inspect).await?;
let app_config = AppConfig::from_config(cfg)?; let app_config = if mcp_inspect {
let vault = Vault::init(&app_config)?; 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)?; mcp::manage::handle(&cli, &vault)?;
@@ -235,16 +292,24 @@ async fn main() -> Result<()> {
} }
if vault_flags { if vault_flags {
let cfg = Config::load_with_interpolation(true).await?; let cfg = Config::load_with_interpolation(true, vault_inspect).await?;
let app_config = AppConfig::from_config(cfg)?; let app_config = if vault_inspect {
AppConfig::from_config_lenient(cfg)?
} else {
AppConfig::from_config(cfg)?
};
let vault = Vault::init(&app_config)?; let vault = Vault::init(&app_config)?;
return Vault::handle_vault_flags(cli, &vault); return Vault::handle_vault_flags(cli, &vault);
} }
let abort_signal = create_abort_signal(); let abort_signal = create_abort_signal();
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none(); let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
let cfg = Config::load_with_interpolation(info_flag).await?; let cfg = Config::load_with_interpolation(info_flag, readout_flag).await?;
let mut app_config = AppConfig::from_config(cfg)?; let mut app_config = if readout_flag {
AppConfig::from_config_lenient(cfg)?
} else {
AppConfig::from_config(cfg)?
};
if cli.no_workspace_mcp { if cli.no_workspace_mcp {
app_config.no_workspace_mcp = true; app_config.no_workspace_mcp = true;
} }
@@ -258,14 +323,17 @@ async fn main() -> Result<()> {
log_path, log_path,
start_mcp_servers, start_mcp_servers,
info_flag, info_flag,
inspection_only,
abort_signal.clone(), abort_signal.clone(),
) )
.await?, .await?,
); );
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?; let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
let app_config = Arc::clone(&ctx.app.config); let app_config = Arc::clone(&ctx.app.config);
if !inspection_only {
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone()) ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
.await?; .await?;
}
{ {
let app = &*ctx.app.config; let app = &*ctx.app.config;
@@ -306,9 +374,12 @@ async fn run(
} }
if cli.list_models { if cli.list_models {
for model in list_models(ctx.app.config.as_ref(), ModelType::Chat) { let models = list_models(ctx.app.config.as_ref(), ModelType::Chat)
println!("{}", model.id()); .iter()
} .map(|model| model.id())
.collect::<Vec<_>>()
.join("\n");
println!("{models}");
return Ok(()); return Ok(());
} }
if cli.list_roles { if cli.list_roles {
@@ -389,11 +460,12 @@ async fn run(
ctx.use_agent(app.as_ref(), agent, session, abort_signal.clone()) ctx.use_agent(app.as_ref(), agent, session, abort_signal.clone())
.await?; .await?;
// The top-level agent bracket does NOT open here: the inspection // The top-level agent bracket does NOT open here: the inspection
// flags below (--list-sessions, --info, --macro, --rebuild-rag, // flags below (--list-sessions, --info, --rebuild-rag, --init-*)
// --init-*) return early without running the agent, and an open // return early without running the agent, and an open bracket on
// bracket on those paths would leak `agent.started` with no // those paths would leak `agent.started` with no terminal event.
// terminal event. It opens at the dispatch sites further down, // It opens at the dispatch sites further down, where a run is
// where an agent run is actually committed. // actually committed, including the --macro arm, which executes
// rather than inspects.
} else { } else {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config); let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
if let Some(prompt) = &cli.temp_role { if let Some(prompt) = &cli.temp_role {
@@ -526,8 +598,11 @@ async fn run(
} }
} }
if let Some(name) = &cli.macro_name { if let Some(name) = &cli.macro_name {
macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await?; ctx.top_level_agent_started();
return Ok(()); 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 { if cli.execute && !is_repl {
// Dispatch committed: open the top-level agent bracket only now, // Dispatch committed: open the top-level agent bracket only now,
@@ -538,7 +613,7 @@ async fn run(
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await
} }
.await; .await;
ctx.top_level_agent_finished(result.as_ref().err()); ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
return result; return result;
} }
@@ -572,7 +647,7 @@ async fn run(
start_directive(&mut ctx, input, cli.code, abort_signal.clone()).await start_directive(&mut ctx, input, cli.code, abort_signal.clone()).await
} }
.await; .await;
ctx.top_level_agent_finished(result.as_ref().err()); ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
result result
} }
@@ -706,7 +781,7 @@ async fn shell_execute(
if code == 0 && app.save_shell_history { if code == 0 && app.save_shell_history {
let _ = append_to_shell_history(&shell.name, &eval_str, code); let _ = append_to_shell_history(&shell.name, &eval_str, code);
} }
ctx.top_level_agent_finished(None); ctx.top_level_agent_finished(None, None);
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
process::exit(code); process::exit(code);
} }
+388 -12
View File
@@ -13,6 +13,7 @@ mod splitter;
use self::graph::{KnowledgeGraph, extract_entities}; use self::graph::{KnowledgeGraph, extract_entities};
use self::provider::RagProvider; use self::provider::RagProvider;
use self::providers::{DuckDbProvider, QdrantProvider, YamlProvider}; use self::providers::{DuckDbProvider, QdrantProvider, YamlProvider};
use crate::hooks::RagSyncHooks;
use crate::sandbox::mcp_credentials; use crate::sandbox::mcp_credentials;
use crate::vault::{Vault, interpolate_secrets}; use crate::vault::{Vault, interpolate_secrets};
@@ -144,6 +145,7 @@ impl Rag {
doc_paths: &[String], doc_paths: &[String],
config: &RagInitConfig, config: &RagInitConfig,
abort_signal: AbortSignal, abort_signal: AbortSignal,
sync_hooks: RagSyncHooks,
) -> Result<Self> { ) -> Result<Self> {
if doc_paths.is_empty() { if doc_paths.is_empty() {
bail!("Cannot build RAG knowledge base '{name}' with no documents"); 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 mut rag = Self::create(app, name, save_path, data).await?;
let loaders = app.document_loaders.clone(); let loaders = app.document_loaders.clone();
let (spinner, spinner_rx) = Spinner::create(""); 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)), rag.sync_documents(doc_paths, true, false, loaders, Some(spinner)),
spinner_rx, spinner_rx,
abort_signal, abort_signal,
) )
.await?; .await;
if rag.save()? { if let Err(err) = synced {
println!("✓ Saved RAG to '{}'.", save_path.display()); 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) Ok(rag)
} }
@@ -261,6 +275,7 @@ impl Rag {
doc_paths: &[String], doc_paths: &[String],
abort_signal: AbortSignal, abort_signal: AbortSignal,
prompt_for_driver: bool, prompt_for_driver: bool,
sync_hooks: RagSyncHooks,
) -> Result<Self> { ) -> Result<Self> {
if !*IS_STDOUT_TERMINAL { if !*IS_STDOUT_TERMINAL {
bail!("Failed to init rag in non-interactive mode"); bail!("Failed to init rag in non-interactive mode");
@@ -305,15 +320,27 @@ impl Rag {
}; };
let loaders = app.document_loaders.clone(); let loaders = app.document_loaders.clone();
let (spinner, spinner_rx) = Spinner::create(""); 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)), rag.sync_documents(&paths, true, false, loaders, Some(spinner)),
spinner_rx, spinner_rx,
abort_signal, abort_signal,
) )
.await?; .await;
if rag.save()? { if let Err(err) = synced {
println!("✓ Saved RAG to '{}'.", save_path.display()); 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) Ok(rag)
} }
@@ -636,10 +663,12 @@ impl Rag {
force_reingest: bool, force_reingest: bool,
app: &AppConfig, app: &AppConfig,
abort_signal: AbortSignal, abort_signal: AbortSignal,
sync_hooks: RagSyncHooks,
) -> Result<()> { ) -> Result<()> {
let loaders = app.document_loaders.clone(); let loaders = app.document_loaders.clone();
let (spinner, spinner_rx) = Spinner::create(""); 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( self.sync_documents(
document_paths, document_paths,
refresh, refresh,
@@ -650,10 +679,21 @@ impl Rag {
spinner_rx, spinner_rx,
abort_signal, abort_signal,
) )
.await?; .await;
if self.save()? { if let Err(err) = synced {
println!("✓ Saved rag to '{}'.", self.path); 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(()) Ok(())
} }
@@ -2280,6 +2320,8 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::hooks::{HookDef, HooksMap, test_sink};
use serial_test::serial;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; 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] #[test]
fn attached_ragdata_serializes_driver_and_attached() { fn attached_ragdata_serializes_driver_and_attached() {
let mut data = RagData { let mut data = RagData {
+998 -47
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -197,6 +197,11 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
) { ) {
out.push(read_mixin(path)?); 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]) { for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?); out.push(read_mixin(path)?);
} }
@@ -206,6 +211,9 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) { for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?); 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() if let Ok(cwd) = env::current_dir()
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd) && let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
@@ -1005,6 +1013,51 @@ network:
let _ = fs::remove_dir_all(&root); 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 /// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat` /// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
/// existed, nothing scanned that shape and attaching a Qdrant RAG from /// existed, nothing scanned that shape and attaching a Qdrant RAG from
+90
View File
@@ -190,6 +190,17 @@ impl Supervisor {
.count() .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 { pub fn max_concurrent(&self) -> usize {
self.max_concurrent self.max_concurrent
} }
@@ -449,6 +460,85 @@ mod tests {
assert!(result.unwrap_err().to_string().contains("at capacity")); 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] #[test]
fn supervisor_register_rejects_exceeding_depth() { fn supervisor_register_rejects_exceeding_depth() {
let mut sup = Supervisor::new(4, 2); let mut sup = Supervisor::new(4, 2);
+135 -17
View File
@@ -1,6 +1,10 @@
//! Test-only helpers shared across modules' unit tests. //! 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 log::{Level, LevelFilter, Log, Metadata, Record};
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::sync::{Mutex, Once, OnceLock}; use std::sync::{Mutex, Once, OnceLock};
struct TestLogCollector; struct TestLogCollector;
@@ -8,25 +12,30 @@ struct TestLogCollector;
static WARN_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new(); static WARN_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
static DEBUG_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new(); static DEBUG_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
/// Debug capture is restricted to the hooks module so the buffer is not /// Debug capture is restricted to the modules whose tests assert on debug
/// flooded by debug output from the rest of the crate. Matching is /// output so the buffer is not flooded by the rest of the crate. Matching is
/// module-boundary-aware: only this exact module or its `::` submodules /// module-boundary-aware: only these exact modules or their `::` submodules
/// qualify, so a sibling like `hooks_registry` would not. /// qualify, so a sibling like `hooks_registry` would not.
const DEBUG_TARGET_PREFIX: &str = concat!(env!("CARGO_CRATE_NAME"), "::hooks"); 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 { fn captures_warn(metadata: &Metadata) -> bool {
metadata.level() <= Level::Warn metadata.level() <= Level::Warn
} }
fn captures_hooks_debug(metadata: &Metadata) -> bool { fn captures_module_debug(metadata: &Metadata) -> bool {
if metadata.level() != Level::Debug { if metadata.level() != Level::Debug {
return false; return false;
} }
let target = metadata.target(); let target = metadata.target();
target == DEBUG_TARGET_PREFIX DEBUG_TARGET_PREFIXES.iter().any(|prefix| {
target == *prefix
|| target || target
.strip_prefix(DEBUG_TARGET_PREFIX) .strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with("::")) .is_some_and(|rest| rest.starts_with("::"))
})
} }
/// Every warn- or error-level message captured since the collector was /// Every warn- or error-level message captured since the collector was
@@ -37,16 +46,35 @@ pub(crate) fn warn_messages() -> &'static Mutex<Vec<String>> {
WARN_MESSAGES.get_or_init(Mutex::default) WARN_MESSAGES.get_or_init(Mutex::default)
} }
/// Every debug-level message from the hooks module captured since the /// Every debug-level message from the [`DEBUG_TARGET_PREFIXES`] modules
/// collector was installed. Same marker-filtering discipline as /// captured since the collector was installed. Same marker-filtering
/// [`warn_messages`]. /// discipline as [`warn_messages`].
pub(crate) fn debug_messages() -> &'static Mutex<Vec<String>> { pub(crate) fn debug_messages() -> &'static Mutex<Vec<String>> {
DEBUG_MESSAGES.get_or_init(Mutex::default) 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 { impl Log for TestLogCollector {
fn enabled(&self, metadata: &Metadata) -> bool { fn enabled(&self, metadata: &Metadata) -> bool {
captures_warn(metadata) || captures_hooks_debug(metadata) captures_warn(metadata) || captures_module_debug(metadata)
} }
// A test that panics while holding a buffer poisons its mutex; the logger // A test that panics while holding a buffer poisons its mutex; the logger
@@ -57,7 +85,7 @@ impl Log for TestLogCollector {
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) .unwrap_or_else(|poisoned| poisoned.into_inner())
.push(record.args().to_string()); .push(record.args().to_string());
} else if captures_hooks_debug(record.metadata()) { } else if captures_module_debug(record.metadata()) {
debug_messages() debug_messages()
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) .unwrap_or_else(|poisoned| poisoned.into_inner())
@@ -68,15 +96,105 @@ impl Log for TestLogCollector {
fn flush(&self) {} fn flush(&self) {}
} }
/// Installs the process-wide log collector. `log::set_logger` accepts one /// Installs the process-wide log collector capturing warn-level messages and
/// logger per process, so every test that captures warns or hooks debug /// debug messages from the [`DEBUG_TARGET_PREFIXES`] modules.
/// output must install through this shared entry point. The max level is /// `log::set_logger` accepts one logger per process, so every test that
/// Debug so the hooks debug capture sees its records; warn capture is /// 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. /// unaffected.
pub(crate) fn install_warn_collector() { pub(crate) fn install_log_collector() {
static INSTALL: Once = Once::new(); static INSTALL: Once = Once::new();
INSTALL.call_once(|| { INSTALL.call_once(|| {
log::set_logger(&TestLogCollector).expect("no other logger should be installed"); log::set_logger(&TestLogCollector).expect("no other logger should be installed");
log::set_max_level(LevelFilter::Debug); 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);
}
}
}
+25
View File
@@ -332,8 +332,14 @@ impl Vault {
} }
if cli.list_secrets { if cli.list_secrets {
if vault.sandbox_mode {
println!(
"The vault is unavailable in sandbox mode. Use `coyote --list-secrets` on your host."
);
} else {
vault.list_secrets(true)?; vault.list_secrets(true)?;
} }
}
Ok(()) Ok(())
} }
@@ -410,4 +416,23 @@ mod tests {
} }
} }
} }
#[test]
fn list_secrets_flag_ok_in_sandbox() {
use clap::Parser;
// Built directly rather than via IS_SANDBOX so this test cannot race
// the env juggling in vault_disabled_in_sandbox.
let vault = Vault {
sandbox_mode: true,
..Vault::default()
};
let cli = Cli::try_parse_from(["coyote", "--list-secrets"]).unwrap();
// Pins the --list-secrets sandbox contract: rc=0 with an informational
// readout instead of the hard failure the mutating flags keep. The
// message goes straight to stdout, so only Ok is asserted here.
Vault::handle_vault_flags(cli, &vault)
.expect("--list-secrets must succeed in sandbox mode");
}
} }
+546
View File
@@ -0,0 +1,546 @@
//! Binary-level contract for inspection flags on a pristine config dir:
//! exit 0 and NOTHING written into the config dir (no first-run wizard,
//! no builtins bootstrap). Only --info guarantees output on stdout; the
//! list flags legitimately emit an empty listing on a pristine dir, so
//! their contract is exit 0 + zero writes. Vault-backed flags like
//! --list-secrets depend on host state and are covered by
//! scripts/usage-probe-inspection.sh instead. Agent runs, by contrast,
//! must still bootstrap builtins even when combined with --info — see
//! agent_info_on_empty_config_dir_bootstraps_builtins. Flags that write,
//! like --sync-models, are NOT inspection flags and must stay on the
//! bootstrap path — see sync_models_stays_on_bootstrap_path.
//!
//! Every test runs against a fresh fake HOME and (unless sandbox mode is the
//! subject under test) a scrubbed IS_SANDBOX/provider env, so host state —
//! a real ~/.coyote_password or a sandbox shell — cannot mask
//! virgin-environment regressions.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{env, process};
fn fresh_config_dir(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = env::temp_dir().join(format!("coyote-inspection-{label}-{unique}"));
fs::create_dir_all(&tmp_dir).unwrap();
tmp_dir
}
fn probe_inspection_flag(flag: &str, require_stdout: bool) {
let label = flag.trim_start_matches("--");
let tmp_dir = fresh_config_dir(label);
let home_dir = fresh_config_dir(&format!("{label}-home"));
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
.arg(flag)
.env("COYOTE_CONFIG_DIR", &tmp_dir)
// A fresh fake HOME hides any real ~/.coyote_password on the host;
// USERPROFILE is the best-effort Windows equivalent (dirs resolves
// home via the Known Folder API there). HOME is set, never removed:
// dirs::home_dir() panics inside gman when home can't be determined.
.env("HOME", &home_dir)
.env("USERPROFILE", &home_dir)
// A leaked sandbox/provider env would reroute the vault and config
// paths under test.
.env_remove("IS_SANDBOX")
.env_remove("COYOTE_PROVIDER")
.env_remove("COYOTE_PLATFORM")
// Workspace mcp.json discovery resolves from the CWD, so pin it to
// the isolated fake HOME to keep host state out of the probe.
.current_dir(&home_dir)
.stdin(Stdio::null())
.output()
.unwrap();
let leftover: Vec<_> = fs::read_dir(&tmp_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert!(
!home_dir.join(".coyote_password").exists(),
"{flag}: the lenient inspection path must never bootstrap a password file"
);
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&home_dir);
assert!(
output.status.success(),
"{flag}: expected exit 0 on an empty config dir, got {:?}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if require_stdout {
assert!(
!output.stdout.is_empty(),
"{flag}: expected some output on stdout"
);
}
assert!(
leftover.is_empty(),
"{flag}: inspection flags must write nothing into the config dir: {leftover:?}"
);
}
#[test]
fn info_on_empty_config_dir_writes_nothing() {
probe_inspection_flag("--info", true);
}
// List flags on a pristine dir correctly print an empty listing, so the
// contract is exit 0 + zero writes only (no stdout assertion).
#[test]
fn list_models_on_empty_config_dir_writes_nothing() {
probe_inspection_flag("--list-models", false);
}
#[test]
fn list_agents_on_empty_config_dir_writes_nothing() {
probe_inspection_flag("--list-agents", false);
}
#[test]
fn mcp_list_on_empty_config_dir_writes_nothing() {
probe_inspection_flag("--mcp-list", false);
}
// The wizard bypass must not swallow the builtins bootstrap for agent runs:
// `--agent <builtin> --info` on a totally fresh config dir has to install
// the bundled definitions before it can render the agent readout.
#[test]
fn agent_info_on_empty_config_dir_bootstraps_builtins() {
let tmp_dir = fresh_config_dir("agent-info");
let home_dir = fresh_config_dir("agent-info-home");
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
.args(["--agent", "adversary", "--info"])
.env("COYOTE_CONFIG_DIR", &tmp_dir)
.env("HOME", &home_dir)
.env("USERPROFILE", &home_dir)
.env_remove("IS_SANDBOX")
.env_remove("COYOTE_PROVIDER")
.env_remove("COYOTE_PLATFORM")
.current_dir(&home_dir)
.stdin(Stdio::null())
.output()
.unwrap();
let agents_dir = tmp_dir.join("agents");
let builtins_installed =
agents_dir.is_dir() && fs::read_dir(&agents_dir).unwrap().next().is_some();
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&home_dir);
assert!(
output.status.success(),
"--agent adversary --info: expected exit 0 on an empty config dir, got {:?}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
builtins_installed,
"--agent adversary --info: expected builtin agents installed into {}",
agents_dir.display()
);
// The --info readout echoes the loaded agent definition including its
// global_tools, so a builtin tool name there proves the definitions were
// loaded into the agent's tool scope, not just written to disk.
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("fs_cat.sh"),
"--agent adversary --info: expected builtin tool fs_cat.sh in the readout\nstdout: {stdout}"
);
}
// Sandbox-mode vault contract: `--list-secrets` is an inspection readout and
// must exit 0 with an informational message (and zero writes) when the vault
// is unreachable inside a sandbox, while mutating vault flags stay strict.
#[test]
fn sandbox_list_secrets_prints_informational_message_and_writes_nothing() {
let tmp_dir = fresh_config_dir("sandbox-list-secrets");
let home_dir = fresh_config_dir("sandbox-list-secrets-home");
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
.arg("--list-secrets")
.env("COYOTE_CONFIG_DIR", &tmp_dir)
.env("IS_SANDBOX", "1")
.env("HOME", &home_dir)
.env("USERPROFILE", &home_dir)
.current_dir(&home_dir)
.stdin(Stdio::null())
.output()
.unwrap();
let leftover: Vec<_> = fs::read_dir(&tmp_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&home_dir);
assert!(
output.status.success(),
"--list-secrets in sandbox mode: expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("The vault is unavailable in sandbox mode"),
"--list-secrets in sandbox mode: expected the informational readout, got: {combined}"
);
assert!(
leftover.is_empty(),
"--list-secrets in sandbox mode: expected zero writes into the config dir: {leftover:?}"
);
}
// Mutating vault flags are NOT inspection flags: in sandbox mode they must
// fail hard with the vault-management error, never the soft informational
// message. A minimal valid config is provided so the run gets past model
// resolution and reaches the vault gate.
#[test]
fn sandbox_mutating_vault_flag_stays_strict() {
let tmp_dir = fresh_config_dir("sandbox-delete-secret");
let home_dir = fresh_config_dir("sandbox-delete-secret-home");
fs::write(
tmp_dir.join("config.yaml"),
"model: dryrun:dry-model\n\
dry_run: true\n\
clients:\n\
\x20 - type: openai\n\
\x20 name: dryrun\n\
\x20 auth: none\n\
\x20 api_key: unused\n\
\x20 models:\n\
\x20 - name: dry-model\n\
\x20 max_input_tokens: 100000\n\
\x20 supports_function_calling: true\n\
save: false\n",
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
.args(["--delete-secret", "probe"])
.env("COYOTE_CONFIG_DIR", &tmp_dir)
.env("IS_SANDBOX", "1")
.env("HOME", &home_dir)
.env("USERPROFILE", &home_dir)
.current_dir(&home_dir)
.stdin(Stdio::null())
.output()
.unwrap();
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&home_dir);
assert!(
!output.status.success(),
"--delete-secret in sandbox mode: expected a non-zero exit, got {:?}\nstdout: {}",
output.status,
String::from_utf8_lossy(&output.stdout)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("Vault management is disabled in sandbox mode"),
"--delete-secret in sandbox mode: expected the strict vault error, got: {combined}"
);
}
// The --mcp-list leniency must not leak into the mutating MCP flags on the
// host path: with no password file (fresh fake HOME) and no sandbox env,
// --mcp-get/--mcp-remove/--mcp-add have to fail on the strict vault
// requirement rather than operate with an unconfigured vault.
fn probe_strict_mcp_flag(label: &str, args: &[&str]) {
let tmp_dir = fresh_config_dir(&format!("{label}-no-vault"));
let home_dir = fresh_config_dir(&format!("{label}-no-vault-home"));
fs::write(
tmp_dir.join("config.yaml"),
"model: dryrun:dry-model\n\
dry_run: true\n\
clients:\n\
\x20 - type: openai\n\
\x20 name: dryrun\n\
\x20 auth: none\n\
\x20 api_key: unused\n\
\x20 models:\n\
\x20 - name: dry-model\n\
\x20 max_input_tokens: 100000\n\
\x20 supports_function_calling: true\n\
save: false\n",
)
.unwrap();
fs::write(
tmp_dir.join("mcp.json"),
r#"{"mcpServers":{"probe-server":{"type":"stdio","command":"echo","args":["hi"]}}}"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
.args(args)
.env("COYOTE_CONFIG_DIR", &tmp_dir)
.env("HOME", &home_dir)
.env("USERPROFILE", &home_dir)
.env_remove("IS_SANDBOX")
.env_remove("COYOTE_PROVIDER")
.env_remove("COYOTE_PLATFORM")
.current_dir(&home_dir)
.stdin(Stdio::null())
.output()
.unwrap();
// The strict path must not touch the server registry either.
let mcp_json_after = fs::read_to_string(tmp_dir.join("mcp.json")).unwrap();
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&home_dir);
assert!(
!output.status.success(),
"{label} without a vault: expected a non-zero exit, got {:?}\nstdout: {}",
output.status,
String::from_utf8_lossy(&output.stdout)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("A password file is required"),
"{label} without a vault: expected the strict vault error, got: {combined}"
);
assert!(
mcp_json_after.contains("probe-server"),
"{label} without a vault: mcp.json must be left untouched, got: {mcp_json_after}"
);
}
#[test]
fn mcp_mutating_flag_stays_strict_without_vault() {
probe_strict_mcp_flag("mcp-remove", &["--mcp-remove", "probe-server"]);
}
// --mcp-get is read-only but consumes resolved server config (which may
// embed secrets), so it stays on the strict vault path by design.
#[test]
fn mcp_get_stays_strict_without_vault() {
probe_strict_mcp_flag("mcp-get", &["--mcp-get", "probe-server"]);
}
#[test]
fn mcp_add_stays_strict_without_vault() {
probe_strict_mcp_flag("mcp-add", &["--mcp-add", "newsrv", "--", "echo", "hi"]);
}
// The listing leniency exercised with content: a configured, secret-free
// server must be listed even when no vault exists (no password file, no
// sandbox env), and the readout must not write anything new into the
// config dir.
#[test]
fn mcp_list_lists_configured_server_without_vault() {
let tmp_dir = fresh_config_dir("mcp-list-configured");
let home_dir = fresh_config_dir("mcp-list-configured-home");
fs::write(
tmp_dir.join("mcp.json"),
r#"{"mcpServers":{"probe-server":{"type":"stdio","command":"echo","args":["hi"]}}}"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
.arg("--mcp-list")
.env("COYOTE_CONFIG_DIR", &tmp_dir)
.env("HOME", &home_dir)
.env("USERPROFILE", &home_dir)
.env_remove("IS_SANDBOX")
.env_remove("COYOTE_PROVIDER")
.env_remove("COYOTE_PLATFORM")
.current_dir(&home_dir)
.stdin(Stdio::null())
.output()
.unwrap();
let leftover: Vec<_> = fs::read_dir(&tmp_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&home_dir);
assert!(
output.status.success(),
"--mcp-list with a configured server and no vault: expected exit 0, got {:?}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("probe-server"),
"--mcp-list with a configured server: expected 'probe-server' in stdout, got: {stdout}"
);
assert_eq!(
leftover,
vec![std::ffi::OsString::from("mcp.json")],
"--mcp-list must not write into the config dir beyond the pre-seeded mcp.json"
);
}
// --sync-models downloads and writes models-override.yaml, so it is NOT an
// inspection flag: on a pristine config dir it must take the bootstrap path
// (builtins installed) and, off a terminal, fail on the missing config
// instead of silently operating on defaults. The host path is the one under
// test: a leaked IS_SANDBOX (or provider env) would reroute the missing
// config to the sandbox wizard prompt instead of the fast failure.
#[test]
fn sync_models_stays_on_bootstrap_path() {
let tmp_dir = fresh_config_dir("sync-models");
let home_dir = fresh_config_dir("sync-models-home");
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
.arg("--sync-models")
.env("COYOTE_CONFIG_DIR", &tmp_dir)
.env("HOME", &home_dir)
.env("USERPROFILE", &home_dir)
.env_remove("IS_SANDBOX")
.env_remove("COYOTE_PROVIDER")
.env_remove("COYOTE_PLATFORM")
.current_dir(&home_dir)
.stdin(Stdio::null())
.output()
.unwrap();
let bootstrapped = fs::read_dir(&tmp_dir).unwrap().next().is_some();
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&home_dir);
assert!(
!output.status.success(),
"--sync-models: expected the strict missing-config failure on an empty config dir, got {:?}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
bootstrapped,
"--sync-models: expected the builtins bootstrap to populate the config dir"
);
}
fn run_list_sessions(
cwd: &Path,
config_dir: &Path,
global_sessions: &Path,
home_dir: &Path,
) -> process::Output {
Command::new(env!("CARGO_BIN_EXE_coyote"))
.arg("--list-sessions")
.env("COYOTE_CONFIG_DIR", config_dir)
.env("COYOTE_SESSIONS_DIR", global_sessions)
.env("HOME", home_dir)
.env("USERPROFILE", home_dir)
.env_remove("IS_SANDBOX")
.env_remove("COYOTE_PROVIDER")
.env_remove("COYOTE_PLATFORM")
.env_remove("COYOTE_WORKSPACE_CONFIG_DIR")
.current_dir(cwd)
.stdin(Stdio::null())
.output()
.unwrap()
}
fn seed_session(dir: &Path, name: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(format!("{name}.yaml")), "messages: []\n").unwrap();
}
#[test]
fn usage_probe_list_sessions_merges_cwd_workspace_scope_with_global() {
let config_dir = fresh_config_dir("list-sessions-ws");
let home_dir = fresh_config_dir("list-sessions-ws-home");
let global_sessions = fresh_config_dir("list-sessions-ws-global");
let workspace = fresh_config_dir("list-sessions-ws-cwd");
let bare_cwd = fresh_config_dir("list-sessions-ws-bare");
let workspace_sessions = workspace.join(".coyote").join("sessions");
seed_session(&global_sessions, "both");
seed_session(&global_sessions, "glob-only");
seed_session(&workspace_sessions, "both");
seed_session(&workspace_sessions, "ws-only");
let in_workspace = run_list_sessions(&workspace, &config_dir, &global_sessions, &home_dir);
let in_bare = run_list_sessions(&bare_cwd, &config_dir, &global_sessions, &home_dir);
let config_leftover: Vec<_> = fs::read_dir(&config_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
let bare_created_workspace = bare_cwd.join(".coyote").exists();
let workspace_entries: Vec<_> = fs::read_dir(&workspace_sessions)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
for dir in [
&config_dir,
&home_dir,
&global_sessions,
&workspace,
&bare_cwd,
] {
let _ = fs::remove_dir_all(dir);
}
for (label, output) in [("workspace", &in_workspace), ("bare", &in_bare)] {
assert!(
output.status.success(),
"--list-sessions ({label}): expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let lines = |output: &process::Output| -> Vec<String> {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_owned)
.collect()
};
assert_eq!(
lines(&in_workspace),
vec!["both", "glob-only", "ws-only"],
"workspace CWD: merged, deduplicated, sorted"
);
assert_eq!(
lines(&in_bare),
vec!["both", "glob-only"],
"CWD without .coyote/: behaviour unchanged (global listing only)"
);
assert!(
!bare_created_workspace,
"--list-sessions must never create .coyote/ in the CWD"
);
assert!(
config_leftover.is_empty(),
"--list-sessions must write nothing into the config dir: {config_leftover:?}"
);
let mut workspace_entries = workspace_entries;
workspace_entries.sort_unstable();
assert_eq!(
workspace_entries,
vec!["both.yaml", "ws-only.yaml"],
"listing is read-only for the workspace sessions dir"
);
}
+293
View File
@@ -0,0 +1,293 @@
//! Binary-level contract for --macro bracket symmetry: a committed macro
//! run fires agent.started plus EXACTLY ONE terminal agent event
//! (agent.completed / agent.failed / agent.interrupted), whether the agent
//! arrives via the --agent flag or an in-macro `.agent` step, in both
//! isolation modes, on success and failure alike. The markers ride on
//! global agent.* hooks admitted through the agent's `global_hooks: ["*"]`
//! wildcard, so the fixture also exercises the whitelist grammar
//! end-to-end — and stays observable even if a failing step leaves the
//! context without a loaded agent.
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const MARKERS: [&str; 4] = ["STARTED", "COMPLETED", "FAILED", "INTERRUPTED"];
fn fresh_config_dir(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = env::temp_dir().join(format!("coyote-macro-bracketing-{label}-{unique}"));
fs::create_dir_all(&tmp_dir).unwrap();
tmp_dir
}
/// Removes the fixture dir even when an assertion panics mid-run, so failed
/// runs do not accumulate temp dirs.
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
/// Path of the file `marker`'s hook appends to. Every event gets its OWN
/// file because cmd opens `>>` targets write-exclusive (no shared write
/// access): the STARTED and terminal hook children run concurrently, and
/// when both appended to one shared log, overlapping opens made the loser
/// fail with a sharing violation and silently drop its line — a permanent
/// marker loss that surfaced as a Windows-CI-only flake. Separate files
/// make writer overlap impossible: each event fires at most once per
/// bracket, and re-opened brackets are step-separated, so every file has
/// one writer at a time.
fn marker_log(dir: &Path, marker: &str) -> PathBuf {
dir.join(format!("agent-events-{marker}.log"))
}
/// Builds the hook command that appends `marker` to its own marker file, in
/// the dialect of the shell the hook engine dispatches through: `sh -c`
/// elsewhere, `cmd /C` on Windows. The cmd form parenthesizes the echo
/// because `echo X >> f` under cmd writes "X " with a trailing space, which
/// would break exact marker matching. Both forms double-quote the path, so
/// the command also survives YAML single-quoting and spaces in temp paths.
fn marker_command(marker: &str, dir: &Path) -> String {
let log = marker_log(dir, marker);
let log = log.display();
if cfg!(windows) {
format!(r#"(echo {marker})>> "{log}""#)
} else {
format!(r#"echo {marker} >> "{log}""#)
}
}
/// Lays out a config dir with a dry_run model, global agent.* hooks that
/// append one marker line per event to that event's marker file, a
/// `probe-macro` agent that whitelists everything, and a `probe` macro with
/// the given YAML body.
fn write_fixture(dir: &Path, macro_yaml: &str) {
// Without IS_SANDBOX (scrubbed below) a run insists on a vault password
// file, so the fixture provides one.
let vault_pass = dir.join("vault-pass");
fs::write(&vault_pass, "test-password\n").unwrap();
// Paths land in single-quoted YAML scalars: double-quoted ones would read
// the backslashes in a Windows temp path (`C:\Users\...`) as escapes.
fs::write(
dir.join("config.yaml"),
format!(
"model: dryrun:dry-model\n\
dry_run: true\n\
vault_password_file: '{vault_pass}'\n\
clients:\n\
\x20 - type: openai\n\
\x20 name: dryrun\n\
\x20 auth: none\n\
\x20 api_key: 'unused'\n\
\x20 models:\n\
\x20 - name: dry-model\n\
\x20 max_input_tokens: 100000\n\
\x20 supports_function_calling: true\n\
save: false\n\
memory: false\n\
stream: false\n\
hooks:\n\
\x20 agent.started:\n\
\x20 - name: mark\n\
\x20 command: '{started}'\n\
\x20 agent.completed:\n\
\x20 - name: mark\n\
\x20 command: '{completed}'\n\
\x20 agent.failed:\n\
\x20 - name: mark\n\
\x20 command: '{failed}'\n\
\x20 agent.interrupted:\n\
\x20 - name: mark\n\
\x20 command: '{interrupted}'\n",
vault_pass = vault_pass.display(),
started = marker_command("STARTED", dir),
completed = marker_command("COMPLETED", dir),
failed = marker_command("FAILED", dir),
interrupted = marker_command("INTERRUPTED", dir),
),
)
.unwrap();
let agent_dir = dir.join("agents/probe-macro");
fs::create_dir_all(&agent_dir).unwrap();
fs::write(
agent_dir.join("config.yaml"),
"name: probe-macro\n\
description: macro bracketing probe agent\n\
version: 0.1.0\n\
instructions: |\n\
\x20 You are a probe agent. Reply briefly.\n\
global_hooks:\n\
\x20 - \"*\"\n",
)
.unwrap();
let macros_dir = dir.join("macros");
fs::create_dir_all(&macros_dir).unwrap();
fs::write(macros_dir.join("probe.yaml"), macro_yaml).unwrap();
}
fn run_coyote(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_coyote"))
.args(args)
.current_dir(dir)
.env("COYOTE_CONFIG_DIR", dir)
.env_remove("IS_SANDBOX")
.stdin(Stdio::null())
.output()
.unwrap()
}
/// Counts `marker`'s exact-match lines in its own marker file; a missing
/// file is zero fires. Matching stays byte-exact on both platforms because
/// `lines()` strips the CRLF that cmd's echo emits on Windows.
fn marker_count(dir: &Path, marker: &str) -> usize {
fs::read_to_string(marker_log(dir, marker))
.unwrap_or_default()
.lines()
.filter(|line| *line == marker)
.count()
}
fn dump_markers(dir: &Path) -> String {
MARKERS
.iter()
.map(|marker| {
let contents = fs::read_to_string(marker_log(dir, marker)).unwrap_or_default();
format!("{marker}: {contents:?}")
})
.collect::<Vec<_>>()
.join(", ")
}
/// Bounded poll until every expected marker is present. The binary's exit
/// drain only acknowledges that each hook process SPAWNED — the hooks
/// themselves keep running concurrently after `output()` returns, and
/// nothing orders their writes (the STARTED writer can land after the
/// terminal one). Waiting on all expected markers, not just the terminal
/// one, keeps the exact-count assertions below race-free.
fn wait_for_markers(dir: &Path, needles: &[&str]) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if needles.iter().all(|needle| marker_count(dir, needle) > 0) {
return;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {needles:?}; markers so far: {}",
dump_markers(dir)
);
thread::sleep(Duration::from_millis(50));
}
}
fn assert_marker_count(dir: &Path, marker: &str, expected: usize) {
let count = marker_count(dir, marker);
assert_eq!(
count,
expected,
"expected {expected}x {marker}; markers so far: {}",
dump_markers(dir)
);
}
fn assert_started_and_single_terminal(dir: &Path, terminal: &str) {
assert_marker_count(dir, "STARTED", 1);
for marker in ["COMPLETED", "FAILED", "INTERRUPTED"] {
let expected = usize::from(marker == terminal);
assert_marker_count(dir, marker, expected);
}
}
fn probe_macro_run(label: &str, macro_yaml: &str, args: &[&str], expect_success: bool) {
let dir = fresh_config_dir(label);
let _cleanup = TempDirGuard(dir.clone());
write_fixture(&dir, macro_yaml);
let output = run_coyote(&dir, args);
assert_eq!(
output.status.success(),
expect_success,
"{label}: unexpected exit {:?}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let terminal = if expect_success {
"COMPLETED"
} else {
"FAILED"
};
wait_for_markers(&dir, &["STARTED", terminal]);
assert_started_and_single_terminal(&dir, terminal);
}
#[test]
fn agent_macro_run_fires_started_and_exactly_one_completed() {
probe_macro_run(
"agent-flag-success",
"steps:\n - \".set temperature 0.5\"\n",
&["--agent", "probe-macro", "--macro", "probe"],
true,
);
}
#[test]
fn agent_macro_failure_closes_the_bracket_with_exactly_one_failed() {
probe_macro_run(
"agent-flag-failure",
"steps:\n - \".agent no-such-agent-zz\"\n",
&["--agent", "probe-macro", "--macro", "probe"],
false,
);
}
#[test]
fn isolated_in_macro_agent_step_fires_started_and_exactly_one_completed() {
probe_macro_run(
"isolated-step-success",
"steps:\n - \".agent probe-macro\"\n - \".set temperature 0.5\"\n",
&["--macro", "probe"],
true,
);
}
#[test]
fn isolated_in_macro_agent_step_failure_closes_the_bracket() {
probe_macro_run(
"isolated-step-failure",
"steps:\n - \".agent probe-macro\"\n - \".agent no-such-agent-zz\"\n",
&["--macro", "probe"],
false,
);
}
#[test]
fn non_isolated_in_macro_agent_step_fires_started_and_exactly_one_completed() {
probe_macro_run(
"non-isolated-step-success",
"isolated: false\nsteps:\n - \".agent probe-macro\"\n - \".set temperature 0.5\"\n",
&["--macro", "probe"],
true,
);
}
#[test]
fn non_isolated_in_macro_agent_step_failure_closes_the_bracket() {
probe_macro_run(
"non-isolated-step-failure",
"isolated: false\nsteps:\n - \".agent probe-macro\"\n - \".agent no-such-agent-zz\"\n",
&["--macro", "probe"],
false,
);
}