Compare commits

..
54 Commits
Author SHA1 Message Date
Dark-Alex-17 4e53e143bb fix: use ssh to clone private git repos in sandboxes
CI / All (ubuntu-latest) (push) Failing after 30s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-09-17 13:29:45 -06:00
Dark-Alex-17 166a3807f8 fix: Route git clones through the sbx proxy so the proxy can inject credentials 2026-09-17 13:16:47 -06:00
Dark-Alex-17 1c73b82812 feat: create file locks on concurrent builds and scaffolding verification/tests to prevent race conditions and conflicts while reviewing 2026-09-17 10:34:00 -06:00
github-actions[bot] 02b9986807 chore: refresh generated model catalog [skip ci] 2026-09-17 12:35:37 +00:00
github-actions[bot] 6804f198bc chore: refresh generated model catalog [skip ci] 2026-09-17 00:50:55 +00:00
Alex Clarke f59421844a Merge pull request #30 from Dark-Alex-17/feat/PLAN-responses-api
PLAN-responses-api: OpenAI Responses API as the default wire format
2026-09-16 15:51:37 -06:00
Dark-Alex-17 3dc84f5aef fix: revert manual changelog update, as it should only be updated by CI/CD 2026-09-16 15:33:35 -06:00
Dark-Alex-17 23fe2c1231 chore(TASK-057): drop the web-search stub harness
The harness served as one-time review evidence for the tool's attempt
chain; nothing in CI executes bash harnesses and the tool script rarely
changes, so it is removed rather than left to rot (user decision).
2026-09-16 15:14:49 -06:00
Dark-Alex-17 5495813baf feat(TASK-057): web_search_coyote works on both OpenAI wire APIs
- openai searches run an attempt chain: the chat-completions mechanism
  (search-preview model + web_search_options patch) first, then the
  Responses-native web_search tool on a responses-capable model; the
  search fails only when both attempts fail
- both wire-keyed patches (COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS /
  COYOTE_PATCH_OPENAI_RESPONSES) are exported per attempt; the client's
  resolved wire applies exactly one
- supports_native_search accepts responses-capable openai families
  (gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4*)
- stub-coyote harness pins attempt order, patch export, model mapping,
  hard failure, and untouched non-openai paths
  (scripts/test-web-search-tool.sh)
- CHANGELOG: responses-default breakage caveat replaced with the
  both-wire feature note
2026-09-16 14:41:05 -06:00
Dark-Alex-17 717a48bef2 Merge remote-tracking branch 'origin/main' into feat/PLAN-responses-api
# Conflicts:
#	src/client/common.rs
2026-09-16 14:33:29 -06:00
Alex Clarke 73cf4a625b Merge pull request #29 from Dark-Alex-17/feat/PLAN-hooks
PLAN-hooks: external fire-and-forget lifecycle hook system
2026-09-16 13:59:45 -06:00
Dark-Alex-17 4087f3b3d0 style: applied improved styling to the whole of the hooks integration 2026-09-16 13:44:35 -06:00
Dark-Alex-17 0defc61bec test(TASK-035): run deep compress tests on a big-stack thread
The .compress-session success test polls through the whole summarization
call chain; those nested poll frames overflow the default test-thread
stack on Windows debug builds even with the outer future boxed. Drive
all four deep-compress tests through the same big-stack run_async
harness the macro tests already use.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 7155b1b4fb docs(TASK-040): add hooks block to config.example.yaml 2026-09-16 13:44:35 -06:00
Dark-Alex-17 42380d4b82 feat(TASK-039): wire hooks into bundle installs and reconcile builtin hook dirs
Bundles: RemoteLayout.hooks + scan/is_empty/plan routing into hooks_dir,
a functional --filter hooks arm replacing the inert interim arm,
TopCategory::Hooks provenance rows, and per-file 'bundle defines hook
script' summary lines (visibility only, no approval prompt).

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

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

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

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

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

Exit-path seams call hooks::drain_pending with a 2s bound so exit hooks
finish spawning before process teardown; interactive mid-session events
never drain.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 363104fe77 fix(TASK-036): reuse hooks::base_envs_parts in job terminal hook firing 2026-09-16 13:44:35 -06:00
Dark-Alex-17 8f775dc2d9 fix(TASK-036): pin exact user__-prefixed COYOTE_TOOL_NAME on tool hook events 2026-09-16 13:44:35 -06:00
Dark-Alex-17 2731e98ed1 feat(TASK-036): fire tool, LLM, and job hook events at their seams
Wire the hook engine into the three runtime seams:

- tool.*: ToolCall::eval fires tool.started once the name and arguments
  are resolved, then exactly one of tool.completed/tool.failed,
  discriminated on both the Err path and tool_call_error-shaped outputs.
  The arguments JSON is handed to the engine as the payload; eval itself
  performs no payload-file I/O.
- llm.request.*: the three transport free functions
  (call_chat_completions, call_chat_completions_streaming, and the quiet
  graph-node variant) fire started/completed/failed for every provider
  and caller, classifying failures as aborted, api_error (with
  COYOTE_LLM_STATUS), or other.
- job.*: handle_job_tool fires job.started before either spawn and the
  terminal event inside the detached closures via a pre-resolved
  fire_resolved snapshot, so job__start's return is never delayed.

The test sink now captures the payload and cwd of each dispatch
(sink-only change), and seam tests pin event cadence, env contracts, and
byte-identical tool results and errors.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 0c2a68ecc4 fix(TASK-037): cover genuine node failure payload and malformed graph.yaml hook degradation
Two test gaps: no test fired a genuine graph.node.failed and inspected
its payload (the only node-failure test asserted the hook's absence
because a fallback absorbed the error), and no test drove a malformed
hook definition through the new graph seams. The first test fails an
agent node with no fallback and asserts the captured envs carry the
node id, node type, a numeric duration, and a non-empty error while
the failure still propagates out of the executor. The second routes a
blank-command hook from graph.yaml's top level through the executor
seam and proves it degrades safely: the node completes, the malformed
hook is skipped with a debug log, and a well-formed sibling on the
same event still fires.
2026-09-16 13:44:35 -06:00
Dark-Alex-17 bd7cad21e2 feat(TASK-037): wire supervisor-agent and graph hook seams
Fire agent.started before the supervisor spawn and agent.completed/failed
from the spawn task's result arms, resolved against the child context so
global hooks stay gated by the child's own whitelist. Graph AgentNode
failures fire agent.failed at the point of failure, before a declared
fallback converts them into a successful route. The graph executor fires
graph.node.started before each branch spawn and graph.node.completed/failed
beside the narrate calls, with per-branch pre-resolved snapshots and
metadata so parallel super-step branches never cross-contaminate. Base
envs for detached dispatches are rebuilt at fire time via a shared
hook_base_envs helper.
2026-09-16 13:44:34 -06:00
Dark-Alex-17 0c4d0d777c feat(TASK-034): hook dispatch engine in src/hooks.rs
- HookEvent (22 dotted variants) + owned ResolvedHook snapshots
- RequestContext::resolved_hooks: global (gated by agent global_hooks)
  -> role (additive) -> agent precedence; per-scope cwds; unknown
  whitelist entries inert + debug-logged with agent name
- fire/fire_resolved: fire-and-forget detached dispatch via platform
  shell (sh -c / cmd /C raw_arg), null stdio, fresh unix process group,
  reap-only await, no-runtime guard (skip + debug, never panic)
- tool.* payload files: crypto-random nonce, create_new + 0600, verbatim
  JSON, best-effort delete after reap, hook still runs on write failure
- base env (COYOTE_EVENT/HOOK_NAME/CONFIG_DIR/EVENT_TIMESTAMP RFC3339
  UTC, SESSION_ID/AGENT_NAME when present) + per-event extras; every
  value truncated engine-side to 2048 bytes (UTF-8-safe; payload path
  documented exempt)
- drain_pending(timeout): opt-in exit-path seam with spawn-ack
  semantics (Drop-guard counter; never waits for child completion)
- #[cfg(test)] synchronous capture sink (records instead of spawning)
  + hooks-scoped debug capture in src/testing.rs (poison-proof,
  boundary-aware target prefix)
- Agent::hooks()/global_hooks() accessors; Role::hooks() dead_code
  marker removed

Engine compiles unused by design; call sites land in TASK-035/036/037.
2026-09-16 13:44:34 -06:00
Dark-Alex-17 b020e1b75e fix(TASK-033): cover hooks env overrides with tests and debug-log malformed hook config 2026-09-16 13:44:34 -06:00
Dark-Alex-17 9693cdd4ac feat(TASK-033): add hook config schema across all scopes
Introduce src/hooks.rs as a types-only module (HookDef, HooksMap) and
plumb hooks:/global_hooks: config fields through every scope:

- Config + AppConfig hooks field, both Default impls, the manual
  from_config copy, and a COYOTE_HOOKS env override
- Role frontmatter: hand-parsed hooks arm, private field, getter, and
  JSON-inline re-serialization in Role::export (malformed maps degrade
  to None)
- AgentConfig + Graph: hooks + global_hooks on both structs, copied in
  AgentConfig::from_graph, with a per-agent global_hooks env override
- paths::hooks_dir() = <config_dir>/hooks with COYOTE_HOOKS_DIR override
- Commented hooks: block in the global config template (global scope
  only; no global_hooks key)

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

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

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

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

No files matched your search

+18 -3
View File
@@ -37,7 +37,9 @@ variables:
description: |
JSON array of shell commands the CALLER declares for the deterministic
run_checks stage — e.g. '["cargo test --all"]'. Never inferred from the
prompt; empty array = none declared.
prompt; empty array = none declared. Must not boot shared stacks or
services (compose projects, fixed ports) — those collide with the probe
lane running in parallel.
default: '[]'
settings:
@@ -52,6 +54,7 @@ settings:
initial_state:
project_dir_in: ''
parse_retries: 0
criteria: []
diff_spec: auto
plan_notes: ''
@@ -88,6 +91,18 @@ nodes:
Split compound criteria ("A and B") into separate entries when they are
independently verifiable. When the caller supplied NO plan/criteria at
all, emit an empty array — downstream fails closed.
An empty array is ONLY correct when the prompt genuinely contains no
plan/criteria section — when the prompt carries one (a "Plan /
acceptance criteria:" or "PLAN —" block), returning zero criteria is
an extraction FAILURE, never a valid result.
The caller's explicit enumeration is AUTHORITATIVE: when the caller
declares "these N are the (only) criteria" or supplies a numbered
criteria list, extract EXACTLY those N — never add entries they did
not enumerate. NEVER mine criteria from sections the caller labels as
context ("FACTS", "Quality bar", "context, not criteria", background
material) — route those into plan_notes / scope_notes / extra_context
instead. Splitting applies only WITHIN a declared criterion; when in
doubt, FEWER criteria, verbatim.
- `diff_spec`: the git diff basis the caller named — a range, a ref/SHA,
"staged", "worktree", or "auto" when unnamed (a caller phrase like
"get_diff --base X" means diff_spec = "X").
@@ -159,7 +174,7 @@ nodes:
branch: check_criterion
output_key: crit_verdict
collect_into: crit_verdicts
max_concurrency: 4
max_concurrency: 6
next: holistic
check_criterion:
@@ -340,7 +355,7 @@ nodes:
observations:
type: string
required: [extra_complaints, observations]
max_iterations: 20
max_iterations: 45
max_attempts: 2
state_updates:
holistic_failure: '{{output}}'
+23 -1
View File
@@ -8,6 +8,7 @@ review proceeds fail-closed rather than crashing.
"""
import json
import re
import os
import subprocess
@@ -22,6 +23,22 @@ def load_state():
state = load_state()
# Zero criteria from a prompt that visibly carries a plan is an extraction
# failure (observed non-deterministically live), not a fail-closed no-plan
# run — re-run the parse ONCE before the verdict fails closed on it.
_crit = state.get("criteria") or []
_prompt = state.get("initial_prompt") or ""
if not _crit and isinstance(_prompt, str) and re.search(
r"acceptance criteria|PLAN\s*—|Plan / acceptance", _prompt, re.I
):
try:
_pr = int(state.get("parse_retries") or 0)
except (TypeError, ValueError):
_pr = 0
if _pr < 1:
print(json.dumps({"_next": "parse", "parse_retries": _pr + 1}))
raise SystemExit(0)
proj = os.path.expanduser(
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
)
@@ -56,8 +73,13 @@ try:
if line.startswith("+++ b/")
})
out["changed_files"] = files[:300]
if len(files) > 300:
out["diff_note"] += (
f" (file list capped at 300 of {len(files)} changed files — use the fs "
"tools for the rest)"
)
if len(text) > MAX_DIFF_CHARS:
out["diff_note"] = (
out["diff_note"] += (
f" (truncated to {MAX_DIFF_CHARS} chars of {len(text)} — use the fs tools "
"to inspect files beyond the cut)"
)
@@ -51,11 +51,43 @@ GRAPH_STATE_FILE, so an inherited live state file would silently win.
import json
import os
import signal
import fcntl
import subprocess
import sys
import time
PER_COMMAND_TIMEOUT_SECS = 900
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_WAIT_SECS = float(os.environ.get("ADVERSARY_STACK_LOCK_WAIT_SECS") or 1200)
def acquire_stack_lock():
"""Serialize with the probe lane's shared-stack work (advisory flock on a
well-known path; the probe is instructed to flock the same file around
stack boot/teardown). Bounded wait so a hogged lock can never turn into a
false red verification: on timeout we proceed WITHOUT the lock and note it
on stderr. The fd is held for the process lifetime (released on exit)."""
try:
os.makedirs(os.path.dirname(STACK_LOCK_PATH), exist_ok=True)
fd = os.open(STACK_LOCK_PATH, os.O_CREAT | os.O_RDWR)
except OSError as e:
sys.stderr.write(f"WARN: review-stack lock unavailable ({e}); running unlocked\n")
return None
limit = time.monotonic() + STACK_LOCK_WAIT_SECS
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return fd
except OSError:
if time.monotonic() >= limit:
os.close(fd)
sys.stderr.write(
"WARN: review-stack lock still held after bounded wait — proceeding "
"WITHOUT it; shared-stack collisions with the probe lane are possible\n"
)
return None
time.sleep(5)
DRAIN_TIMEOUT_SECS = 10
TAIL_LINES = 50
MAX_TAIL_CHARS = 8000
@@ -181,6 +213,7 @@ def main():
)
deadline = time.monotonic() + TOTAL_DEADLINE_SECS
results = []
_stack_lock = acquire_stack_lock() if cmds else None # noqa: F841 — held until exit
for cmd in cmds:
remaining = deadline - time.monotonic()
if remaining <= 0:
+10
View File
@@ -340,6 +340,12 @@ instructions: |
Verification commands go in `--variables` (a JSON-array string), never in the prompt: the
prompt also carries pasted plan text from the repo under review, and whatever is declared
here is executed with a shell — a trust boundary the prose must not cross.
Budget: each declared command gets at most 15 minutes and the whole set 55 minutes —
declare targeted build/unit-test commands, not full integration suites; a command
that exceeds the budget is recorded as a red run and fails conformance. Never declare
commands that boot shared stacks or services (docker compose projects, dev servers,
fixed ports) — the probe lane owns end-to-end runs and WILL collide with them in
parallel; keep verification to generate/lint/build/unit/integration commands.
It returns `GAUNTLET: PASS/BLOCKED`. On BLOCKED with real review findings apply the same
fix-and-rerun rules as below (resume the SAME Sisyphus session with the findings verbatim,
@@ -363,6 +369,10 @@ instructions: |
The direct `adversary`/probe spawns below are the FALLBACK when the gauntlet is not installed;
on that path an adversary/probe result that is ONLY a `PIPELINE-FAULT` (no verdict on the
code) is likewise an infrastructure fault to retry, never a divergence.
Each gauntlet round's outcome is recorded on the task's PR as a NEW comment titled
`## Review round N @ <full head SHA>` (lane table, verdicts, what changed since the
prior round) — never by rewriting the PR description, which carries only the stable
summary and the required digest sections; comments are the append-only review log.
- **Spawn `adversary`** with the diff base and the criteria pasted in:
```
+18 -2
View File
@@ -56,6 +56,7 @@ settings:
initial_state:
project_dir_in: ''
verify_retries: 0
diff_spec: auto
intent: ''
rigor_in: ''
@@ -146,7 +147,7 @@ nodes:
extra_context:
type: string
required: [diff_spec, intent, rigor_in, surfaces_in, project_dir_in, extra_context]
max_iterations: 3
max_iterations: 6
max_attempts: 2
state_updates:
parse_failure: '{{output}}'
@@ -227,7 +228,7 @@ nodes:
type: string
required: [domain, files]
required: [groups]
max_iterations: 3
max_iterations: 6
max_attempts: 2
state_updates:
refine_failure: '{{output}}'
@@ -435,6 +436,13 @@ nodes:
(cross-domain concerns from the leaders' reports + teammate alerts, or "None"),
`operational_history` (from prior_art_notes; "" when the lane didn't run),
`org_context_section` (from org_context_notes; "" when the lane didn't run).
Prior review decisions supplied in the caller context are SETTLED: never
invert or re-litigate one on the same line without NEW evidence (the line
changed, a new failure appeared). If you believe a settled decision is
wrong, do not re-flag it as a finding — surface it in `cross_file` as an
explicit conflict citing the prior decision, so the human sees a
disagreement, not a flip-flop.
prompt: |
Domain reports ({{file_count}} changed files, grouped into the domain slices below):
{{domain_reports}}
@@ -522,6 +530,14 @@ nodes:
state_updates:
verifier_output: '{{output}}'
fallback: verdict
next: verify_gate
verify_gate:
id: verify_gate
type: script
description: Local verifier-fault retry — when the verifier completed but its output carries PIPELINE-FAULT markers (or the verifier died), re-run the cheap finding-verifier ONCE instead of letting the fault escalate into a full lane re-run at the gauntlet. Falls through to verdict when clean or out of retries; a gate crash falls forward on the declared edge.
script: scripts/verify_gate.py
timeout: 60
next: verdict
verdict:
@@ -80,6 +80,11 @@ except Exception as e: # noqa: BLE001
out["resolved_diff_spec"] = resolved
out["changed_files"] = files[:300]
out["file_count"] = len(files)
if len(files) > 300:
notes.append(
f"file list truncated: {len(files)} changed files, only the first 300 are "
"grouped and reviewed"
)
def diff_text():
+43 -5
View File
@@ -165,14 +165,45 @@ def main():
if v.get("id") == "pipeline-fault"
or (isinstance(v.get("note"), str) and v["note"].lstrip().startswith("PIPELINE-FAULT:"))
]
if fv_faults:
first = fv_faults[0].get("note")
# parse_fault's sentinel = the verifier verified NOTHING (hard fault, lane-
# degrading). Per-finding verify_fault / crash-guard notes = those findings
# simply stand UNVERIFIED (soft: proportionate NEEDS-HUMAN attention — a
# ~1 h lane re-run to re-check a few findings is the wrong trade, and the
# verify_gate node already retried the verifier locally once).
hard_fv = [v for v in fv_faults if v.get("id") == "pipeline-fault"]
soft_fv = [v for v in fv_faults if v.get("id") != "pipeline-fault"]
def _fv_detail(entries):
first = entries[0].get("note")
detail = first.strip().replace("\n", " ") if isinstance(first, str) else ""
if len(detail) > MAX_DETAIL_CHARS:
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
return detail
soft_notes = []
if hard_fv:
faults.append(
f"PIPELINE-FAULT: finding verification degraded — {len(fv_faults)} verifier "
f"verdict(s) carry a pipeline fault ({detail})"
"PIPELINE-FAULT: finding verification degraded — the verifier verified "
f"nothing ({_fv_detail(hard_fv)})"
)
if soft_fv:
soft_notes.append(
f"Verifier: {len(soft_fv)} finding(s) could not be verified (verifier "
f"branch fault: {_fv_detail(soft_fv)}) — they stand above UNVERIFIED; "
"verify them manually"
)
# Files beyond facts.py's 300-file cap were never grouped or reviewed —
# disclose the gap and force NEEDS-HUMAN with wording that does NOT match
# the gauntlet's degraded-run anchors (a lane re-run cannot fix a cap).
coverage_notes = []
try:
total_files = int(state.get("file_count") or 0)
except (TypeError, ValueError):
total_files = 0
if total_files > len(changed):
coverage_notes.append(
f"Coverage gap: {total_files - len(changed)} of {total_files} changed files "
"were NOT reviewed (file-list cap) — review them manually or split the PR"
)
# Keyed off synth_failure + a non-empty diff, NEVER off findings == [] —
# a clean review legitimately has zero findings and stays MERGE-READY.
@@ -190,7 +221,7 @@ def main():
# faults go FIRST and uncapped so the display cap can never hide one;
# the trigger reason keys off the human-facing triggers alone
has_triggers = bool(attention)
attention = faults + attention[:5]
attention = faults + soft_notes + coverage_notes + attention[:5]
reasons = []
if counts["🔴"]:
@@ -200,6 +231,13 @@ def main():
if faults:
# CONTRACT: review-gauntlet/verdict_gate.py anchors on this wording
reasons.append("pipeline fault(s) recorded — degraded run")
if soft_notes:
# Deliberately does NOT match the gauntlet's degraded-run anchors:
# unverified findings are a human-attention item, not a re-runnable
# lane fault.
reasons.append(f"{len(soft_fv)} finding(s) unverified (verifier branch fault)")
if coverage_notes:
reasons.append(f"{total_files - len(changed)} changed file(s) not reviewed (file cap)")
if has_triggers:
reasons.append("always-human trigger(s) fired")
verdict = "NEEDS-HUMAN" if reasons else "MERGE-READY"
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Local verifier-fault retry gate for the code-reviewer graph.
finding-verifier converts its own branch faults into DATA (UNVERIFIABLE
verdicts with a PIPELINE-FAULT: note, or parse_fault's pipeline-fault
sentinel), so the verify agent node completes "successfully" and its
max_attempts never fires. Without this gate the fault escalates all the
way to the gauntlet, which re-runs the ENTIRE code-review lane (~1 h) to
recover a ~4-minute verifier — the wrong trade. This gate re-runs just
the verifier ONCE; if faults persist, verdict.py downgrades them to a
proportionate NEEDS-HUMAN (findings stand, unverified).
Anchored detection only: the JSON-encoded note prefix and the sentinel id
— a finding merely QUOTING the marker mid-text does not trip it. Always
exits 0 with valid JSON; a crash falls forward on the declared edge.
"""
import json
import os
import sys
def load_state():
if path := os.environ.get("GRAPH_STATE_FILE"):
with open(path) as f:
return json.load(f)
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
try:
state = load_state()
raw = state.get("verifier_output")
text = raw if isinstance(raw, str) else (json.dumps(raw) if raw is not None else "")
faulted = (
text.lstrip().startswith("Agent node failed:")
or '"note": "PIPELINE-FAULT:' in text
or "'note': 'PIPELINE-FAULT:" in text
or '"id": "pipeline-fault"' in text
)
try:
retries = int(state.get("verify_retries") or 0)
except (TypeError, ValueError):
retries = 0
if faulted and retries < 1:
sys.stderr.write(
"WARN: verifier output carries pipeline-fault markers — re-running "
"finding-verifier locally (attempt 2/2) before computing the verdict.\n"
)
print(json.dumps({"_next": "verify", "verify_retries": retries + 1}))
else:
print(json.dumps({"verify_retries": retries}))
except Exception as e: # never block the verdict
print(json.dumps({"verify_gate_error": f"verify_gate crashed: {e}"}))
+2 -2
View File
@@ -25,7 +25,7 @@ variables:
settings:
max_loop_iterations: 8
log_state_snapshots: true
timeout: 900
timeout: 3000
initial_state:
findings_list: []
@@ -174,7 +174,7 @@ nodes:
- fs_read
- fs_cat
- fs_grep
max_iterations: 8
max_iterations: 14
max_attempts: 2
state_updates:
verdict: '{{output}}'
+8
View File
@@ -101,6 +101,14 @@ instructions: |
## Rules
0. **Shared-stack discipline (parallel review lanes).** Wrap every stack boot/teardown
(docker compose, dev servers, fixed-port services) in
`flock "$HOME/.cache/coyote/review-stack.lock" <command>` — the adversary's
verification stage holds the same lock. A held port NEVER justifies removing or
stopping a container you did not create THIS RUN: wait on the lock, pick another
port, or return INCONCLUSIVE naming the conflict. Killing another lane's container
is a defect, not a remedy.
1. **Never modify implementation code.** Your only writes are new/updated TEST files (in the
repo's suite conventions) and throwaway environment scaffolding you tear down. The
implementer owns all fixes.
+10 -2
View File
@@ -51,6 +51,7 @@ settings:
timeout: 69600
initial_state:
retry_notes: []
diff_spec: worktree
plan_context: ''
rigor: production
@@ -114,8 +115,10 @@ nodes:
the change (absolute path preferred); empty string when the caller
named none.
- `plan_context`: the plan / spec / acceptance-criteria text the
implementation must satisfy, verbatim (or a file path the caller
pointed at). Empty string when the caller supplied none.
implementation must satisfy, copied through VERBATIM — never
summarize it and never replace pasted text with file paths or a
sentence saying it was included; the adversary lane fails closed on
whatever you drop. Empty string when the caller supplied none.
- `rigor`: poc | prototype | production. Default "production" when
not stated.
- `security_posture`: prototype | standard | hardened. Default
@@ -404,6 +407,11 @@ nodes:
The workspace may contain test files from an aborted earlier attempt of
this lane; reconcile rather than duplicate.
Shared-stack rule: wrap any stack boot/teardown in
`flock "$HOME/.cache/coyote/review-stack.lock" <command>` (the adversary's
verification stage holds the same lock), and NEVER remove or stop containers
you did not create this run — a held port means wait, re-port, or INCONCLUSIVE.
Derive expected behaviors from the plan/contract BEFORE reading
implementation code, run existing suites first, then author spec-first
tests for the uncovered delta. Return your USAGE_PROBE verdict.
@@ -30,6 +30,7 @@ hard rules:
import json
import os
import re
import sys
import time
# 69600 (graph timeout) − 2 × 11400 (largest lane envelope) − 1200 (script stages)
@@ -184,6 +185,26 @@ def main():
"review_incomplete": incomplete,
}
)
prior_notes = state.get("retry_notes")
notes = [n for n in prior_notes if isinstance(n, str)] if isinstance(prior_notes, list) else []
for lane in declined:
notes.append(
f"lane {lane} faulted and was NOT re-run "
f"(attempts {attempts.get(lane, '?')}/{MAX_ATTEMPTS}"
+ ("" if within_budget else "; wall-clock budget exhausted")
+ ") — recorded in review_incomplete"
)
for lane in retry:
notes.append(
f"lane {lane} faulted (missing / sentinel-less / degraded report) — "
f"re-running: attempt {attempts.get(lane, 0) + 1}/{MAX_ATTEMPTS}"
)
if retry or declined:
# stderr is discarded on success by today's engine but is the
# forward-compatible live-visibility channel; retry_notes carries the
# same lines into the final report either way.
sys.stderr.write("WARN: " + " | ".join(notes[-(len(retry) + len(declined)):]) + "\n")
out["retry_notes"] = notes
if retry:
out["_next"] = "build_items"
print(json.dumps(out))
@@ -38,6 +38,25 @@ def git(*args):
out = {"project_dir": proj}
# Deterministic plan_context recovery: the parse LLM proved a lossy channel
# for verbatim spec text (observed replacing ~4 KB of pasted criteria with
# file paths + a summary sentence). When the raw prompt carries the callers'
# labeled plan block, cut it out verbatim and override the extraction — but
# only when the recovered block is LONGER, so a good extraction is never
# degraded. The adversary lane fails closed on whatever goes missing here.
_prompt = state.get("initial_prompt")
if isinstance(_prompt, str) and _prompt:
_m = re.search(
r"Plan / acceptance criteria:\s*(.+?)"
r"(?=\n\s*(?:Local-run recipe|Forced lanes|Rigor:|Security posture:|Extra context)\b|\Z)",
_prompt,
re.S | re.I,
)
if _m:
_block = _m.group(1).strip()
if len(_block) > len((state.get("plan_context") or "").strip()):
out["plan_context"] = _block
# retry_gate measures its wall-clock budget from this stamp; it is set
# before the git work so a failed diff still starts the clock.
if not state.get("gauntlet_started_at"):
@@ -360,6 +360,11 @@ def main():
+ [f"| {lane} | {status} | {detail} |" for lane, (status, detail) in rows.items()]
)
md += "\n\n## Lane selection\n" + (state.get("lanes_summary") or "(none recorded)")
_notes = state.get("retry_notes")
if isinstance(_notes, list):
_shown = [n for n in _notes if isinstance(n, str)][:8]
if _shown:
md += "\n\n## Pipeline notes\n" + "\n".join(f"- {n}" for n in _shown)
if blockers:
md += "\n\n## Blockers\n" + "\n".join(f"- {b}" for b in blockers)
if attention:
+19
View File
@@ -314,6 +314,9 @@ instructions: |
Diff: <git range like main...HEAD, or 'worktree' for uncommitted changes>
Rigor: <rigor> Security posture: <posture>
Plan / acceptance criteria: <pasted verbatim — enables the adversary lane; omit when none>
Prior review decisions: <on re-review rounds ONLY: earlier rounds' resolved/rejected
findings verbatim (rejected-finding log lines, round-comment reconciliations) — settled
decisions the reviewers must not re-litigate; omit on the first round>
Local-run recipe / usage suites: <from the plan — enables the probe lane; omit when none>"
--variables {"verification_commands": "[\"cargo test --all\", \"cargo clippy -- -D warnings\"]"}
<— the cargo commands above are only an EXAMPLE: replace them with THIS project's exact build/test/lint commands; never guess, and pass "[]" when none apply>
@@ -322,6 +325,12 @@ instructions: |
Verification commands go in `--variables` (a JSON-array string), never in the prompt: the prompt
also carries pasted plan text from the repo under review, and whatever is declared here is
executed with a shell — a trust boundary the prose must not cross.
Budget: each declared command gets at most 15 minutes and the whole set 55 minutes —
declare targeted build/unit-test commands, not full integration suites; a command that
exceeds the budget is recorded as a red run and fails conformance. Never declare commands
that boot shared stacks or services (docker compose projects, dev servers, fixed ports) —
the probe lane owns end-to-end runs and WILL collide with them in parallel; keep
verification to generate/lint/build/unit/integration commands.
It computes diff signals deterministically, selects lanes (code-review always; adversary when a
spec/plan is present; security on attack-surface signals; probe on consumer-facing surface with a
@@ -691,6 +700,16 @@ instructions: |
and what changed (including any plan amendment). Zero autonomous decisions → omit the section.
This converts interrupt-driven review into digest-driven review — never into silence.
**The PR review log (MANDATORY when you author a PR).** The PR DESCRIPTION is a stable
document: what the PR does, how to verify it, and the required digest sections (autonomous
decisions, skipped reviews, observability). Every review round's evidence — the gauntlet
lane table and verdicts, the gates you ran, rulings recorded that round, and what changed
since the previous round — is posted as a NEW PR comment (`gh pr comment`) titled
`## Review round N @ <full head SHA>`. NEVER record round evidence by rewriting the
description: in-place edits destroy the review history and hide which commits each round
actually reviewed. Comments are the append-only review log; the SHA in the title names
exactly what was reviewed.
**Close the loop.** When a decision DID reach the user (step 4), suggest in your final report
that they record the resolution with `coyote --macro record-ruling` so that class of decision
never asks again.
+36
View File
@@ -149,6 +149,18 @@ mcp_tools: null # Per-server MCP tool allowlists (glob patterns
# - list_*
# slack: []
# ---- Hooks ----
# Fire-and-forget external commands that run when lifecycle events fire. Each event name
# maps to a list of named hooks; every listed hook's command runs when that event fires.
# Hooks never block coyote and never change its output or exit code.
# See the [Hooks documentation](https://github.com/Dark-Alex-17/coyote/wiki/Hooks) for more details
hooks: {} # Event -> list of named hook commands (default: none)
# Example:
# hooks:
# turn.completed:
# - name: notify
# command: ./hooks/notify.sh
# ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically
@@ -290,4 +302,28 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons
#
# The claude, bedrock, and vertexai clients also support:
# prompt_cache: true # Enable Anthropic prompt caching (default: true)
#
# The openai and openai-compatible clients also support:
# wire_api: responses # Which OpenAI wire protocol to use; Possible values: responses and chat
# # Defaults when unset: stock openai (no api_base) -> responses;
# # openai with a custom api_base -> chat; openai-compatible -> chat.
# # Codex OAuth always uses responses (`wire_api: chat` there is a config error)
#
# Patches are selected per wire: `chat_completions` patches only ever touch chat bodies. To patch
# requests on the responses wire, use the `responses` key on the client `patch` block (same
# <regex> -> url/body/headers shape as chat_completions):
# patch:
# responses:
# <regex>:
# body:
# <json>
# Model patches likewise: the top-level `patch` keys apply to the chat wire only, and a
# `responses:` sub-key applies to the responses wire only:
# models:
# - name: xxxx
# patch:
# responses:
# body:
# <json>
# Env override per wire: COYOTE_PATCH_<client>_RESPONSES / COYOTE_PATCH_<client>_CHAT_COMPLETIONS
__CLIENTS_BLOCK__
+65 -9
View File
@@ -23,10 +23,16 @@ set -e
# - perplexity:*
# - ernie:*
# - claude:* (Anthropic native web_search server tool)
# - openai:gpt-4o-search-preview (and -mini-; requires an api-key openai
# client — the codex OAuth path uses the
# Responses API where this parameter
# does not exist)
# - openai:gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4*
# OpenAI serves web search through two different mechanisms depending on
# the wire API the client resolves (the script cannot pick the wire):
# Chat Completions serves it via `web_search_options` on the chat-only
# *-search-preview models, while the Responses API serves it via its
# native `web_search` tool on regular models. Both request patches are
# exported (request patching is wire-aware, so the resolved wire applies
# exactly one) and openai searches run as an attempt chain: the
# chat-completions mechanism first, then the Responses mechanism, and
# the search fails only when both attempts fail.
# @env WEB_SEARCH_USE_CURRENT_MODEL=true Prefer the running session's model when it natively supports web search.
# @env LLM_OUTPUT=/dev/stdout The output path
@@ -34,7 +40,7 @@ set -e
supports_native_search() {
case "$1" in
gemini:* | vertexai:gemini-* | perplexity:* | ernie:* | claude:*) return 0 ;;
openai:gpt-4o*search-preview*) return 0 ;;
openai:gpt-4o* | openai:gpt-4.1* | openai:gpt-5* | openai:o3* | openai:o4*) return 0 ;;
*) return 1 ;;
esac
}
@@ -46,7 +52,8 @@ export_search_patch() {
COYOTE_PATCH_VERTEXAI_CHAT_COMPLETIONS \
COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS \
COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS \
COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS
COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS \
COYOTE_PATCH_OPENAI_RESPONSES
case "${1%%:*}" in
gemini)
@@ -65,18 +72,67 @@ export_search_patch() {
export COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS='{".*":{"body":{"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}}}'
;;
openai)
# Chat Completions native search exists only on the search-preview
# models; the regex scopes the patch so other OpenAI models run
# unpatched instead of erroring on an unsupported parameter.
# Both patches are exported; request patching is wire-aware, so the
# client's resolved wire applies exactly one of them. Each regex
# scopes its patch to the models that actually support that
# mechanism, so other OpenAI models run unpatched instead of
# erroring on an unsupported parameter:
# - Chat Completions native search exists only on the chat-only
# *-search-preview models (`web_search_options`).
# - The Responses API serves search through its native
# `web_search` tool, which only some model families support.
export COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS='{"gpt-4o.*search-preview.*":{"body":{"web_search_options":{}}}}'
export COYOTE_PATCH_OPENAI_RESPONSES='{"(gpt-4o|gpt-4\\.1|gpt-5|o3|o4).*":{"body":{"tools":[{"type":"web_search"}]}}}'
;;
esac
}
# Maps an openai model to the one used for the chat-completions attempt:
# Chat Completions web search is served only by the *-search-preview models.
openai_chat_search_model() {
case "${1#openai:}" in
gpt-4o*search-preview*) echo "$1" ;;
gpt-4o-mini*) echo "openai:gpt-4o-mini-search-preview" ;;
*) echo "openai:gpt-4o-search-preview" ;;
esac
}
# Maps an openai model to the one used for the Responses attempt: the
# *-search-preview models are chat-only and do not exist on /v1/responses,
# so they map back to their base models.
openai_responses_search_model() {
case "${1#openai:}" in
gpt-4o-mini-search-preview*) echo "openai:gpt-4o-mini" ;;
gpt-4o*search-preview*) echo "openai:gpt-4o" ;;
*) echo "$1" ;;
esac
}
# OpenAI attempt chain: try the chat-completions mechanism first (works when
# the client's wire is chat); if that fails — e.g. the client resolved the
# responses wire, where the search-preview models do not exist — retry with
# the Responses mechanism on a responses-capable model. Fails when both
# attempts fail.
# shellcheck disable=SC2154
run_openai_search() {
if coyote -m "$(openai_chat_search_model "$1")" "$argc_query" >>"$LLM_OUTPUT"; then
return 0
fi
echo "web_search: openai chat-completions attempt failed; retrying via the Responses API" >&2
coyote -m "$(openai_responses_search_model "$1")" "$argc_query" >>"$LLM_OUTPUT"
}
# shellcheck disable=SC2154
run_search() {
export_search_patch "$1"
case "$1" in
openai:*)
run_openai_search "$1"
;;
*)
coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT"
;;
esac
}
main() {
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Event-logging hook: appends a timestamped snapshot of every COYOTE_* env var
# to a log file, so you can see exactly which events fire and what context
# each one carries.
#
# Env vars read:
# COYOTE_HOOK_LOG log file to append to (default: /tmp/coyote-hooks.log)
# COYOTE_* everything coyote sets for the event is dumped verbatim
#
# Secrets (COYOTE_SECRET_*) are excluded from the snapshot; widening the grep
# filter below opts into logging them.
#
# Wire it up under `hooks:` in your config.yaml (any event works):
# hooks:
# tool.completed:
# - name: log-events
# command: ./hooks/log-events.sh
#
# A relative command path like ./hooks/log-events.sh resolves against the
# coyote config directory for global hooks, so this works with the default
# hooks dir.
#
# See https://github.com/Dark-Alex-17/coyote/wiki/Hooks for the full list of
# events and env vars. Hooks are fire-and-forget: this script never fails
# loudly, and coyote ignores its exit code either way.
set -u
log_file="${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}"
# The default lives in shared /tmp: refuse to append through a symlink someone
# else planted there. Point COYOTE_HOOK_LOG at a private path to avoid the
# shared directory entirely.
[ -L "$log_file" ] && exit 0
umask 077
{
echo "=== $(date '+%Y-%m-%dT%H:%M:%S%z') ${COYOTE_EVENT:-unknown}"
env | grep '^COYOTE_' | grep -v '^COYOTE_SECRET_' | sort
echo
} >> "$log_file" 2> /dev/null || true
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Desktop-notification hook: posts a notification describing the event that
# fired, falling back to a message on the controlling terminal when no
# notifier is present.
#
# Env vars read (set by coyote when the hook fires):
# COYOTE_EVENT dotted event name, e.g. turn.completed
# COYOTE_AGENT_NAME agent name, when the event carries one
# COYOTE_TOOL_NAME tool name, when the event carries one
#
# Wire it up under `hooks:` in your config.yaml:
# hooks:
# turn.completed:
# - name: notify
# command: ./hooks/notify.sh
#
# A relative command path like ./hooks/notify.sh resolves against the coyote
# config directory for global hooks, so this works with the default hooks dir.
#
# See https://github.com/Dark-Alex-17/coyote/wiki/Hooks for the full list of
# events and env vars. Hooks are fire-and-forget: this script never fails
# loudly, and coyote ignores its exit code either way.
set -u
title="coyote"
body="${COYOTE_EVENT:-event}"
[ -n "${COYOTE_AGENT_NAME:-}" ] && body="$body agent=$COYOTE_AGENT_NAME"
[ -n "${COYOTE_TOOL_NAME:-}" ] && body="$body tool=$COYOTE_TOOL_NAME"
if command -v notify-send > /dev/null 2>&1; then
notify-send "$title" "$body" 2> /dev/null || true
elif command -v osascript > /dev/null 2>&1; then
# $body carries model-influenced values (e.g. tool names); strip quotes and
# backslashes so it cannot escape the AppleScript string literal.
safe_body=${body//[\"\\]/}
osascript -e "display notification \"$safe_body\" with title \"$title\"" 2> /dev/null || true
else
# coyote discards hook stdout, so aim the fallback at the controlling
# terminal when one exists; plain stdout keeps direct/manual runs working.
{ echo "[$title] $body" > /dev/tty; } 2> /dev/null || echo "[$title] $body"
fi
+29
View File
@@ -206,6 +206,18 @@ enabled_macros: null # Which macros are invocable by default (no ro
# Example (comma-separated form):
# enabled_macros: generate-commit-message,review
# ---- Hooks ----
# Fire-and-forget external commands that run when lifecycle events fire. Each event name
# maps to a list of named hooks; every listed hook's command runs when that event fires.
# Hooks never block coyote and never change its output or exit code.
# See the [Hooks documentation](https://github.com/Dark-Alex-17/coyote/wiki/Hooks) for more details
hooks: {} # Event -> list of named hook commands (default: none)
# Example:
# hooks:
# turn.completed:
# - name: notify
# command: ./hooks/notify.sh
# ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically
@@ -338,6 +350,9 @@ clients:
api_base: https://api.openai.com/v1 # Optional
api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
organization_id: org-xxx # Optional
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
# Supports a custom `oauth:` block like the claude client below. `api_base` is REQUIRED with it
# For any platform compatible with OpenAI's API
- type: openai-compatible
@@ -385,6 +400,20 @@ clients:
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
prompt_cache: true # Optional - enable Anthropic prompt caching (default: true)
# Optional: custom OAuth endpoints (e.g. a private gateway). Requires `auth: oauth` above.
# With `auth: oauth` and no `oauth:` block you get the stock Claude Pro/Max provider; adding
# `oauth:` switches to generic OAuth against your endpoints (the Claude-Code system-prefix
# and anthropic-beta header are not sent). An `oauth:` block makes `api_base` REQUIRED —
# Coyote refuses to fall back to api.anthropic.com with a gateway token.
# auth: oauth
# api_base: https://gateway.example/anthropic/v1
# oauth:
# client_id: '{{GATEWAY_CLIENT_ID}}'
# device_authorization_url: https://auth.gateway.example/oauth/device_authorization
# token_url: https://auth.gateway.example/oauth/token
# flow: device_code
# scopes: [claude.chat]
# resource: https://gateway.example/v1
# See https://docs.mistral.ai/
- type: openai-compatible
+269 -188
View File
@@ -351,6 +351,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
max_output_tokens: 100000
supports_vision: true
supports_function_calling: true
@@ -370,6 +374,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
max_output_tokens: 100000
supports_vision: true
supports_function_calling: true
@@ -429,6 +437,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
max_output_tokens: 100000
supports_function_calling: true
reasoning_levels:
@@ -540,6 +552,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
max_output_tokens: 100000
supports_vision: true
supports_function_calling: true
@@ -556,6 +574,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
supports_vision: true
supports_function_calling: true
system_prompt_prefix: Formatting re-enabled
@@ -571,6 +595,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
max_output_tokens: 100000
supports_vision: true
supports_function_calling: true
@@ -2234,6 +2264,14 @@
- high
- xhigh
- max
- name: zai-org/glm-5.2-maas
type: chat
max_input_tokens: 1000000
input_price: 1.4
output_price: 4.4
cache_read_price: 0.14
max_output_tokens: 64000
supports_function_calling: true
- name: claude-fable-5@default
type: chat
max_input_tokens: 1000000
@@ -3092,10 +3130,10 @@
- name: au.anthropic.claude-opus-5
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3140,10 +3178,10 @@
- name: jp.anthropic.claude-opus-5
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3156,10 +3194,10 @@
- name: us.anthropic.claude-opus-5
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
require_max_tokens: true
supports_vision: true
@@ -3377,10 +3415,10 @@
- name: au.anthropic.claude-sonnet-5
type: chat
max_input_tokens: 1000000
input_price: 2.0
output_price: 10.0
cache_read_price: 0.2
cache_write_price: 2.5
input_price: 2.2
output_price: 11.0
cache_read_price: 0.22
cache_write_price: 2.75
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3425,10 +3463,10 @@
- name: jp.anthropic.claude-sonnet-5
type: chat
max_input_tokens: 1000000
input_price: 2.0
output_price: 10.0
cache_read_price: 0.2
cache_write_price: 2.5
input_price: 2.2
output_price: 11.0
cache_read_price: 0.22
cache_write_price: 2.75
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3441,10 +3479,10 @@
- name: us.anthropic.claude-sonnet-5
type: chat
max_input_tokens: 1000000
input_price: 2.0
output_price: 10.0
cache_read_price: 0.2
cache_write_price: 2.5
input_price: 2.2
output_price: 11.0
cache_read_price: 0.22
cache_write_price: 2.75
max_output_tokens: 128000
require_max_tokens: true
supports_vision: true
@@ -3507,10 +3545,10 @@
- name: us.anthropic.claude-fable-5
type: chat
max_input_tokens: 1000000
input_price: 10.0
output_price: 50.0
cache_read_price: 1.0
cache_write_price: 12.5
input_price: 11.0
output_price: 55.0
cache_read_price: 1.1
cache_write_price: 13.75
max_output_tokens: 128000
require_max_tokens: true
supports_vision: true
@@ -3541,10 +3579,10 @@
- name: au.anthropic.claude-opus-4-8
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3589,10 +3627,10 @@
- name: jp.anthropic.claude-opus-4-8
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3605,10 +3643,10 @@
- name: us.anthropic.claude-opus-4-8
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
require_max_tokens: true
supports_vision: true
@@ -3716,10 +3754,10 @@
- name: jp.anthropic.claude-opus-4-7
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3732,10 +3770,10 @@
- name: us.anthropic.claude-opus-4-7
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
require_max_tokens: true
supports_vision: true
@@ -3805,11 +3843,11 @@
- name: anthropic.claude-sonnet-4-6
type: chat
max_input_tokens: 1000000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
max_output_tokens: 64000
input_price: 3.3
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
reasoning_levels:
@@ -3839,7 +3877,7 @@
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
max_output_tokens: 64000
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
reasoning_levels:
@@ -3854,7 +3892,7 @@
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
max_output_tokens: 64000
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
reasoning_levels:
@@ -3865,11 +3903,11 @@
- name: jp.anthropic.claude-sonnet-4-6
type: chat
max_input_tokens: 1000000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
max_output_tokens: 64000
input_price: 3.3
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
reasoning_levels:
@@ -3880,11 +3918,11 @@
- name: us.anthropic.claude-sonnet-4-6
type: chat
max_input_tokens: 1000000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
max_output_tokens: 64000
input_price: 3.3
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
max_output_tokens: 128000
require_max_tokens: true
supports_vision: true
supports_function_calling: true
@@ -3898,10 +3936,10 @@
type: chat
real_name: us.anthropic.claude-sonnet-4-6
max_input_tokens: 1000000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
input_price: 3.3
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
patch:
body:
inferenceConfig:
@@ -3932,10 +3970,10 @@
- name: anthropic.claude-opus-4-6-v1
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3947,10 +3985,10 @@
- name: au.anthropic.claude-opus-4-6-v1
type: chat
max_input_tokens: 1000000
input_price: 16.5
output_price: 82.5
cache_read_price: 1.65
cache_write_price: 20.625
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
supports_vision: true
supports_function_calling: true
@@ -3992,10 +4030,10 @@
- name: us.anthropic.claude-opus-4-6-v1
type: chat
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 128000
require_max_tokens: true
supports_vision: true
@@ -4010,10 +4048,10 @@
type: chat
real_name: us.anthropic.claude-opus-4-6-v1
max_input_tokens: 1000000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
patch:
body:
inferenceConfig:
@@ -4186,6 +4224,13 @@
output_price: 1.85
max_output_tokens: 81920
supports_function_calling: true
- name: moonshot.kimi-k2-thinking
type: chat
max_input_tokens: 262143
input_price: 0.6
output_price: 2.5
max_output_tokens: 16000
supports_function_calling: true
- name: anthropic.claude-opus-4-5-20251101-v1:0
type: chat
max_input_tokens: 200000
@@ -4231,10 +4276,10 @@
- name: us.anthropic.claude-opus-4-5-20251101-v1:0
type: chat
max_input_tokens: 200000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
max_output_tokens: 64000
require_max_tokens: true
supports_vision: true
@@ -4247,10 +4292,10 @@
type: chat
real_name: us.anthropic.claude-opus-4-5-20251101-v1:0
max_input_tokens: 200000
input_price: 5.0
output_price: 25.0
cache_read_price: 0.5
cache_write_price: 6.25
input_price: 5.5
output_price: 27.5
cache_read_price: 0.55
cache_write_price: 6.875
patch:
body:
inferenceConfig:
@@ -4263,13 +4308,6 @@
max_output_tokens: 24000
require_max_tokens: true
supports_vision: true
- name: moonshot.kimi-k2-thinking
type: chat
max_input_tokens: 262143
input_price: 0.6
output_price: 2.5
max_output_tokens: 16000
supports_function_calling: true
- name: openai.gpt-oss-safeguard-120b
type: chat
max_input_tokens: 128000
@@ -4320,10 +4358,10 @@
- name: au.anthropic.claude-haiku-4-5-20251001-v1:0
type: chat
max_input_tokens: 200000
input_price: 1.0
output_price: 5.0
cache_read_price: 0.1
cache_write_price: 1.25
input_price: 1.1
output_price: 5.5
cache_read_price: 0.11
cache_write_price: 1.375
max_output_tokens: 64000
supports_vision: true
supports_function_calling: true
@@ -4350,20 +4388,20 @@
- name: jp.anthropic.claude-haiku-4-5-20251001-v1:0
type: chat
max_input_tokens: 200000
input_price: 1.0
output_price: 5.0
cache_read_price: 0.1
cache_write_price: 1.25
input_price: 1.1
output_price: 5.5
cache_read_price: 0.11
cache_write_price: 1.375
max_output_tokens: 64000
supports_vision: true
supports_function_calling: true
- name: us.anthropic.claude-haiku-4-5-20251001-v1:0
type: chat
max_input_tokens: 200000
input_price: 1.0
output_price: 5.0
cache_read_price: 0.1
cache_write_price: 1.25
input_price: 1.1
output_price: 5.5
cache_read_price: 0.11
cache_write_price: 1.375
max_output_tokens: 64000
require_max_tokens: true
supports_vision: true
@@ -4372,10 +4410,10 @@
type: chat
real_name: us.anthropic.claude-haiku-4-5-20251001-v1:0
max_input_tokens: 200000
input_price: 1.0
output_price: 5.0
cache_read_price: 0.1
cache_write_price: 1.25
input_price: 1.1
output_price: 5.5
cache_read_price: 0.11
cache_write_price: 1.375
patch:
body:
inferenceConfig:
@@ -4401,10 +4439,10 @@
- name: au.anthropic.claude-sonnet-4-5-20250929-v1:0
type: chat
max_input_tokens: 200000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
input_price: 3.3
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
max_output_tokens: 64000
supports_vision: true
supports_function_calling: true
@@ -4431,20 +4469,20 @@
- name: jp.anthropic.claude-sonnet-4-5-20250929-v1:0
type: chat
max_input_tokens: 200000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
input_price: 3.3
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
max_output_tokens: 64000
supports_vision: true
supports_function_calling: true
- name: us.anthropic.claude-sonnet-4-5-20250929-v1:0
type: chat
max_input_tokens: 200000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
input_price: 3.3
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
max_output_tokens: 64000
require_max_tokens: true
supports_vision: true
@@ -4453,10 +4491,10 @@
type: chat
real_name: us.anthropic.claude-sonnet-4-5-20250929-v1:0
max_input_tokens: 200000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
input_price: 3.3
output_price: 16.5
cache_read_price: 0.33
cache_write_price: 4.125
patch:
body:
inferenceConfig:
@@ -4628,6 +4666,47 @@
output_price: 0.3
max_output_tokens: 8192
supports_function_calling: true
- name: apac.anthropic.claude-sonnet-4-20250514-v1:0
type: chat
max_input_tokens: 200000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
max_output_tokens: 64000
supports_vision: true
supports_function_calling: true
- name: eu.anthropic.claude-sonnet-4-20250514-v1:0
type: chat
max_input_tokens: 200000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
max_output_tokens: 64000
supports_vision: true
supports_function_calling: true
- name: global.anthropic.claude-sonnet-4-20250514-v1:0
type: chat
max_input_tokens: 200000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
max_output_tokens: 64000
supports_vision: true
supports_function_calling: true
- name: us.anthropic.claude-sonnet-4-20250514-v1:0
type: chat
max_input_tokens: 200000
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
cache_write_price: 3.75
max_output_tokens: 64000
require_max_tokens: true
supports_vision: true
supports_function_calling: true
- name: us.amazon.nova-premier-v1:0
type: chat
max_input_tokens: 300000
@@ -5306,6 +5385,19 @@
reasoning_levels:
- high
- max
- name: kimi-k3
type: chat
max_input_tokens: 1048576
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
max_output_tokens: 1048576
supports_vision: true
supports_function_calling: true
reasoning_levels:
- low
- high
- max
- name: glm-5.2
type: chat
max_input_tokens: 1000000
@@ -6956,9 +7048,9 @@
- name: deepseek/deepseek-v4-pro-0813
type: chat
max_input_tokens: 1048576
input_price: 0.9834
output_price: 2.9502
cache_read_price: 0.0328
input_price: 0.66
output_price: 1.98
cache_read_price: 0.022
max_output_tokens: 384000
supports_function_calling: true
- name: deepseek/deepseek-v4-pro-0813:batch
@@ -7364,14 +7456,6 @@
max_output_tokens: 16384
supports_vision: true
supports_function_calling: true
- name: google/gemma-4-31b-it:batch
type: chat
max_input_tokens: 262144
input_price: 0.39
output_price: 0.97
max_output_tokens: 235929
supports_vision: true
supports_function_calling: true
- name: google/gemma-4-31b-it:free
type: chat
max_input_tokens: 262144
@@ -7397,8 +7481,8 @@
- name: gryphe/mythomax-l2-13b
type: chat
max_input_tokens: 8192
input_price: 0.06
output_price: 0.06
input_price: 0.08
output_price: 0.11
max_output_tokens: 3686
- name: ibm-granite/granite-4.0-h-micro
type: chat
@@ -7588,8 +7672,8 @@
- name: meta/muse-glimmer-30b
type: chat
max_input_tokens: 131072
input_price: 0.35
output_price: 1.5
input_price: 0.3
output_price: 1.1
cache_read_price: 0.04
max_output_tokens: 117964
supports_vision: true
@@ -7670,7 +7754,7 @@
- name: minimax/minimax-m1
type: chat
max_input_tokens: 1000000
input_price: 0.55
input_price: 0.4
output_price: 2.2
max_output_tokens: 40000
supports_function_calling: true
@@ -7916,8 +8000,8 @@
- name: mistralai/mistral-small-3.2-24b-instruct
type: chat
max_input_tokens: 256000
input_price: 0.075
output_price: 0.2
input_price: 0.0938
output_price: 0.25
max_output_tokens: 16384
supports_vision: true
supports_function_calling: true
@@ -7980,18 +8064,18 @@
- name: moonshotai/kimi-k2.7-code
type: chat
max_input_tokens: 262144
input_price: 0.71
output_price: 3.5
cache_read_price: 0.15
input_price: 0.7062
output_price: 3.21
cache_read_price: 0.18
max_output_tokens: 235929
supports_vision: true
supports_function_calling: true
- name: moonshotai/kimi-k3
type: chat
max_input_tokens: 1048576
input_price: 2.6481
output_price: 13.2827
cache_read_price: 0.3026
input_price: 3.0
output_price: 15.0
cache_read_price: 0.3
max_output_tokens: 943718
supports_vision: true
supports_function_calling: true
@@ -8053,9 +8137,8 @@
- name: nvidia/nemotron-3-nano-30b-a3b
type: chat
max_input_tokens: 262144
input_price: 0.05
output_price: 0.2
cache_read_price: 0.03
input_price: 0.06
output_price: 0.24
max_output_tokens: 235929
supports_function_calling: true
- name: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
@@ -8083,10 +8166,10 @@
- name: nvidia/nemotron-3-ultra-550b-a55b
type: chat
max_input_tokens: 262144
input_price: 0.6
output_price: 2.4
cache_read_price: 0.12
max_output_tokens: 182520
input_price: 0.625
output_price: 3.125
cache_read_price: 0.1875
max_output_tokens: 32768
supports_function_calling: true
- name: nvidia/nemotron-3-ultra-550b-a55b:free
type: chat
@@ -8969,12 +9052,6 @@
cache_read_price: 0.03
max_output_tokens: 117964
supports_function_calling: true
- name: openai/gpt-oss-20b:batch
type: chat
max_input_tokens: 131072
input_price: 0.05
output_price: 0.2
max_output_tokens: 117964
- name: openai/gpt-oss-safeguard-20b
type: chat
max_input_tokens: 131072
@@ -9378,9 +9455,9 @@
- name: qwen/qwen3-vl-30b-a3b-instruct
type: chat
max_input_tokens: 262144
input_price: 0.15
output_price: 0.6
max_output_tokens: 16384
input_price: 0.13
output_price: 0.52
max_output_tokens: 32768
supports_vision: true
supports_function_calling: true
- name: qwen/qwen3-vl-30b-a3b-thinking
@@ -9689,6 +9766,14 @@
input_price: 0.65
output_price: 0.75
max_output_tokens: 16384
- name: stealth/union-alpha
type: chat
max_input_tokens: 262144
input_price: 0.0
output_price: 0.0
max_output_tokens: 131072
supports_vision: true
supports_function_calling: true
- name: stepfun/step-3.5-flash
type: chat
max_input_tokens: 262144
@@ -9773,7 +9858,6 @@
input_price: 0.4
output_price: 0.4
max_output_tokens: 819200
supports_function_calling: true
- name: thinkingmachines/inkling
type: chat
max_input_tokens: 1048576
@@ -9792,15 +9876,6 @@
max_output_tokens: 262144
supports_vision: true
supports_function_calling: true
- name: thinkingmachines/inkling-small:batch
type: chat
max_input_tokens: 524288
input_price: 0.5
output_price: 1.2
cache_read_price: 0.1
max_output_tokens: 471859
supports_vision: true
supports_function_calling: true
- name: thinkingmachines/inkling-small:free
type: chat
max_input_tokens: 1048576
@@ -10020,7 +10095,7 @@
input_price: 1.4
output_price: 4.4
cache_read_price: 0.14
max_output_tokens: 128000
max_output_tokens: 131072
supports_function_calling: true
- name: z-ai/glm-5.2:batch
type: chat
@@ -10030,6 +10105,12 @@
cache_read_price: 0.07
max_output_tokens: 943718
supports_function_calling: true
- name: z-ai/glm-5.2:free
type: chat
max_input_tokens: 32768
input_price: 0.0
output_price: 0.0
max_output_tokens: 29491
- name: z-ai/glm-5.3
type: chat
max_input_tokens: 1310720
@@ -10041,9 +10122,9 @@
- name: z-ai/glm-5.3-flash
type: chat
max_input_tokens: 1310720
input_price: 0.15
output_price: 0.5
cache_read_price: 0.03
input_price: 0.09
output_price: 0.3
cache_read_price: 0.018
max_output_tokens: 131072
supports_vision: true
supports_function_calling: true
@@ -10133,8 +10214,8 @@
- name: ~deepseek/deepseek-v4-flash-latest
type: chat
max_input_tokens: 1310720
input_price: 0.04
output_price: 0.1
input_price: 0.03
output_price: 0.13
cache_read_price: 0.01
max_output_tokens: 393216
supports_function_calling: true
+30
View File
@@ -51,6 +51,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
- match: "o3"
system_prompt_prefix: Formatting re-enabled
patch:
@@ -58,6 +62,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
- match: "o3-mini"
system_prompt_prefix: Formatting re-enabled
patch:
@@ -65,6 +73,10 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
models:
- name: text-embedding-3-large
type: embedding
@@ -94,6 +106,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: o3-high
real_name: o3
max_input_tokens: 200000
@@ -108,6 +126,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: o3-mini-high
max_output_tokens: 100000
real_name: o3-mini
@@ -123,6 +147,12 @@
max_tokens: null
temperature: null
top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: chat-latest
max_input_tokens: 400000
max_output_tokens: 128000
+232
View File
@@ -0,0 +1,232 @@
#!/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"
+10
View File
@@ -609,6 +609,10 @@ mod tests {
parse(&["--install-builtins", "mcp_config"]).install_builtins,
Some(AssetCategory::McpConfig)
);
assert_eq!(
parse(&["--install-builtins", "hooks"]).install_builtins,
Some(AssetCategory::Hooks)
);
}
#[test]
@@ -620,12 +624,14 @@ mod tests {
assert!(category.get_name_and_aliases().any(|n| n == "mcp_config"));
assert!(AssetCategory::NAMES.contains(&"mcp-config"));
assert!(!AssetCategory::NAMES.contains(&"mcp_config"));
assert!(AssetCategory::NAMES.contains(&"hooks"));
let filter = InstallFilter::McpConfig.to_possible_value().unwrap();
assert_eq!(filter.get_name(), "mcp-config");
assert!(filter.get_name_and_aliases().any(|n| n == "mcp_config"));
assert!(InstallFilter::NAMES.contains(&"mcp-config"));
assert!(!InstallFilter::NAMES.contains(&"mcp_config"));
assert!(InstallFilter::NAMES.contains(&"hooks"));
}
#[test]
@@ -685,6 +691,10 @@ mod tests {
.filter,
Some(InstallFilter::McpConfig)
);
assert_eq!(
parse(&["--install", "https://github.com/x/y", "--filter", "hooks"]).filter,
Some(InstallFilter::Hooks)
);
}
#[test]
+43 -8
View File
@@ -73,6 +73,7 @@ impl BedrockClient {
url: _,
headers,
body,
..
} = request_data;
let builder = aws_fetch(
@@ -126,6 +127,7 @@ impl BedrockClient {
url: _,
headers,
body,
..
} = request_data;
let builder = aws_fetch(
@@ -427,7 +429,9 @@ fn build_chat_completions_body(
let mut user_parts = vec![];
for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking {
assistant_parts.push(converse_reasoning_block(block));
if let Some(part) = converse_reasoning_block(block) {
assistant_parts.push(part);
}
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
@@ -496,7 +500,9 @@ fn build_chat_completions_body(
chunk_ids.insert(id);
}
for block in &tool_result.thinking {
assistant_parts.push(converse_reasoning_block(block));
if let Some(part) = converse_reasoning_block(block) {
assistant_parts.push(part);
}
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
@@ -661,23 +667,26 @@ fn bedrock_tool_result_content(output: &Value) -> Value {
}
/// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse
/// reasoningContent union so signed blocks replay verbatim.
fn converse_reasoning_block(block: &ThinkingBlock) -> Value {
/// reasoningContent union so signed blocks replay verbatim. OpenAI Responses
/// reasoning items have no Converse encoding, so they are dropped when the
/// session moves to Bedrock.
fn converse_reasoning_block(block: &ThinkingBlock) -> Option<Value> {
match block {
ThinkingBlock::Thinking {
thinking,
signature,
} => json!({
} => Some(json!({
"reasoningContent": {
"reasoningText": {
"text": thinking,
"signature": signature,
}
}
}),
ThinkingBlock::RedactedThinking { data } => json!({
})),
ThinkingBlock::RedactedThinking { data } => Some(json!({
"reasoningContent": { "redactedContent": data }
}),
})),
ThinkingBlock::Reasoning { .. } => None,
}
}
@@ -1185,6 +1194,32 @@ mod tests {
}
}
#[test]
fn skips_openai_reasoning_blocks_in_converse_shape() {
let reasoning = ToolResult {
call: ToolCall::new(
"fs_read".into(),
json!({"path": "x"}),
Some("tool_A".into()),
),
output: json!("ok"),
text: None,
thinking: vec![ThinkingBlock::Reasoning {
id: "rs_1".into(),
summary: json!([{ "type": "summary_text", "text": "thinking" }]),
encrypted_content: Some("enc123".into()),
}],
};
for sequence in [false, true] {
let body = build_body_with(vec![reasoning.clone()], "", sequence);
let content = body["messages"][1]["content"].as_array().unwrap();
assert_eq!(content.len(), 1, "body: {body}");
assert!(content[0].get("toolUse").is_some(), "body: {body}");
}
}
#[test]
fn extract_chat_completions_captures_reasoning_content() {
let data = json!({
+477 -13
View File
@@ -2,13 +2,12 @@ use std::collections::HashSet;
use std::mem;
use super::access_token::get_access_token;
use super::claude_oauth::ClaudeOAuthProvider;
use super::oauth::{self, OAuthProvider};
use super::oauth::{self, OAuthConfig};
use super::*;
use crate::utils::strip_think_tag;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result, anyhow, bail};
use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -22,6 +21,7 @@ pub struct ClaudeConfig {
pub api_key: Option<String>,
pub api_base: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)]
pub models: Vec<ModelData>,
pub prompt_cache: Option<bool>,
@@ -71,9 +71,17 @@ async fn prepare_chat_completions(
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<RequestData> {
let api_base = self_
.get_api_base()
.unwrap_or_else(|_| API_BASE.to_string());
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if !uses_oauth && self_.config.oauth.is_some() {
bail!(
"'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.",
self_.name(),
self_.name()
);
}
let api_base = resolve_api_base(self_)?;
let url = format!("{}/messages", api_base.trim_end_matches('/'));
let body = claude_build_chat_completions_body(
@@ -86,24 +94,27 @@ async fn prepare_chat_completions(
request_data.header("anthropic-version", "2023-06-01");
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if uses_oauth {
let provider = ClaudeOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
let client_name = self_.name();
let cc = locate_client_config(self_)?;
let (provider, uses_stock_provider) =
oauth::claude_oauth_provider_for_client(cc, &ALL_PROVIDER_MODELS);
let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
self_.name(),
self_.name()
client_name,
client_name
);
}
let token = get_access_token(self_.name())?;
let token = get_access_token(client_name)?;
request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
if uses_stock_provider {
inject_oauth_system_prompt(&mut request_data.body);
}
} else if let Ok(api_key) = self_.get_api_key() {
request_data.header("x-api-key", api_key);
} else {
@@ -117,6 +128,50 @@ async fn prepare_chat_completions(
Ok(request_data)
}
fn resolve_api_base(self_: &ClaudeClient) -> Result<String> {
resolve_api_base_against(self_, &ALL_PROVIDER_MODELS)
}
fn resolve_api_base_against(
self_: &ClaudeClient,
all_provider_models: &[ProviderModels],
) -> Result<String> {
if let Ok(api_base) = self_.get_api_base() {
return Ok(api_base);
}
let uses_config_oauth = self_.config.auth.as_deref() == Some("oauth")
&& match locate_client_config(self_) {
Ok(cc) => oauth::config_oauth_for_client(cc, all_provider_models).is_some(),
// Safe: oauth prepare paths bail on this locate failure before attaching any token.
Err(_) => self_.config.oauth.is_some(),
};
if uses_config_oauth {
bail!(
"'{}' has a config-driven OAuth configuration (an `oauth:` block in the client entry or models catalog) but no `api_base`; refusing to fall back to {} (the oauth token would be sent to the wrong host). Set `api_base` on the '{}' client entry.",
self_.name(),
API_BASE,
self_.name()
);
}
Ok(API_BASE.to_string())
}
fn locate_client_config(self_: &ClaudeClient) -> Result<&ClientConfig> {
let client_name = self_.name();
self_
.app_config()
.clients
.iter()
.find(|cc| {
matches!(
cc,
ClientConfig::ClaudeConfig(c)
if c.name.as_deref().unwrap_or(ClaudeClient::NAME) == client_name
)
})
.ok_or_else(|| anyhow!("Could not locate ClientConfig entry for '{}'", client_name))
}
/// Anthropic requires OAuth-authenticated requests to include a Claude Code
/// system prompt prefix in order to consider a request body as "valid".
///
@@ -352,8 +407,17 @@ pub fn claude_build_chat_completions_body(
let mut user_parts = vec![];
for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking {
// Only Anthropic thinking blocks replay here;
// Responses reasoning items (and any future
// variant) are dropped.
if matches!(
block,
ThinkingBlock::Thinking { .. }
| ThinkingBlock::RedactedThinking { .. }
) {
assistant_parts.push(json!(block));
}
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
} else {
@@ -420,8 +484,17 @@ pub fn claude_build_chat_completions_body(
chunk_ids.insert(id);
}
for block in &tool_result.thinking {
// Only Anthropic thinking blocks replay here;
// Responses reasoning items (and any future
// variant) are dropped.
if matches!(
block,
ThinkingBlock::Thinking { .. }
| ThinkingBlock::RedactedThinking { .. }
) {
assistant_parts.push(json!(block));
}
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
} else {
@@ -632,7 +705,11 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
#[cfg(test)]
mod tests {
use super::*;
use crate::client::access_token::set_access_token;
use crate::config::AppConfig;
use crate::function::{FunctionDeclaration, ToolCall, ToolResult};
use chrono::Utc;
use std::sync::Arc;
fn tool_result(id: &str, text: Option<&str>) -> ToolResult {
ToolResult {
@@ -816,6 +893,48 @@ mod tests {
assert_eq!(messages.len(), 1, "body: {body}");
}
#[test]
fn replays_only_anthropic_thinking_blocks() {
let mixed = ToolResult {
call: ToolCall::new(
"fs_read".into(),
json!({"path": "x"}),
Some("toolu_A".into()),
),
output: json!("ok"),
text: None,
thinking: vec![
ThinkingBlock::Thinking {
thinking: "hmm".into(),
signature: "sig123".into(),
},
ThinkingBlock::RedactedThinking {
data: "b64data".into(),
},
ThinkingBlock::Reasoning {
id: "rs_1".into(),
summary: json!([{ "type": "summary_text", "text": "thinking" }]),
encrypted_content: Some("enc123".into()),
},
],
};
for sequence in [false, true] {
let body = build_body_with(vec![mixed.clone()], "", sequence, false);
let content = body["messages"][1]["content"].as_array().unwrap();
let types: Vec<_> = content
.iter()
.map(|block| block["type"].as_str().unwrap())
.collect();
assert_eq!(
types,
["thinking", "redacted_thinking", "tool_use"],
"body: {body}"
);
}
}
#[test]
fn prompt_cache_places_breakpoints() {
let functions = vec![function_declaration("a"), function_declaration("b")];
@@ -905,4 +1024,349 @@ mod tests {
assert_eq!(usage.cache_creation_input_tokens, Some(100));
assert_eq!(usage.cache_read_input_tokens, Some(200));
}
fn claude_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> ClaudeConfig {
ClaudeConfig {
name: Some(name.into()),
api_key: None,
api_base: oauth
.as_ref()
.map(|_| "https://gateway.example/v1".to_string()),
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
models: vec![],
prompt_cache: None,
patch: None,
extra: None,
}
}
fn minimal_oauth_config() -> OAuthConfig {
serde_yaml::from_str("client_id: gateway\ntoken_url: https://gateway.example/token")
.unwrap()
}
fn make_client(config: ClaudeConfig, clients: Vec<ClientConfig>) -> ClaudeClient {
ClaudeClient {
app_config: Arc::new(AppConfig {
clients,
..AppConfig::default()
}),
config,
model: Model::new("claude", "claude-test"),
}
}
fn prepare(client: &ClaudeClient) -> Result<RequestData> {
let data = ChatCompletionsData {
messages: vec![Message::new(
MessageRole::User,
MessageContent::Text("hello".to_string()),
)],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_chat_completions(
client,
&ReqwestClient::new(),
data,
))
}
#[test]
fn oauth_stock_provider_applies_claude_code_spoof() {
let name = "claude-gate-stock-test";
let config = claude_config(name, Some("oauth"), None);
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
set_access_token(name, "stock-at".into(), Utc::now().timestamp() + 3600, None);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer stock-at")
);
assert_eq!(
request_data
.headers
.get("anthropic-beta")
.map(String::as_str),
Some("oauth-2025-04-20")
);
assert_eq!(request_data.body["system"][0]["text"], CLAUDE_CODE_PREFIX);
}
#[test]
fn oauth_config_provider_skips_claude_code_spoof() {
let name = "claude-gate-gateway-test";
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
set_access_token(
name,
"gateway-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer gateway-at")
);
assert!(
!request_data.headers.contains_key("anthropic-beta"),
"headers: {:?}",
request_data.headers
);
assert!(
request_data.body.get("system").is_none(),
"body: {}",
request_data.body
);
}
#[test]
fn api_key_auth_sets_x_api_key_without_spoof() {
let name = "claude-gate-apikey-test";
let mut config = claude_config(name, None, None);
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data.headers.get("x-api-key").map(String::as_str),
Some("sk-test")
);
assert!(!request_data.headers.contains_key("authorization"));
assert!(!request_data.headers.contains_key("anthropic-beta"));
assert!(
request_data.body.get("system").is_none(),
"body: {}",
request_data.body
);
}
#[test]
fn oauth_block_without_auth_oauth_is_rejected() {
let name = "claude-gate-contradiction-test";
let mut config = claude_config(name, None, Some(minimal_oauth_config()));
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let err = match prepare(&client) {
Ok(_) => panic!("expected the contradictory config to be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains("has an `oauth:` block configured but `auth: oauth` is not set"),
"unexpected error: {err}"
);
}
#[test]
fn oauth_block_without_api_base_is_rejected() {
let name = "claude-gate-apibase-missing-test";
let mut config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
config.api_base = None;
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let err = resolve_api_base(&client).unwrap_err().to_string();
assert!(
err.contains(name) && err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn catalog_only_oauth_block_without_api_base_is_rejected() {
let name = "claude-gate-catalog-oauth-test";
let config = claude_config(name, Some("oauth"), None);
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let catalog = vec![ProviderModels {
provider: name.into(),
oauth: Some(minimal_oauth_config()),
models: vec![],
}];
let err = resolve_api_base_against(&client, &catalog)
.unwrap_err()
.to_string();
assert!(
err.contains(name) && err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn catalog_oauth_block_with_api_key_auth_falls_back_to_stock_api_base() {
let name = "claude-gate-catalog-apikey-test";
let mut config = claude_config(name, None, None);
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let catalog = vec![ProviderModels {
provider: name.into(),
oauth: Some(minimal_oauth_config()),
models: vec![],
}];
let api_base = resolve_api_base_against(&client, &catalog).unwrap();
assert_eq!(api_base, API_BASE);
}
#[test]
fn auth_mismatch_bails_before_missing_api_base_guard() {
let name = "claude-gate-bail-order-test";
let mut config = claude_config(name, None, Some(minimal_oauth_config()));
config.api_base = None;
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let err = match prepare(&client) {
Ok(_) => panic!("expected the contradictory config to be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains("has an `oauth:` block configured but `auth: oauth` is not set")
&& !err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn oauth_block_with_api_base_resolves_it() {
let name = "claude-gate-apibase-set-test";
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let api_base = resolve_api_base(&client).unwrap();
assert_eq!(api_base, "https://gateway.example/v1");
}
#[test]
fn no_oauth_block_falls_back_to_stock_api_base() {
let name = "claude-gate-apibase-fallback-test";
let config = claude_config(name, None, None);
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
let api_base = resolve_api_base(&client).unwrap();
assert_eq!(api_base, API_BASE);
}
#[test]
fn oauth_lookup_resolves_entry_matching_client_name() {
let name = "claude-gate-lookup-test";
// The sibling entry resolves to the stock provider; if the lookup
// ignored the name it would apply the Claude Code spoof here.
let sibling = claude_config("claude-gate-lookup-sibling-test", Some("oauth"), None);
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(
config.clone(),
vec![
ClientConfig::ClaudeConfig(sibling),
ClientConfig::ClaudeConfig(config),
],
);
set_access_token(
name,
"lookup-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer lookup-at")
);
assert!(!request_data.headers.contains_key("anthropic-beta"));
assert!(request_data.body.get("system").is_none());
}
#[test]
fn oauth_lookup_falls_back_to_default_name_for_unnamed_entry() {
// An entry with `name: None` must match a client named "claude" via
// the `unwrap_or(ClaudeClient::NAME)` fallback.
let sibling = claude_config("claude-gate-fallback-sibling-test", Some("oauth"), None);
let mut config = claude_config("ignored", Some("oauth"), Some(minimal_oauth_config()));
config.name = None;
let client = make_client(
config.clone(),
vec![
ClientConfig::ClaudeConfig(sibling),
ClientConfig::ClaudeConfig(config),
],
);
set_access_token(
ClaudeClient::NAME,
"fallback-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare(&client).unwrap();
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer fallback-at")
);
assert!(!request_data.headers.contains_key("anthropic-beta"));
assert!(request_data.body.get("system").is_none());
}
#[test]
fn oauth_lookup_errors_when_config_entry_missing() {
let config = claude_config("claude-gate-missing-test", Some("oauth"), None);
let client = make_client(config, vec![]);
let err = match prepare(&client) {
Ok(_) => panic!("expected the lookup to fail"),
Err(err) => err.to_string(),
};
assert!(
err.contains("Could not locate ClientConfig entry"),
"unexpected error: {err}"
);
}
#[test]
fn shipped_catalog_resolves_stock_claude_provider() {
let cc = ClientConfig::ClaudeConfig(claude_config("claude", Some("oauth"), None));
let bundled: Vec<ProviderModels> = serde_yaml::from_str(MODELS_YAML).unwrap();
let (_, is_stock) = oauth::claude_oauth_provider_for_client(&cc, &bundled);
assert!(
is_stock,
"bundled models.yaml must not carry a claude oauth block: it would silently drop the Claude Code spoof for stock Pro/Max users"
);
}
}
+796 -15
View File
@@ -2,6 +2,7 @@ use super::*;
use super::access_token::{distrust_access_token, get_access_token};
use crate::config::{RenderMode, paths};
use crate::hooks::{self, HookEvent};
use crate::{
config::{AppConfig, Input, RequestContext},
function::{FunctionDeclaration, ToolCall, ToolResult, eval_tool_calls},
@@ -20,7 +21,7 @@ use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::LazyLock;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::mpsc::unbounded_channel;
pub const MODELS_YAML: &str = include_str!("../../models.yaml");
@@ -221,23 +222,39 @@ pub trait Client: Sync + Send {
request_data.into_builder(client)
}
/// Patches are chat-shaped unless keyed otherwise: on the Responses wire
/// only the `responses` sub-patch of a model patch and the `responses`
/// section of a client patch apply, so chat-only keys never leak into a
/// responses body.
fn patch_request_data(&self, request_data: &mut RequestData) {
let model_type = self.model().model_type();
let wire = request_data.wire;
if let Some(patch) = self.model().patch() {
match wire {
WireApi::Chat => request_data.apply_patch(patch.clone()),
WireApi::Responses => {
if let Some(patch) = patch.get("responses") {
request_data.apply_patch(patch.clone());
}
}
}
}
let api_name = match wire {
WireApi::Responses => "responses",
WireApi::Chat => model_type.api_name(),
};
let patch_map = std::env::var(get_env_name(&format!(
"patch_{}_{}",
"patch_{}_{api_name}",
self.model().client_name(),
model_type.api_name(),
)))
.ok()
.and_then(|v| serde_json::from_str(&v).ok())
.or_else(|| {
self.patch_config()
.and_then(|v| model_type.extract_patch(v))
.cloned()
self.patch_config().and_then(|v| match wire {
WireApi::Responses => v.responses.clone(),
WireApi::Chat => model_type.extract_patch(v).cloned(),
})
});
let patch_map = match patch_map {
Some(v) => v,
@@ -271,16 +288,26 @@ pub struct ExtraConfig {
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RequestPatch {
pub chat_completions: Option<ApiPatch>,
pub responses: Option<ApiPatch>,
pub embeddings: Option<ApiPatch>,
pub rerank: Option<ApiPatch>,
}
pub type ApiPatch = IndexMap<String, Value>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WireApi {
Responses,
Chat,
}
#[derive(Debug)]
pub struct RequestData {
pub url: String,
pub headers: IndexMap<String, String>,
pub body: Value,
pub wire: WireApi,
}
impl RequestData {
@@ -292,9 +319,15 @@ impl RequestData {
url: url.to_string(),
headers: Default::default(),
body,
wire: WireApi::Chat,
}
}
pub fn wire(mut self, wire: WireApi) -> Self {
self.wire = wire;
self
}
pub fn bearer_auth<T>(&mut self, auth: T)
where
T: std::fmt::Display,
@@ -312,7 +345,9 @@ impl RequestData {
}
pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {
let RequestData { url, headers, body } = self;
let RequestData {
url, headers, body, ..
} = self;
debug!("Request {url} {body}");
let mut builder = client.post(url);
@@ -546,6 +581,47 @@ pub async fn create_openai_compatible_client_config(
Ok(Some((model, clients)))
}
fn llm_request_extras(client: &dyn Client) -> Vec<(&'static str, String)> {
vec![
("COYOTE_LLM_PROVIDER", client.name().to_string()),
("COYOTE_LLM_MODEL", client.model().id()),
]
}
fn llm_completed_extras(client: &dyn Client, started_at: Instant) -> Vec<(&'static str, String)> {
let mut extras = llm_request_extras(client);
extras.push((
"COYOTE_LLM_DURATION_MS",
started_at.elapsed().as_millis().to_string(),
));
extras
}
fn llm_failed_extras(
client: &dyn Client,
err: Option<&anyhow::Error>,
aborted: bool,
) -> Vec<(&'static str, String)> {
let mut extras = llm_request_extras(client);
if aborted {
extras.push(("COYOTE_LLM_ERROR_KIND", "aborted".to_string()));
} else if let Some(api_err) = err.and_then(|err| err.downcast_ref::<ApiStatusError>()) {
extras.push(("COYOTE_LLM_ERROR_KIND", "api_error".to_string()));
extras.push(("COYOTE_LLM_STATUS", api_err.status.to_string()));
} else {
extras.push(("COYOTE_LLM_ERROR_KIND", "other".to_string()));
}
extras.push((
"COYOTE_LLM_ERROR",
// `{:#}` renders the whole context chain, so the root cause survives
// the `context(...)` wrapping the client trait defaults apply.
err.map(|err| format!("{err:#}"))
.unwrap_or_else(|| "Aborted.".to_string()),
));
extras
}
pub async fn call_chat_completions(
input: &Input,
print: bool,
@@ -557,15 +633,28 @@ pub async fn call_chat_completions(
let is_child_agent = ctx.current_depth > 0;
let suppress_spinner = is_child_agent || ctx.render_mode == RenderMode::Silent;
let spinner_message = if suppress_spinner { "" } else { "Generating" };
hooks::fire(
HookEvent::LlmRequestStarted,
ctx,
&llm_request_extras(client),
None,
);
let request_started_at = Instant::now();
let ret = abortable_run_with_spinner(
client.chat_completions(input.clone()),
spinner_message,
abort_signal,
abort_signal.clone(),
)
.await;
match ret {
Ok(ret) => {
hooks::fire(
HookEvent::LlmRequestCompleted,
ctx,
&llm_completed_extras(client, request_started_at),
None,
);
let ChatCompletionsOutput {
mut text,
tool_calls,
@@ -583,7 +672,15 @@ pub async fn call_chat_completions(
ctx.record_token_usage(usage, client.model());
finish_completion(ctx, text, tool_calls, thinking).await
}
Err(err) => Err(err),
Err(err) => {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), abort_signal.aborted()),
None,
);
Err(err)
}
}
}
@@ -600,6 +697,13 @@ pub async fn call_chat_completions_streaming(
handler.set_silent(true);
}
hooks::fire(
HookEvent::LlmRequestStarted,
ctx,
&llm_request_extras(client),
None,
);
let request_started_at = Instant::now();
let (send_ret, render_ret) = tokio::join!(
client.chat_completions_streaming(input, &mut handler),
render_stream(rx, client.app_config(), abort_signal.clone(), silent),
@@ -615,12 +719,32 @@ pub async fn call_chat_completions_streaming(
ctx.record_token_usage(usage, client.model());
if aborted_ctrld {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
bail!("Aborted.");
}
render_ret?;
if let Err(err) = render_ret {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), false),
None,
);
return Err(err);
}
if aborted_ctrlc {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
if !ctx.working_mode.is_repl() || ctx.session.is_none() {
bail!("Aborted.");
}
@@ -644,6 +768,12 @@ pub async fn call_chat_completions_streaming(
match send_ret {
Ok(_) => {
hooks::fire(
HookEvent::LlmRequestCompleted,
ctx,
&llm_completed_extras(client, request_started_at),
None,
);
if !silent && !text.is_empty() && !text.ends_with('\n') {
println!();
}
@@ -653,6 +783,12 @@ pub async fn call_chat_completions_streaming(
if !silent && !text.is_empty() {
println!();
}
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), false),
None,
);
Err(err)
}
}
@@ -675,10 +811,36 @@ pub async fn call_chat_completions_streaming_quiet(
ctx: &mut RequestContext,
abort_signal: AbortSignal,
) -> Result<(String, Vec<ToolResult>)> {
hooks::fire(
HookEvent::LlmRequestStarted,
ctx,
&llm_request_extras(client),
None,
);
let request_started_at = Instant::now();
if client.model().no_stream() {
let output = tokio::select! {
ret = client.chat_completions(input.clone()) => ret?,
_ = wait_abort_signal(&abort_signal) => bail!("Aborted."),
ret = client.chat_completions(input.clone()) => match ret {
Ok(output) => output,
Err(err) => {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), false),
None,
);
return Err(err);
}
},
_ = wait_abort_signal(&abort_signal) => {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
bail!("Aborted.")
}
};
let ChatCompletionsOutput {
text,
@@ -688,8 +850,20 @@ pub async fn call_chat_completions_streaming_quiet(
} = output;
ctx.record_token_usage(usage, client.model());
if abort_signal.aborted() {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
bail!("Aborted.");
}
hooks::fire(
HookEvent::LlmRequestCompleted,
ctx,
&llm_completed_extras(client, request_started_at),
None,
);
return finish_completion(ctx, text, tool_calls, thinking).await;
}
@@ -705,10 +879,30 @@ pub async fn call_chat_completions_streaming_quiet(
ctx.record_token_usage(usage, client.model());
if abort_signal.aborted() {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, None, true),
None,
);
bail!("Aborted.");
}
send_ret?;
if let Err(err) = send_ret {
hooks::fire(
HookEvent::LlmRequestFailed,
ctx,
&llm_failed_extras(client, Some(&err), false),
None,
);
return Err(err);
}
hooks::fire(
HookEvent::LlmRequestCompleted,
ctx,
&llm_completed_extras(client, request_started_at),
None,
);
finish_completion(ctx, text, tool_calls, thinking).await
}
@@ -969,6 +1163,7 @@ mod tests {
use super::super::access_token::{is_rejected, set_access_token};
use crate::config::{AppState, WorkingMode};
use anyhow::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::time::Instant;
@@ -1185,8 +1380,8 @@ mod tests {
/// Wrapped in `.context(...)` so every test below proves the downcast
/// works through an anyhow context chain, as in the trait methods.
fn api_status_error(status: u16) -> anyhow::Error {
anyhow::Error::new(ApiStatusError {
fn api_status_error(status: u16) -> Error {
Error::new(ApiStatusError {
status,
message: format!("error (status: {status})"),
})
@@ -1524,4 +1719,590 @@ mod tests {
assert_eq!(client.streaming_calls.load(Ordering::SeqCst), 1);
assert_paced_reply(&text, &results);
}
/// Fake provider whose every request fails with an [`ApiStatusError`],
/// wrapped in the same context chain the real trait defaults produce.
struct FailingStatusClient {
config: AppConfig,
model: Model,
status: u16,
}
impl FailingStatusClient {
fn new(status: u16) -> Self {
Self {
config: AppConfig::default(),
model: Model::default(),
status,
}
}
fn error(&self) -> Error {
Error::new(ApiStatusError {
status: self.status,
message: format!("error (status: {})", self.status),
})
}
}
#[async_trait::async_trait]
impl Client for FailingStatusClient {
fn app_config(&self) -> &AppConfig {
&self.config
}
fn extra_config(&self) -> Option<&ExtraConfig> {
None
}
fn patch_config(&self) -> Option<&RequestPatch> {
None
}
fn name(&self) -> &str {
"failing-status"
}
fn model(&self) -> &Model {
&self.model
}
async fn chat_completions_inner(
&self,
_client: &ReqwestClient,
_data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
Err(self.error())
}
async fn chat_completions_streaming_inner(
&self,
_client: &ReqwestClient,
_handler: &mut SseHandler,
_data: ChatCompletionsData,
) -> Result<()> {
Err(self.error())
}
}
fn llm_hooks_map(marker: &str) -> hooks::HooksMap {
[
"llm.request.started",
"llm.request.completed",
"llm.request.failed",
]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![crate::hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
)
})
.collect()
}
fn llm_hooked_ctx(marker: &str) -> RequestContext {
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks: llm_hooks_map(marker),
..Default::default()
});
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
ctx.render_mode = RenderMode::Silent;
ctx
}
fn llm_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn plain_transport_fires_llm_request_started_and_completed() {
let marker = "llm-hooks-plain-ok-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let mut client = paced_client(false);
client.nonstream_replies = true;
let _guard = hooks::test_sink::install();
call_chat_completions(
&input,
false,
false,
&client,
&mut ctx,
create_abort_signal(),
)
.await
.unwrap();
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
assert_eq!(
captures[0]
.envs
.get("COYOTE_LLM_PROVIDER")
.map(String::as_str),
Some("paced-stream")
);
assert!(captures[0].envs.contains_key("COYOTE_LLM_MODEL"));
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
assert!(!captures[1].envs.contains_key("COYOTE_LLM_ERROR_KIND"));
}
#[tokio::test]
#[serial_test::serial]
async fn plain_transport_fires_llm_request_failed_with_api_error_kind() {
let marker = "llm-hooks-plain-api-err-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = FailingStatusClient::new(429);
let _guard = hooks::test_sink::install();
let err = call_chat_completions(
&input,
false,
false,
&client,
&mut ctx,
create_abort_signal(),
)
.await
.unwrap_err();
assert!(
err.downcast_ref::<ApiStatusError>().is_some(),
"hooks must not change the error: {err:?}"
);
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
Some("api_error")
);
assert_eq!(
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
Some("429")
);
assert!(
failed
.envs
.get("COYOTE_LLM_ERROR")
.unwrap()
.contains("error (status: 429)")
);
assert!(!failed.envs.contains_key("COYOTE_LLM_DURATION_MS"));
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn streaming_transport_fires_llm_request_started_and_completed() {
let marker = "llm-hooks-stream-ok-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(false);
let _guard = hooks::test_sink::install();
call_chat_completions_streaming(&input, &client, &mut ctx, create_abort_signal())
.await
.unwrap();
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn streaming_transport_abort_fires_llm_request_failed_aborted() {
let marker = "llm-hooks-stream-abort-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(true);
let abort = create_abort_signal();
let trigger = abort.clone();
tokio::spawn(async move {
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
trigger.set_ctrlc();
});
let _guard = hooks::test_sink::install();
let err = call_chat_completions_streaming(&input, &client, &mut ctx, abort)
.await
.expect_err("an aborted stream must not succeed");
assert_eq!(err.to_string(), "Aborted.");
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
Some("aborted")
);
assert!(!failed.envs.contains_key("COYOTE_LLM_STATUS"));
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn quiet_transport_fires_llm_request_started_and_completed() {
let marker = "llm-hooks-quiet-ok-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(false);
let _guard = hooks::test_sink::install();
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
.await
.unwrap();
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
assert_eq!(
captures[0]
.envs
.get("COYOTE_LLM_PROVIDER")
.map(String::as_str),
Some("paced-stream")
);
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
}
#[tokio::test(start_paused = true)]
#[serial_test::serial]
async fn quiet_transport_abort_fires_llm_request_failed_aborted() {
let marker = "llm-hooks-quiet-abort-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(true);
let abort = create_abort_signal();
let trigger = abort.clone();
tokio::spawn(async move {
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
trigger.set_ctrlc();
});
let _guard = hooks::test_sink::install();
let err = call_chat_completions_streaming_quiet(&input, &client, &mut ctx, abort)
.await
.expect_err("an aborted stream must not succeed");
assert_eq!(err.to_string(), "Aborted.");
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
Some("aborted")
);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR").map(String::as_str),
Some("Aborted.")
);
}
#[tokio::test]
#[serial_test::serial]
async fn quiet_transport_fires_llm_request_failed_with_api_error_kind() {
let marker = "llm-hooks-quiet-api-err-x7x";
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = FailingStatusClient::new(500);
let _guard = hooks::test_sink::install();
let err =
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
.await
.unwrap_err();
assert!(err.downcast_ref::<ApiStatusError>().is_some());
let captures = llm_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
assert_eq!(
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
Some("api_error")
);
assert_eq!(
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
Some("500")
);
}
/// Minimal client for exercising `patch_request_data`: only the model
/// and the client-level patch config matter.
struct PatchProbeClient {
config: AppConfig,
model: Model,
patch: Option<RequestPatch>,
}
#[async_trait::async_trait]
impl Client for PatchProbeClient {
fn app_config(&self) -> &AppConfig {
&self.config
}
fn extra_config(&self) -> Option<&ExtraConfig> {
None
}
fn patch_config(&self) -> Option<&RequestPatch> {
self.patch.as_ref()
}
fn name(&self) -> &str {
self.model.client_name()
}
fn model(&self) -> &Model {
&self.model
}
async fn chat_completions_inner(
&self,
_client: &ReqwestClient,
_data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
bail!("the patch probe makes no requests")
}
async fn chat_completions_streaming_inner(
&self,
_client: &ReqwestClient,
_handler: &mut SseHandler,
_data: ChatCompletionsData,
) -> Result<()> {
bail!("the patch probe makes no requests")
}
}
fn patch_probe(
client_name: &str,
model_patch: Option<Value>,
patch: Option<RequestPatch>,
) -> PatchProbeClient {
let mut data = ModelData::new("gpt-test");
data.patch = model_patch;
PatchProbeClient {
config: AppConfig::default(),
model: Model::from_config(client_name, &[data]).remove(0),
patch,
}
}
fn client_patch(json: Value) -> RequestPatch {
serde_json::from_value(json).unwrap()
}
fn wire_request(wire: WireApi) -> RequestData {
RequestData::new("https://example.test/v1", json!({"model": "gpt-test"})).wire(wire)
}
#[test]
fn request_patch_accepts_a_responses_section() {
let patch = client_patch(json!({
"responses": {".*": {"body": {"store": true}}},
"chat_completions": {".*": {"body": {"temperature": 0.5}}},
}));
assert!(patch.responses.is_some());
assert!(patch.chat_completions.is_some());
}
#[test]
fn non_map_request_patch_responses_section_is_rejected() {
let err = serde_json::from_value::<RequestPatch>(json!({"responses": "bogus"}))
.unwrap_err()
.to_string();
assert!(err.contains("expected a map"), "unexpected error: {err}");
}
#[test]
fn chat_client_patch_never_touches_the_responses_wire() {
let client = patch_probe(
"patchprobechatiso",
None,
Some(client_patch(json!({
"chat_completions": {".*": {"body": {"temperature": 0.5}}}
}))),
);
let mut request_data = wire_request(WireApi::Responses);
let before = request_data.body.clone();
client.patch_request_data(&mut request_data);
assert_eq!(request_data.body, before);
}
#[test]
fn responses_client_patch_never_touches_the_chat_wire() {
let patch = Some(client_patch(json!({
"responses": {".*": {"body": {"store": true}}}
})));
let client = patch_probe("patchproberespiso", None, patch);
let mut chat_request = wire_request(WireApi::Chat);
let before = chat_request.body.clone();
client.patch_request_data(&mut chat_request);
assert_eq!(chat_request.body, before);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
assert_eq!(responses_request.body["store"], json!(true));
}
#[test]
fn chat_shaped_model_patch_never_touches_the_responses_wire() {
let client = patch_probe(
"patchprobemodeliso",
Some(json!({"body": {"reasoning_effort": "high"}})),
None,
);
let mut request_data = wire_request(WireApi::Responses);
let before = request_data.body.clone();
client.patch_request_data(&mut request_data);
assert_eq!(request_data.body, before);
}
#[test]
fn model_patch_responses_subkey_is_selected_per_wire() {
let model_patch = json!({
"body": {"temperature": 0.2},
"responses": {"body": {"store": true}},
});
let client = patch_probe("patchprobesubkey", Some(model_patch), None);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
assert_eq!(responses_request.body["store"], json!(true));
assert!(responses_request.body.get("temperature").is_none());
let mut chat_request = wire_request(WireApi::Chat);
client.patch_request_data(&mut chat_request);
assert_eq!(chat_request.body["temperature"], json!(0.2));
assert!(chat_request.body.get("store").is_none());
}
/// Pins the silent-ignore contract for a malformed `responses` sub-key:
/// model patches are untyped JSON maps and the whole patch layer is
/// deliberately lenient — `apply_patch` only reads object-shaped
/// `url`/`body`/`headers` keys — so a non-object `responses` sub-key
/// degrades to a no-op on the Responses wire (never a request-time
/// error, never a partial merge) and stays inert on the Chat wire,
/// where the patch's top-level keys still apply normally.
#[test]
fn malformed_model_patch_responses_subkey_is_silently_ignored() {
for malformed in [json!("oops"), json!([{"body": {"store": true}}])] {
let model_patch = json!({
"body": {"temperature": 0.2},
"responses": malformed,
});
let client = patch_probe("patchprobemalformed", Some(model_patch), None);
let mut responses_request = wire_request(WireApi::Responses);
let before_url = responses_request.url.clone();
let before_body = responses_request.body.clone();
let before_headers = responses_request.headers.clone();
client.patch_request_data(&mut responses_request);
assert_eq!(responses_request.url, before_url);
assert_eq!(responses_request.body, before_body);
assert_eq!(responses_request.headers, before_headers);
let mut chat_request = wire_request(WireApi::Chat);
client.patch_request_data(&mut chat_request);
assert_eq!(chat_request.body["temperature"], json!(0.2));
assert!(chat_request.body.get("store").is_none());
}
}
#[test]
fn env_patch_override_is_selected_per_wire() {
// Unique client name so the env vars cannot race parallel tests.
let client_name = "patchprobeenvwire";
let responses_env = get_env_name(&format!("patch_{client_name}_responses"));
let chat_env = get_env_name(&format!("patch_{client_name}_chat_completions"));
unsafe {
std::env::set_var(&responses_env, r#"{".*": {"body": {"store": true}}}"#);
std::env::set_var(&chat_env, r#"{".*": {"body": {"temperature": 0.7}}}"#);
}
let client = patch_probe(client_name, None, None);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
let mut chat_request = wire_request(WireApi::Chat);
client.patch_request_data(&mut chat_request);
unsafe {
std::env::remove_var(&responses_env);
std::env::remove_var(&chat_env);
}
assert_eq!(responses_request.body["store"], json!(true));
assert!(responses_request.body.get("temperature").is_none());
assert_eq!(chat_request.body["temperature"], json!(0.7));
assert!(chat_request.body.get("store").is_none());
}
#[test]
fn chat_wire_patching_is_unchanged_by_wire_awareness() {
let client = patch_probe(
"patchproberegress",
Some(json!({"body": {"max_tokens": null}, "headers": {"x-model": "m"}})),
Some(client_patch(json!({
"chat_completions": {".*": {
"url": "https://patched.example/v1/chat/completions",
"body": {"temperature": 0.9},
}}
}))),
);
let mut request_data = RequestData::new(
"https://example.test/v1/chat/completions",
json!({"model": "gpt-test", "max_tokens": 100}),
);
request_data.bearer_auth("sk-test");
client.patch_request_data(&mut request_data);
assert_eq!(
request_data.url,
"https://patched.example/v1/chat/completions"
);
assert_eq!(
request_data.body,
json!({"model": "gpt-test", "temperature": 0.9})
);
assert_eq!(
request_data.headers.get("x-model").map(String::as_str),
Some("m")
);
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer sk-test")
);
}
}
+385 -15
View File
@@ -1,10 +1,10 @@
use super::access_token::get_access_token;
use super::gemini_oauth::GeminiOAuthProvider;
use super::oauth;
use super::oauth::{self, OAuthConfig, OAuthProvider};
use super::vertexai::*;
use super::*;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result, anyhow, bail};
use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -17,6 +17,7 @@ pub struct GeminiConfig {
pub api_key: Option<String>,
pub api_base: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)]
pub models: Vec<ModelData>,
pub patch: Option<RequestPatch>,
@@ -85,9 +86,17 @@ async fn prepare_chat_completions(
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<RequestData> {
let api_base = self_
.get_api_base()
.unwrap_or_else(|_| API_BASE.to_string());
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if !uses_oauth && self_.config.oauth.is_some() {
bail!(
"'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.",
self_.name(),
self_.name()
);
}
let api_base = resolve_api_base(self_)?;
let func = match data.stream {
true => "streamGenerateContent",
@@ -104,11 +113,9 @@ async fn prepare_chat_completions(
let body = gemini_build_chat_completions_body(data, &self_.model)?;
let mut request_data = RequestData::new(url, body);
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if uses_oauth {
let provider = GeminiOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
let (provider, _) = resolve_oauth_provider(self_)?;
let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
@@ -118,6 +125,9 @@ async fn prepare_chat_completions(
}
let token = get_access_token(self_.name())?;
request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
} else if let Ok(api_key) = self_.get_api_key() {
request_data.header("x-goog-api-key", api_key);
} else {
@@ -136,12 +146,18 @@ async fn prepare_embeddings(
client: &ReqwestClient,
data: &EmbeddingsData,
) -> Result<RequestData> {
let api_base = self_
.get_api_base()
.unwrap_or_else(|_| API_BASE.to_string());
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if !uses_oauth && self_.config.oauth.is_some() {
bail!(
"'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.",
self_.name(),
self_.name()
);
}
let api_base = resolve_api_base(self_)?;
let url = if uses_oauth {
format!(
"{}/models/{}:batchEmbedContents",
@@ -177,8 +193,8 @@ async fn prepare_embeddings(
let mut request_data = RequestData::new(url, body);
if uses_oauth {
let provider = GeminiOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
let (provider, _) = resolve_oauth_provider(self_)?;
let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
@@ -188,11 +204,72 @@ async fn prepare_embeddings(
}
let token = get_access_token(self_.name())?;
request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
}
Ok(request_data)
}
fn resolve_api_base(self_: &GeminiClient) -> Result<String> {
resolve_api_base_against(self_, &ALL_PROVIDER_MODELS)
}
fn resolve_api_base_against(
self_: &GeminiClient,
all_provider_models: &[ProviderModels],
) -> Result<String> {
if let Ok(api_base) = self_.get_api_base() {
return Ok(api_base);
}
let uses_config_oauth = self_.config.auth.as_deref() == Some("oauth")
&& match locate_client_config(self_) {
Ok(cc) => oauth::config_oauth_for_client(cc, all_provider_models).is_some(),
// Safe: on locate failure, provider resolution bails when an inline oauth block exists; otherwise the stock provider is used and issuer-stamped gateway tokens are rejected before attach.
Err(_) => self_.config.oauth.is_some(),
};
if uses_config_oauth {
bail!(
"'{}' has a config-driven OAuth configuration (an `oauth:` block in the client entry or models catalog) but no `api_base`; refusing to fall back to {} (the oauth token would be sent to the wrong host). Set `api_base` on the '{}' client entry.",
self_.name(),
API_BASE,
self_.name()
);
}
Ok(API_BASE.to_string())
}
fn locate_client_config(self_: &GeminiClient) -> Result<&ClientConfig> {
let client_name = self_.name();
self_
.app_config()
.clients
.iter()
.find(|cc| {
matches!(
cc,
ClientConfig::GeminiConfig(c)
if c.name.as_deref().unwrap_or(GeminiClient::NAME) == client_name
)
})
.ok_or_else(|| anyhow!("Could not locate ClientConfig entry for '{}'", client_name))
}
fn resolve_oauth_provider(self_: &GeminiClient) -> Result<(Box<dyn OAuthProvider>, bool)> {
match locate_client_config(self_) {
Ok(cc) => Ok(oauth::gemini_oauth_provider_for_client(
cc,
&ALL_PROVIDER_MODELS,
)),
Err(_) if self_.config.oauth.is_none() => Ok((Box::new(GeminiOAuthProvider), true)),
Err(err) => Err(err),
}
}
async fn embeddings(builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {
let res = builder.send().await?;
let status = res.status();
@@ -219,3 +296,296 @@ struct EmbeddingsResBody {
struct EmbeddingsResBodyEmbedding {
values: Vec<f32>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::access_token::set_access_token;
use crate::config::AppConfig;
use chrono::Utc;
use std::sync::Arc;
fn gemini_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> GeminiConfig {
GeminiConfig {
name: Some(name.into()),
api_base: oauth
.as_ref()
.map(|_| "https://gateway.example/v1".to_string()),
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
..Default::default()
}
}
fn make_client(config: GeminiConfig, clients: Vec<ClientConfig>) -> GeminiClient {
GeminiClient {
app_config: Arc::new(AppConfig {
clients,
..AppConfig::default()
}),
config,
model: Model::new("gemini", "gemini-test"),
}
}
fn minimal_oauth_config() -> OAuthConfig {
serde_yaml::from_str("client_id: gateway\ntoken_url: https://gateway.example/token")
.unwrap()
}
fn prepare_chat(client: &GeminiClient) -> Result<RequestData> {
let data = ChatCompletionsData {
messages: vec![Message::new(
MessageRole::User,
MessageContent::Text("hello".to_string()),
)],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_chat_completions(
client,
&ReqwestClient::new(),
data,
))
}
fn prepare_embed(client: &GeminiClient) -> Result<RequestData> {
let data = EmbeddingsData::new(vec!["hello".to_string()], false);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_embeddings(client, &ReqwestClient::new(), &data))
}
#[test]
fn oauth_block_without_api_base_is_rejected() {
let name = "gemini-gate-apibase-missing-test";
let mut config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config()));
config.api_base = None;
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let err = resolve_api_base(&client).unwrap_err().to_string();
assert!(
err.contains(name) && err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn catalog_only_oauth_block_without_api_base_is_rejected() {
let name = "gemini-gate-catalog-oauth-test";
let config = gemini_config(name, Some("oauth"), None);
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let catalog = vec![ProviderModels {
provider: name.into(),
oauth: Some(minimal_oauth_config()),
models: vec![],
}];
let err = resolve_api_base_against(&client, &catalog)
.unwrap_err()
.to_string();
assert!(
err.contains(name) && err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn oauth_block_with_api_base_resolves_it() {
let name = "gemini-gate-apibase-set-test";
let config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let api_base = resolve_api_base(&client).unwrap();
assert_eq!(api_base, "https://gateway.example/v1");
}
#[test]
fn no_oauth_block_falls_back_to_stock_api_base() {
let name = "gemini-gate-apibase-fallback-test";
let config = gemini_config(name, None, None);
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let api_base = resolve_api_base(&client).unwrap();
assert_eq!(api_base, API_BASE);
}
#[test]
fn oauth_block_without_auth_oauth_is_rejected() {
let name = "gemini-gate-contradiction-test";
let mut config = gemini_config(name, None, Some(minimal_oauth_config()));
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let err = match prepare_chat(&client) {
Ok(_) => panic!("expected the contradictory config to be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains("has an `oauth:` block configured but `auth: oauth` is not set"),
"unexpected error: {err}"
);
}
#[test]
fn config_oauth_embeddings_url_omits_key_param() {
let name = "gemini-gate-embed-oauth-test";
let config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
set_access_token(
name,
"gateway-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare_embed(&client).unwrap();
assert_eq!(
request_data.url,
"https://gateway.example/v1/models/gemini-test:batchEmbedContents"
);
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer gateway-at")
);
}
#[test]
fn api_key_embeddings_url_appends_key_param() {
let name = "gemini-gate-embed-apikey-test";
let mut config = gemini_config(name, None, None);
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let request_data = prepare_embed(&client).unwrap();
assert_eq!(
request_data.url,
format!("{API_BASE}/models/gemini-test:batchEmbedContents?key=sk-test")
);
}
#[test]
fn config_oauth_extra_request_headers_reach_request() {
let name = "gemini-gate-extra-headers-test";
let oauth: OAuthConfig = serde_yaml::from_str(
"client_id: gateway\ntoken_url: https://gateway.example/token\nextra_request_headers:\n x-gateway-tenant: acme",
)
.unwrap();
let config = gemini_config(name, Some("oauth"), Some(oauth));
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
set_access_token(
name,
"gateway-at".into(),
Utc::now().timestamp() + 3600,
None,
);
let request_data = prepare_chat(&client).unwrap();
assert_eq!(
request_data
.headers
.get("x-gateway-tenant")
.map(String::as_str),
Some("acme")
);
}
#[test]
fn catalog_oauth_block_with_api_key_auth_falls_back_to_stock_api_base() {
let name = "gemini-gate-catalog-apikey-test";
let mut config = gemini_config(name, None, None);
config.api_key = Some("sk-test".into());
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let catalog = vec![ProviderModels {
provider: name.into(),
oauth: Some(minimal_oauth_config()),
models: vec![],
}];
let api_base = resolve_api_base_against(&client, &catalog).unwrap();
assert_eq!(api_base, API_BASE);
}
#[test]
fn missing_config_entry_without_inline_oauth_resolves_stock_provider() {
let config = gemini_config("gemini-gate-missing-entry-test", Some("oauth"), None);
let client = make_client(config, vec![]);
let (_, is_stock) = resolve_oauth_provider(&client).unwrap();
assert!(is_stock);
}
#[test]
fn missing_config_entry_with_inline_oauth_is_a_hard_error() {
let config = gemini_config(
"gemini-gate-missing-entry-inline-test",
Some("oauth"),
Some(minimal_oauth_config()),
);
let client = make_client(config, vec![]);
let err = match resolve_oauth_provider(&client) {
Ok(_) => panic!("expected the missing ClientConfig entry to be a hard error"),
Err(err) => err.to_string(),
};
assert!(
err.contains("Could not locate ClientConfig entry"),
"unexpected error: {err}"
);
}
#[test]
fn auth_mismatch_bails_before_missing_api_base_guard() {
let name = "gemini-gate-bail-order-test";
let mut config = gemini_config(name, None, Some(minimal_oauth_config()));
config.api_base = None;
let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]);
let err = match prepare_chat(&client) {
Ok(_) => panic!("expected the contradictory config to be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains("has an `oauth:` block configured but `auth: oauth` is not set")
&& !err.contains("refusing to fall back"),
"unexpected error: {err}"
);
}
#[test]
fn shipped_catalog_resolves_stock_gemini_provider() {
let cc = ClientConfig::GeminiConfig(gemini_config("gemini", Some("oauth"), None));
let bundled: Vec<ProviderModels> = serde_yaml::from_str(MODELS_YAML).unwrap();
let (_, is_stock) = oauth::gemini_oauth_provider_for_client(&cc, &bundled);
assert!(
is_stock,
"bundled models.yaml must not carry a gemini oauth block: it would silently replace the stock Google oauth provider"
);
}
}
+101 -5
View File
@@ -188,15 +188,27 @@ pub struct ImageUrl {
pub url: String,
}
/// An extended-thinking block returned by Anthropic-protocol models.
/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`)
/// An extended-thinking block returned by Anthropic-protocol models or an
/// OpenAI Responses reasoning item. Serialized to match each API's wire
/// format (`type: thinking` / `type: redacted_thinking` / `type: reasoning`)
/// so blocks can be replayed verbatim, signature intact, in subsequent
/// tool-loop rounds as the API requires.
/// tool-loop rounds as the APIs require.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThinkingBlock {
Thinking { thinking: String, signature: String },
RedactedThinking { data: String },
Thinking {
thinking: String,
signature: String,
},
RedactedThinking {
data: String,
},
Reasoning {
id: String,
summary: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -263,3 +275,87 @@ pub fn extract_system_message(messages: &mut Vec<Message>) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn old_session_thinking_blocks_still_deserialize() {
let json = json!([
{ "type": "thinking", "thinking": "hmm", "signature": "sig123" },
{ "type": "redacted_thinking", "data": "b64data" },
]);
let blocks: Vec<ThinkingBlock> = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&blocks[0], ThinkingBlock::Thinking { thinking, signature } if thinking == "hmm" && signature == "sig123")
);
assert!(
matches!(&blocks[1], ThinkingBlock::RedactedThinking { data } if data == "b64data")
);
assert_eq!(serde_json::to_value(&blocks).unwrap(), json);
}
#[test]
fn reasoning_block_round_trips() {
let json = json!({
"type": "reasoning",
"id": "rs_1",
"summary": [{ "type": "summary_text", "text": "thinking about it" }],
"encrypted_content": "enc123",
});
let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&block, ThinkingBlock::Reasoning { id, encrypted_content, .. } if id == "rs_1" && encrypted_content.as_deref() == Some("enc123"))
);
assert_eq!(serde_json::to_value(&block).unwrap(), json);
}
#[test]
fn reasoning_block_without_encrypted_content_round_trips() {
let json = json!({
"type": "reasoning",
"id": "rs_1",
"summary": [],
});
let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&block, ThinkingBlock::Reasoning { encrypted_content, .. } if encrypted_content.is_none())
);
assert_eq!(serde_json::to_value(&block).unwrap(), json);
}
#[test]
fn thinking_blocks_round_trip_through_yaml() {
let json = json!([
{ "type": "thinking", "thinking": "hmm", "signature": "sig123" },
{ "type": "redacted_thinking", "data": "b64data" },
{
"type": "reasoning",
"id": "rs_1",
"summary": [{ "type": "summary_text", "text": "thinking about it" }],
"encrypted_content": "enc123",
},
{
"type": "reasoning",
"id": "rs_2",
"summary": [],
},
]);
let blocks: Vec<ThinkingBlock> = serde_json::from_value(json.clone()).unwrap();
let yaml = serde_yaml::to_string(&blocks).unwrap();
assert!(!yaml.contains("encrypted_content: null"));
let restored: Vec<ThinkingBlock> = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(serde_json::to_value(&restored).unwrap(), json);
}
}
+21
View File
@@ -536,4 +536,25 @@ reasoning_levels: [none, low, medium, high]
assert_eq!(data.default_reasoning_effort.as_deref(), Some("none"));
}
#[test]
fn shipped_catalog_carries_no_credentials_and_only_intended_oauth_defaults() {
assert!(
!super::super::common::MODELS_YAML.contains("api_key"),
"the shipped models.yaml must never carry an api key: the model catalog is credential-free by policy"
);
let bundled: Vec<ProviderModels> =
serde_yaml::from_str(super::super::common::MODELS_YAML).unwrap();
let with_oauth: Vec<&str> = bundled
.iter()
.filter(|p| p.oauth.is_some())
.map(|p| p.provider.as_str())
.collect();
assert_eq!(
with_oauth,
["xai"],
"oauth blocks in the shipped models.yaml are limited to providers with no built-in stock oauth path (currently only xai's bundled defaults); adding one for claude/openai/gemini would silently swap stock oauth users onto a non-stock provider"
);
}
}
+623 -22
View File
@@ -1,5 +1,8 @@
use super::access_token::{clear_rejected, is_rejected, is_valid_access_token, set_access_token};
use super::claude_oauth::ClaudeOAuthProvider;
use super::gemini_oauth::GeminiOAuthProvider;
use super::openai_compatible_oauth::OpenAICompatibleOAuthProvider;
use super::openai_oauth::OpenAIOAuthProvider;
use super::{ClientConfig, ProviderModels};
use crate::config::paths;
use anyhow::{Context, Error, Result, anyhow, bail};
@@ -211,6 +214,9 @@ pub trait OAuthProvider: Send + Sync {
fn use_pkce_in_device_flow(&self) -> bool {
false
}
fn requires_issuer_stamp(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -221,6 +227,10 @@ pub struct OAuthTokens {
pub expires_at: i64,
#[serde(default)]
pub account_id: Option<String>,
/// `"{token_url}|{client_id}"` of the issuing provider, stamped on save.
/// `None` on files predating the stamp; those are grandfathered.
#[serde(default)]
pub issuer: Option<String>,
}
const TOKEN_ENDPOINT_TIMEOUT: Duration = Duration::from_secs(30);
@@ -376,9 +386,10 @@ async fn run_pkce_flow(provider: &dyn OAuthProvider, client_name: &str) -> Resul
refresh_token,
expires_at,
account_id,
issuer: None,
};
save_oauth_tokens(client_name, &tokens)?;
store_tokens(client_name, provider, tokens)?;
println!(
"Successfully authenticated client '{}' with {} via OAuth. Tokens saved.",
@@ -430,8 +441,9 @@ async fn run_client_credentials_flow(
refresh_token: None,
expires_at,
account_id: provider.extract_account_id(&response),
issuer: None,
};
save_oauth_tokens(client_name, &tokens)?;
store_tokens(client_name, provider, tokens)?;
Ok(())
}
@@ -597,8 +609,9 @@ async fn run_device_code_flow(provider: &dyn OAuthProvider, client_name: &str) -
refresh_token,
expires_at,
account_id,
issuer: None,
};
save_oauth_tokens(client_name, &tokens)?;
store_tokens(client_name, provider, tokens)?;
println!(
"Successfully authenticated client '{}' with {} via OAuth (device_code). Tokens saved.",
client_name,
@@ -637,12 +650,22 @@ pub fn load_oauth_tokens(client_name: &str) -> Option<OAuthTokens> {
serde_json::from_str(&content).ok()
}
fn save_oauth_tokens(client_name: &str, tokens: &OAuthTokens) -> Result<()> {
/// Stamps the issuer fingerprint of `provider` before writing so stored
/// tokens stay bound to the provider that issued them.
fn save_oauth_tokens(
client_name: &str,
provider: &(impl OAuthProvider + ?Sized),
tokens: &OAuthTokens,
) -> Result<()> {
let tokens = OAuthTokens {
issuer: Some(issuer_fingerprint(provider)),
..tokens.clone()
};
let path = paths::token_file(client_name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(tokens)?;
let json = serde_json::to_string_pretty(&tokens)?;
// Write-then-rename so a crash mid-write never truncates the live token file.
let mut tmp = path.clone().into_os_string();
tmp.push(".tmp");
@@ -660,6 +683,35 @@ fn save_oauth_tokens(client_name: &str, tokens: &OAuthTokens) -> Result<()> {
Ok(())
}
/// Persists freshly-minted tokens and primes the in-memory cache so a running
/// session picks them up immediately instead of the stale token.
fn store_tokens(
client_name: &str,
provider: &(impl OAuthProvider + ?Sized),
tokens: OAuthTokens,
) -> Result<()> {
save_oauth_tokens(client_name, provider, &tokens)?;
set_access_token(
client_name,
tokens.access_token,
tokens.expires_at,
tokens.account_id,
);
clear_rejected(client_name);
Ok(())
}
fn issuer_fingerprint(provider: &(impl OAuthProvider + ?Sized)) -> String {
format!("{}|{}", provider.token_url(), provider.client_id())
}
fn issuer_matches(tokens: &OAuthTokens, provider: &(impl OAuthProvider + ?Sized)) -> bool {
match &tokens.issuer {
Some(issuer) => *issuer == issuer_fingerprint(provider),
None => !provider.requires_issuer_stamp(),
}
}
pub(crate) fn token_response_keys(response: &Value) -> String {
match response.as_object() {
Some(map) => {
@@ -764,9 +816,10 @@ pub async fn refresh_oauth_token(
refresh_token,
expires_at,
account_id,
issuer: None,
};
save_oauth_tokens(client_name, &new_tokens)?;
save_oauth_tokens(client_name, provider, &new_tokens)?;
Ok(new_tokens)
}
@@ -799,6 +852,10 @@ pub async fn prepare_oauth_access_token(
None => return Ok(false),
};
if !issuer_matches(&tokens, provider) {
return Ok(false);
}
let tokens = if Utc::now().timestamp() >= tokens.expires_at
|| is_rejected(client_name, &tokens.access_token)
{
@@ -817,6 +874,10 @@ pub async fn prepare_oauth_access_token(
None => return Ok(false),
};
if !issuer_matches(&tokens, provider) {
return Ok(false);
}
if Utc::now().timestamp() >= tokens.expires_at
|| is_rejected(client_name, &tokens.access_token)
{
@@ -998,13 +1059,85 @@ fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> {
pub fn get_oauth_provider(provider_type: &str) -> Option<Box<dyn OAuthProvider>> {
match provider_type {
"claude" => Some(Box::new(super::claude_oauth::ClaudeOAuthProvider)),
"gemini" => Some(Box::new(super::gemini_oauth::GeminiOAuthProvider)),
"openai" => Some(Box::new(super::openai_oauth::OpenAIOAuthProvider)),
"claude" => Some(Box::new(ClaudeOAuthProvider)),
"gemini" => Some(Box::new(GeminiOAuthProvider)),
"openai" => Some(Box::new(OpenAIOAuthProvider)),
_ => None,
}
}
pub(crate) fn config_oauth_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
) -> Option<OAuthConfig> {
let (client_name, _, _) = client_config_info(client_config);
let user_oauth = match client_config {
ClientConfig::OpenAICompatibleConfig(c) => c.oauth.clone().map(|b| *b),
ClientConfig::ClaudeConfig(c) => c.oauth.clone().map(|b| *b),
ClientConfig::OpenAIConfig(c) => c.oauth.clone().map(|b| *b),
ClientConfig::GeminiConfig(c) => c.oauth.clone().map(|b| *b),
_ => return None,
};
let base = all_provider_models
.iter()
.find(|p| p.provider == client_name)
.and_then(|p| p.oauth.clone());
match (base, user_oauth) {
(None, None) => None,
(Some(b), None) => Some(b),
(None, Some(u)) => Some(u),
(Some(b), Some(u)) => Some(b.merge(u)),
}
}
/// Resolves a config-driven `oauth:` block to the generic provider, or falls back to `stock`.
/// Returns the provider and whether the stock provider is in use.
fn oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
stock: fn() -> Box<dyn OAuthProvider>,
) -> (Box<dyn OAuthProvider>, bool) {
let (client_name, _, _) = client_config_info(client_config);
match config_oauth_for_client(client_config, all_provider_models) {
Some(merged) => (
Box::new(OpenAICompatibleOAuthProvider {
config: merged,
client_name: client_name.to_string(),
requires_issuer_stamp: true,
}),
false,
),
None => (stock(), true),
}
}
pub(crate) fn claude_oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
) -> (Box<dyn OAuthProvider>, bool) {
oauth_provider_for_client(client_config, all_provider_models, || {
Box::new(ClaudeOAuthProvider)
})
}
pub(crate) fn openai_oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
) -> (Box<dyn OAuthProvider>, bool) {
oauth_provider_for_client(client_config, all_provider_models, || {
Box::new(OpenAIOAuthProvider)
})
}
pub(crate) fn gemini_oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
) -> (Box<dyn OAuthProvider>, bool) {
oauth_provider_for_client(client_config, all_provider_models, || {
Box::new(GeminiOAuthProvider)
})
}
pub fn get_oauth_provider_for_client(
client_config: &ClientConfig,
all_provider_models: &[ProviderModels],
@@ -1015,23 +1148,29 @@ pub fn get_oauth_provider_for_client(
}
match client_config {
ClientConfig::OpenAICompatibleConfig(c) => {
let base = all_provider_models
.iter()
.find(|p| p.provider == client_name)
.and_then(|p| p.oauth.clone());
let user_oauth = c.oauth.clone().map(|b| *b);
let merged = match (base, user_oauth) {
(None, None) => return None,
(Some(b), None) => b,
(None, Some(u)) => u,
(Some(b), Some(u)) => b.merge(u),
};
ClientConfig::OpenAICompatibleConfig(_) => {
let merged = config_oauth_for_client(client_config, all_provider_models)?;
Some(Box::new(OpenAICompatibleOAuthProvider {
config: merged,
client_name: client_name.to_string(),
requires_issuer_stamp: false,
}))
}
ClientConfig::ClaudeConfig(_) => {
let (provider, _) =
claude_oauth_provider_for_client(client_config, all_provider_models);
Some(provider)
}
ClientConfig::OpenAIConfig(_) => {
let (provider, _) =
openai_oauth_provider_for_client(client_config, all_provider_models);
Some(provider)
}
ClientConfig::GeminiConfig(_) => {
let (provider, _) =
gemini_oauth_provider_for_client(client_config, all_provider_models);
Some(provider)
}
_ => get_oauth_provider(provider_type),
}
}
@@ -1097,6 +1236,9 @@ mod tests {
use super::*;
use crate::client::access_token::{distrust_access_token, get_access_token};
use crate::client::claude::ClaudeConfig;
use crate::client::gemini::GeminiConfig;
use crate::client::openai::OpenAIConfig;
use crate::client::openai_compatible::OpenAICompatibleConfig;
use crate::client::{ModelData, ProviderModels};
use crate::utils::get_env_name;
@@ -1318,6 +1460,61 @@ echo_pkce_in_token_exchange: true
api_key: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
wire_api: None,
models: vec![],
patch: None,
extra: None,
})
}
fn make_claude_client(
name: &str,
auth: Option<&str>,
oauth: Option<OAuthConfig>,
) -> ClientConfig {
ClientConfig::ClaudeConfig(ClaudeConfig {
name: Some(name.into()),
api_key: None,
api_base: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
models: vec![],
prompt_cache: None,
patch: None,
extra: None,
})
}
fn make_openai_client(
name: &str,
auth: Option<&str>,
oauth: Option<OAuthConfig>,
) -> ClientConfig {
ClientConfig::OpenAIConfig(OpenAIConfig {
name: Some(name.into()),
api_key: None,
api_base: None,
organization_id: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
wire_api: None,
models: vec![],
patch: None,
extra: None,
})
}
fn make_gemini_client(
name: &str,
auth: Option<&str>,
oauth: Option<OAuthConfig>,
) -> ClientConfig {
ClientConfig::GeminiConfig(GeminiConfig {
name: Some(name.into()),
api_key: None,
api_base: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
models: vec![],
patch: None,
extra: None,
@@ -1386,6 +1583,173 @@ echo_pkce_in_token_exchange: true
assert!(get_oauth_provider_for_client(&cc, &models).is_none());
}
#[test]
fn claude_client_with_user_oauth_block_uses_generic_provider() {
let mut user = empty_user_override("gateway-id", "https://gateway.example/token");
user.device_authorization_url = Some("https://gateway.example/device".into());
let cc = make_claude_client("claude", Some("oauth"), Some(user));
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "claude");
assert_eq!(provider.client_id(), "gateway-id");
assert_eq!(provider.token_url(), "https://gateway.example/token");
assert_eq!(
provider.device_authorization_url(),
Some("https://gateway.example/device")
);
}
#[test]
fn claude_client_without_oauth_block_falls_back_to_stock_provider() {
let cc = make_claude_client("claude", Some("oauth"), None);
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "claude");
assert_eq!(provider.client_id(), "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
assert_eq!(
provider.extra_request_headers(),
vec![("anthropic-beta", "oauth-2025-04-20")]
);
}
#[test]
fn claude_client_merges_bundled_defaults_with_user_override() {
let base = base_config();
let user = empty_user_override("user-id", "https://user.example/token");
let models = vec![make_provider_models("claude", Some(base))];
let cc = make_claude_client("claude", Some("oauth"), Some(user));
let provider = get_oauth_provider_for_client(&cc, &models).unwrap();
assert_eq!(provider.provider_name(), "claude");
assert_eq!(provider.client_id(), "user-id");
assert_eq!(provider.token_url(), "https://user.example/token");
assert_eq!(provider.authorize_url(), "https://base.example/authorize");
}
#[test]
fn claude_bundled_only_oauth_block_uses_generic_provider() {
let base = base_config();
let models = vec![make_provider_models("claude", Some(base))];
let cc = make_claude_client("claude", Some("oauth"), None);
let (provider, is_stock) = claude_oauth_provider_for_client(&cc, &models);
assert!(!is_stock);
assert_eq!(provider.client_id(), "base-id");
assert_eq!(provider.token_url(), "https://base.example/token");
assert!(provider.extra_request_headers().is_empty());
}
#[test]
fn claude_config_provider_requires_issuer_stamp() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_claude_client("claude", Some("oauth"), Some(user));
let (provider, is_stock) = claude_oauth_provider_for_client(&cc, &[]);
assert!(!is_stock);
assert!(provider.requires_issuer_stamp());
}
#[test]
fn claude_stock_provider_grandfathers_legacy_tokens() {
let cc = make_claude_client("claude", Some("oauth"), None);
let (provider, is_stock) = claude_oauth_provider_for_client(&cc, &[]);
assert!(is_stock);
assert!(!provider.requires_issuer_stamp());
}
#[test]
fn openai_client_with_user_oauth_block_uses_generic_provider() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_openai_client("openai", Some("oauth"), Some(user));
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "openai");
assert_eq!(provider.client_id(), "gateway-id");
assert_eq!(provider.token_url(), "https://gateway.example/token");
}
#[test]
fn openai_client_without_oauth_block_falls_back_to_stock_provider() {
let cc = make_openai_client("openai", Some("oauth"), None);
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "openai");
assert_eq!(provider.client_id(), "app_EMoamEEZ73f0CkXaXp7hrann");
}
#[test]
fn openai_config_provider_requires_issuer_stamp() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_openai_client("openai", Some("oauth"), Some(user));
let (provider, is_stock) = openai_oauth_provider_for_client(&cc, &[]);
assert!(!is_stock);
assert!(provider.requires_issuer_stamp());
}
#[test]
fn openai_stock_provider_grandfathers_legacy_tokens() {
let cc = make_openai_client("openai", Some("oauth"), None);
let (provider, is_stock) = openai_oauth_provider_for_client(&cc, &[]);
assert!(is_stock);
assert!(!provider.requires_issuer_stamp());
}
#[test]
fn gemini_client_with_user_oauth_block_uses_generic_provider() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_gemini_client("gemini", Some("oauth"), Some(user));
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "gemini");
assert_eq!(provider.client_id(), "gateway-id");
assert_eq!(provider.token_url(), "https://gateway.example/token");
}
#[test]
fn gemini_client_without_oauth_block_falls_back_to_stock_provider() {
let cc = make_gemini_client("gemini", Some("oauth"), None);
let provider = get_oauth_provider_for_client(&cc, &[]).unwrap();
assert_eq!(provider.provider_name(), "gemini");
assert_eq!(provider.token_url(), "https://oauth2.googleapis.com/token");
}
#[test]
fn gemini_config_provider_requires_issuer_stamp() {
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_gemini_client("gemini", Some("oauth"), Some(user));
let (provider, is_stock) = gemini_oauth_provider_for_client(&cc, &[]);
assert!(!is_stock);
assert!(provider.requires_issuer_stamp());
}
#[test]
fn gemini_stock_provider_grandfathers_legacy_tokens() {
let cc = make_gemini_client("gemini", Some("oauth"), None);
let (provider, is_stock) = gemini_oauth_provider_for_client(&cc, &[]);
assert!(is_stock);
assert!(!provider.requires_issuer_stamp());
}
#[test]
fn openai_compatible_provider_joins_scopes_with_spaces() {
let mut cfg = base_config();
@@ -1394,6 +1758,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(provider.scopes(), "one two three");
@@ -1408,6 +1773,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(
@@ -1425,6 +1791,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.uses_localhost_redirect());
@@ -1436,6 +1803,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.extra_token_params().is_empty());
@@ -1546,6 +1914,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(
@@ -1559,6 +1928,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.device_authorization_url().is_none());
@@ -1569,6 +1939,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(!provider.use_pkce_in_device_flow());
@@ -1582,6 +1953,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.use_pkce_in_device_flow());
@@ -1596,6 +1968,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(provider.resource(), Some("https://rs.example/"));
@@ -1655,6 +2028,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert!(provider.fixed_redirect_uri().is_none());
@@ -1669,6 +2043,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(
@@ -1686,6 +2061,7 @@ echo_pkce_in_token_exchange: true
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
assert_eq!(
@@ -1815,6 +2191,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
let request = build_token_request(
@@ -1967,6 +2344,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
let request = build_token_request(
@@ -1991,6 +2369,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
let url = build_authorize_url(
@@ -2010,6 +2389,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
let url = build_authorize_url(
@@ -2032,6 +2412,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
let url = build_authorize_url(
@@ -2062,6 +2443,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: cfg,
client_name: "test".into(),
requires_issuer_stamp: false,
};
let params = build_device_authorization_params(&provider, "a b", None);
@@ -2076,6 +2458,7 @@ extra_token_params:
let provider = OpenAICompatibleOAuthProvider {
config: base_config(),
client_name: "test".into(),
requires_issuer_stamp: false,
};
let params = build_device_authorization_params(&provider, "a b", Some("challenge"));
@@ -2094,9 +2477,10 @@ extra_token_params:
refresh_token: Some("rt-456".into()),
expires_at: 1234567890,
account_id: Some("acct-789".into()),
issuer: None,
};
save_oauth_tokens("atomic-test", &tokens).unwrap();
save_oauth_tokens("atomic-test", &ResourceStubProvider, &tokens).unwrap();
let loaded = load_oauth_tokens("atomic-test").unwrap();
assert_eq!(loaded.access_token, "at-123");
@@ -2130,11 +2514,13 @@ extra_token_params:
let expires_at = Utc::now().timestamp() + 3600;
save_oauth_tokens(
client_name,
&ResourceStubProvider,
&OAuthTokens {
access_token: "rejected-at".into(),
refresh_token: None,
expires_at,
account_id: None,
issuer: None,
},
)
.unwrap();
@@ -2169,11 +2555,13 @@ extra_token_params:
assert!(distrust_access_token(client_name, "rejected-at"));
save_oauth_tokens(
client_name,
&ResourceStubProvider,
&OAuthTokens {
access_token: "fresh-at".into(),
refresh_token: None,
expires_at,
account_id: None,
issuer: None,
},
)
.unwrap();
@@ -2198,6 +2586,219 @@ extra_token_params:
});
}
#[test]
#[serial]
fn store_tokens_caches_token_and_clears_rejection() {
with_temp_cache(|| {
let client_name = "store-tokens-test";
let expires_at = Utc::now().timestamp() + 3600;
set_access_token(client_name, "old-at".into(), expires_at, None);
assert!(distrust_access_token(client_name, "old-at"));
store_tokens(
client_name,
&ResourceStubProvider,
OAuthTokens {
access_token: "fresh-at".into(),
refresh_token: None,
expires_at,
account_id: None,
issuer: None,
},
)
.unwrap();
assert_eq!(get_access_token(client_name).unwrap(), "fresh-at");
assert!(is_valid_access_token(client_name));
assert!(
!is_rejected(client_name, "old-at"),
"rejection marker not cleared by store_tokens"
);
});
}
fn write_raw_token_file(client_name: &str, json: &str) {
let path = paths::token_file(client_name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, json).unwrap();
}
#[test]
#[serial]
fn save_oauth_tokens_stamps_issuer_fingerprint() {
with_temp_cache(|| {
save_oauth_tokens(
"issuer-stamp-test",
&ResourceStubProvider,
&OAuthTokens {
access_token: "at".into(),
refresh_token: None,
expires_at: 1234567890,
account_id: None,
issuer: None,
},
)
.unwrap();
let loaded = load_oauth_tokens("issuer-stamp-test").unwrap();
assert_eq!(
loaded.issuer.as_deref(),
Some("https://as.example/token|stub-client")
);
});
}
#[test]
#[serial]
fn prepare_rejects_unexpired_tokens_with_mismatched_issuer() {
with_temp_cache(|| {
let client_name = "issuer-mismatch-valid-test";
let expires_at = Utc::now().timestamp() + 3600;
write_raw_token_file(
client_name,
&format!(
r#"{{"access_token":"foreign-at","expires_at":{expires_at},"issuer":"https://old.example/token|old-client"}}"#
),
);
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&ResourceStubProvider,
client_name,
))
.unwrap();
assert!(!ready);
assert!(
get_access_token(client_name).is_err(),
"foreign token must not enter the cache"
);
});
}
#[test]
#[serial]
fn prepare_rejects_expired_mismatched_issuer_without_refreshing() {
with_temp_cache(|| {
let client_name = "issuer-mismatch-expired-test";
write_raw_token_file(
client_name,
r#"{"access_token":"foreign-at","refresh_token":"foreign-rt","expires_at":0,"issuer":"https://old.example/token|old-client"}"#,
);
// A refresh attempt would POST to the stub's token_url and fail
// with a transport error; Ok(false) proves the refresh token was
// never forwarded.
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&ResourceStubProvider,
client_name,
))
.unwrap();
assert!(!ready);
});
}
#[test]
#[serial]
fn prepare_accepts_legacy_tokens_without_issuer() {
with_temp_cache(|| {
let client_name = "issuer-legacy-test";
let expires_at = Utc::now().timestamp() + 3600;
write_raw_token_file(
client_name,
&format!(r#"{{"access_token":"legacy-at","expires_at":{expires_at}}}"#),
);
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&ResourceStubProvider,
client_name,
))
.unwrap();
assert!(ready);
assert_eq!(get_access_token(client_name).unwrap(), "legacy-at");
});
}
#[test]
#[serial]
fn prepare_rejects_legacy_tokens_for_claude_config_provider() {
with_temp_cache(|| {
let client_name = "issuer-legacy-claude-config-test";
let user = empty_user_override("gateway-id", "https://gateway.example/token");
let cc = make_claude_client(client_name, Some("oauth"), Some(user));
let (provider, _) = claude_oauth_provider_for_client(&cc, &[]);
write_raw_token_file(
client_name,
r#"{"access_token":"legacy-at","refresh_token":"legacy-rt","expires_at":0}"#,
);
// Legacy files predate config-driven claude oauth, so they are
// Anthropic-issued; Ok(false) proves neither the access token
// nor the refresh token reaches the configured endpoint.
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&*provider,
client_name,
))
.unwrap();
assert!(!ready);
assert!(
get_access_token(client_name).is_err(),
"legacy token must not enter the cache"
);
});
}
#[test]
#[serial]
fn prepare_accepts_legacy_tokens_for_stock_claude_provider() {
with_temp_cache(|| {
let client_name = "issuer-legacy-claude-stock-test";
let cc = make_claude_client(client_name, Some("oauth"), None);
let (provider, _) = claude_oauth_provider_for_client(&cc, &[]);
let expires_at = Utc::now().timestamp() + 3600;
write_raw_token_file(
client_name,
&format!(r#"{{"access_token":"legacy-at","expires_at":{expires_at}}}"#),
);
let ready = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_oauth_access_token(
&ReqwestClient::new(),
&*provider,
client_name,
))
.unwrap();
assert!(ready);
assert_eq!(get_access_token(client_name).unwrap(), "legacy-at");
});
}
#[test]
fn parse_refresh_response_invalid_grant_redacts_and_prompts_reauth() {
let response = serde_json::json!({
+1391 -77
View File
File diff suppressed because it is too large. Load diff
+158 -8
View File
@@ -16,6 +16,7 @@ pub struct OpenAICompatibleConfig {
pub api_key: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
pub wire_api: Option<WireApi>,
#[serde(default)]
pub models: Vec<ModelData>,
pub patch: Option<RequestPatch>,
@@ -42,10 +43,13 @@ impl Client for OpenAICompatibleClient {
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
let request_data = prepare_chat_completions(self, client, data).await?;
let (request_data, wire) = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
openai_chat_completions(builder, self.model()).await
match wire {
WireApi::Responses => openai_responses_chat_completions(builder, self.model()).await,
WireApi::Chat => openai_chat_completions(builder, self.model()).await,
}
}
async fn chat_completions_streaming_inner(
@@ -54,11 +58,16 @@ impl Client for OpenAICompatibleClient {
handler: &mut SseHandler,
data: ChatCompletionsData,
) -> Result<()> {
let request_data = prepare_chat_completions(self, client, data).await?;
let (request_data, wire) = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
match wire {
WireApi::Responses => openai_responses_streaming(builder, handler).await,
WireApi::Chat => {
openai_chat_completions_streaming(builder, handler, self.model()).await
}
}
}
async fn embeddings_inner(
&self,
@@ -87,15 +96,22 @@ async fn prepare_chat_completions(
self_: &OpenAICompatibleClient,
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<RequestData> {
) -> Result<(RequestData, WireApi)> {
let wire = resolve_wire_api(self_.config.wire_api, false, false)?;
let api_base = get_api_base_ext(self_)?;
let url = format!("{api_base}/chat/completions");
let body = openai_build_chat_completions_body(data, &self_.model);
let mut request_data = RequestData::new(url, body);
let url = match wire {
WireApi::Responses => format!("{api_base}/responses"),
WireApi::Chat => format!("{api_base}/chat/completions"),
};
let body = match wire {
WireApi::Responses => openai_build_responses_body(data, &self_.model),
WireApi::Chat => openai_build_chat_completions_body(data, &self_.model),
};
let mut request_data = RequestData::new(url, body).wire(wire);
apply_auth(self_, client, &mut request_data).await?;
Ok(request_data)
Ok((request_data, wire))
}
async fn prepare_embeddings(
@@ -246,3 +262,137 @@ pub fn generic_build_rerank_body(data: &RerankData, model: &Model) -> Value {
}
body
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use std::sync::Arc;
fn compat_config(wire_api: Option<WireApi>) -> OpenAICompatibleConfig {
OpenAICompatibleConfig {
name: Some("compat-test".into()),
api_base: Some("https://compat.example/v1".into()),
api_key: Some("sk-test".into()),
auth: None,
oauth: None,
wire_api,
models: vec![],
patch: None,
extra: None,
}
}
fn make_client(config: OpenAICompatibleConfig) -> OpenAICompatibleClient {
OpenAICompatibleClient {
app_config: Arc::new(AppConfig::default()),
config,
model: Model::new("compat-test", "gpt-test"),
}
}
fn prepare(
client: &OpenAICompatibleClient,
stream: bool,
) -> Result<(RequestData, WireApi)> {
let data = ChatCompletionsData {
messages: vec![Message::new(
MessageRole::User,
MessageContent::Text("hello".to_string()),
)],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream,
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_chat_completions(
client,
&ReqwestClient::new(),
data,
))
}
#[test]
fn wire_api_deserializes_on_openai_compatible_config() {
let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: responses").unwrap();
assert_eq!(config.wire_api, Some(WireApi::Responses));
let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: chat").unwrap();
assert_eq!(config.wire_api, Some(WireApi::Chat));
}
#[test]
fn bogus_wire_api_is_a_config_error() {
let err = serde_yaml::from_str::<OpenAICompatibleConfig>("wire_api: bogus")
.unwrap_err()
.to_string();
assert!(
err.contains("responses") && err.contains("chat"),
"unexpected error: {err}"
);
}
#[test]
fn default_wire_is_chat() {
let client = make_client(compat_config(None));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Chat);
assert_eq!(
request_data.url,
"https://compat.example/v1/chat/completions"
);
assert!(
request_data.body.get("messages").is_some(),
"body: {}",
request_data.body
);
}
#[test]
fn explicit_responses_wire_posts_a_responses_body_to_the_responses_endpoint() {
let client = make_client(compat_config(Some(WireApi::Responses)));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, "https://compat.example/v1/responses");
assert_eq!(request_data.body["store"], json!(false));
assert!(
request_data.body.get("input").is_some() && request_data.body.get("messages").is_none(),
"body: {}",
request_data.body
);
}
#[test]
fn explicit_responses_wire_forks_the_streaming_path_too() {
let client = make_client(compat_config(Some(WireApi::Responses)));
let (request_data, wire) = prepare(&client, true).unwrap();
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, "https://compat.example/v1/responses");
assert_eq!(request_data.body["stream"], json!(true));
}
#[test]
fn explicit_chat_wire_keeps_the_chat_endpoint() {
let client = make_client(compat_config(Some(WireApi::Chat)));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Chat);
assert_eq!(
request_data.url,
"https://compat.example/v1/chat/completions"
);
}
}
+5
View File
@@ -5,6 +5,7 @@ use super::oauth::{OAuthConfig, OAuthFlow, OAuthProvider, TokenRequestFormat};
pub struct OpenAICompatibleOAuthProvider {
pub config: OAuthConfig,
pub client_name: String,
pub requires_issuer_stamp: bool,
}
fn is_loopback_uri(uri: &str) -> bool {
@@ -122,4 +123,8 @@ impl OAuthProvider for OpenAICompatibleOAuthProvider {
fn use_pkce_in_device_flow(&self) -> bool {
self.config.use_pkce_in_device_flow
}
fn requires_issuer_stamp(&self) -> bool {
self.requires_issuer_stamp
}
}
+25
View File
@@ -215,6 +215,14 @@ impl SseHandler {
!self.buffer.is_empty() || !self.tool_calls.is_empty() || !self.thinking.is_empty()
}
/// Whether visible output (text or tool calls) has been accumulated.
/// Unlike [`Self::has_received_content`], thinking blocks do not count:
/// a stream truncated before any visible output is still an empty
/// response to the caller.
pub fn has_received_visible_output(&self) -> bool {
!self.buffer.is_empty() || !self.tool_calls.is_empty()
}
pub fn abort(&self) -> AbortSignal {
self.abort_signal.clone()
}
@@ -544,6 +552,23 @@ mod tests {
assert!(handler.has_received_content());
}
/// Pins the deliberate exclusion: thinking blocks count as received
/// content but not as visible output, so a thinking-only stream is
/// still an empty response to the caller.
#[test]
fn thinking_blocks_do_not_count_as_visible_output() {
let (mut handler, _rx) = new_handler();
handler.thinking_block(ThinkingBlock::Thinking {
thinking: "hmm".to_string(),
signature: "sig".to_string(),
});
assert!(handler.has_received_content());
assert!(!handler.has_received_visible_output());
handler.text("hello").unwrap();
assert!(handler.has_received_visible_output());
}
/// Claude splits usage across events: `message_start` carries the input
/// and cache counts with a provisional output count, `message_delta`
/// carries only the final output count. Later non-None fields must win.
+165
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::hooks::HooksMap;
use crate::{
client::Model,
config::memory,
@@ -12,6 +13,7 @@ use crate::{
};
use super::rag_cache::RagKey;
use crate::config::builtin_manifest;
use crate::config::paths;
use crate::config::prompts::{
DEFAULT_JOB_INSTRUCTIONS, DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS,
@@ -27,6 +29,7 @@ use fancy_regex::Captures;
use inquire::{Text, validator::Validation};
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::{env, ffi::OsStr, path::Path};
const DEFAULT_AGENT_NAME: &str = "rag";
@@ -46,6 +49,20 @@ pub type AgentVariables = IndexMap<String, String>;
#[folder = "assets/agents/"]
struct AgentAssets;
/// Splits an embedded asset path of the form `<agent>/hooks/<file>` into
/// its agent and hook-file names. Only direct children of `hooks/` count:
/// they are the only shape the builtin manifest tracks.
fn parse_direct_hook_path(path: &str) -> Option<(&str, &str)> {
let (agent, rest) = path.split_once('/')?;
let hook = direct_hook_name(rest)?;
Some((agent, hook))
}
fn direct_hook_name(rest: &str) -> Option<&str> {
let name = rest.strip_prefix("hooks/")?;
(!name.is_empty() && !name.contains('/')).then_some(name)
}
#[derive(Debug, Clone)]
pub struct Agent {
name: String,
@@ -70,6 +87,7 @@ impl Agent {
paths::agents_data_dir().display()
);
let mut written_hooks: HashMap<String, BTreeSet<String>> = HashMap::new();
for file in AgentAssets::iter() {
debug!("Processing agent file: {}", file.as_ref());
@@ -96,6 +114,12 @@ impl Agent {
info!("Creating agent file: {}", file_path.display());
let mut agent_file = File::create(&file_path)?;
agent_file.write_all(content.as_bytes())?;
if let Some((agent, hook)) = parse_direct_hook_path(file.as_ref()) {
written_hooks
.entry(agent.to_string())
.or_default()
.insert(hook.to_string());
}
#[cfg(unix)]
if is_script {
@@ -134,6 +158,27 @@ impl Agent {
}
}
// Hook filenames are open-ended (unlike AGENT_DEFINITION_FILES), so
// each bundled agent's hooks/ directory reconciles through its
// builtin manifest: only files the installer previously shipped are
// removal candidates, and user-created files in the same directory
// are never touched. Only direct children of hooks/ are tracked;
// nested shipped files install but are not reconciled.
for (agent, files) in &bundled_files {
let shipped: BTreeSet<String> = files
.iter()
.filter_map(|rest| direct_hook_name(rest))
.map(str::to_string)
.collect();
let written = written_hooks.remove(agent.as_str()).unwrap_or_default();
let hooks_dir = paths::agents_data_dir().join(agent).join("hooks");
if let Err(err) =
builtin_manifest::reconcile_builtin_dir(&hooks_dir, &shipped, &written)
{
warn!("Failed to reconcile builtin hooks for agent '{agent}': {err}");
}
}
Ok(())
}
@@ -639,6 +684,14 @@ impl Agent {
self.config.compression_model.as_deref()
}
pub fn hooks(&self) -> &HooksMap {
&self.config.hooks
}
pub fn global_hooks(&self) -> &[String] {
&self.config.global_hooks
}
pub fn is_dynamic_instructions(&self) -> bool {
self.config.dynamic_instructions
}
@@ -848,6 +901,10 @@ pub struct AgentConfig {
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)]
pub global_tools: Vec<String>,
#[serde(default)]
pub hooks: HooksMap,
#[serde(default)]
pub global_hooks: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -917,6 +974,8 @@ impl AgentConfig {
reasoning_effort: graph.reasoning_effort.clone(),
description: graph.description.clone(),
global_tools: graph.global_tools.clone(),
hooks: graph.hooks.clone(),
global_hooks: graph.global_hooks.clone(),
mcp_servers: graph.mcp_servers.clone(),
mcp_tools: graph.mcp_tools.clone(),
skills_enabled: graph.skills_enabled,
@@ -965,6 +1024,14 @@ impl AgentConfig {
{
self.global_tools = v;
}
if let Ok(v) = env::var(with_prefix("global_hooks")) {
match serde_json::from_str(&v) {
Ok(v) => self.global_hooks = v,
Err(err) => {
debug!("Ignoring malformed global_hooks env override for agent '{name}': {err}")
}
}
}
if let Ok(v) = env::var(with_prefix("mcp_servers"))
&& let Ok(v) = serde_json::from_str(&v)
{
@@ -1295,6 +1362,16 @@ pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>
mod tests {
use super::*;
#[test]
fn parse_direct_hook_path_accepts_only_direct_hook_children() {
assert_eq!(parse_direct_hook_path("x/hooks/a.sh"), Some(("x", "a.sh")));
assert_eq!(parse_direct_hook_path("x/hooks/nested/a.sh"), None);
assert_eq!(parse_direct_hook_path("hooks/a.sh"), None);
assert_eq!(parse_direct_hook_path("x/hooks/"), None);
assert_eq!(parse_direct_hook_path("x/config.yaml"), None);
assert_eq!(parse_direct_hook_path("config.yaml"), None);
}
#[test]
fn agent_config_parses_from_yaml() {
let yaml = r#"
@@ -1336,6 +1413,92 @@ variables:
assert_eq!(config.variables[0].name, "username");
}
#[test]
fn agent_config_hooks_and_global_hooks_round_trip() {
let yaml = "\
name: hooked
instructions: hi
hooks:
tool.started:
- name: notify
command: ./hooks/notify.sh
global_hooks:
- tool.started.notify
";
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.hooks["tool.started"][0].name, "notify");
assert_eq!(config.hooks["tool.started"][0].command, "./hooks/notify.sh");
assert_eq!(config.global_hooks, vec!["tool.started.notify"]);
let serialized = serde_yaml::to_string(&config).unwrap();
let reparsed: AgentConfig = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(reparsed.hooks, config.hooks);
assert_eq!(reparsed.global_hooks, config.global_hooks);
}
#[test]
fn load_envs_overrides_global_hooks() {
let yaml = "name: hooks-env-probe\ninstructions: hi\nglobal_hooks:\n - initial.hook\n";
let mut config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
let env_name = normalize_env_name("hooks-env-probe_global_hooks");
let prev = env::var_os(&env_name);
unsafe {
env::set_var(
&env_name,
r#"["tool.started.notify","turn.completed.webhook"]"#,
)
};
config.load_envs(&AppConfig::default());
assert_eq!(
config.global_hooks,
vec!["tool.started.notify", "turn.completed.webhook"]
);
unsafe { env::set_var(&env_name, "not json") };
config.load_envs(&AppConfig::default());
assert_eq!(
config.global_hooks,
vec!["tool.started.notify", "turn.completed.webhook"]
);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
fn from_graph_carries_hooks_and_global_hooks() {
let yaml = "\
name: g
start: e
hooks:
turn.completed:
- name: webhook
command: curl -s https://example.com/hook
global_hooks:
- turn.completed.webhook
nodes:
e:
id: e
type: end
output: done
";
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let config = AgentConfig::from_graph("g", &graph);
assert_eq!(config.hooks, graph.hooks);
assert_eq!(config.global_hooks, graph.global_hooks);
assert_eq!(config.hooks["turn.completed"][0].name, "webhook");
assert_eq!(config.global_hooks, vec!["turn.completed.webhook"]);
}
#[test]
fn agent_config_defaults() {
let yaml = "name: minimal\ninstructions: hi\n";
@@ -1350,6 +1513,8 @@ variables:
assert_eq!(config.escalation_timeout, 0);
assert!(config.mcp_servers.is_empty());
assert!(config.global_tools.is_empty());
assert!(config.hooks.is_empty());
assert!(config.global_hooks.is_empty());
assert!(config.conversation_starters.is_empty());
assert!(config.variables.is_empty());
assert!(config.model_id.is_none());
+88
View File
@@ -1,4 +1,5 @@
use crate::client::{ClientConfig, Model, ModelType, list_models};
use crate::hooks::HooksMap;
use crate::render::{MarkdownRender, RenderOptions};
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, drain_stale_tty_input, get_env_name};
@@ -52,6 +53,9 @@ pub struct AppConfig {
pub enabled_mcp_servers: Option<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)]
pub hooks: HooksMap,
pub auto_continue: bool,
pub max_auto_continues: usize,
pub inject_todo_instructions: bool,
@@ -143,6 +147,8 @@ impl Default for AppConfig {
enabled_mcp_servers: None,
mcp_tools: None,
hooks: Default::default(),
auto_continue: false,
max_auto_continues: 10,
inject_todo_instructions: true,
@@ -235,6 +241,8 @@ impl AppConfig {
enabled_mcp_servers: config.enabled_mcp_servers,
mcp_tools: config.mcp_tools,
hooks: config.hooks,
auto_continue: config.auto_continue,
max_auto_continues: config.max_auto_continues,
inject_todo_instructions: config.inject_todo_instructions,
@@ -572,6 +580,15 @@ impl AppConfig {
self.enabled_mcp_servers = v.map(|raw| super::csv_to_vec(&raw));
}
if let Ok(v) = env::var(get_env_name("hooks")) {
match serde_json::from_str(&v) {
Ok(v) => self.hooks = v,
Err(err) => {
debug!("Ignoring malformed hooks env override: {err}")
}
}
}
if let Some(v) = super::read_env_value::<String>(&get_env_name("repl_prelude")) {
self.repl_prelude = v;
}
@@ -788,6 +805,77 @@ mod tests {
assert_eq!(app.clients.len(), 1);
}
#[test]
fn from_config_copies_hooks() {
let mut hooks = HooksMap::new();
hooks.insert(
"turn.completed".to_string(),
vec![crate::hooks::HookDef {
name: "notify".to_string(),
command: "./hooks/notify.sh".to_string(),
}],
);
let cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
hooks: hooks.clone(),
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.hooks, hooks);
}
#[test]
fn from_config_defaults_hooks_to_empty() {
let cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert!(app.hooks.is_empty());
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_hooks() {
let env_name = get_env_name("hooks");
let prev = env::var_os(&env_name);
let mut hooks = HooksMap::new();
hooks.insert(
"turn.completed".to_string(),
vec![crate::hooks::HookDef {
name: "notify".to_string(),
command: "./hooks/notify.sh".to_string(),
}],
);
let mut app = AppConfig::default();
unsafe { env::set_var(&env_name, serde_json::to_string(&hooks).unwrap()) };
app.load_envs();
assert_eq!(app.hooks, hooks);
unsafe { env::set_var(&env_name, "[not json") };
app.load_envs();
assert_eq!(app.hooks, hooks);
unsafe { env::remove_var(&env_name) };
app.load_envs();
assert_eq!(app.hooks, hooks);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
fn from_config_copies_mapping_fields() {
let mut cfg = Config {
+293
View File
@@ -0,0 +1,293 @@
//! Per-directory tracking of files the builtin installer shipped, so a later
//! release that stops shipping an asset can remove the stale copy without
//! ever touching user-created files that live in the same directory.
//!
//! Unlike agent definition files, hook filenames are open-ended: no closed
//! candidate list can distinguish "previously bundled" from "user-authored".
//! The manifest records exactly what the installer shipped; anything absent
//! from it is user-owned and untouchable.
use crate::config::ensure_parent_exists;
use crate::function::write_file_atomic;
use anyhow::{Context, Result};
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
pub(crate) const BUILTIN_MANIFEST_FILE: &str = ".builtin-manifest";
pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
name.trim_end_matches(['.', ' '])
.to_uppercase()
.eq_ignore_ascii_case(BUILTIN_MANIFEST_FILE)
}
/// Reconciles `dir` against the currently shipped set of builtin filenames:
/// a file is removed ONLY if it appears in the previous manifest AND is
/// absent from `shipped`. The manifest is then rewritten to the names this
/// installer actually owns: files written this run (`written`), plus shipped
/// names it already owned, plus stale files whose removal failed (kept so a
/// later run retries). A shipped name the installer declined to write (e.g., a
/// pre-existing user file with a colliding name) is never claimed.
///
/// Failure modes degrade toward keeping files: a missing or unreadable
/// manifest yields no deletions, and entries that are not plain filenames
/// are ignored.
pub(crate) fn reconcile_builtin_dir(
dir: &Path,
shipped: &BTreeSet<String>,
written: &BTreeSet<String>,
) -> Result<()> {
let previous = read_manifest(dir);
let mut retained: BTreeSet<String> = written.clone();
retained.extend(shipped.intersection(&previous).cloned());
for name in previous.difference(shipped) {
let path = dir.join(name);
if !path.is_file() {
continue;
}
info!(
"Removing stale builtin file no longer shipped: {}",
path.display()
);
if let Err(err) = fs::remove_file(&path) {
warn!(
"Failed to remove stale builtin file {}: {err}",
path.display()
);
retained.insert(name.clone());
}
}
write_manifest(dir, &retained)
}
fn read_manifest(dir: &Path) -> BTreeSet<String> {
let path = dir.join(BUILTIN_MANIFEST_FILE);
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
debug!(
"No readable builtin manifest at {} ({err}); skipping stale-file removal",
path.display()
);
return BTreeSet::new();
}
};
content
.lines()
.map(str::trim)
.filter(|line| {
if line.is_empty() || is_builtin_manifest_name(line) {
return false;
}
if !is_plain_file_name(line) {
debug!(
"Ignoring suspicious builtin manifest entry in {}: {line:?}",
path.display()
);
return false;
}
true
})
.map(str::to_string)
.collect()
}
/// Manifest entries name direct children of the manifest's directory and
/// nothing else; anything path-like is rejected so a corrupted or crafted
/// manifest can never direct a deletion outside that directory. The same
/// component rules the bundle store applies to recorded paths are reused so
/// Windows drive prefixes (`C:x`), ADS colons, trailing dots/spaces, and
/// reserved device names are rejected too.
fn is_plain_file_name(name: &str) -> bool {
!name.contains(['/', '\\'])
&& name != "."
&& name != ".."
&& super::install_remote::is_safe_component(name)
}
fn write_manifest(dir: &Path, names: &BTreeSet<String>) -> Result<()> {
let path = dir.join(BUILTIN_MANIFEST_FILE);
if names.is_empty() {
if path.is_file() {
fs::remove_file(&path)
.with_context(|| format!("failed to remove builtin manifest {}", path.display()))?;
}
return Ok(());
}
let mut content = names.iter().cloned().collect::<Vec<_>>().join("\n");
content.push('\n');
ensure_parent_exists(&path)?;
write_file_atomic(&path, &content, None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils;
use std::path::PathBuf;
fn fresh_dir(label: &str) -> PathBuf {
let dir = utils::temp_file(label, "");
fs::create_dir_all(&dir).unwrap();
dir
}
fn shipped(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|n| n.to_string()).collect()
}
#[test]
fn removes_only_manifest_listed_files_absent_from_shipped() {
let dir = fresh_dir("builtin-manifest-stale-");
fs::write(dir.join(BUILTIN_MANIFEST_FILE), "old.sh\nkept.sh\n").unwrap();
fs::write(dir.join("old.sh"), "stale").unwrap();
fs::write(dir.join("kept.sh"), "current").unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["kept.sh"]), &shipped(&[])).unwrap();
assert!(!dir.join("old.sh").exists(), "stale bundled file removed");
assert!(dir.join("kept.sh").exists(), "still-shipped file kept");
assert!(dir.join("user.sh").exists(), "unlisted user file untouched");
assert_eq!(
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
"kept.sh\n"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_manifest_deletes_nothing_and_writes_shipped_set() {
let dir = fresh_dir("builtin-manifest-missing-");
fs::write(dir.join("user.sh"), "user-owned").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["new.sh"]), &shipped(&["new.sh"])).unwrap();
assert!(dir.join("user.sh").exists());
assert_eq!(
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
"new.sh\n"
);
let _ = fs::remove_dir_all(&dir);
}
/// A shipped name the installer skipped (pre-existing user file with a
/// colliding name) is never claimed by the manifest, so a later release
/// dropping that hook can never delete the user's file.
#[test]
fn skipped_preexisting_user_file_is_never_claimed() {
let dir = fresh_dir("builtin-manifest-unclaimed-");
fs::write(dir.join("user.sh"), "user-owned").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["user.sh"]), &shipped(&[])).unwrap();
assert!(
!dir.join(BUILTIN_MANIFEST_FILE).exists(),
"nothing was written this run and nothing was owned before"
);
reconcile_builtin_dir(&dir, &shipped(&[]), &shipped(&[])).unwrap();
assert!(
dir.join("user.sh").exists(),
"the unclaimed user file survives the hook being dropped"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn suspicious_manifest_entries_never_direct_deletions() {
let dir = fresh_dir("builtin-manifest-suspicious-");
let outside = fresh_dir("builtin-manifest-outside-");
let escape = format!(
"../{}/victim.sh",
outside.file_name().unwrap().to_string_lossy()
);
fs::write(
dir.join(BUILTIN_MANIFEST_FILE),
format!("{escape}\nsub/nested.sh\nC:victim.sh\ntrailing.\n..\n.\n\n"),
)
.unwrap();
fs::write(outside.join("victim.sh"), "keep").unwrap();
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("sub/nested.sh"), "keep").unwrap();
reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap();
assert!(outside.join("victim.sh").exists());
assert!(dir.join("sub/nested.sh").exists());
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&outside);
}
#[test]
fn unreadable_manifest_deletes_nothing() {
let dir = fresh_dir("builtin-manifest-unreadable-");
fs::write(dir.join(BUILTIN_MANIFEST_FILE), [0xff, 0xfe, 0x00]).unwrap();
fs::write(dir.join("old.sh"), "keep").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["new.sh"]), &shipped(&["new.sh"])).unwrap();
assert!(
dir.join("old.sh").exists(),
"a bad manifest must fail safe toward keeping files"
);
assert_eq!(
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
"new.sh\n",
"manifest self-heals to the current shipped set"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn empty_shipped_set_removes_manifest_and_creates_nothing() {
let dir = fresh_dir("builtin-manifest-empty-");
fs::write(dir.join(BUILTIN_MANIFEST_FILE), "old.sh\n").unwrap();
fs::write(dir.join("old.sh"), "stale").unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap();
assert!(!dir.join("old.sh").exists());
assert!(dir.join("user.sh").exists());
assert!(!dir.join(BUILTIN_MANIFEST_FILE).exists());
let absent = dir.join("never-created");
reconcile_builtin_dir(&absent, &BTreeSet::new(), &BTreeSet::new()).unwrap();
assert!(!absent.exists(), "no directory is created for an empty set");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn manifest_listing_itself_never_deletes_it() {
let dir = fresh_dir("builtin-manifest-self-");
fs::write(
dir.join(BUILTIN_MANIFEST_FILE),
format!("{BUILTIN_MANIFEST_FILE}\n.BUILTIN-MANIFEST\nkept.sh\n"),
)
.unwrap();
fs::write(dir.join("kept.sh"), "current").unwrap();
reconcile_builtin_dir(&dir, &shipped(&["kept.sh"]), &shipped(&[])).unwrap();
assert!(dir.join(BUILTIN_MANIFEST_FILE).exists());
assert!(dir.join("kept.sh").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn manifest_name_matcher_covers_filesystem_aliases() {
assert!(is_builtin_manifest_name(".builtin-manifest"));
assert!(is_builtin_manifest_name(".BUILTIN-MANIFEST"));
assert!(is_builtin_manifest_name(".Builtin-Manifest."));
assert!(is_builtin_manifest_name(".builtin-manifest "));
assert!(is_builtin_manifest_name(".builtin-manifeſt"));
assert!(!is_builtin_manifest_name("builtin-manifest"));
assert!(!is_builtin_manifest_name(".builtin-manifesto"));
assert!(!is_builtin_manifest_name("notify.sh"));
}
}
+7
View File
@@ -1183,6 +1183,13 @@ mod tests {
assert_eq!(resolved.name, "y/mcp_config");
assert_eq!(resolved.qualified_from.as_deref(), Some("mcp_config"));
let resolved = store
.resolve_bundle_name("https://github.com/z/hooks", None)
.unwrap();
assert_eq!(resolved.name, "z/hooks");
assert_eq!(resolved.qualified_from.as_deref(), Some("hooks"));
}
#[test]
+324 -5
View File
@@ -2,6 +2,7 @@ use super::bundles::{
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes,
hash_file,
};
use crate::config::builtin_manifest::is_builtin_manifest_name;
use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths};
#[cfg(not(windows))]
use crate::function::Language;
@@ -32,7 +33,7 @@ pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool)
if layout.is_empty() {
println!(
"No recognized assets found in {git_url}. Expected one or more of: \
agents/, roles/, skills/, macros/, functions/tools/, mcp.json"
agents/, roles/, skills/, macros/, functions/tools/, hooks/, mcp.json"
);
return Ok(());
}
@@ -805,7 +806,7 @@ fn is_safe_relative_path(path: &str) -> bool {
/// Rejects names Windows refuses or silently rewrites (alternate data stream
/// colons, reserved device names, trailing dots or spaces) so a recorded path
/// denotes the same regular file on every platform.
fn is_safe_component(name: &str) -> bool {
pub(crate) fn is_safe_component(name: &str) -> bool {
!name.contains(':')
&& !name.ends_with('.')
&& !name.ends_with(' ')
@@ -1270,6 +1271,7 @@ struct RemoteLayout {
roles: Option<PathBuf>,
skills: Option<PathBuf>,
macros: Option<PathBuf>,
hooks: Option<PathBuf>,
functions_tools: Option<PathBuf>,
mcp_json: Option<PathBuf>,
manifest: Option<BundleManifest>,
@@ -1282,6 +1284,7 @@ impl RemoteLayout {
&& self.roles.is_none()
&& self.skills.is_none()
&& self.macros.is_none()
&& self.hooks.is_none()
&& self.functions_tools.is_none()
&& self.mcp_json.is_none()
}
@@ -1313,6 +1316,11 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
layout.macros = Some(macros);
}
let hooks = root.join("hooks");
if hooks.is_dir() {
layout.hooks = Some(hooks);
}
let root_mcp = root.join("mcp.json");
if root_mcp.is_file() {
layout.mcp_json = Some(root_mcp);
@@ -1540,6 +1548,10 @@ fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> Remo
functions_tools: layout.functions_tools.take(),
..base
},
InstallFilter::Hooks => RemoteLayout {
hooks: layout.hooks.take(),
..base
},
InstallFilter::McpConfig => RemoteLayout {
mcp_json: layout.mcp_json.take(),
..base
@@ -1591,6 +1603,7 @@ enum TopCategory {
Roles,
Skills,
Macros,
Hooks,
FunctionsTools,
}
@@ -1601,6 +1614,7 @@ impl TopCategory {
TopCategory::Roles => "roles",
TopCategory::Skills => "skills",
TopCategory::Macros => "macros",
TopCategory::Hooks => "hooks",
TopCategory::FunctionsTools => "functions/tools",
}
}
@@ -1619,6 +1633,8 @@ enum PlannedKind {
struct PlannedFile {
src: PathBuf,
dst: PathBuf,
/// Path relative to the category's source directory in the bundle.
rel: PathBuf,
kind: PlannedKind,
top_category: TopCategory,
}
@@ -1660,6 +1676,9 @@ fn plan_changes(layout: &RemoteLayout) -> Result<InstallPlan> {
&mut files,
)?;
}
if let Some(src_dir) = &layout.hooks {
plan_dir_into(src_dir, &paths::hooks_dir(), TopCategory::Hooks, &mut files)?;
}
if let Some(src_dir) = &layout.functions_tools {
plan_dir_into(
src_dir,
@@ -1686,7 +1705,28 @@ fn plan_dir_into(
for src in walk_files(src_dir)? {
let rel = src
.strip_prefix(src_dir)
.expect("walk_files only returns paths under src_dir");
.expect("walk_files only returns paths under src_dir")
.to_path_buf();
// The builtin installer's per-directory manifests drive stale-file
// deletion; letting a bundle install one would hand it control over
// which local files the next builtin reconcile removes. Every path
// component is checked (a bundle-shipped DIRECTORY of that name would
// permanently occupy the manifest path and disable reconciliation),
// under the filesystem-alias-aware match of is_builtin_manifest_name.
let is_builtin_manifest = rel.components().any(|c| match c.as_os_str().to_str() {
Some(name) => is_builtin_manifest_name(name),
// A non-UTF-8 name can never equal the ASCII manifest constant.
None => false,
});
if is_builtin_manifest {
log::warn!(
"Ignoring bundle file {} ({}): the name is reserved for the builtin installer",
rel.display(),
category.label()
);
continue;
}
if category == TopCategory::Skills {
let skill_name = rel
@@ -1707,11 +1747,12 @@ fn plan_dir_into(
})?;
}
let dst = dst_dir.join(rel);
let dst = dst_dir.join(&rel);
let kind = classify_file(&src, &dst)?;
out.push(PlannedFile {
src,
dst,
rel,
kind,
top_category: category,
});
@@ -1790,6 +1831,7 @@ fn print_plan_summary(plan: &InstallPlan) {
TopCategory::Roles,
TopCategory::Skills,
TopCategory::Macros,
TopCategory::Hooks,
TopCategory::FunctionsTools,
] {
let new_ = count_kind(plan, cat, PlannedKind::New);
@@ -1808,6 +1850,33 @@ fn print_plan_summary(plan: &InstallPlan) {
println!("{line}");
}
}
for line in hook_script_lines(plan) {
println!("{line}");
}
}
/// Bundle-shipped hook scripts execute on the user's machine once wired into
/// config, so every planned file landing under a hooks/ directory is called
/// out individually. Visibility only: installing them needs no extra approval.
fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
plan.files
.iter()
.filter(|planned| {
planned.top_category == TopCategory::Hooks
|| planned
.rel
.parent()
.is_some_and(|parent| parent.components().any(|c| c.as_os_str() == "hooks"))
})
.map(|planned| {
let rel = planned.rel.to_string_lossy().replace('\\', "/");
format!(
"bundle defines hook script: {}/{rel}",
planned.top_category.label()
)
})
.collect()
}
fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize {
@@ -2349,6 +2418,8 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::utils::get_env_name;
use serial_test::serial;
@@ -2551,6 +2622,7 @@ mod tests {
roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
macros: Some(PathBuf::from("m")),
hooks: Some(PathBuf::from("h")),
functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
@@ -2560,6 +2632,7 @@ mod tests {
assert!(out.agents.is_some() && out.roles.is_some() && out.skills.is_some());
assert!(out.macros.is_some() && out.functions_tools.is_some() && out.mcp_json.is_some());
assert!(out.hooks.is_some());
}
#[test]
@@ -2638,6 +2711,37 @@ mod tests {
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
}
#[test]
fn apply_filter_hooks_keeps_only_hooks() {
let l = RemoteLayout {
agents: Some(PathBuf::from("a")),
roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
macros: Some(PathBuf::from("m")),
hooks: Some(PathBuf::from("h")),
functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
};
let out = apply_filter(l, Some(InstallFilter::Hooks));
assert_eq!(out.hooks, Some(PathBuf::from("h")));
assert!(!out.is_empty());
assert!(out.agents.is_none() && out.roles.is_none() && out.skills.is_none());
assert!(out.macros.is_none() && out.functions_tools.is_none() && out.mcp_json.is_none());
}
#[test]
fn remote_layout_with_only_hooks_is_not_empty() {
let l = RemoteLayout {
hooks: Some(PathBuf::from("h")),
..RemoteLayout::default()
};
assert!(!l.is_empty());
}
#[test]
fn walk_files_skips_dot_git_and_collects_regular_files() {
let root = fresh_temp_dir("walk-test-");
@@ -2684,6 +2788,7 @@ mod tests {
fs::create_dir_all(root.join("roles")).unwrap();
fs::create_dir_all(root.join("skills")).unwrap();
fs::create_dir_all(root.join("macros")).unwrap();
fs::create_dir_all(root.join("hooks")).unwrap();
fs::create_dir_all(root.join("functions/tools")).unwrap();
touch(&root.join("functions/mcp.json"));
touch(&root.join("README.md"));
@@ -2693,11 +2798,29 @@ mod tests {
assert!(layout.roles.is_some());
assert!(layout.skills.is_some());
assert!(layout.macros.is_some());
assert!(layout.hooks.is_some());
assert!(layout.functions_tools.is_some());
assert!(layout.mcp_json.is_some());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_finds_hooks_only() {
let root = fresh_temp_dir("scan-hooks-only-");
fs::create_dir_all(root.join("hooks")).unwrap();
touch(&root.join("hooks/notify.sh"));
let layout = scan_remote_layout(&root).unwrap();
assert_eq!(layout.hooks, Some(root.join("hooks")));
assert!(layout.agents.is_none());
assert!(layout.roles.is_none());
assert!(layout.macros.is_none());
assert!(layout.functions_tools.is_none());
assert!(layout.mcp_json.is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_finds_root_mcp_json() {
let root = fresh_temp_dir("scan-root-mcp-test-");
@@ -2752,6 +2875,71 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn plan_dir_into_skips_bundle_shipped_builtin_manifests() {
let root = fresh_temp_dir("plan-manifest-skip-");
let src_dir = root.join("hooks");
touch(&src_dir.join("notify.sh"));
touch(&src_dir.join(BUILTIN_MANIFEST_FILE));
touch(&src_dir.join(format!("sub/{BUILTIN_MANIFEST_FILE}")));
// Case and trailing-dot variants alias the real manifest path on
// case-insensitive filesystems; they live in separate subdirs so this
// fixture also builds on such filesystems without name collisions.
touch(&src_dir.join("sub2/.BUILTIN-MANIFEST"));
touch(&src_dir.join("sub3/.Builtin-Manifest."));
// A DIRECTORY of the manifest name would occupy the manifest path and
// disable reconciliation; a Unicode case-fold alias (U+017F ſ → s)
// aliases it on case-insensitive APFS/NTFS volumes.
touch(&src_dir.join("sub4/.builtin-manifest/payload.sh"));
touch(&src_dir.join("sub5/.builtin-manifeſt"));
let dst_dir = root.join("dst");
let mut files = Vec::new();
plan_dir_into(&src_dir, &dst_dir, TopCategory::Hooks, &mut files).unwrap();
let rels: Vec<&Path> = files.iter().map(|p| p.rel.as_path()).collect();
assert_eq!(
rels,
vec![Path::new("notify.sh")],
"a bundle must never install a builtin manifest: it would hand the \
bundle control over builtin stale-file deletion"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn hook_script_lines_cover_every_hooks_dir_and_nothing_else() {
let file = |rel: &str, cat: TopCategory| PlannedFile {
src: PathBuf::from("src").join(rel),
dst: PathBuf::from("dst").join(rel),
rel: PathBuf::from(rel),
kind: PlannedKind::New,
top_category: cat,
};
let plan = InstallPlan {
files: vec![
file("notify.sh", TopCategory::Hooks),
file("x/hooks/pre.sh", TopCategory::Agents),
file("hooks/role-hook.sh", TopCategory::Roles),
file("x/config.yaml", TopCategory::Agents),
file("reviewer.md", TopCategory::Roles),
file("hello.yaml", TopCategory::Macros),
],
mcp_json: None,
};
let lines = hook_script_lines(&plan);
assert_eq!(
lines,
vec![
"bundle defines hook script: hooks/notify.sh",
"bundle defines hook script: agents/x/hooks/pre.sh",
"bundle defines hook script: roles/hooks/role-hook.sh",
]
);
}
#[test]
fn classify_file_new_when_dst_missing() {
let dir = fresh_temp_dir("classify-new-");
@@ -3420,6 +3608,91 @@ mod tests {
let _ = fs::remove_dir_all(&src_root);
}
fn hooks_fixture_bundle(label: &str) -> (PathBuf, PathBuf) {
let src_root = fresh_temp_dir(label);
let repo = src_root.join("hooky");
write_src(&repo, "hooks/notify-me.sh", "#!/bin/sh\necho hi\n");
write_src(&repo, "roles/hooks/role-hook.sh", "#!/bin/sh\n");
write_src(&repo, "agents/x/hooks/pre.sh", "#!/bin/sh\n");
write_src(&repo, "macros/hello.yaml", "name: hello\n");
init_bundle_repo(&repo);
(src_root, repo)
}
#[cfg(unix)]
fn assert_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path).unwrap().permissions().mode();
assert_ne!(
mode & 0o111,
0,
"{} must carry the executable bit",
path.display()
);
}
#[test]
#[serial]
fn install_remote_installs_all_hooks_dirs_and_records_hooks_category() {
let _guard = TestVaultConfigGuard::new("prov-hooks-dirs");
let (src_root, repo) = hooks_fixture_bundle("prov-hooks-dirs-src-");
install_remote(repo.to_str().unwrap(), None, false).unwrap();
let top = paths::hooks_dir().join("notify-me.sh");
let role = paths::roles_dir().join("hooks/role-hook.sh");
let agent = paths::agents_data_dir().join("x/hooks/pre.sh");
assert!(top.is_file(), "top-level hooks/ installs to hooks_dir");
assert!(role.is_file(), "roles/hooks/ installs via the roles walk");
assert!(
agent.is_file(),
"agents/<name>/hooks/ installs via the agents walk"
);
#[cfg(unix)]
{
assert_executable(&top);
assert_executable(&role);
assert_executable(&agent);
}
let store = BundleStore::load().unwrap();
let record = store.get("hooky").unwrap();
let category_of = |path: &str| {
record
.files
.iter()
.find(|f| f.path == path)
.unwrap_or_else(|| panic!("no provenance row for {path}"))
.category
.clone()
};
assert_eq!(category_of("hooks/notify-me.sh"), "hooks");
assert_eq!(category_of("roles/hooks/role-hook.sh"), "roles");
assert_eq!(category_of("agents/x/hooks/pre.sh"), "agents");
let _ = fs::remove_dir_all(&src_root);
}
#[test]
#[serial]
fn install_remote_filter_hooks_restricts_to_top_level_hooks_dir() {
let _guard = TestVaultConfigGuard::new("prov-hooks-filter");
let (src_root, repo) = hooks_fixture_bundle("prov-hooks-filter-src-");
install_remote(repo.to_str().unwrap(), Some(InstallFilter::Hooks), false).unwrap();
assert!(paths::hooks_dir().join("notify-me.sh").is_file());
assert!(!paths::roles_dir().join("hooks/role-hook.sh").exists());
assert!(!paths::agents_data_dir().join("x/hooks/pre.sh").exists());
assert!(!paths::macros_dir().join("hello.yaml").exists());
let store = BundleStore::load().unwrap();
let record = store.get("hooky").unwrap();
assert_eq!(record.files.len(), 1);
assert_eq!(record.files[0].path, "hooks/notify-me.sh");
assert_eq!(record.files[0].category, "hooks");
let _ = fs::remove_dir_all(&src_root);
}
#[test]
#[serial]
fn install_remote_without_manifest_uses_repo_slug_and_short_sha_version() {
@@ -3467,12 +3740,14 @@ mod tests {
PlannedFile {
src: src_new,
dst: dst_new.clone(),
rel: PathBuf::from("new.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Macros,
},
PlannedFile {
src: src_conflict,
dst: dst_conflict.clone(),
rel: PathBuf::from("conflict.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
},
@@ -3525,6 +3800,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3656,12 +3932,50 @@ mod tests {
}
let _guard = TestVaultConfigGuard::new("prov-builtin");
crate::config::install_assets(crate::config::AssetCategory::Macros).unwrap();
config::install_assets(config::AssetCategory::Macros).unwrap();
assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
assert!(!paths::installed_bundles_file().exists());
}
#[test]
#[serial]
fn builtin_hooks_category_installs_example_scripts() {
if *IS_STDOUT_TERMINAL {
eprintln!(
"Skipping builtin_hooks_category_installs_example_scripts: requires non-TTY stdout"
);
return;
}
let _guard = TestVaultConfigGuard::new("prov-hooks");
let hooks_key = get_env_name("hooks_dir");
let previous_hooks = env::var_os(&hooks_key);
let hooks_dir = fresh_temp_dir("prov-hooks-dir-");
unsafe {
env::set_var(&hooks_key, &hooks_dir);
}
// Capture outcomes before restoring the env var so a failed assertion
// cannot leave the hooks-dir override pointing at a deleted temp dir.
let result = config::install_assets(AssetCategory::Hooks);
let installed = (
hooks_dir.join("notify.sh").is_file(),
hooks_dir.join("log-events.sh").is_file(),
);
unsafe {
match previous_hooks {
Some(v) => env::set_var(&hooks_key, v),
None => env::remove_var(&hooks_key),
}
}
let _ = fs::remove_dir_all(&hooks_dir);
result.unwrap();
assert!(installed.0, "notify.sh must be installed");
assert!(installed.1, "log-events.sh must be installed");
}
#[test]
#[serial]
fn update_silently_refreshes_owned_unmodified_files() {
@@ -3798,6 +4112,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3844,6 +4159,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3923,6 +4239,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("shared.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -4195,6 +4512,7 @@ mod tests {
files: vec![PlannedFile {
src: dir.join("bundle-src/agents/foo/graph.yaml"),
dst: dir.join("agents/foo/graph.yaml"),
rel: PathBuf::from("foo/graph.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Agents,
}],
@@ -4237,6 +4555,7 @@ mod tests {
files: vec![PlannedFile {
src: dir.join("bundle-src/agents/foo/graph.yaml"),
dst: dir.join("agents/foo/graph.yaml"),
rel: PathBuf::from("foo/graph.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Agents,
}],
+80 -2
View File
@@ -1,6 +1,7 @@
mod agent;
mod app_config;
mod app_state;
pub(crate) mod builtin_manifest;
mod bundles;
mod input;
mod install_remote;
@@ -75,12 +76,14 @@ use crate::client::{
oauth, set_client_models_config,
};
use crate::function::{FunctionDeclaration, Functions};
use crate::hooks::HooksMap;
use crate::rag::Rag;
use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::utils::*;
pub use macros::macro_execute;
use crate::config::macros::Macro;
use crate::hooks;
use crate::vault::{
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice,
};
@@ -162,6 +165,7 @@ const AGENT_GRAPH_FILE_NAME: &str = "graph.yaml";
const ROLES_DIR_NAME: &str = "roles";
const SKILLS_DIR_NAME: &str = "skills";
const MACROS_DIR_NAME: &str = "macros";
const HOOKS_DIR_NAME: &str = "hooks";
const ENV_FILE_NAME: &str = ".env";
const MESSAGES_FILE_NAME: &str = "messages.md";
const SESSIONS_DIR_NAME: &str = "sessions";
@@ -262,6 +266,9 @@ pub struct Config {
pub enabled_mcp_servers: Option<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)]
pub hooks: HooksMap,
pub auto_continue: bool,
pub max_auto_continues: usize,
pub inject_todo_instructions: bool,
@@ -350,6 +357,8 @@ impl Default for Config {
enabled_mcp_servers: None,
mcp_tools: None,
hooks: Default::default(),
auto_continue: false,
max_auto_continues: 10,
inject_todo_instructions: true,
@@ -412,6 +421,8 @@ pub fn install_builtins() -> Result<()> {
Agent::install_builtin_agents(false)?;
Macro::install_macros(false)?;
Skill::install_builtin_skills(false)?;
hooks::install_builtin_hooks(false)?;
Role::install_builtin_role_hooks(false)?;
Ok(())
}
@@ -421,12 +432,20 @@ pub enum AssetCategory {
Macros,
Functions,
Skills,
Hooks,
#[value(name = "mcp-config", alias = "mcp_config")]
McpConfig,
}
impl AssetCategory {
pub const NAMES: [&'static str; 5] = ["agents", "macros", "functions", "skills", "mcp-config"];
pub const NAMES: [&'static str; 6] = [
"agents",
"macros",
"functions",
"skills",
"hooks",
"mcp-config",
];
pub fn parse(name: &str) -> Option<Self> {
match name {
@@ -434,6 +453,7 @@ impl AssetCategory {
"macros" => Some(Self::Macros),
"functions" => Some(Self::Functions),
"skills" => Some(Self::Skills),
"hooks" => Some(Self::Hooks),
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
_ => None,
}
@@ -453,17 +473,19 @@ pub enum InstallFilter {
Skills,
Macros,
Functions,
Hooks,
#[value(name = "mcp-config", alias = "mcp_config")]
McpConfig,
}
impl InstallFilter {
pub const NAMES: [&'static str; 6] = [
pub const NAMES: [&'static str; 7] = [
"agents",
"roles",
"skills",
"macros",
"functions",
"hooks",
"mcp-config",
];
@@ -474,6 +496,7 @@ impl InstallFilter {
"skills" => Some(Self::Skills),
"macros" => Some(Self::Macros),
"functions" => Some(Self::Functions),
"hooks" => Some(Self::Hooks),
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
_ => None,
}
@@ -486,6 +509,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
AssetCategory::Macros => ("macros", paths::macros_dir()),
AssetCategory::Functions => ("functions", paths::functions_dir()),
AssetCategory::Skills => ("skills", paths::skills_dir()),
AssetCategory::Hooks => ("hooks", paths::hooks_dir()),
AssetCategory::McpConfig => ("MCP config", paths::mcp_config_file()),
};
@@ -499,6 +523,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
AssetCategory::Macros => Macro::install_macros(true)?,
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
AssetCategory::Hooks => hooks::install_builtin_hooks(true)?,
AssetCategory::McpConfig => Functions::install_mcp_config()?,
}
@@ -1393,6 +1418,59 @@ clients:
assert!(!CONFIG_TEMPLATE.contains("escalation_timeout"));
}
#[test]
fn config_template_carries_hooks_block_without_global_hooks() {
// `global_hooks` is a per-agent whitelist; the global template must never grow it.
assert!(CONFIG_TEMPLATE.contains("\nhooks:"));
assert!(!CONFIG_TEMPLATE.contains("global_hooks"));
}
#[test]
fn config_template_renders_with_empty_hooks() {
let clients = json!([{ "type": "openai", "api_key": "sk-test" }]);
let rendered = render_config_template("openai:gpt-4o", None, &clients).unwrap();
assert!(rendered.contains("\nhooks:"));
assert!(!rendered.contains("global_hooks"));
let cfg = Config::load_from_str(&rendered).unwrap();
assert!(cfg.hooks.is_empty());
}
#[test]
fn config_parses_hooks_map_preserving_order() {
let yaml = "\
hooks:
tool.started:
- name: notify
command: ./hooks/notify.sh
- name: audit
command: ./hooks/audit.sh
turn.completed:
- name: webhook
command: curl -s https://example.com/hook
";
let cfg: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
cfg.hooks.keys().collect::<Vec<_>>(),
vec!["tool.started", "turn.completed"]
);
assert_eq!(cfg.hooks["tool.started"].len(), 2);
assert_eq!(cfg.hooks["tool.started"][0].name, "notify");
assert_eq!(cfg.hooks["tool.started"][0].command, "./hooks/notify.sh");
assert_eq!(cfg.hooks["turn.completed"][0].name, "webhook");
}
#[test]
fn config_without_hooks_parses_unchanged() {
let cfg: Config = serde_yaml::from_str("model: openai:gpt-4o").unwrap();
assert!(cfg.hooks.is_empty());
assert_eq!(cfg.model_id, "openai:gpt-4o");
}
#[test]
fn config_enabled_macros_empty_string_is_some_empty() {
let cfg: Config = serde_yaml::from_str("enabled_macros: \"\"").unwrap();
+33 -1
View File
@@ -2,7 +2,7 @@ use super::role::Role;
use super::{
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, INSTALLED_BUNDLES_FILE_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_NAME, INSTALLED_BUNDLES_FILE_NAME,
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
@@ -184,6 +184,13 @@ pub fn role_file(name: &str) -> PathBuf {
roles_dir().join(format!("{name}.md"))
}
pub fn hooks_dir() -> PathBuf {
match env::var(get_env_name("hooks_dir")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(HOOKS_DIR_NAME),
}
}
pub fn skills_dir() -> PathBuf {
match env::var(get_env_name("skills_dir")) {
Ok(value) => PathBuf::from(value),
@@ -947,6 +954,31 @@ mod tests {
}
}
#[test]
#[serial_test::serial]
fn hooks_dir_reflects_env_var_state() {
let env_name = get_env_name("hooks_dir");
let prev = env::var_os(&env_name);
unsafe {
env::remove_var(&env_name);
}
assert_eq!(hooks_dir(), config_dir().join("hooks"));
let probe = PathBuf::from("/tmp/coyote-hooks-dir-probe");
unsafe {
env::set_var(&env_name, &probe);
}
assert_eq!(hooks_dir(), probe);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
fn list_skills_skips_invalid_directory_names() {
let unique = time::SystemTime::now()
+525 -11
View File
@@ -50,6 +50,7 @@ use super::memory::{
DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory,
};
use crate::graph;
use crate::hooks::{self, HookEvent};
use anyhow::{Context, Error, Result, bail};
use colored::Colorize;
use gman::providers::SupportedProvider;
@@ -59,6 +60,7 @@ use inquire::{Confirm, MultiSelect, Text, list_option::ListOption, validator::Va
use log::warn;
use parking_lot::RwLock;
use prompts::DEFAULT_SKILL_INSTRUCTIONS;
use rand::distr::{Alphanumeric, SampleString};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs::{File, OpenOptions, read_dir, read_to_string, remove_dir_all, remove_file};
use std::io::Write;
@@ -354,6 +356,13 @@ pub struct RequestContext {
pub session_abort: Option<AbortSignal>,
/// Synthesized `COYOTE_AGENT_ID` for a top-level agent run (headless
/// `--agent` dispatch or an interactive agent context). Scheme:
/// `top-<agent-name>-<8-char alphanumeric nonce>`; spawned sub-agents
/// carry supervisor-issued ids instead. `Some` exactly while the
/// top-level `agent.*` bracket is open.
pub top_level_agent_id: Option<String>,
pub render_mode: RenderMode,
}
@@ -398,6 +407,7 @@ impl RequestContext {
auto_continue_paused: None,
pending_prefill: None,
session_abort: None,
top_level_agent_id: None,
render_mode: RenderMode::default(),
}
}
@@ -469,6 +479,7 @@ impl RequestContext {
auto_continue_paused: None,
pending_prefill: None,
session_abort: None,
top_level_agent_id: None,
render_mode: RenderMode::default(),
};
ctx.refresh_mcp_tool_filters();
@@ -529,6 +540,9 @@ impl RequestContext {
auto_continue_paused: self.auto_continue_paused.clone(),
pending_prefill: None,
session_abort: self.session_abort.clone(),
// Branches never own the top-level `agent.*` bracket; only the
// root flow that opened it may fire its terminal event.
top_level_agent_id: None,
render_mode: self.render_mode,
}
}
@@ -585,6 +599,7 @@ impl RequestContext {
auto_continue_paused: None,
pending_prefill: None,
session_abort: None,
top_level_agent_id: None,
render_mode: parent.render_mode,
}
}
@@ -1054,6 +1069,12 @@ impl RequestContext {
}
pub fn exit_session(&mut self) -> Result<()> {
// Fired while the session is still attached so the hook sees
// COYOTE_SESSION_ID and session-held role hooks still resolve; no
// session means no event.
if self.session.is_some() {
hooks::fire(HookEvent::SessionEnded, self, &[], None);
}
if let Some(mut session) = self.session.take() {
let sessions_dir = self.sessions_dir();
session.exit(&sessions_dir, self.working_mode.is_repl())?;
@@ -4613,6 +4634,7 @@ impl RequestContext {
);
}
let mut session;
let mut created_new_session = false;
match session_name {
None | Some(TEMP_SESSION_NAME) => {
let session_file = self.session_file(TEMP_SESSION_NAME);
@@ -4622,11 +4644,13 @@ impl RequestContext {
})?;
}
session = Some(Session::new_from_ctx(self, app, TEMP_SESSION_NAME)?);
created_new_session = true;
}
Some(name) => {
let session_path = self.session_file(name);
if !session_path.exists() {
session = Some(Session::new_from_ctx(self, app, name)?);
created_new_session = true;
} else {
session = Some(Session::load_from_ctx(self, app, name, &session_path)?);
}
@@ -4709,6 +4733,9 @@ impl RequestContext {
}
self.refresh_mcp_tool_filters();
self.init_agent_session_variables(new_session)?;
if created_new_session {
hooks::fire(HookEvent::SessionStarted, self, &[], None);
}
Ok(())
}
@@ -4874,6 +4901,9 @@ impl RequestContext {
tool_tracker,
};
// Interactive switch-away closes the top-level agent bracket while
// the agent is still attached, so agent-scoped hooks resolve.
self.top_level_agent_finished(None);
if self.agent.take().is_some() {
if let Some(supervisor) = self.supervisor.clone() {
supervisor.read().cancel_recursive();
@@ -4901,6 +4931,50 @@ impl RequestContext {
Ok(())
}
pub fn top_level_agent_started(&mut self) {
if self.top_level_agent_id.is_some() {
return;
}
let Some(agent) = self.agent.as_ref() else {
return;
};
let nonce = Alphanumeric.sample_string(&mut rand::rng(), 8);
let id = format!("top-{}-{nonce}", agent.name());
self.top_level_agent_id = Some(id.clone());
hooks::fire(
HookEvent::AgentStarted,
self,
&[("COYOTE_AGENT_ID", id)],
None,
);
}
pub fn top_level_agent_finished(&mut self, error: Option<&Error>) {
let Some(id) = self.top_level_agent_id.take() else {
return;
};
match error {
None => hooks::fire(
HookEvent::AgentCompleted,
self,
&[("COYOTE_AGENT_ID", id)],
None,
),
Some(err) => hooks::fire(
HookEvent::AgentFailed,
self,
&[
("COYOTE_AGENT_ID", id),
("COYOTE_AGENT_ERROR", format!("{err:#}")),
],
None,
),
}
}
pub async fn edit_role(&mut self, app: &AppConfig, abort_signal: AbortSignal) -> Result<()> {
let role_name;
if let Some(session) = self.session.as_ref() {
@@ -5421,6 +5495,7 @@ mod tests {
use crate::config::tool_scope::test_fixtures::{FixtureServer, fixture_runtime};
use crate::function::jobs::RingBuf;
use crate::function::{ToolCall, skill};
use crate::hooks::{HookDef, HooksMap, test_sink};
use crate::mcp::{McpServer, McpServerFeatures, McpServersConfig, McpTransportType};
use crate::supervisor::{
AgentExitStatus, AgentHandle, AgentResult, JobHandle, JobResult, JobState, JobStatus,
@@ -5428,6 +5503,7 @@ mod tests {
use crate::utils;
use crate::utils::get_env_name;
use crate::vault::Vault;
use anyhow::anyhow;
use rmcp::model::PromptArgument;
use serde_json::json;
use serial_test::serial;
@@ -8230,6 +8306,349 @@ mod tests {
assert!(ctx.session.is_none());
}
fn hooks_map_of(events: &[&str], marker: &str) -> HooksMap {
let mut map = HooksMap::default();
for event in events {
map.insert(
event.to_string(),
vec![HookDef {
name: format!("{marker}-{event}"),
command: "true".to_string(),
}],
);
}
map
}
fn marker_captures(marker: &str) -> Vec<test_sink::Capture> {
test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name.starts_with(marker))
.collect()
}
/// Writes an agent whose config carries agent-scoped hooks for every
/// `agent.*` event, named `<marker>-<event>`.
fn seed_agent_with_hooks(agent_name: &str, marker: &str) {
let agent_dir = paths::agent_data_dir(agent_name);
create_dir_all(&agent_dir).unwrap();
let mut config = format!("name: {agent_name}\ninstructions: hi\nhooks:\n");
for event in ["agent.started", "agent.completed", "agent.failed"] {
config.push_str(&format!(
" {event}:\n - name: {marker}-{event}\n command: 'true'\n"
));
}
write(agent_dir.join("config.yaml"), config).unwrap();
}
fn unique_name(prefix: &str) -> String {
format!(
"{prefix}_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
)
}
#[test]
#[serial]
fn use_session_fires_session_started_for_new_sessions_only() {
let _guard = TestConfigDirGuard::new();
let sessions_dir = paths::local_dir("sessions");
create_dir_all(&sessions_dir).unwrap();
let _sink = test_sink::install();
let marker = "sess-started-q8n";
let mut ctx = create_test_ctx();
ctx.update_app_config(|app| app.hooks = hooks_map_of(&["session.started"], marker));
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_session(&app, Some("hook-fresh"), abort.clone())).unwrap();
let captures = marker_captures(marker);
assert_eq!(captures.len(), 1, "a new session fires exactly one event");
assert_eq!(
captures[0]
.envs
.get("COYOTE_SESSION_ID")
.map(String::as_str),
Some("hook-fresh")
);
// Resuming a persisted session fires nothing.
write_paused_todo_session(&ctx, "hook-resume");
let mut resumed_ctx = create_test_ctx();
resumed_ctx.update_app_config(|app| app.hooks = hooks_map_of(&["session.started"], marker));
let resumed_app = resumed_ctx.app.config.clone();
run_async(resumed_ctx.use_session(&resumed_app, Some("hook-resume"), abort)).unwrap();
assert_eq!(
marker_captures(marker).len(),
1,
"resume must not fire session.started"
);
}
#[test]
#[serial]
fn exit_session_fires_session_ended_only_with_a_session() {
let _guard = TestConfigDirGuard::new();
let _sink = test_sink::install();
let marker = "sess-ended-w5c";
let mut ctx = create_test_ctx();
ctx.update_app_config(|app| app.hooks = hooks_map_of(&["session.ended"], marker));
// Sessionless (headless) exit path: nothing fires.
ctx.exit_session().unwrap();
assert!(marker_captures(marker).is_empty());
ctx.session = Some(Session::default());
ctx.exit_session().unwrap();
ctx.exit_session().unwrap();
assert_eq!(
marker_captures(marker).len(),
1,
"session.ended fires exactly once, only while a session is attached"
);
}
#[test]
#[serial]
fn chat_completion_seams_fire_no_turn_events_in_child_contexts() {
let _guard = TestConfigDirGuard::new();
let _sink = test_sink::install();
let marker = "child-turn-b4h";
let mut ctx = create_test_ctx();
ctx.update_app_config(|app| {
app.hooks = hooks_map_of(
&[
"turn.started",
"turn.completed",
"turn.interrupted",
"turn.failed",
],
marker,
)
});
let app = ctx.app.config.clone();
let input = Input::from_str(&ctx, "hello", None).unwrap();
ctx.before_chat_completion(&input).unwrap();
ctx.after_chat_completion(&app, &input, "output", &[])
.unwrap();
assert!(
marker_captures(marker).is_empty(),
"per-round chat-completion seams must never fire turn events"
);
}
#[test]
#[serial]
fn top_level_agent_bracket_fires_started_and_completed_once() {
let _guard = TestConfigDirGuard::new();
let _sink = test_sink::install();
let marker = "agent-top-ok-m2s";
let agent_name = unique_name("hook_agent");
seed_agent_with_hooks(&agent_name, marker);
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
ctx.top_level_agent_started();
// Per agent RUN, never per message: re-entering while open is a no-op.
ctx.top_level_agent_started();
ctx.top_level_agent_finished(None);
ctx.top_level_agent_finished(None);
let captures = marker_captures(marker);
let started: Vec<_> = captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-agent.started"))
.collect();
let completed: Vec<_> = captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-agent.completed"))
.collect();
assert_eq!(started.len(), 1);
assert_eq!(completed.len(), 1);
assert!(
captures
.iter()
.all(|capture| capture.hook_name != format!("{marker}-agent.failed"))
);
let id = started[0]
.envs
.get("COYOTE_AGENT_ID")
.expect("agent id env");
assert!(
id.starts_with(&format!("top-{agent_name}-")),
"synthesized id must follow the top-<agent>-<nonce> scheme: {id}"
);
assert_eq!(completed[0].envs.get("COYOTE_AGENT_ID"), Some(id));
assert_eq!(
started[0].envs.get("COYOTE_AGENT_NAME").map(String::as_str),
Some(agent_name.as_str())
);
}
#[test]
#[serial]
fn top_level_agent_failure_fires_agent_failed_with_error() {
let _guard = TestConfigDirGuard::new();
let _sink = test_sink::install();
let marker = "agent-top-err-f7d";
let agent_name = unique_name("hook_agent");
seed_agent_with_hooks(&agent_name, marker);
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
ctx.top_level_agent_started();
let err = anyhow!("dispatch exploded");
ctx.top_level_agent_finished(Some(&err));
let captures = marker_captures(marker);
let failed: Vec<_> = captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-agent.failed"))
.collect();
assert_eq!(failed.len(), 1);
assert!(
captures
.iter()
.all(|capture| capture.hook_name != format!("{marker}-agent.completed"))
);
assert_eq!(
failed[0].envs.get("COYOTE_AGENT_ERROR").map(String::as_str),
Some("dispatch exploded")
);
assert!(failed[0].envs.contains_key("COYOTE_AGENT_ID"));
}
#[test]
#[serial]
fn top_level_agent_helpers_noop_without_agent() {
let _guard = TestConfigDirGuard::new();
let _sink = test_sink::install();
let marker = "agent-top-role-only-c9k";
let mut ctx = create_test_ctx();
ctx.update_app_config(|app| {
app.hooks = hooks_map_of(
&["agent.started", "agent.completed", "agent.failed"],
marker,
)
});
ctx.role = Some(Role::new("plain", "prompt"));
ctx.top_level_agent_started();
ctx.top_level_agent_finished(None);
assert!(
marker_captures(marker).is_empty(),
"a role-only context must fire no agent events"
);
assert!(ctx.top_level_agent_id.is_none());
}
#[test]
#[serial]
fn exit_agent_closes_top_level_agent_bracket() {
let _guard = TestConfigDirGuard::new();
let _sink = test_sink::install();
let marker = "agent-top-exit-v3p";
let agent_name = unique_name("hook_agent");
seed_agent_with_hooks(&agent_name, marker);
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
ctx.top_level_agent_started();
ctx.exit_agent(&app).unwrap();
// A later exit seam (REPL teardown) must not double-fire.
ctx.top_level_agent_finished(None);
let captures = marker_captures(marker);
assert_eq!(
captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-agent.completed"))
.count(),
1,
"switch-away fires the terminal event exactly once"
);
}
#[test]
#[serial]
fn resolved_hooks_resolves_role_hooks_held_by_fresh_session() {
let _guard = TestConfigDirGuard::new();
let sessions_dir = paths::local_dir("sessions");
create_dir_all(&sessions_dir).unwrap();
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_session(&app, Some("role-holder"), abort)).unwrap();
let role_content = "---\nhooks:\n turn.completed:\n - name: session-role-probe\n command: role-cmd\n---\nPrompt";
ctx.use_role_obj(Role::new("hooked-role", role_content))
.unwrap();
assert!(
ctx.role.is_none(),
"use_role_obj must move the role into the active session"
);
let resolved = ctx.resolved_hooks(HookEvent::TurnCompleted);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "session-role-probe");
assert_eq!(resolved[0].command, "role-cmd");
assert_eq!(resolved[0].cwd, paths::roles_dir());
}
#[test]
#[serial]
fn resolved_hooks_resolves_role_hooks_after_session_resume() {
let _guard = TestConfigDirGuard::new();
let sessions_dir = paths::local_dir("sessions");
create_dir_all(&sessions_dir).unwrap();
let roles_dir = paths::roles_dir();
create_dir_all(&roles_dir).unwrap();
write(
roles_dir.join("resume-hooked.md"),
"---\nhooks:\n session.ended:\n - name: resume-role-probe\n command: role-cmd\n---\nYou are hooked.",
)
.unwrap();
let seed_ctx = create_test_ctx();
let session_path = seed_ctx.session_file("resume-role");
ensure_parent_exists(&session_path).unwrap();
write(
&session_path,
"model: test-seeded:test-chat\nrole_name: resume-hooked\nmessages: []\n",
)
.unwrap();
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_session(&app, Some("resume-role"), abort)).unwrap();
assert!(ctx.role.is_none());
let resolved = ctx.resolved_hooks(HookEvent::SessionEnded);
assert_eq!(resolved.len(), 1, "resumed session must restore role hooks");
assert_eq!(resolved[0].name, "resume-role-probe");
}
fn write_paused_todo_session(ctx: &RequestContext, name: &str) {
let session_path = ctx.session_file(name);
ensure_parent_exists(&session_path).unwrap();
@@ -8859,6 +9278,53 @@ mod tests {
assert!(agents_dir.join("architect").join("config.yaml").exists());
}
#[test]
#[serial]
fn install_builtin_agents_reconciles_hooks_subdir_via_manifest() {
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
let agents_dir = paths::agents_data_dir();
// Simulate an upgrade from an older install whose coder shipped a
// hook script that the current embed no longer contains: the manifest
// records it, so reconciliation removes it — while a user-created
// script in the same directory (absent from the manifest) survives.
let hooks_dir = agents_dir.join("coder").join("hooks");
create_dir_all(&hooks_dir).unwrap();
write(hooks_dir.join(BUILTIN_MANIFEST_FILE), "old-hook.sh\n").unwrap();
write(hooks_dir.join("old-hook.sh"), "stale").unwrap();
write(hooks_dir.join("user-hook.sh"), "user-owned").unwrap();
// A custom agent outside the bundle is never reconciled, even with a
// manifest present.
let custom_hooks = agents_dir.join("myagent").join("hooks");
create_dir_all(&custom_hooks).unwrap();
write(custom_hooks.join(BUILTIN_MANIFEST_FILE), "custom.sh\n").unwrap();
write(custom_hooks.join("custom.sh"), "keep").unwrap();
Agent::install_builtin_agents(false).unwrap();
assert!(
!hooks_dir.join("old-hook.sh").exists(),
"manifest-listed hook absent from the embed must be removed"
);
assert!(
hooks_dir.join("user-hook.sh").exists(),
"user-created hook files must survive reconciliation"
);
assert!(
!hooks_dir.join(BUILTIN_MANIFEST_FILE).exists(),
"an empty shipped set removes the manifest itself"
);
assert!(
custom_hooks.join("custom.sh").exists(),
"custom agents outside the bundle must survive reconciliation"
);
assert!(custom_hooks.join(BUILTIN_MANIFEST_FILE).exists());
}
#[test]
#[serial]
fn install_builtin_skills_force_overwrites_only_with_force() {
@@ -11545,7 +12011,7 @@ mod tests {
let NodeType::Llm(holistic) = &holistic.node_type else {
panic!("holistic must be an llm node");
};
assert_eq!(holistic.max_iterations, 20);
assert_eq!(holistic.max_iterations, 45);
}
const BUNDLED_GRAPHS: [&str; 6] = [
@@ -14601,6 +15067,16 @@ mod tests {
.map(str::to_owned)
}
fn fv_soft_note(out: &serde_json::Value) -> Option<String> {
out["verdict_out"]["attention"]
.as_array()
.unwrap()
.iter()
.filter_map(|a| a.as_str())
.find(|a| a.starts_with("Verifier: ") && a.contains("could not be verified"))
.map(str::to_owned)
}
#[test]
fn code_reviewer_verdict_fv_duplicate_unknown_faults_count_individually() {
if !cmd_available("python3") {
@@ -14609,6 +15085,9 @@ mod tests {
}
// finding-verifier's crash guard emits every fault entry under the
// same "unknown" id; an id-indexed dict would collapse them to one.
// Non-sentinel faults are SOFT: the findings stand unverified with a
// human-attention note, but the lane is NOT flagged as a re-runnable
// pipeline fault.
let entry = json!({
"id": "unknown",
"verdict": "UNVERIFIABLE",
@@ -14622,10 +15101,23 @@ mod tests {
});
let out = run_code_reviewer_script("verdict.py", &state);
assert_eq!(out["verdict_out"]["verdict"], "NEEDS-HUMAN", "{out}");
let fault = fv_degraded_fault(&out).unwrap_or_else(|| panic!("{out}"));
assert!(
fault.contains("2 verifier verdict(s)"),
"each crash-guard entry must count: {fault}"
fv_degraded_fault(&out).is_none(),
"crash-guard entries are soft faults, not a degraded lane: {out}"
);
let note = fv_soft_note(&out).unwrap_or_else(|| panic!("{out}"));
assert!(
note.contains("2 finding(s) could not be verified"),
"each crash-guard entry must count: {note}"
);
let reason = out["verdict_out"]["reason"].as_str().unwrap();
assert!(
reason.contains("2 finding(s) unverified (verifier branch fault)"),
"the reason must count the unverified findings: {out}"
);
assert!(
!reason.contains("pipeline fault(s) recorded"),
"soft faults must not trip the gauntlet's degraded-run anchor: {out}"
);
}
@@ -14638,6 +15130,8 @@ mod tests {
// finding-verifier completes "successfully" with parse_fault's
// sentinel entry inside the payload — not an "Agent node failed:"
// banner — so the gate must read the sentinel id as a fault.
// The sentinel means the verifier verified NOTHING: this is the HARD,
// lane-degrading path.
let state = json!({
"changed_files": ["a.rs"],
"domain_reports": ["clean report. DOMAIN_REVIEW_COMPLETE"],
@@ -14665,8 +15159,12 @@ mod tests {
panic!("the degraded-verification fault must be in attention: {out}")
});
assert!(
fault.contains("1 verifier verdict(s) carry a pipeline fault"),
"the fault must count the faulted verdicts: {fault}"
fault.contains("the verifier verified nothing"),
"the fault must state that nothing was verified: {fault}"
);
assert!(
fault.contains("parse failed"),
"the fault must carry the sentinel's detail: {fault}"
);
}
@@ -14678,6 +15176,10 @@ mod tests {
}
// verify_fault's per-finding entry keeps the real finding id; the
// fault is recognized by the note's PIPELINE-FAULT prefix.
// Per-finding faults are SOFT: the finding stands unverified with a
// human-attention note instead of a lane-degrading pipeline fault
// (re-running an entire ~1h lane over a few unverified findings is
// the wrong trade).
let state = json!({
"changed_files": ["a.rs"],
"domain_reports": ["clean report. DOMAIN_REVIEW_COMPLETE"],
@@ -14694,12 +15196,24 @@ mod tests {
v["verdict"], "NEEDS-HUMAN",
"a lane-faulted verifier verdict must block: {out}"
);
let fault = fv_degraded_fault(&out).unwrap_or_else(|| {
panic!("the degraded-verification fault must be in attention: {out}")
});
assert!(
fault.contains("verifier lane failed after retries"),
"the fault must carry the verifier's note: {fault}"
fv_degraded_fault(&out).is_none(),
"a per-finding fault must not degrade the lane: {out}"
);
let note = fv_soft_note(&out)
.unwrap_or_else(|| panic!("the unverified-findings note must be in attention: {out}"));
assert!(
note.contains("verifier lane failed after retries"),
"the note must carry the verifier's fault detail: {note}"
);
let reason = v["reason"].as_str().unwrap();
assert!(
reason.contains("1 finding(s) unverified (verifier branch fault)"),
"the reason must count the unverified finding: {out}"
);
assert!(
!reason.contains("pipeline fault(s) recorded"),
"soft faults must not trip the gauntlet's degraded-run anchor: {out}"
);
}
+240
View File
@@ -1,12 +1,17 @@
use super::*;
use crate::client::{Message, MessageContent, MessageRole, Model};
use crate::config::builtin_manifest;
use crate::function::write_file_atomic;
use crate::hooks::HooksMap;
use anyhow::Result;
use fancy_regex::Regex;
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::LazyLock;
pub const SHELL_ROLE: &str = "shell";
@@ -99,6 +104,8 @@ pub struct Role {
skill_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
memory: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
hooks: Option<HooksMap>,
#[serde(skip)]
model: Model,
@@ -161,6 +168,7 @@ impl Role {
role.skill_instructions = value.as_str().map(|v| v.to_string())
}
"memory" => role.memory = value.as_bool(),
"hooks" => role.hooks = parse_hooks_map(value),
_ => (),
}
}
@@ -181,6 +189,26 @@ impl Role {
.collect()
}
/// Installs builtin role hook scripts into `<roles_dir>/hooks/` and
/// reconciles ones a release stopped shipping. This is the only builtin
/// install machinery roles have: general role installation deliberately
/// does not exist (builtin roles load straight from the embed).
pub fn install_builtin_role_hooks(force: bool) -> Result<()> {
let shipped: Vec<(String, String)> = RolesAsset::iter()
.filter_map(|file| {
let name = file.as_ref().strip_prefix("hooks/")?;
if name.is_empty() || name.contains('/') {
return None;
}
let embedded = RolesAsset::get(file.as_ref())?;
let content = unsafe { std::str::from_utf8_unchecked(&embedded.data) };
Some((name.to_string(), content.to_string()))
})
.collect();
install_and_reconcile_role_hooks(&paths::roles_dir().join("hooks"), &shipped, force)
}
pub fn has_args(&self) -> bool {
self.name.contains('#')
}
@@ -248,6 +276,10 @@ impl Role {
if let Some(memory) = self.memory {
metadata.push(format!("memory: {memory}"));
}
if let Some(hooks) = &self.hooks {
let inline = serde_json::to_string(hooks).unwrap_or_else(|_| "{}".to_string());
metadata.push(format!("hooks: {inline}"));
}
if metadata.is_empty() {
format!("{}\n", self.prompt)
} else if self.prompt.is_empty() {
@@ -380,6 +412,10 @@ impl Role {
self.memory
}
pub fn hooks(&self) -> Option<&HooksMap> {
self.hooks.as_ref()
}
pub fn skills_enabled(&self) -> Option<bool> {
self.skills_enabled
}
@@ -544,6 +580,16 @@ fn parse_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
Some(mcp_tools)
}
fn parse_hooks_map(value: &Value) -> Option<HooksMap> {
match serde_json::from_value(value.clone()) {
Ok(map) => Some(map),
Err(err) => {
debug!("Ignoring malformed hooks configuration: {err}");
None
}
}
}
fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
let mut text = prompt;
let mut search_input = true;
@@ -592,6 +638,46 @@ fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
(prompt, vec![])
}
/// Only direct children of the hooks/ directory are supported: they are the
/// only shape the builtin manifest tracks, so nested asset paths are skipped
/// by the caller rather than installed unreconciled.
fn install_and_reconcile_role_hooks(
dir: &Path,
shipped: &[(String, String)],
force: bool,
) -> Result<()> {
if !shipped.is_empty() {
info!("Installing built-in role hooks in {}", dir.display());
}
let mut written = BTreeSet::new();
for (name, content) in shipped {
let path = dir.join(name);
if path.exists() && !force {
debug!(
"Role hook file already exists, skipping: {}",
path.display()
);
continue;
}
ensure_parent_exists(&path)?;
info!("Creating role hook file: {}", path.display());
write_file_atomic(&path, content, Some(0o755))?;
written.insert(name.clone());
}
let names: BTreeSet<String> = shipped.iter().map(|(name, _)| name.clone()).collect();
// Reconciliation is best-effort housekeeping: a failure here must not
// abort startup (install_builtins), matching the agent-side policy.
if let Err(err) = builtin_manifest::reconcile_builtin_dir(dir, &names, &written) {
warn!(
"Failed to reconcile builtin role hooks in {}: {err}",
dir.display()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -641,6 +727,42 @@ mod tests {
);
}
#[test]
fn role_hooks_round_trip_through_export() {
let content = "---\nhooks:\n turn.completed:\n - name: notify\n command: ./hooks/notify.sh\n---\nPrompt";
let role = Role::new("test", content);
let hooks = role.hooks().expect("hooks should be parsed");
assert_eq!(hooks["turn.completed"][0].name, "notify");
assert_eq!(hooks["turn.completed"][0].command, "./hooks/notify.sh");
let exported = role.export();
assert!(exported.contains("hooks:"));
let reparsed = Role::new("test", &exported);
assert_eq!(reparsed.hooks(), role.hooks());
assert_eq!(reparsed.prompt(), "Prompt");
}
#[test]
fn role_without_hooks_has_none_and_export_omits_key() {
let role = Role::new("test", "---\nmemory: true\n---\nPrompt");
assert!(role.hooks().is_none());
assert!(!role.export().contains("hooks:"));
}
#[test]
fn role_malformed_hooks_degrades_to_none() {
let content = "---\nhooks: [not, a, map]\n---\nPrompt";
let role = Role::new("test", content);
assert!(role.hooks().is_none());
assert_eq!(role.prompt(), "Prompt");
}
#[test]
fn role_new_no_metadata_has_none_fields() {
let role = Role::new("test", "Just a prompt");
@@ -909,4 +1031,122 @@ Input 1
assert_eq!(parse_structure_prompt(prompt), (prompt, vec![]));
}
fn fixture(entries: &[(&str, &str)]) -> Vec<(String, String)> {
entries
.iter()
.map(|(name, content)| (name.to_string(), content.to_string()))
.collect()
}
fn hooks_fixture_dir(label: &str) -> PathBuf {
let dir = temp_file(label, "");
create_dir_all(&dir).unwrap();
dir.join("hooks")
}
#[test]
fn role_hooks_install_writes_scripts_and_manifest() {
let dir = hooks_fixture_dir("role-hooks-install-");
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
.unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n");
assert_eq!(
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"a.sh\n"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(dir.join("a.sh")).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755, "role hook scripts install executable");
let manifest_mode = fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
.unwrap()
.permissions()
.mode();
assert_eq!(
manifest_mode & 0o111,
0,
"the manifest must never be executable"
);
}
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_install_overwrites_only_with_force() {
let dir = hooks_fixture_dir("role-hooks-force-");
let shipped = fixture(&[("a.sh", "new content\n")]);
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
install_and_reconcile_role_hooks(&dir, &shipped, false).unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL");
install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_reconcile_removes_dropped_hook_and_keeps_user_files() {
let dir = hooks_fixture_dir("role-hooks-reconcile-");
install_and_reconcile_role_hooks(
&dir,
&fixture(&[("keep.sh", "#!/bin/sh\n"), ("drop.sh", "#!/bin/sh\n")]),
false,
)
.unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(&dir, &fixture(&[("keep.sh", "#!/bin/sh\n")]), false)
.unwrap();
assert!(
!dir.join("drop.sh").exists(),
"dropped builtin hook removed"
);
assert!(dir.join("keep.sh").exists());
assert!(dir.join("user.sh").exists(), "user file survives reconcile");
assert_eq!(
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"keep.sh\n"
);
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_never_claim_a_preexisting_user_file() {
let dir = hooks_fixture_dir("role-hooks-unclaimed-");
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
.unwrap();
assert!(
!dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists(),
"a skipped pre-existing file must not be claimed by the manifest"
);
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
assert_eq!(
read_to_string(dir.join("a.sh")).unwrap(),
"user-owned",
"the user file survives the hook being dropped from the embed"
);
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_empty_shipped_set_creates_nothing() {
let dir = hooks_fixture_dir("role-hooks-empty-");
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
assert!(!dir.exists(), "an empty shipped set must not create hooks/");
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
}
+64
View File
@@ -3,6 +3,7 @@ use super::todo::TodoList;
use super::*;
use crate::client::{Message, MessageContent, MessageRole, TokenUsage};
use crate::hooks::HooksMap;
use crate::render::MarkdownRender;
use anyhow::{Context, Result, bail};
@@ -113,6 +114,8 @@ pub struct Session {
#[serde(skip)]
role_prompt: String,
#[serde(skip)]
role_hooks: Option<HooksMap>,
#[serde(skip)]
name: String,
#[serde(skip)]
path: Option<String>,
@@ -193,6 +196,7 @@ impl Session {
&& let Ok(role) = ctx.retrieve_role(app, role_name)
{
session.role_prompt = role.prompt().to_string();
session.role_hooks = role.hooks().cloned();
}
session.update_tokens();
@@ -254,6 +258,13 @@ impl Session {
self.role_name.as_deref()
}
/// Hooks of the role this session holds. `set_role` snapshots them
/// because only the frontmatter-stripped role prompt is persisted;
/// `load_from_ctx` restores them by re-reading the role file on resume.
pub fn role_hooks(&self) -> Option<&HooksMap> {
self.role_hooks.as_ref()
}
pub fn dirty(&self) -> bool {
self.dirty
}
@@ -561,6 +572,7 @@ impl Session {
self.model = role.model().clone();
self.role_name = convert_option_string(role.name());
self.role_prompt = role.prompt().to_string();
self.role_hooks = role.hooks().cloned();
self.dirty = true;
self.update_tokens();
}
@@ -568,11 +580,13 @@ impl Session {
pub fn clear_role(&mut self) {
self.role_name = None;
self.role_prompt.clear();
self.role_hooks = None;
}
pub fn sync_agent(&mut self, agent: &Agent) {
self.role_name = None;
self.role_prompt = agent.interpolated_instructions();
self.role_hooks = None;
self.agent_variables = agent.variables().clone();
self.agent_instructions = self.role_prompt.clone();
if let Some(threshold) = agent.compression_threshold() {
@@ -1111,6 +1125,25 @@ mod tests {
assert!(!session.dirty());
}
#[test]
fn set_role_captures_and_clear_role_drops_role_hooks() {
let content = "---\nhooks:\n turn.completed:\n - name: role-probe\n command: role-cmd\n---\nPrompt";
let mut session = Session::default();
session.set_role(Role::new("hooked", content));
let hooks = session.role_hooks().expect("set_role must snapshot hooks");
assert_eq!(hooks["turn.completed"][0].name, "role-probe");
session.clear_role();
assert!(session.role_hooks().is_none());
session.set_role(Role::new("plain", "No frontmatter"));
assert!(
session.role_hooks().is_none(),
"a role without hooks must not leave a stale snapshot"
);
}
fn push_interrupted_turn(session: &mut Session) {
session.messages.push(Message::new(
MessageRole::User,
@@ -1233,6 +1266,37 @@ mod tests {
assert!(!yaml.contains("token_usage"));
}
#[test]
fn session_role_hooks_is_not_serialized() {
let content = "---\nhooks:\n turn.completed:\n - name: role-probe\n command: role-cmd\n---\nPrompt";
let mut session = Session::default();
session.set_role(Role::new("hooked", content));
assert!(session.role_hooks().is_some());
let yaml = serde_yaml::to_string(&session).unwrap();
assert!(
!yaml.contains("role_hooks"),
"the role-hooks snapshot must never reach session YAML"
);
assert!(
!yaml.contains("role-cmd"),
"no hook command text may leak into session YAML under any key"
);
}
#[test]
fn session_deserialization_ignores_injected_role_hooks() {
// A session file is attacker-writable data: a `role_hooks:` key
// crafted into it must never deserialize into executable hook
// config. Hooks are only ever re-read from the role file itself.
let yaml = "model: ''\nmessages: []\nrole_hooks:\n session.started:\n - name: injected\n command: touch /tmp/pwned\n";
let session: Session = serde_yaml::from_str(yaml).unwrap();
assert!(
session.role_hooks().is_none(),
"injected role_hooks in session YAML must be ignored"
);
}
#[test]
fn session_cost_accumulates_and_survives_yaml_round_trip() {
let mut session = Session::default();
+249 -2
View File
@@ -4,8 +4,9 @@ use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::{
Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike,
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
list_agents_with_descriptions, load_agent_variables,
list_agents_with_descriptions, load_agent_variables, paths,
};
use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
use crate::supervisor::notification::agent_notification;
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
@@ -14,7 +15,7 @@ use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_use
use crate::graph;
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
use chrono::{SecondsFormat, Utc};
use indexmap::IndexMap;
use log::{debug, warn};
use parking_lot::RwLock;
@@ -996,6 +997,86 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
Ok(())
}
/// Mirrors the base-env contract `hooks::fire` assembles from a live context,
/// for detached call sites that dispatch pre-resolved hook snapshots after
/// the context they were resolved from is gone. The names are captured by
/// the caller when it takes the snapshot; the timestamp is taken here, at
/// fire time, so it reflects when the event actually happened.
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 {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
session_name: Option<String>,
agent_id: String,
agent_name: String,
}
impl SpawnResultHooks {
fn resolve(child_ctx: &RequestContext, agent_id: &str, agent_name: &str) -> Self {
Self {
completed: child_ctx.resolved_hooks(HookEvent::AgentCompleted),
failed: child_ctx.resolved_hooks(HookEvent::AgentFailed),
session_name: child_ctx
.session
.as_ref()
.map(|session| session.name().to_string()),
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
}
}
/// Fires `agent.completed`, or `agent.failed` when `error` is given.
fn fire(self, error: Option<&str>) {
let mut extras = vec![
("COYOTE_AGENT_ID", self.agent_id),
("COYOTE_AGENT_NAME", self.agent_name.clone()),
];
let (event, resolved) = match error {
None => (HookEvent::AgentCompleted, self.completed),
Some(error) => {
extras.push(("COYOTE_AGENT_ERROR", error.to_string()));
(HookEvent::AgentFailed, self.failed)
}
};
hooks::fire_resolved(
event,
resolved,
hook_base_envs(event, self.session_name.as_deref(), Some(&self.agent_name)),
&extras,
None,
);
}
}
async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let agent_name = args
.get("agent")
@@ -1149,6 +1230,19 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
debug!("Spawning child agent '{agent_name}' as '{agent_id}'");
hooks::fire(
HookEvent::AgentStarted,
&child_ctx,
&[
("COYOTE_AGENT_ID", agent_id.clone()),
("COYOTE_AGENT_NAME", agent_name.clone()),
],
None,
);
// Resolved before the spawn: the result arms outlive the child context
// the snapshots come from.
let result_hooks = SpawnResultHooks::resolve(&child_ctx, &agent_id, &agent_name);
let spawn_agent_id = agent_id.clone();
let spawn_agent_name = agent_name.clone();
let spawn_abort = child_abort.clone();
@@ -1173,6 +1267,10 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
},
};
let success = agent_result.exit_status == AgentExitStatus::Completed;
match &agent_result.exit_status {
AgentExitStatus::Completed => result_hooks.fire(None),
AgentExitStatus::Failed(e) => result_hooks.fire(Some(e)),
}
spawn_notifications.push(agent_notification(
&agent_result.id,
&agent_result.agent_name,
@@ -2154,6 +2252,155 @@ mod tests {
.unwrap();
}
/// A graph agent whose graph.yaml wires `agent.*` hooks at its top
/// level. With `failing`, the start node references a script that does
/// not exist, so the child run fails at runtime — after the spawn seam
/// has already fired `agent.started`.
fn write_graph_agent_with_hooks(agent_name: &str, marker: &str, failing: bool) {
let agent_dir = paths::agent_data_dir(agent_name);
create_dir_all(&agent_dir).unwrap();
let (start, nodes) = if failing {
(
"work",
" work:\n type: script\n script: missing.sh\n next: done\n done:\n type: end\n output: done\n",
)
} else {
("done", " done:\n type: end\n output: done\n")
};
write(
agent_dir.join("graph.yaml"),
format!(
"name: {agent_name}\n\
start: {start}\n\
settings:\n\
\x20 validate_before_run: false\n\
hooks:\n\
\x20 agent.started:\n\
\x20 - name: {marker}_started\n\
\x20 command: \"true\"\n\
\x20 agent.completed:\n\
\x20 - name: {marker}_completed\n\
\x20 command: \"true\"\n\
\x20 agent.failed:\n\
\x20 - name: {marker}_failed\n\
\x20 command: \"true\"\n\
nodes:\n\
{nodes}"
),
)
.unwrap();
}
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == name)
.collect()
}
/// Spawns an agent through the supervisor seam and awaits its spawn
/// task, returning the spawn id. The result arms fire their hooks
/// before that task returns, so the sink is complete afterwards.
fn spawn_and_collect(agent_name: &str) -> String {
run_async(async {
let mut ctx = ctx_with_supervisor(4, 3);
let spawned = handle_spawn(&mut ctx, &json!({"agent": agent_name, "prompt": "hi"}))
.await
.unwrap();
assert_eq!(spawned["status"], "ok", "{spawned}");
let id = spawned["id"].as_str().unwrap().to_string();
let handle = ctx
.supervisor
.as_ref()
.unwrap()
.write()
.take(&id)
.expect("spawned agent must be registered");
handle
.join_handle
.await
.expect("spawn task must not panic")
.expect("spawn task must return an AgentResult");
id
})
}
#[test]
#[serial]
fn spawned_agent_success_fires_started_and_completed_with_matching_id() {
let _guard = TestConfigDirGuard::new();
let _sink = hooks::test_sink::install();
let agent_name = unique_agent_name("test_hook_spawn_ok");
write_graph_agent_with_hooks(&agent_name, "t037_spawn_ok", false);
let id = spawn_and_collect(&agent_name);
let started = hook_captures_named("t037_spawn_ok_started");
assert_eq!(started.len(), 1, "{started:?}");
assert_eq!(started[0].envs["COYOTE_EVENT"], "agent.started");
assert_eq!(started[0].envs["COYOTE_AGENT_ID"], id);
assert_eq!(started[0].envs["COYOTE_AGENT_NAME"], agent_name);
let completed = hook_captures_named("t037_spawn_ok_completed");
assert_eq!(completed.len(), 1, "{completed:?}");
assert_eq!(completed[0].envs["COYOTE_EVENT"], "agent.completed");
assert_eq!(completed[0].envs["COYOTE_AGENT_ID"], id);
assert_eq!(completed[0].envs["COYOTE_AGENT_NAME"], agent_name);
assert!(hook_captures_named("t037_spawn_ok_failed").is_empty());
}
#[test]
#[serial]
fn spawned_agent_failure_fires_started_and_failed_with_matching_id() {
let _guard = TestConfigDirGuard::new();
let _sink = hooks::test_sink::install();
let agent_name = unique_agent_name("test_hook_spawn_fail");
write_graph_agent_with_hooks(&agent_name, "t037_spawn_fail", true);
let id = spawn_and_collect(&agent_name);
let started = hook_captures_named("t037_spawn_fail_started");
assert_eq!(started.len(), 1, "{started:?}");
assert_eq!(started[0].envs["COYOTE_AGENT_ID"], id);
let failed = hook_captures_named("t037_spawn_fail_failed");
assert_eq!(failed.len(), 1, "{failed:?}");
assert_eq!(failed[0].envs["COYOTE_EVENT"], "agent.failed");
assert_eq!(failed[0].envs["COYOTE_AGENT_ID"], id);
assert!(
!failed[0].envs["COYOTE_AGENT_ERROR"].is_empty(),
"a failed spawn must carry its error"
);
assert!(hook_captures_named("t037_spawn_fail_completed").is_empty());
}
#[test]
fn hook_base_envs_carries_optional_names_only_when_present() {
let envs = hook_base_envs(HookEvent::AgentCompleted, None, None);
let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect();
assert!(keys.contains(&"COYOTE_EVENT"));
assert!(keys.contains(&"COYOTE_CONFIG_DIR"));
assert!(keys.contains(&"COYOTE_EVENT_TIMESTAMP"));
assert!(!keys.contains(&"COYOTE_SESSION_ID"));
assert!(!keys.contains(&"COYOTE_AGENT_NAME"));
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed")
);
let envs = hook_base_envs(HookEvent::AgentFailed, Some("sess"), Some("bot"));
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess")
);
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_AGENT_NAME" && value == "bot")
);
}
fn run_agent_for_graph_error(
agent_name: &str,
graph_inputs: Option<HashMap<String, Value>>,
+274
View File
@@ -9,6 +9,7 @@ use crate::config::{
McpRuntime, RequestContext, effective_max_concurrent_jobs, jobs_enabled, paths,
};
use crate::graph;
use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_PROMPT_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX,
@@ -53,6 +54,63 @@ pub fn is_agent_task(supervisor: Option<&Arc<RwLock<Supervisor>>>, id: &str) ->
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
}
struct JobHookSnapshot {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
session_name: Option<String>,
agent_name: Option<String>,
job_id: String,
tool: String,
}
impl JobHookSnapshot {
fn resolve(ctx: &RequestContext, job_id: &str, tool: &str) -> Self {
Self {
completed: ctx.resolved_hooks(HookEvent::JobCompleted),
failed: ctx.resolved_hooks(HookEvent::JobFailed),
session_name: ctx
.session
.as_ref()
.map(|session| session.name().to_string()),
agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()),
job_id: job_id.to_string(),
tool: tool.to_string(),
}
}
/// Fires `job.completed` or `job.failed`. `fire_resolved` callers supply
/// their own base envs; the event name and timestamp are computed here so
/// they reflect the actual outcome rather than the moment the snapshot
/// was taken.
fn fire_terminal(self, success: bool, error: Option<String>) {
let (event, resolved) = if success {
(HookEvent::JobCompleted, self.completed)
} else {
(HookEvent::JobFailed, self.failed)
};
if resolved.is_empty() {
return;
}
let base_envs = hooks::base_envs_parts(
event,
self.session_name.as_deref(),
self.agent_name.as_deref(),
);
let mut extras = vec![
("COYOTE_JOB_ID", self.job_id),
("COYOTE_TOOL_NAME", self.tool),
];
if let Some(error) = error {
extras.push(("COYOTE_JOB_ERROR", error));
}
hooks::fire_resolved(event, resolved, base_envs, &extras, None);
}
}
pub struct RingBuf {
buf: Vec<u8>,
capacity: usize,
@@ -470,15 +528,27 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let task_notifications = Arc::clone(&ctx.notification_queue);
let notify_id = job_id.clone();
let notify_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
hooks::fire(
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.clone()),
("COYOTE_TOOL_NAME", tool.clone()),
],
None,
);
tokio::spawn(async move {
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
let success = result.is_ok();
let error = result.as_ref().err().map(|err| err.to_string());
task_state.lock().status = if success {
JobStatus::Completed
} else {
JobStatus::Failed
};
task_notifications.push(job_notification(&notify_id, &notify_tool, success));
hook_snapshot.fire_terminal(success, error);
result
})
} else {
@@ -488,9 +558,26 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let task_notifications = Arc::clone(&ctx.notification_queue);
let notify_id = job_id.clone();
let notify_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
hooks::fire(
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.clone()),
("COYOTE_TOOL_NAME", tool.clone()),
],
None,
);
tokio::spawn(async move {
let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await;
let success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0));
let error = (!success).then(|| match &result {
Err(err) => err.to_string(),
Ok(job_result) => match job_result.exit_code {
Some(code) => format!("exited with code {code}"),
None => "terminated without an exit code".to_string(),
},
});
let mut job_state = task_state.lock();
job_state.pgid = None;
job_state.status = if success {
@@ -502,6 +589,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
drop(job_state);
task_notifications.push(job_notification(&notify_id, &notify_tool, success));
hook_snapshot.fire_terminal(success, error);
result
})
};
@@ -3119,4 +3207,190 @@ mod tests {
);
assert!(ctx.supervisor.is_none(), "no job may be spawned");
}
fn job_hooks_map(marker: &str) -> hooks::HooksMap {
["job.started", "job.completed", "job.failed"]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
)
})
.collect()
}
fn job_hooked_ctx(marker: &str) -> RequestContext {
let app = app_state_with_config(|config| {
config.hooks = job_hooks_map(marker);
});
RequestContext::new(app, WorkingMode::Cmd)
}
fn job_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn job_hooks_fire_started_and_completed() {
run_async(async {
let marker = "job-hooks-ok-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("echo".into());
let _guard = hooks::test_sink::install();
let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
.await
.unwrap();
assert_eq!(started["status"], "ok");
let job_id = started["job_id"].as_str().unwrap().to_string();
// `job.started` is dispatched before `handle_start` returns.
let captures = job_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
assert_eq!(
captures[0].envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "completed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let completed = &captures[1];
assert_eq!(completed.event, HookEvent::JobCompleted);
assert_eq!(
completed.envs.get("COYOTE_EVENT").map(String::as_str),
Some("job.completed")
);
assert_eq!(
completed.envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
completed.envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
assert!(completed.envs.contains_key("COYOTE_CONFIG_DIR"));
assert!(completed.envs.contains_key("COYOTE_EVENT_TIMESTAMP"));
assert!(!completed.envs.contains_key("COYOTE_SESSION_ID"));
assert!(!completed.envs.contains_key("COYOTE_JOB_ERROR"));
});
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn job_hooks_fire_failed_with_job_error() {
run_async(async {
let marker = "job-hooks-fail-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("false".into());
let _guard = hooks::test_sink::install();
let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
.await
.unwrap();
let job_id = started["job_id"].as_str().unwrap().to_string();
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "failed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::JobFailed);
assert_eq!(
failed.envs.get("COYOTE_EVENT").map(String::as_str),
Some("job.failed")
);
assert_eq!(
failed.envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
failed.envs.get("COYOTE_JOB_ERROR").map(String::as_str),
Some("exited with code 1")
);
});
}
#[test]
#[serial_test::serial]
fn job_hooks_fire_for_mcp_jobs() {
run_async(async {
let marker = "job-hooks-mcp-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names
.insert("mcp_invoke_fixture".into());
let fixture = FixtureServer::default();
let (mut runtime, _server) = fixture_runtime(fixture).await;
let mut filter = ToolFilter::default();
filter.push_layer(LayerSource::Global, &[]);
runtime.tool_filters.insert("fixture".to_string(), filter);
ctx.tool_scope.mcp_runtime = runtime;
let _guard = hooks::test_sink::install();
let started = handle_start(
&mut ctx,
&json!({"tool": "mcp_invoke_fixture", "arguments": {"tool": "dup"}}),
)
.await
.unwrap();
let job_id = started["job_id"].as_str().unwrap().to_string();
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "failed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("mcp_invoke_fixture")
);
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::JobFailed);
assert!(
failed
.envs
.get("COYOTE_JOB_ERROR")
.unwrap()
.contains("dup not found"),
"{failed:?}"
);
});
}
#[test]
#[serial_test::serial]
fn rejected_job_start_fires_no_hooks() {
let marker = "job-hooks-rejected-x7x";
let mut ctx = job_hooked_ctx(marker);
let _guard = hooks::test_sink::install();
let result = run_async(handle_start(
&mut ctx,
&json!({"tool": "undeclared_tool", "arguments": {}}),
))
.unwrap();
assert_eq!(result["status"], "error");
assert!(job_captures(marker).is_empty());
}
}
+364 -5
View File
@@ -17,6 +17,7 @@ use crate::{
use crate::config::ensure_parent_exists;
use crate::config::paths;
use crate::hooks::{self, HookEvent};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_META_FUNCTION_PREFIXES, MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
@@ -1539,6 +1540,22 @@ impl ToolCall {
}
pub async fn eval(&self, ctx: &mut RequestContext) -> Result<Value> {
let result = self.eval_inner(ctx).await;
if let Err(err) = &result {
hooks::fire(
HookEvent::ToolFailed,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
("COYOTE_TOOL_ERROR", err.to_string()),
],
None,
);
}
result
}
async fn eval_inner(&self, ctx: &mut RequestContext) -> Result<Value> {
let agent = ctx.agent.clone();
let functions = ctx.tool_scope.functions.clone();
let current_depth = ctx.current_depth;
@@ -1566,7 +1583,8 @@ impl ToolCall {
);
};
cmd_args.push(json_data.to_string());
let args_json = json_data.to_string();
cmd_args.push(args_json.clone());
if (*IS_STDOUT_TERMINAL || *SHOW_TOOL_CALLS)
&& current_depth == 0
@@ -1575,6 +1593,14 @@ impl ToolCall {
println!("{}", format_call_log(&cmd_name, &cmd_args, &json_data));
}
hooks::fire(
HookEvent::ToolStarted,
ctx,
&[("COYOTE_TOOL_NAME", self.name.clone())],
Some(args_json.clone()),
);
let tool_started_at = Instant::now();
let output = match cmd_name.as_str() {
_ if cmd_name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX) => {
Self::search_mcp_tools(ctx, &cmd_name, &json_data)
@@ -1699,6 +1725,38 @@ impl ToolCall {
},
};
match output.get("tool_call_error") {
Some(error) => {
let error = error
.as_str()
.map(str::to_string)
.unwrap_or_else(|| error.to_string());
hooks::fire(
HookEvent::ToolFailed,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
("COYOTE_TOOL_ERROR", error),
],
Some(args_json),
);
}
None => {
hooks::fire(
HookEvent::ToolCompleted,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
(
"COYOTE_TOOL_DURATION_MS",
tool_started_at.elapsed().as_millis().to_string(),
),
],
Some(args_json),
);
}
}
Ok(output)
}
@@ -2518,13 +2576,16 @@ fn polyfill_cmd_name<T: AsRef<Path>>(cmd_name: &str, bin_dir: &[T]) -> String {
cmd_name
}
// Polling tools are expected to repeat; recording them would also let them
// break up detection of a real loop in the calls they interleave with.
const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 4] = [
// Polling tools are expected to repeat with identical arguments (status probes,
// list views, inbox checks); recording them would also let them break up
// detection of a real loop in the calls they interleave with.
const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 6] = [
"job__check",
"job__list",
"agent__check",
"agent__list_running",
"agent__task_list",
"agent__check_inbox",
];
fn is_loop_tracker_exempt(name: &str) -> bool {
@@ -3328,10 +3389,12 @@ mod tests {
"job__list",
"agent__check",
"agent__list_running",
"agent__task_list",
"agent__check_inbox",
]
.into_iter()
.collect();
assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 4);
assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 6);
assert_eq!(actual, expected);
}
@@ -3368,6 +3431,29 @@ mod tests {
assert!(tracker.check_loop(&x).is_some());
}
/// Zero-argument polls (`agent__check_inbox`, `agent__task_list`) are
/// byte-identical on every call, so the protocol-mandated "check your inbox
/// after each round" cadence must never read as a loop — even when the
/// polls are back-to-back and well past the repeat threshold.
#[test]
fn tracker_zero_arg_polls_never_trip_even_when_consecutive() {
for name in ["agent__check_inbox", "agent__task_list"] {
let mut tracker = ToolCallTracker::default();
let poll = call_with_args(name, json!({}));
for _ in 0..10 {
assert!(
tracker.check_loop(&poll).is_none(),
"{name} must never trip the loop tracker"
);
tracker.record_call(poll.clone());
}
assert!(
tracker.last_calls.is_empty(),
"{name} must not be recorded in the call history"
);
}
}
#[test]
fn tracker_non_exempt_behavior_unchanged() {
let mut tracker = ToolCallTracker::default();
@@ -5243,4 +5329,277 @@ mod tests {
assert!(err.starts_with(expected), "{name}: {err}");
}
}
fn tool_hooks_map(marker: &str) -> hooks::HooksMap {
["tool.started", "tool.completed", "tool.failed"]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
)
})
.collect()
}
fn ctx_with_tool_hooks(marker: &str, update: impl FnOnce(&mut AppConfig)) -> RequestContext {
let mut app = AppState::test_default();
let mut config = AppConfig {
hooks: tool_hooks_map(marker),
..Default::default()
};
update(&mut config);
app.config = Arc::new(config);
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn tool_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
}
#[test]
#[serial]
fn eval_fires_started_and_completed_for_builtin_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-builtin-ok-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |config| {
config.function_calling_support = true;
config.auto_continue = true;
});
ctx.tool_scope.functions.append_todo_functions();
let _guard = hooks::test_sink::install();
let output =
run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap();
assert_eq!(output["status"], "ok");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("todo__init")
);
assert_eq!(captures[0].payload.as_deref(), Some(r#"{"goal":"g"}"#));
assert_eq!(captures[0].cwd, paths::config_dir());
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
assert!(captures[1].envs.contains_key("COYOTE_TOOL_DURATION_MS"));
assert_eq!(captures[1].payload.as_deref(), Some(r#"{"goal":"g"}"#));
}
#[test]
#[serial]
fn eval_fires_started_and_completed_for_mcp_meta_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-mcp-ok-x7x";
run_async(async {
let mut result = CallToolResult::success(vec![ContentBlock::text("hi")]);
result.structured_content = Some(json!({"ok": true}));
let fixture = FixtureServer {
tool_result: Some(result),
..Default::default()
};
let (runtime, _server) = fixture_runtime(fixture).await;
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope.mcp_runtime = runtime;
ctx.tool_scope
.functions
.append_mcp_meta_functions(vec![tools_only("fixture")]);
let _guard = hooks::test_sink::install();
let output = call_with_args("mcp_invoke_fixture", json!({"tool": "dup"}))
.eval(&mut ctx)
.await
.unwrap();
assert_eq!(output["isError"], false);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("mcp_invoke_fixture")
);
assert_eq!(captures[0].payload.as_deref(), Some(r#"{"tool":"dup"}"#));
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
});
}
#[cfg(unix)]
#[test]
#[serial]
fn eval_fires_started_and_completed_for_external_command_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-external-ok-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope
.functions
.append_declaration(FunctionDeclaration {
name: "echo".to_string(),
description: String::new(),
parameters: JsonSchema::default(),
agent: false,
});
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("echo", json!({"x": 1})).eval(&mut ctx)).unwrap();
assert_eq!(output, Value::Null);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
}
#[cfg(unix)]
#[test]
#[serial]
fn eval_fires_failed_for_external_command_nonzero_exit() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-external-fail-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope
.functions
.append_declaration(FunctionDeclaration {
name: "false".to_string(),
description: String::new(),
parameters: JsonSchema::default(),
agent: false,
});
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("false", json!({})).eval(&mut ctx)).unwrap();
assert!(output["tool_call_error"].is_string());
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.unwrap()
.contains("exited with code 1")
);
assert!(!captures[1].envs.contains_key("COYOTE_TOOL_DURATION_MS"));
}
#[test]
#[serial]
fn eval_fires_failed_on_error_shaped_output_with_identical_result() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-error-shape-x7x";
// Default config leaves todo tools disabled, so the handler bails
// and eval shapes the failure as a `tool_call_error` output.
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
hooked.tool_scope.functions.append_todo_functions();
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
plain.tool_scope.functions.append_todo_functions();
let call = call_with_args("todo__done", json!({}));
let _guard = hooks::test_sink::install();
let hooked_output = run_async(call.eval(&mut hooked)).unwrap();
let plain_output = run_async(call.eval(&mut plain)).unwrap();
assert_eq!(
hooked_output.to_string(),
plain_output.to_string(),
"hooks must not change the tool result"
);
let error = hooked_output["tool_call_error"].as_str().unwrap();
assert!(error.starts_with("Todo tool failed:"), "{error}");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert_eq!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.map(String::as_str),
Some(error)
);
}
#[test]
#[serial]
fn eval_fires_hooks_with_exact_user_prefixed_tool_name() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-user-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope.functions.append_user_interaction_functions();
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("user__confirm", json!({})).eval(&mut ctx)).unwrap();
// Without a question the user handler errors before prompting, so the
// error-shaped output must fire started + failed with the exact name.
let error = output["tool_call_error"].as_str().unwrap();
assert!(error.starts_with("User interaction failed"), "{error}");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("user__confirm")
);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert_eq!(
captures[1].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("user__confirm")
);
assert_eq!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.map(String::as_str),
Some(error)
);
}
#[test]
#[serial]
fn eval_err_fires_failed_without_started_and_identical_error() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-eval-err-x7x";
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
let call = call_with_args("nonexistent_tool", json!({}));
let _guard = hooks::test_sink::install();
let hooked_err = run_async(call.eval(&mut hooked)).unwrap_err();
let plain_err = run_async(call.eval(&mut plain)).unwrap_err();
assert_eq!(
hooked_err.to_string(),
plain_err.to_string(),
"hooks must not change the error"
);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolFailed);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("nonexistent_tool")
);
assert!(
captures[0]
.envs
.get("COYOTE_TOOL_ERROR")
.unwrap()
.contains("Unexpected call")
);
assert!(captures[0].payload.is_none());
}
}
+18
View File
@@ -6,6 +6,7 @@ use super::types::AgentNode;
use super::wall_clock;
use crate::config::RequestContext;
use crate::function::agents::run_agent_for_graph;
use crate::hooks::{self, HookEvent};
use crate::supervisor::mailbox::{Inbox, PeerRegistry};
use crate::utils::create_abort_signal;
use anyhow::{Context, Error, Result, anyhow};
@@ -35,6 +36,11 @@ impl AgentNodeExecutor {
parent_ctx: &mut RequestContext,
retire_peer_on_return: bool,
) -> Result<AgentExecutionOutcome> {
let agent_hook_id = parent_ctx
.peer_assignment
.as_ref()
.map(|(id, _)| id.clone())
.unwrap_or_else(|| node_id.to_string());
let result = run(
node_id,
node,
@@ -43,6 +49,18 @@ impl AgentNodeExecutor {
retire_peer_on_return,
)
.await;
if let Err(e) = &result {
hooks::fire(
HookEvent::AgentFailed,
parent_ctx,
&[
("COYOTE_AGENT_ID", agent_hook_id),
("COYOTE_AGENT_NAME", node.agent.clone()),
("COYOTE_AGENT_ERROR", format!("{e:#}")),
],
None,
);
}
outcome_from(node_id, node, state_manager, result)
}
}
+467 -2
View File
@@ -1,6 +1,6 @@
use super::agent::{AgentExecutionOutcome, AgentNodeExecutor};
use super::llm::{LlmExecutionOutcome, LlmNodeExecutor};
use super::logging::{GraphLogger, narrate_node_complete, narrate_node_failed};
use super::logging::{GraphLogger, narrate_node_complete, narrate_node_failed, node_type_label};
use super::map::MapNodeExecutor;
use super::rag::RagNodeExecutor;
use super::script::ScriptExecutor;
@@ -11,6 +11,8 @@ use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor};
use super::validator::{AgentValidationContext, GraphValidator};
use super::wall_clock;
use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext};
use crate::function::agents::hook_base_envs;
use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt};
use anyhow::{Context, Result, anyhow, bail};
@@ -234,6 +236,16 @@ impl GraphExecutor {
})?
.clone();
logger.node_start(&node, in_super_step);
let node_hooks = NodeHookEmitter::resolve(ctx, node_id, node_type_label(&node));
hooks::fire(
HookEvent::GraphNodeStarted,
ctx,
&[
("COYOTE_NODE_ID", node_id.clone()),
("COYOTE_NODE_TYPE", node_hooks.node_type.to_string()),
],
None,
);
let branch_state = state.fork_for_branch_state();
let mut branch_ctx = ctx.fork_for_branch();
let mut peer_id: Option<String> = None;
@@ -281,6 +293,7 @@ impl GraphExecutor {
"aborted",
in_super_step,
);
node_hooks.failed(Duration::default(), "aborted");
return (
current.clone(),
branch_state,
@@ -315,6 +328,7 @@ impl GraphExecutor {
route.as_deref(),
in_super_step,
);
node_hooks.completed(elapsed, route.as_deref());
}
Ok(StepResult::End(_)) => {
narrate_node_complete(
@@ -324,6 +338,7 @@ impl GraphExecutor {
Some("END"),
in_super_step,
);
node_hooks.completed(elapsed, Some("END"));
}
Err(e) => {
narrate_node_failed(
@@ -333,6 +348,7 @@ impl GraphExecutor {
&e.to_string(),
in_super_step,
);
node_hooks.failed(elapsed, &e.to_string());
}
}
(current, state, result, elapsed)
@@ -498,6 +514,67 @@ fn provision_frontier_peers(
(Some(registry), assignments)
}
struct NodeHookEmitter {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
session_name: Option<String>,
agent_name: Option<String>,
node_id: String,
node_type: &'static str,
}
impl NodeHookEmitter {
fn resolve(ctx: &RequestContext, node_id: &str, node_type: &'static str) -> Self {
Self {
completed: ctx.resolved_hooks(HookEvent::GraphNodeCompleted),
failed: ctx.resolved_hooks(HookEvent::GraphNodeFailed),
session_name: ctx
.session
.as_ref()
.map(|session| session.name().to_string()),
agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()),
node_id: node_id.to_string(),
node_type,
}
}
fn extras(&self, elapsed: Duration) -> Vec<(&'static str, String)> {
vec![
("COYOTE_NODE_ID", self.node_id.clone()),
("COYOTE_NODE_TYPE", self.node_type.to_string()),
("COYOTE_NODE_DURATION_MS", elapsed.as_millis().to_string()),
]
}
fn dispatch(&self, event: HookEvent, resolved: &[ResolvedHook], extras: &[(&str, String)]) {
hooks::fire_resolved(
event,
resolved.to_vec(),
hook_base_envs(
event,
self.session_name.as_deref(),
self.agent_name.as_deref(),
),
extras,
None,
);
}
fn completed(&self, elapsed: Duration, route: Option<&str>) {
let mut extras = self.extras(elapsed);
if let Some(route) = route {
extras.push(("COYOTE_NODE_ROUTE", route.to_string()));
}
self.dispatch(HookEvent::GraphNodeCompleted, &self.completed, &extras);
}
fn failed(&self, elapsed: Duration, error: &str) {
let mut extras = self.extras(elapsed);
extras.push(("COYOTE_NODE_ERROR", error.to_string()));
self.dispatch(HookEvent::GraphNodeFailed, &self.failed, &extras);
}
}
pub(super) struct TaskCancelGuard(Vec<AbortHandle>);
impl TaskCancelGuard {
@@ -928,6 +1005,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,
@@ -1048,9 +1127,10 @@ mod tests {
mod integration_tests {
use super::*;
use crate::config::paths;
use crate::config::{AppState, WorkingMode};
use crate::config::{Agent, AgentConfig, AppConfig, AppState, WorkingMode};
#[cfg(unix)]
use crate::function::jobs::RingBuf;
use crate::hooks::HookDef;
#[cfg(unix)]
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor, notification};
use crate::utils::{create_abort_signal, get_env_name, temp_file};
@@ -1066,6 +1146,33 @@ mod integration_tests {
which::which(name).is_ok()
}
fn hooks_config(entries: &[(&str, &str)]) -> hooks::HooksMap {
let mut map = hooks::HooksMap::new();
for (event, name) in entries {
map.entry((*event).to_string()).or_default().push(HookDef {
name: (*name).to_string(),
command: "true".to_string(),
});
}
map
}
fn ctx_with_global_hooks(hooks: hooks::HooksMap) -> RequestContext {
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks,
..Default::default()
});
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == name)
.collect()
}
struct TestWorkspace {
dir: PathBuf,
}
@@ -1208,6 +1315,364 @@ nodes:
assert_eq!(result, "held|held");
}
/// An agent node that fails but declares a fallback must surface the
/// failure to observers as `agent.failed`, fired at the point of
/// failure, even though the fallback then converts it into a normal
/// node completion that routes to the fallback target — so the same
/// node also yields `graph.node.completed`, never `graph.node.failed`.
#[tokio::test]
async fn agent_node_failure_with_fallback_fires_agent_failed_and_node_completed() {
let _sink = hooks::test_sink::install();
let mut ctx = ctx_with_global_hooks(hooks_config(&[
("agent.failed", "t037_fb_agent_failed"),
("graph.node.completed", "t037_fb_node_completed"),
("graph.node.failed", "t037_fb_node_failed"),
]));
// Forces the agent node to fail before it ever loads the (absent)
// agent from disk, keeping the test hermetic.
ctx.current_depth = crate::config::default_max_agent_depth();
let yaml = r#"
name: t
settings:
validate_before_run: false
start: worker
nodes:
worker:
type: agent
agent: missing_agent
prompt: p
fallback: recover
next: done
recover:
type: end
output: recovered
done:
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, ".")
.execute(&mut ctx, abort)
.await
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
assert_eq!(result, "recovered");
let agent_failed = hook_captures_named("t037_fb_agent_failed");
assert_eq!(agent_failed.len(), 1, "{agent_failed:?}");
assert_eq!(agent_failed[0].envs["COYOTE_EVENT"], "agent.failed");
assert_eq!(agent_failed[0].envs["COYOTE_AGENT_ID"], "worker");
assert_eq!(agent_failed[0].envs["COYOTE_AGENT_NAME"], "missing_agent");
assert!(
agent_failed[0].envs["COYOTE_AGENT_ERROR"].contains("Max agent depth exceeded"),
"{:?}",
agent_failed[0].envs
);
let node_completed = hook_captures_named("t037_fb_node_completed");
let worker_completed: Vec<_> = node_completed
.iter()
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == "worker")
.collect();
assert_eq!(worker_completed.len(), 1, "{node_completed:?}");
assert_eq!(worker_completed[0].envs["COYOTE_NODE_TYPE"], "agent");
assert_eq!(worker_completed[0].envs["COYOTE_NODE_ROUTE"], "recover");
let node_failed = hook_captures_named("t037_fb_node_failed");
assert!(
node_failed
.iter()
.all(|capture| capture.envs["COYOTE_NODE_ID"] != "worker"),
"a node that continues via fallback must not fire graph.node.failed: {node_failed:?}"
);
}
/// Two branches running in the same super-step each carry their own
/// node metadata, captured task-locally before the branch was spawned;
/// the distinct routes prove one branch's metadata never leaks into a
/// sibling's events.
#[tokio::test]
async fn parallel_super_step_branches_fire_per_node_metadata() {
if !cmd_available("bash") {
eprintln!("skipping: bash not available");
return;
}
let _sink = hooks::test_sink::install();
let ws = TestWorkspace::new();
for script in [
"dispatcher.sh",
"worker_a.sh",
"worker_b.sh",
"sink_a.sh",
"sink_b.sh",
] {
ws.write_script(script, "#!/bin/bash\necho '{}'\n");
}
let mut ctx = ctx_with_global_hooks(hooks_config(&[
("graph.node.started", "t037_par_started"),
("graph.node.completed", "t037_par_completed"),
]));
let yaml = r#"
name: t
start: dispatcher
nodes:
dispatcher:
type: script
script: dispatcher.sh
state_updates: {}
next: [worker_a, worker_b]
worker_a:
type: script
script: worker_a.sh
state_updates: {}
next: sink_a
worker_b:
type: script
script: worker_b.sh
state_updates: {}
next: sink_b
sink_a:
type: script
script: sink_a.sh
state_updates: {}
next: join
sink_b:
type: script
script: sink_b.sh
state_updates: {}
next: join
join:
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, &ws.dir)
.execute(&mut ctx, abort)
.await
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
assert_eq!(result, "done");
let started = hook_captures_named("t037_par_started");
for node_id in [
"dispatcher",
"worker_a",
"worker_b",
"sink_a",
"sink_b",
"join",
] {
assert_eq!(
started
.iter()
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == node_id)
.count(),
1,
"exactly one graph.node.started for '{node_id}': {started:?}"
);
}
let completed = hook_captures_named("t037_par_completed");
let completed_route = |node_id: &str| -> String {
let matches: Vec<_> = completed
.iter()
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == node_id)
.collect();
assert_eq!(
matches.len(),
1,
"exactly one graph.node.completed for '{node_id}': {completed:?}"
);
let expected_type = if node_id == "join" { "end" } else { "script" };
assert_eq!(matches[0].envs["COYOTE_NODE_TYPE"], expected_type);
let duration = &matches[0].envs["COYOTE_NODE_DURATION_MS"];
let _: u128 = duration
.parse()
.unwrap_or_else(|_| panic!("non-numeric duration for '{node_id}': {duration}"));
matches[0].envs["COYOTE_NODE_ROUTE"].clone()
};
assert_eq!(completed_route("dispatcher"), "worker_a, worker_b");
assert_eq!(completed_route("worker_a"), "sink_a");
assert_eq!(completed_route("worker_b"), "sink_b");
assert_eq!(completed_route("sink_a"), "join");
assert_eq!(completed_route("sink_b"), "join");
assert_eq!(completed_route("join"), "END");
}
/// A graph agent's hooks come from graph.yaml's top level: they resolve
/// through the agent scope end-to-end, while a global hook on the same
/// event stays gated off because the agent whitelisted nothing.
#[tokio::test]
async fn graph_agent_hooks_resolve_from_graph_yaml_top_level() {
let _sink = hooks::test_sink::install();
let mut ctx =
ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")]));
let yaml = r#"
name: hooked_graph
settings:
validate_before_run: false
start: done
hooks:
graph.node.started:
- name: t037_gy_started
command: "true"
graph.node.completed:
- name: t037_gy_completed
command: "true"
nodes:
done:
type: end
output: ok
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
ctx.agent = Some(Agent::test_new(AgentConfig::from_graph(
"hooked_graph",
&graph,
)));
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, ".")
.execute(&mut ctx, abort)
.await
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
assert_eq!(result, "ok");
let started = hook_captures_named("t037_gy_started");
assert_eq!(started.len(), 1, "{started:?}");
assert_eq!(started[0].envs["COYOTE_EVENT"], "graph.node.started");
assert_eq!(started[0].envs["COYOTE_NODE_ID"], "done");
assert_eq!(started[0].envs["COYOTE_NODE_TYPE"], "end");
assert_eq!(started[0].envs["COYOTE_AGENT_NAME"], "hooked_graph");
let completed = hook_captures_named("t037_gy_completed");
assert_eq!(completed.len(), 1, "{completed:?}");
assert_eq!(completed[0].envs["COYOTE_NODE_ID"], "done");
assert_eq!(completed[0].envs["COYOTE_NODE_ROUTE"], "END");
assert_eq!(completed[0].envs["COYOTE_AGENT_NAME"], "hooked_graph");
assert!(
hook_captures_named("t037_gy_global").is_empty(),
"an agent context must not run global hooks it did not whitelist"
);
}
/// A node that genuinely fails — no fallback to absorb the error — fires
/// `graph.node.failed` with the full payload observers rely on: the node's
/// id and type, a numeric duration, and the error text, even though the
/// failure then propagates out of the executor.
#[tokio::test]
async fn genuine_node_failure_fires_graph_node_failed_with_payload() {
let _sink = hooks::test_sink::install();
let mut ctx = ctx_with_global_hooks(hooks_config(&[(
"graph.node.failed",
"t037_gf_node_failed",
)]));
// Forces the agent node to fail before it ever loads the (absent)
// agent from disk, keeping the test hermetic.
ctx.current_depth = crate::config::default_max_agent_depth();
let yaml = r#"
name: t
settings:
validate_before_run: false
start: worker
nodes:
worker:
type: agent
agent: missing_agent
prompt: p
next: done
done:
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, ".")
.execute(&mut ctx, abort)
.await;
let error = result.expect_err("a node failure without a fallback must propagate");
let failed = hook_captures_named("t037_gf_node_failed");
assert_eq!(failed.len(), 1, "{failed:?}");
let envs = &failed[0].envs;
assert_eq!(envs["COYOTE_EVENT"], "graph.node.failed");
assert_eq!(envs["COYOTE_NODE_ID"], "worker");
assert_eq!(envs["COYOTE_NODE_TYPE"], "agent");
let duration = &envs["COYOTE_NODE_DURATION_MS"];
let _: u128 = duration
.parse()
.unwrap_or_else(|_| panic!("non-numeric duration: {duration}"));
assert!(!envs["COYOTE_NODE_ERROR"].is_empty(), "{envs:?}");
assert!(
format!("{error:#}").contains("Max agent depth exceeded"),
"unexpected executor error: {error:#}"
);
}
/// A malformed hook definition reaching the executor seam through
/// graph.yaml's top level — an entry whose command is blank — degrades
/// safely: the node still runs, the malformed hook is skipped with a
/// debug log, and a well-formed sibling on the same event still fires.
#[tokio::test]
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
crate::testing::install_warn_collector();
let _sink = hooks::test_sink::install();
let mut ctx = make_ctx();
let yaml = r#"
name: hooked_graph
settings:
validate_before_run: false
start: done
hooks:
graph.node.completed:
- name: t037_mal_empty
command: " "
- name: t037_mal_valid
command: "true"
nodes:
done:
type: end
output: ok
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
ctx.agent = Some(Agent::test_new(AgentConfig::from_graph(
"hooked_graph",
&graph,
)));
let abort = create_abort_signal();
let result = GraphExecutor::new(graph, ".")
.execute(&mut ctx, abort)
.await
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
assert_eq!(result, "ok");
assert!(
hook_captures_named("t037_mal_empty").is_empty(),
"a blank-command hook must be skipped, not dispatched"
);
let valid = hook_captures_named("t037_mal_valid");
assert_eq!(valid.len(), 1, "{valid:?}");
assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done");
let debugs: Vec<String> = crate::testing::debug_messages()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
assert!(
debugs
.iter()
.any(|message| message.contains("t037_mal_empty")
&& message.contains("empty command")),
"expected a debug log for the skipped hook: {debugs:?}"
);
}
/// Declared variables are seeded into state at run start, so the agent
/// node's strict prompt interpolation of `{{foo}}` succeeds instead of
/// bailing with "not found in state". `ctx.agent` is None here, so each
+2
View File
@@ -497,6 +497,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,
+53
View File
@@ -1,4 +1,5 @@
use crate::config::AgentVariable;
use crate::hooks::HooksMap;
use anyhow::Result;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
@@ -43,6 +44,12 @@ pub struct Graph {
#[serde(default)]
pub global_tools: Vec<String>,
#[serde(default)]
pub hooks: HooksMap,
#[serde(default)]
pub global_hooks: Vec<String>,
#[serde(default)]
pub mcp_servers: Vec<String>,
@@ -611,6 +618,52 @@ nodes:
}
}
#[test]
fn graph_hooks_and_global_hooks_parse_and_round_trip() {
let yaml = r#"
name: g
start: e
hooks:
tool.started:
- name: notify
command: ./hooks/notify.sh
global_hooks:
- tool.started.notify
nodes:
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert_eq!(graph.hooks["tool.started"][0].name, "notify");
assert_eq!(graph.hooks["tool.started"][0].command, "./hooks/notify.sh");
assert_eq!(graph.global_hooks, vec!["tool.started.notify"]);
let reparsed: Graph =
serde_yaml::from_str(&serde_yaml::to_string(&graph).unwrap()).unwrap();
assert_eq!(reparsed.hooks, graph.hooks);
assert_eq!(reparsed.global_hooks, graph.global_hooks);
}
#[test]
fn graph_without_hooks_keys_parses_empty() {
let yaml = r#"
name: g
start: e
nodes:
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert!(graph.hooks.is_empty());
assert!(graph.global_hooks.is_empty());
}
#[test]
fn agent_node_teammates_parses_and_defaults_false() {
let yaml = r#"
+2
View File
@@ -1459,6 +1459,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,
+1760
View File
File diff suppressed because it is too large. Load diff
+68 -21
View File
@@ -4,6 +4,7 @@ mod client;
mod config;
mod function;
mod graph;
mod hooks;
mod rag;
mod render;
mod repl;
@@ -22,7 +23,8 @@ extern crate log;
use crate::cli::Cli;
use crate::client::{
ModelType, call_chat_completions, call_chat_completions_streaming, catalog, list_models, oauth,
Client, ModelType, call_chat_completions, call_chat_completions_streaming, catalog,
list_models, oauth,
};
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{
@@ -35,7 +37,7 @@ use crate::config::{memory, paths};
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
use crate::mcp::McpServersConfig;
use crate::render::{prompt_theme, render_error};
use crate::repl::Repl;
use crate::repl::{EXIT_HOOK_DRAIN_TIMEOUT, Repl, TurnBracket};
use crate::utils::*;
use crate::vault::{Vault, interpolate_secrets};
use anyhow::{Context, Result, anyhow, bail};
@@ -386,6 +388,12 @@ async fn run(
let app = Arc::clone(&ctx.app.config);
ctx.use_agent(app.as_ref(), agent, session, abort_signal.clone())
.await?;
// The top-level agent bracket does NOT open here: the inspection
// flags below (--list-sessions, --info, --macro, --rebuild-rag,
// --init-*) return early without running the agent, and an open
// bracket on those paths would leak `agent.started` with no
// terminal event. It opens at the dispatch sites further down,
// where an agent run is actually committed.
} else {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
if let Some(prompt) = &cli.temp_role {
@@ -522,9 +530,17 @@ async fn run(
return Ok(());
}
if cli.execute && !is_repl {
// Dispatch committed: open the top-level agent bracket only now,
// past every inspection-flag early return above.
ctx.top_level_agent_started();
let result = async {
let input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await?;
return Ok(());
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await
}
.await;
ctx.top_level_agent_finished(result.as_ref().err());
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
return result;
}
{
@@ -542,11 +558,23 @@ async fn run(
return acp::run_acp_server(ctx, abort_signal).await;
}
// Dispatch committed (headless directive or REPL): open the top-level
// agent bracket only now, past every inspection-flag early return
// above. The ACP server stays unbracketed deliberately. The headless
// arm closes the bracket below; the REPL closes it at teardown (or on
// an interactive agent switch).
ctx.top_level_agent_started();
match is_repl {
false => {
let result = async {
let mut input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
input.use_embeddings(abort_signal.clone()).await?;
start_directive(&mut ctx, input, cli.code, abort_signal).await
start_directive(&mut ctx, input, cli.code, abort_signal.clone()).await
}
.await;
ctx.top_level_agent_finished(result.as_ref().err());
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
result
}
true => {
if !*IS_STDOUT_TERMINAL {
@@ -563,6 +591,17 @@ async fn start_directive(
input: Input,
code_mode: bool,
abort_signal: AbortSignal,
) -> Result<()> {
let bracket = TurnBracket::enter(ctx);
let result = start_directive_inner(ctx, input, code_mode, abort_signal.clone()).await;
bracket.finish(ctx, &abort_signal, result)
}
async fn start_directive_inner(
ctx: &mut RequestContext,
input: Input,
code_mode: bool,
abort_signal: AbortSignal,
) -> Result<()> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
ctx.session_abort = Some(abort_signal.clone());
@@ -638,22 +677,10 @@ async fn shell_execute(
abort_signal: AbortSignal,
) -> Result<()> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
let client = input.create_client()?;
ctx.before_chat_completion(&input)?;
let (eval_str, _) = call_chat_completions(
&input,
false,
true,
client.as_ref(),
ctx,
abort_signal.clone(),
)
.await?;
ctx.after_chat_completion(app.as_ref(), &input, &eval_str, &[])?;
if eval_str.is_empty() {
bail!("No command generated");
}
// Each revision round is its own turn: the recursive 'r' call re-enters.
let bracket = TurnBracket::enter(ctx);
let result = shell_execute_turn(ctx, &input, abort_signal.clone()).await;
let (client, eval_str) = bracket.finish(ctx, &abort_signal, result)?;
if app.dry_run {
app.print_markdown(&eval_str)?;
return Ok(());
@@ -679,6 +706,8 @@ async fn shell_execute(
if code == 0 && app.save_shell_history {
let _ = append_to_shell_history(&shell.name, &eval_str, code);
}
ctx.top_level_agent_finished(None);
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
process::exit(code);
}
'r' => {
@@ -726,6 +755,24 @@ async fn shell_execute(
Ok(())
}
async fn shell_execute_turn(
ctx: &mut RequestContext,
input: &Input,
abort_signal: AbortSignal,
) -> Result<(Box<dyn Client>, String)> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
let client = input.create_client()?;
ctx.before_chat_completion(input)?;
let (eval_str, _) =
call_chat_completions(input, false, true, client.as_ref(), ctx, abort_signal).await?;
ctx.after_chat_completion(app.as_ref(), input, &eval_str, &[])?;
if eval_str.is_empty() {
bail!("No command generated");
}
Ok((client, eval_str))
}
async fn create_input(
ctx: &RequestContext,
text: Option<String>,
+389 -5
View File
@@ -17,6 +17,7 @@ use crate::config::{
};
use crate::config::{AssetCategory, paths};
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
use crate::hooks::{self, HookEvent};
use crate::render::render_error;
use crate::utils::{
AbortSignal, SHELL, abortable_run_with_spinner, create_abort_signal, dimmed_text,
@@ -41,11 +42,58 @@ use reedline::{
use reedline::{MenuBuilder, Signal};
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::Duration;
use std::{env, process, sync::Arc};
use tokio::task;
const MENU_NAME: &str = "completion_menu";
/// Bounded wait applied at process-exit seams (REPL teardown, headless and
/// shell-execute exits) so already-fired exit hooks — `session.ended`, the
/// final `turn.*` / `agent.*` events — finish spawning before the process
/// tears down. Never used mid-session.
pub(crate) const EXIT_HOOK_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
/// Brackets one user-visible turn at an outer boundary (`ask`,
/// `start_directive`, `shell_execute`): `enter` fires `turn.started`, and
/// `finish` fires exactly one of `turn.completed` / `turn.interrupted` /
/// `turn.failed`. The interrupted-vs-failed discrimination lives here and
/// nowhere else: a ctrl-c abort wins over the result, then an error means
/// failed (carrying `COYOTE_ERROR`), otherwise completed. Tool rounds,
/// auto-continue, and compression retries run inside one bracket and never
/// re-fire turn events.
pub(crate) struct TurnBracket(());
impl TurnBracket {
#[must_use]
pub(crate) fn enter(ctx: &RequestContext) -> Self {
hooks::fire(HookEvent::TurnStarted, ctx, &[], None);
Self(())
}
pub(crate) fn finish<T>(
self,
ctx: &RequestContext,
abort_signal: &AbortSignal,
result: Result<T>,
) -> Result<T> {
if abort_signal.aborted_ctrlc() {
hooks::fire(HookEvent::TurnInterrupted, ctx, &[], None);
} else {
match &result {
Ok(_) => hooks::fire(HookEvent::TurnCompleted, ctx, &[], None),
Err(err) => hooks::fire(
HookEvent::TurnFailed,
ctx,
&[("COYOTE_ERROR", format!("{err:#}"))],
None,
),
}
}
result
}
}
pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
[SYSTEM REMINDER - TODO CONTINUATION]
You have incomplete tasks. Rules:
@@ -482,8 +530,12 @@ Type ".help" for additional help.
supervisor.read().cancel_recursive();
}
self.ctx.write().exit_session()?;
Ok(())
let exit_result = self.ctx.write().exit_session();
self.ctx
.write()
.top_level_agent_finished(exit_result.as_ref().err());
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
exit_result
}
fn create_editor(ctx: Arc<RwLock<RequestContext>>, app: &AppConfig) -> Result<Reedline> {
@@ -983,6 +1035,7 @@ pub async fn run_repl_command(
let app = Arc::clone(&ctx.app.config);
ctx.use_agent(app.as_ref(), agent_name, session_name, abort_signal.clone())
.await?;
ctx.top_level_agent_started();
if let Some(session) = &ctx.session {
let (compressed, active) = replay::snapshot(session);
replay::render(app.as_ref(), &compressed, &active)?;
@@ -1090,6 +1143,7 @@ pub async fn run_repl_command(
abort_signal.clone(),
)
.await?;
hooks::fire(HookEvent::SessionCompressed, ctx, &[], None);
println!("✓ Successfully compressed the session.");
}
_ => {
@@ -1433,6 +1487,17 @@ async fn ask(
abort_signal: AbortSignal,
input: Input,
with_embeddings: bool,
) -> Result<()> {
let bracket = TurnBracket::enter(ctx);
let result = ask_inner(ctx, abort_signal.clone(), input, with_embeddings).await;
bracket.finish(ctx, &abort_signal, result)
}
async fn ask_inner(
ctx: &mut RequestContext,
abort_signal: AbortSignal,
input: Input,
with_embeddings: bool,
) -> Result<()> {
let mut input = input;
let mut with_embeddings = with_embeddings;
@@ -1579,9 +1644,7 @@ async fn ask(
};
eprintln!("\n📢 {}", color.italic().paint("Compressing the session."),);
if let Err(err) = ctx.compress_session().await {
warn!("Failed to compress the session: {err}");
}
auto_compress_session(ctx).await;
if let Some(session) = ctx.session.as_mut() {
session.set_compressing(false);
}
@@ -1632,6 +1695,13 @@ fn print_turn_divider() {
println!();
}
async fn auto_compress_session(ctx: &mut RequestContext) {
match ctx.compress_session().await {
Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None),
Err(err) => warn!("Failed to compress the session: {err}"),
}
}
fn should_continue(ctx: &RequestContext) -> bool {
let config = ctx.auto_continue_config();
ctx.app.config.function_calling_support
@@ -1939,6 +2009,320 @@ pub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{ClientConfig, Model};
use crate::config::{AppState, RoleLike, Session, WorkingMode};
use crate::hooks::{HookDef, HooksMap, test_sink};
use anyhow::anyhow;
use serial_test::serial;
use std::future::Future;
const TURN_EVENTS: [&str; 4] = [
"turn.started",
"turn.completed",
"turn.interrupted",
"turn.failed",
];
fn ctx_with_hooks(events: &[&str], marker: &str) -> RequestContext {
let mut hooks_map = HooksMap::default();
for event in events {
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::Repl)
}
/// Sink captures for `marker`, ordered (started, completed, interrupted, failed).
fn turn_counts(marker: &str) -> (usize, usize, usize, usize) {
let captures = test_sink::snapshot();
let count = |event: &str| {
captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-{event}"))
.count()
};
(
count("turn.started"),
count("turn.completed"),
count("turn.interrupted"),
count("turn.failed"),
)
}
#[tokio::test]
#[serial]
async fn turn_bracket_ok_final_fires_started_and_completed() {
let _sink = test_sink::install();
let marker = "tb-ok-x7q";
let ctx = ctx_with_hooks(&TURN_EVENTS, marker);
let abort_signal = create_abort_signal();
let bracket = TurnBracket::enter(&ctx);
let result: Result<()> = async { Ok(()) }.await;
bracket.finish(&ctx, &abort_signal, result).unwrap();
assert_eq!(turn_counts(marker), (1, 1, 0, 0));
}
#[tokio::test]
#[serial]
async fn turn_bracket_tool_rounds_then_final_fire_one_turn() {
let _sink = test_sink::install();
let marker = "tb-rounds-p2m";
let ctx = ctx_with_hooks(&TURN_EVENTS, marker);
let abort_signal = create_abort_signal();
let bracket = TurnBracket::enter(&ctx);
let result: Result<()> = async {
// Simulates a turn that loops through tool rounds before the
// final round; rounds happen inside the bracket and never re-fire.
let mut rounds = 0;
loop {
rounds += 1;
if rounds == 3 {
break Ok(());
}
}
}
.await;
bracket.finish(&ctx, &abort_signal, result).unwrap();
assert_eq!(turn_counts(marker), (1, 1, 0, 0));
}
#[tokio::test]
#[serial]
async fn turn_bracket_error_fires_failed_with_error_env() {
let _sink = test_sink::install();
let marker = "tb-err-k4d";
let ctx = ctx_with_hooks(&TURN_EVENTS, marker);
let abort_signal = create_abort_signal();
let bracket = TurnBracket::enter(&ctx);
let result: Result<()> = async { Err(anyhow!("turn exploded")) }.await;
let err = bracket.finish(&ctx, &abort_signal, result).unwrap_err();
assert_eq!(err.to_string(), "turn exploded");
assert_eq!(turn_counts(marker), (1, 0, 0, 1));
let captures = test_sink::snapshot();
let failed = captures
.iter()
.find(|capture| capture.hook_name == format!("{marker}-turn.failed"))
.expect("turn.failed capture");
assert_eq!(
failed.envs.get("COYOTE_ERROR").map(String::as_str),
Some("turn exploded")
);
}
#[tokio::test]
#[serial]
async fn turn_bracket_ctrlc_error_fires_interrupted_not_failed() {
let _sink = test_sink::install();
let marker = "tb-ctrlc-err-j6w";
let ctx = ctx_with_hooks(&TURN_EVENTS, marker);
let abort_signal = create_abort_signal();
abort_signal.set_ctrlc();
let bracket = TurnBracket::enter(&ctx);
let result: Result<()> = async { Err(anyhow!("Aborted!")) }.await;
assert!(bracket.finish(&ctx, &abort_signal, result).is_err());
assert_eq!(turn_counts(marker), (1, 0, 1, 0));
}
#[tokio::test]
#[serial]
async fn turn_bracket_ctrlc_with_ok_exit_fires_interrupted() {
let _sink = test_sink::install();
let marker = "tb-ctrlc-ok-r3v";
let ctx = ctx_with_hooks(&TURN_EVENTS, marker);
let abort_signal = create_abort_signal();
abort_signal.set_ctrlc();
let bracket = TurnBracket::enter(&ctx);
let result: Result<()> = async { Ok(()) }.await;
bracket.finish(&ctx, &abort_signal, result).unwrap();
assert_eq!(turn_counts(marker), (1, 0, 1, 0));
}
/// Drives a deep REPL future to completion on a thread with extra stack
/// headroom: nested `run_repl_command`/`compress_session` poll frames
/// are deep in debug builds and overflow the default test-thread stack
/// (Windows exhausts it first).
fn run_async<F>(f: F) -> F::Output
where
F: Future + Send,
F::Output: Send,
{
std::thread::scope(|scope| {
std::thread::Builder::new()
.stack_size(8 * 1024 * 1024)
.spawn_scoped(scope, || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(f)
})
.unwrap()
.join()
.unwrap()
})
}
#[test]
#[serial]
fn compress_command_failure_fires_no_session_compressed() {
let _sink = test_sink::install();
let marker = "compress-fail-z9t";
run_async(async {
let mut ctx = ctx_with_hooks(&["session.compressed"], marker);
ctx.session = Some(Session::default());
let abort_signal = create_abort_signal();
// Boxed: `run_repl_command`'s state machine is far larger than a
// test thread's stack.
let result = Box::pin(run_repl_command(
&mut ctx,
abort_signal,
".compress session",
))
.await;
assert!(result.is_err(), "compressing an empty session must fail");
});
assert_eq!(
test_sink::snapshot()
.iter()
.filter(|capture| capture.hook_name.starts_with(marker))
.count(),
0,
"a failed compression must fire no session.compressed hook"
);
}
/// A ctx whose compression can actually SUCCEED offline: `dry_run`
/// makes the summarization call echo instead of hitting the network,
/// the default openai client config makes the session model
/// resolvable, and the session already holds a user exchange so
/// `compress_session` has something to summarize.
fn compressible_ctx(marker: &str) -> RequestContext {
let mut hooks_map = HooksMap::default();
hooks_map.insert(
"session.compressed".to_string(),
vec![HookDef {
name: format!("{marker}-session.compressed"),
command: "true".to_string(),
}],
);
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks: hooks_map,
dry_run: true,
clients: vec![ClientConfig::default()],
..Default::default()
});
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Repl);
let mut session = Session::default();
session.set_model(Model::new("openai", "test-compress-model"));
ctx.session = Some(session);
let input = Input::from_str(&ctx, "hello there", None).unwrap();
ctx.session
.as_mut()
.unwrap()
.add_message(&input, "hi")
.unwrap();
ctx
}
fn session_compressed_count(marker: &str) -> usize {
test_sink::snapshot()
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-session.compressed"))
.count()
}
#[test]
#[serial]
fn compress_command_success_fires_session_compressed_once() {
let _sink = test_sink::install();
let marker = "compress-ok-w2p";
run_async(async {
let mut ctx = compressible_ctx(marker);
let abort_signal = create_abort_signal();
// Boxed: `run_repl_command`'s state machine is far larger than a
// test thread's stack.
let result = Box::pin(run_repl_command(
&mut ctx,
abort_signal,
".compress session",
))
.await;
assert!(
result.is_ok(),
"dry-run compression must succeed: {result:?}"
);
});
assert_eq!(
session_compressed_count(marker),
1,
"a successful `.compress session` must fire session.compressed exactly once"
);
}
#[test]
#[serial]
fn auto_compress_success_fires_session_compressed_once() {
// Covers the ask-loop call site through its extracted seam;
// driving `needs_compression` itself requires a full LLM turn and
// is exercised end-to-end by integration coverage, not here.
let _sink = test_sink::install();
let marker = "auto-compress-ok-f8r";
run_async(async {
let mut ctx = compressible_ctx(marker);
auto_compress_session(&mut ctx).await;
});
assert_eq!(
session_compressed_count(marker),
1,
"a successful auto-compression must fire session.compressed exactly once"
);
}
#[test]
#[serial]
fn auto_compress_failure_fires_no_session_compressed_and_swallows_error() {
let _sink = test_sink::install();
let marker = "auto-compress-fail-d3k";
run_async(async {
let mut ctx = compressible_ctx(marker);
// An empty session makes compress_session fail; the seam must
// swallow the error (no panic, no propagation) and fire nothing.
ctx.session = Some(Session::default());
auto_compress_session(&mut ctx).await;
});
assert_eq!(
session_compressed_count(marker),
0,
"a failed auto-compression must fire no session.compressed hook"
);
}
#[test]
fn test_process_command_line() {
+65
View File
@@ -200,6 +200,12 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
if let Ok(cwd) = env::current_dir()
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
@@ -940,6 +946,65 @@ network:
let _ = fs::remove_dir_all(&root);
}
#[test]
#[serial_test::serial]
fn discover_scans_roles_and_role_hooks_mixins() {
use crate::utils::get_env_name;
let root = unique_root("discover-roles");
let roles = root.join("roles");
fs::create_dir_all(roles.join("hooks")).unwrap();
let role_mixin = roles.join("review.sbx-mixin.yaml");
let hook_mixin = roles.join("hooks").join("notify.sbx-mixin.yaml");
fs::write(&role_mixin, "kind: mixin\n").unwrap();
fs::write(&hook_mixin, "kind: mixin\n").unwrap();
// The builtin installer's hidden manifest lives in the same dir but
// is not a mixin and must never be picked up by the flat scan.
let manifest = roles
.join("hooks")
.join(crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE);
fs::write(&manifest, "notify.sh\n").unwrap();
let config_env = get_env_name("config_dir");
let roles_env = get_env_name("roles_dir");
let prev_config = env::var_os(&config_env);
let prev_roles = env::var_os(&roles_env);
unsafe {
env::set_var(&config_env, &root);
env::set_var(&roles_env, &roles);
}
let discovered = discover();
unsafe {
match prev_config {
Some(v) => env::set_var(&config_env, v),
None => env::remove_var(&config_env),
}
match prev_roles {
Some(v) => env::set_var(&roles_env, v),
None => env::remove_var(&roles_env),
}
}
let found: Vec<PathBuf> = discovered
.unwrap()
.into_iter()
.map(|mixin| mixin.path)
.collect();
assert!(found.contains(&role_mixin), "missing role mixin: {found:?}");
assert!(
found.contains(&hook_mixin),
"missing role hook mixin: {found:?}"
);
assert!(
!found.contains(&manifest),
"builtin manifest must not be scanned as a mixin: {found:?}"
);
let _ = fs::remove_dir_all(&root);
}
/// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
/// existed, nothing scanned that shape and attaching a Qdrant RAG from
+53 -14
View File
@@ -3,27 +3,64 @@
use log::{Level, LevelFilter, Log, Metadata, Record};
use std::sync::{Mutex, Once, OnceLock};
struct WarnCollector;
struct TestLogCollector;
static WARN_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
static DEBUG_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
/// Every warn-level message captured since the collector was installed. The
/// buffer is process-global and shared by all tests, so assert by filtering
/// for a marker unique to the test rather than on the whole buffer.
/// Debug capture is restricted to the hooks module so the buffer is not
/// flooded by debug output from the rest of the crate. Matching is
/// module-boundary-aware: only this exact module or its `::` submodules
/// qualify, so a sibling like `hooks_registry` would not.
const DEBUG_TARGET_PREFIX: &str = concat!(env!("CARGO_CRATE_NAME"), "::hooks");
fn captures_warn(metadata: &Metadata) -> bool {
metadata.level() <= Level::Warn
}
fn captures_hooks_debug(metadata: &Metadata) -> bool {
if metadata.level() != Level::Debug {
return false;
}
let target = metadata.target();
target == DEBUG_TARGET_PREFIX
|| target
.strip_prefix(DEBUG_TARGET_PREFIX)
.is_some_and(|rest| rest.starts_with("::"))
}
/// Every warn- or error-level message captured since the collector was
/// installed. The buffer is process-global and shared by all tests, so assert
/// by filtering for a marker unique to the test rather than on the whole
/// buffer.
pub(crate) fn warn_messages() -> &'static Mutex<Vec<String>> {
WARN_MESSAGES.get_or_init(Mutex::default)
}
impl Log for WarnCollector {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= Level::Warn
/// Every debug-level message from the hooks module captured since the
/// collector was installed. Same marker-filtering discipline as
/// [`warn_messages`].
pub(crate) fn debug_messages() -> &'static Mutex<Vec<String>> {
DEBUG_MESSAGES.get_or_init(Mutex::default)
}
impl Log for TestLogCollector {
fn enabled(&self, metadata: &Metadata) -> bool {
captures_warn(metadata) || captures_hooks_debug(metadata)
}
// A test that panics while holding a buffer poisons its mutex; the logger
// recovers via `into_inner` so every later test can still log.
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
if captures_warn(record.metadata()) {
warn_messages()
.lock()
.unwrap()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(record.args().to_string());
} else if captures_hooks_debug(record.metadata()) {
debug_messages()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(record.args().to_string());
}
}
@@ -31,13 +68,15 @@ impl Log for WarnCollector {
fn flush(&self) {}
}
/// Installs the process-wide warn collector. `log::set_logger` accepts one
/// logger per process, so every test that captures warns must install through
/// this shared entry point.
/// Installs the process-wide log collector. `log::set_logger` accepts one
/// logger per process, so every test that captures warns or hooks debug
/// output must install through this shared entry point. The max level is
/// Debug so the hooks debug capture sees its records; warn capture is
/// unaffected.
pub(crate) fn install_warn_collector() {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
log::set_logger(&WarnCollector).expect("no other logger should be installed");
log::set_max_level(LevelFilter::Warn);
log::set_logger(&TestLogCollector).expect("no other logger should be installed");
log::set_max_level(LevelFilter::Debug);
});
}