Compare commits
138
Commits
cfb83a629e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2846d488b6
|
||
|
|
4c3c3e69b2
|
||
|
|
3a017d7fda | ||
|
|
53e0f3b54b | ||
|
|
22809e2a1b | ||
|
|
9ae26f4194 | ||
|
|
a3c4b6a364 | ||
|
|
72eb5a100c | ||
|
|
afab1b6da5 | ||
|
|
c9fcea2849 | ||
|
|
e7a7cd9704 | ||
|
|
2b3839881c | ||
|
|
c32ba9157e | ||
|
|
0442d77059 | ||
|
|
4e33979a92 | ||
|
|
fac1569646 | ||
|
|
6490128f07 | ||
|
|
3e496ec2fd | ||
|
|
57809ddf29 | ||
|
|
184712621e | ||
|
|
c45d73c94a | ||
|
|
4b2ab2f867 | ||
|
|
4cec912f8e | ||
|
|
e0bcf340fe | ||
|
|
9609e333f3 | ||
|
|
a8c0ec64fa | ||
|
|
37ba25adc5 | ||
|
|
a65280754e | ||
|
|
b6022654eb | ||
|
|
0c17076bdf | ||
|
|
a85d933d33 | ||
|
|
13640981c7 | ||
|
|
f106b101ab | ||
|
|
77322698e4 | ||
|
|
e237845827 | ||
|
|
4571ed647e | ||
|
|
0b55f7c735 | ||
|
|
461245a57c | ||
|
|
dcf2d99fa9 | ||
|
|
d524328d86 | ||
|
|
e002bd6727 | ||
|
|
56ed4664cd | ||
|
|
3ee78a46df | ||
|
|
dc6c176b4f | ||
|
|
00de9d40d1 | ||
|
|
08dfbac6f6 | ||
|
|
74e7bc54eb | ||
|
|
dc2f26d0e6 | ||
|
|
4e53e143bb | ||
|
|
166a3807f8 | ||
|
|
04b983459b | ||
|
|
fcdcb303a3 | ||
|
|
5bd1174ddd | ||
|
|
bfa058130a | ||
|
|
1c73b82812 | ||
|
|
9e811c088f | ||
|
|
02b9986807 | ||
|
|
ad9b319149 | ||
|
|
611900057c | ||
|
|
ec66494e8f | ||
|
|
3d37ed237a | ||
|
|
83978617bc | ||
|
|
bf10aeb74d | ||
|
|
c2e30c12a6 | ||
|
|
879cf8e097 | ||
|
|
22078b1ddc | ||
|
|
f7d0f93fa1 | ||
|
|
4b2428b334 | ||
|
|
dee89d4ca3 | ||
|
|
90f7631b35 | ||
|
|
6ae093a482 | ||
|
|
c73d5317a0 | ||
|
|
a3b71a6889 | ||
|
|
739c1be8bb | ||
|
|
1744d2ba52 | ||
|
|
6804f198bc | ||
|
|
7ace1731c6 | ||
|
|
89a9398c28 | ||
|
|
e7bd75947c | ||
|
|
d89d24aff4 | ||
|
|
f59421844a | ||
|
|
3dc84f5aef | ||
|
|
23fe2c1231 | ||
|
|
9da0b91673 | ||
|
|
5495813baf | ||
|
|
717a48bef2 | ||
|
|
6514588e05 | ||
|
|
73cf4a625b | ||
|
|
4087f3b3d0 | ||
|
|
0defc61bec | ||
|
|
7155b1b4fb | ||
|
|
42380d4b82 | ||
|
|
e62502c862 | ||
|
|
fc3bb259cb | ||
|
|
0c6b7c8594 | ||
|
|
8710737964 | ||
|
|
363104fe77 | ||
|
|
8f775dc2d9 | ||
|
|
2731e98ed1 | ||
|
|
0c2a68ecc4 | ||
|
|
bd7cad21e2 | ||
|
|
0c4d0d777c | ||
|
|
b020e1b75e | ||
|
|
9693cdd4ac | ||
|
|
a0cc5f291e | ||
|
|
ce20927713 | ||
|
|
9e7c957a3d | ||
|
|
3b66101361 | ||
|
|
0dc9dd91c3 | ||
|
|
9825ccd31e | ||
|
|
688ababecc | ||
|
|
d4605794e8 | ||
|
|
3a2736b667 | ||
|
|
003d036e31 | ||
|
|
e4dfbe906c | ||
|
|
57c1a86195 | ||
|
|
8be9c8e152 | ||
|
|
fa7f96e9e8 | ||
|
|
5046aebca3 | ||
|
|
1d6823ca0c | ||
|
|
9fb90be851 | ||
|
|
4cc5563ba8 | ||
|
|
fcc0049ab2 | ||
|
|
dca9b1553e | ||
|
|
ea31236d29 | ||
|
|
d16db663df | ||
|
|
94d48fb76e | ||
|
|
6c50c60c22 | ||
|
|
f31885ba07 | ||
|
|
fd26f43b84 | ||
|
|
e240be8bda | ||
|
|
9e2f3c0f12 | ||
|
|
8746e3e29a | ||
|
|
5b9852d80e | ||
|
|
99d4b20ac3 | ||
|
|
17864c8864 | ||
|
|
2a04c4f5e1 | ||
|
|
ca58176ef9 |
No files matched your search
@@ -1 +1,7 @@
|
|||||||
assets/config-template.yaml text eol=lf
|
assets/config-template.yaml text eol=lf
|
||||||
|
assets/hooks/*.sh text eol=lf
|
||||||
|
assets/agents/** text eol=lf
|
||||||
|
assets/functions/tools/* text eol=lf
|
||||||
|
assets/functions/scripts/* text eol=lf
|
||||||
|
assets/functions/utils/*.sh text eol=lf
|
||||||
|
scripts/*.sh text eol=lf
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
### AI assistance (if any):
|
|
||||||
- List tools here and files touched by them
|
|
||||||
|
|
||||||
### Authorship & Understanding
|
|
||||||
|
|
||||||
- [ ] I wrote or heavily modified this code myself
|
|
||||||
- [ ] I understand how it works end-to-end
|
|
||||||
- [ ] I can maintain this code in the future
|
|
||||||
- [ ] No undisclosed AI-generated code was used
|
|
||||||
- [ ] If AI assistance was used, it is documented below
|
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
### Description
|
||||||
|
<!-- What problem is this PR solving? -->
|
||||||
|
|
||||||
|
### Alternatives Considered (Optional)
|
||||||
|
<!-- What alternatives did you consider? Why did you choose this approach? -->
|
||||||
|
|
||||||
|
### AI assistance (if any)
|
||||||
|
<!-- Required if AI assistance was used to write or modify code in this PR. -->
|
||||||
|
<!-- List tools here -->
|
||||||
|
|
||||||
|
### Authorship & Understanding
|
||||||
|
|
||||||
|
- [ ] I wrote or heavily modified this code myself. If I didn't, I gave proper attribution to AI assistance above.
|
||||||
|
- [ ] I understand how it works end-to-end
|
||||||
|
- [ ] I can maintain this code in the future
|
||||||
|
- [ ] No undisclosed AI-generated code was used
|
||||||
|
- [ ] If AI assistance was used, it is documented above
|
||||||
|
|
||||||
+110
@@ -1,3 +1,113 @@
|
|||||||
|
## v0.10.3 (2026-09-15)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- create a config alias for default_reasoning_level (to match some wording and make the config flexible)
|
||||||
|
- support custom resource definitions for openai-compatible Oauth customization
|
||||||
|
- Support specifying the compression model to use when compressing a session for greater cost controls
|
||||||
|
- Added full cost tracking to Coyote and added it to the default right prompt
|
||||||
|
- added support for input/output token cache cost tracking
|
||||||
|
- Support persisting paused todo lists to sessions so users can exit and resume paused todo sessions; Also fixed a bug so todo list pausing is only supported when being used in a session since it typically requires multi-turn communication
|
||||||
|
- Support token caching to reduce LLM costs in Anthropic models (not typically handled automatically by APIs)
|
||||||
|
- **TASK-030**: callers escalate on GAUNTLET_REVIEW_INCOMPLETE instead of findings-routing
|
||||||
|
- **TASK-032**: review-gauntlet incomplete-review machine line + ×N attempt details
|
||||||
|
- **TASK-029**: review-gauntlet fault-only lane re-run loop
|
||||||
|
- **TASK-028**: escalation_timeout 0 waits indefinitely and is the default
|
||||||
|
- **graph**: stream llm-node completions so node timeout governs long generations
|
||||||
|
- **TASK-023**: deep-research hardening — fail-safe scripts, fault fallbacks, pipeline notes
|
||||||
|
- **TASK-020**: finding-verifier + step-runner fail-closed hardening
|
||||||
|
- **TASK-019**: code-reviewer fail-closed review pipeline
|
||||||
|
- **TASK-018**: review-gauntlet lane degradation + verification passthrough
|
||||||
|
- **TASK-017**: adversary execution evidence + fail-closed routing
|
||||||
|
- **TASK-016**: engine + test fixes from the Phase-1 re-review
|
||||||
|
- **TASK-011**: traverse agent fallback edges in map peer-discovery BFS
|
||||||
|
- **TASK-010**: add max_attempts to read-only agent lanes in bundled graphs
|
||||||
|
- **TASK-009**: add fallback routing to agent nodes
|
||||||
|
- **TASK-008**: add shared transient matcher and retry support to agent nodes
|
||||||
|
- Fixed graph agents with new agent variable passing functionality
|
||||||
|
- **graph**: warn when a declared variable is shadowed by an initial_state key
|
||||||
|
- **agents**: advertise declared agent variables in agent__list_available
|
||||||
|
- **agents**: allow passing agent variables to agent__spawn and agent__task_create
|
||||||
|
- seed variables into initial graph state
|
||||||
|
- Significant code review suite rewrite with improved graph agent features
|
||||||
|
- let install commands remove files that have been removed from bundles, both internal and external
|
||||||
|
- update the web_search_coyote tool to attempt to use the current model first then fallback to the configured model for web searches
|
||||||
|
- expose the current coyote model to tools
|
||||||
|
- removed temperature specification in deep-research and finding-verifier agents to make them more univerally usable
|
||||||
|
- **graph**: thread the graph abort into llm nodes and tool execution
|
||||||
|
- **graph**: abort a graph run on Ctrl-C from anywhere
|
||||||
|
- **graph**: warn when an llm node's turn cap is disabled
|
||||||
|
- **graph**: treat llm max_iterations: 0 as no turn cap
|
||||||
|
- **graph**: warn when the visit cap is disabled
|
||||||
|
- **graph**: treat max_loop_iterations: 0 as no visit cap
|
||||||
|
- **graph**: warn when a timeout is disabled
|
||||||
|
- **graph**: treat timeout: 0 as no wall-clock bound
|
||||||
|
- **graph**: validate agent node inputs at load time
|
||||||
|
- **graph**: seed a child graph agent's state from an agent node's inputs map
|
||||||
|
- **graph**: validate map branch subgraphs instead of single branch nodes
|
||||||
|
- **graph**: run map branches as per-item subgraph chains
|
||||||
|
- **graph**: accept a `{{template}}` for a map node's `max_concurrency`
|
||||||
|
- created the finding-verifier and whetstone agents for continuous code quality improvements
|
||||||
|
- teammates flag enabling peer messaging between concurrent graph agent nodes
|
||||||
|
- support explicit can_spawn_agents, max_concurrent_agents, and max_agent_depth keys in graph.yaml
|
||||||
|
- support setting the tool_timeout in the main config file instead of only via an environment variable
|
||||||
|
- Added new environment variable to force showing tool call logging when not in a tty
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- empty tool results can't cause errors in bedrock, claude, openai, or vertexai clients anymore
|
||||||
|
- proper support for reasoning/thinking in bedrock models
|
||||||
|
- **TASK-032**: enforce incomplete⇒BLOCKED structurally; anchor adversary degraded detection
|
||||||
|
- **TASK-028**: pin example-doc wording; make not-found escalation hint match queue semantics
|
||||||
|
- **TASK-028**: pin global-template exclusion, tighten timeout boundary test, reword stale hint
|
||||||
|
- **adversary**: red verification runs block CONFORMS; re-derive timeout envelope; robust runner
|
||||||
|
- **review-suite**: detect nested finding-verifier faults; harden render/fault markers
|
||||||
|
- **review-gauntlet**: summary-line critical count, nested-fault hook, fail-closed lane fallback
|
||||||
|
- **graph**: typed ApiStatusError transient classification; warn on bare output_schema
|
||||||
|
- **review-suite**: mark example verification commands as placeholders; pin variable description and prompt note
|
||||||
|
- **graph**: auto-merge only schema-declared keys from llm output
|
||||||
|
- **review-suite**: pass verification_commands as a declared variable, never LLM-extracted from prose
|
||||||
|
- **agents**: scrub GRAPH_STATE* from adversary verification commands; raise review-suite max_agent_depth
|
||||||
|
- **adversary**: narrow died-marker predicate, degraded banner for died criteria, hermetic script tests
|
||||||
|
- **adversary**: fail closed on PIPELINE-FAULT evidence regardless of status; pin bail-chain and validator shape
|
||||||
|
- **adversary**: fail closed per criterion when check_criterion dies
|
||||||
|
- **engine**: carry full error chain in llm node fault text
|
||||||
|
- **TASK-020**: skip POSIX bash script tests on Windows
|
||||||
|
- **TASK-020**: surface step-runner LLM-fault captures where the fallback lands
|
||||||
|
- **TASK-019**: carry PIPELINE-FAULT prefix through verdict.py's crash guard
|
||||||
|
- **TASK-018**: harden verdict_gate fault routing and widen default_lanes fallback
|
||||||
|
- **TASK-017**: bound run_checks runtime and fail-close its node-level death
|
||||||
|
- **tests**: collision-proof parser test temp files to deflake macOS CI
|
||||||
|
- Don't infinitely compress a session once it passes a threshold when the threshold is close to the max token count
|
||||||
|
- fix spinner ctrl-c early exit not clearing message and restoring cursor
|
||||||
|
- **graph**: build peer retirement guards before spawning their tasks
|
||||||
|
- **graph**: reject agents that ship both config.yaml and graph.yaml at load
|
||||||
|
- **graph**: bound concurrency caps by the semaphore permit limit
|
||||||
|
- **graph**: make turn-cap and template checks consistent across validation modes
|
||||||
|
- **graph**: leave output absent after state_updates when it was absent before
|
||||||
|
- **graph**: validate the whole max_concurrency template string
|
||||||
|
- **graph**: reject as == output_key on map nodes
|
||||||
|
- **graph**: inherit the session abort in agent-node children
|
||||||
|
- **graph**: retire the teammate identity before structured extraction
|
||||||
|
- **graph**: reject a literal max_concurrency of 0 instead of clamping it
|
||||||
|
- **graph**: clear peer identity fields after every map chain step
|
||||||
|
- **graph**: pick the map teammate identity name by BFS from the branch entry
|
||||||
|
- **graph**: name the offending node type when a map branch reaches a disallowed node
|
||||||
|
- **function**: build bundled bash tools without writing back to the source tree
|
||||||
|
- **agents**: name the map-branch output key in deep-research and finding-verifier
|
||||||
|
- **graph**: honor an explicit `output` key in state_updates
|
||||||
|
- Fixed the bad formatting in the finding-verifier agent
|
||||||
|
- properly anchored the role frontmatter regex so it doesn't catch in-instruction separators
|
||||||
|
- support non-object tool result outputs for bedrock
|
||||||
|
- exclude null bytes from fs_grep output on binaries
|
||||||
|
- suppress subagent error and warning output to stderr/stdout
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **graph**: keep the ordered visit log test-only
|
||||||
|
- **graph**: move the cfg(test) OUTPUT_KEY import into llm's test module
|
||||||
|
- **graph**: drop the duplicated locator prefix from map chain step errors
|
||||||
|
|
||||||
## v0.10.2 (2026-09-04)
|
## v0.10.2 (2026-09-04)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
+3
-2
@@ -78,8 +78,9 @@ act -W .github/workflows/release.yml --input_type bump=minor
|
|||||||
|
|
||||||
## Authorship Policy
|
## Authorship Policy
|
||||||
|
|
||||||
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
|
All code in this repository is written and reviewed by humans, or AI-generated where explicitly disclosed.
|
||||||
Claude, etc.) is not permitted unless explicitly disclosed and approved.
|
AI-generated code (e.g., Copilot, ChatGPT, Claude, etc.) is permitted only when explicitly disclosed and
|
||||||
|
approved. For example: disclosing the use of AI-generated code in a PR description.
|
||||||
|
|
||||||
Submissions must certify that the contributor understands and can maintain the code they submit.
|
Submissions must certify that the contributor understands and can maintain the code they submit.
|
||||||
|
|
||||||
|
|||||||
Generated
+299
-215
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coyote-ai"
|
name = "coyote-ai"
|
||||||
version = "0.10.2"
|
version = "0.10.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||||
description = "The batteries-included runtime for LLMs"
|
description = "The batteries-included runtime for LLMs"
|
||||||
|
|||||||
+6
-1
@@ -82,6 +82,8 @@ RUN set -euo pipefail; \
|
|||||||
chown 1000:1000 /home/agent/.cargo/bin/coyote; \
|
chown 1000:1000 /home/agent/.cargo/bin/coyote; \
|
||||||
rm -rf "$TMPDIR"
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
COPY --chmod=0755 scripts/docker-entrypoint.sh /usr/local/bin/coyote-entrypoint
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|
||||||
ARG COYOTE_VERSION
|
ARG COYOTE_VERSION
|
||||||
@@ -107,4 +109,7 @@ WORKDIR /home/agent/workspace
|
|||||||
|
|
||||||
USER 1000
|
USER 1000
|
||||||
|
|
||||||
ENTRYPOINT ["coyote"]
|
# tini as PID 1 (as in the sandbox-templates base) reaps orphans and forwards
|
||||||
|
# signals; coyote-entrypoint runs coyote by default, or the given command
|
||||||
|
# (e.g. the Docker Sandboxes keep-alive) when the first arg is sh/bash/a path.
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/coyote-entrypoint"]
|
||||||
@@ -56,6 +56,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
|||||||
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
||||||
* [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns).
|
* [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns).
|
||||||
* [Background Jobs](https://github.com/Dark-Alex-17/coyote/wiki/Background-Jobs): Run long tool calls (builds, test suites, slow MCP calls) in the background with the `job__*` tools while the model keeps working, and completion arrives as a push notification.
|
* [Background Jobs](https://github.com/Dark-Alex-17/coyote/wiki/Background-Jobs): Run long tool calls (builds, test suites, slow MCP calls) in the background with the `job__*` tools while the model keeps working, and completion arrives as a push notification.
|
||||||
|
* [Hooks](https://github.com/Dark-Alex-17/coyote/wiki/Hooks): Run your own fire-and-forget commands when lifecycle events fire. Turns, tools, LLM requests, agents, sub-agent escalations, and more (26 events). Perfect for desktop notifications, activity logging, and downstream automation; hooks never block Coyote or change its behavior.
|
||||||
* [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models.
|
* [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models.
|
||||||
* [Environment Variables](https://github.com/Dark-Alex-17/coyote/wiki/Environment-Variables): Override and customize your Coyote configuration at runtime with environment variables.
|
* [Environment Variables](https://github.com/Dark-Alex-17/coyote/wiki/Environment-Variables): Override and customize your Coyote configuration at runtime with environment variables.
|
||||||
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
|
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
|
||||||
@@ -143,6 +144,13 @@ docker run --rm -it \
|
|||||||
darkalex17/coyote
|
darkalex17/coyote
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Arguments are passed to `coyote`. If the first argument is `sh`, `bash`, or an absolute path, that command runs instead,
|
||||||
|
e.g. to get a shell inside the image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -it darkalex17/coyote bash
|
||||||
|
```
|
||||||
|
|
||||||
### Scripts
|
### Scripts
|
||||||
#### Linux/MacOS (`bash`)
|
#### Linux/MacOS (`bash`)
|
||||||
You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
|
You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ variables:
|
|||||||
description: |
|
description: |
|
||||||
JSON array of shell commands the CALLER declares for the deterministic
|
JSON array of shell commands the CALLER declares for the deterministic
|
||||||
run_checks stage — e.g. '["cargo test --all"]'. Never inferred from the
|
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: '[]'
|
default: '[]'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
@@ -52,6 +54,7 @@ settings:
|
|||||||
|
|
||||||
initial_state:
|
initial_state:
|
||||||
project_dir_in: ''
|
project_dir_in: ''
|
||||||
|
parse_retries: 0
|
||||||
criteria: []
|
criteria: []
|
||||||
diff_spec: auto
|
diff_spec: auto
|
||||||
plan_notes: ''
|
plan_notes: ''
|
||||||
@@ -88,6 +91,18 @@ nodes:
|
|||||||
Split compound criteria ("A and B") into separate entries when they are
|
Split compound criteria ("A and B") into separate entries when they are
|
||||||
independently verifiable. When the caller supplied NO plan/criteria at
|
independently verifiable. When the caller supplied NO plan/criteria at
|
||||||
all, emit an empty array — downstream fails closed.
|
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,
|
- `diff_spec`: the git diff basis the caller named — a range, a ref/SHA,
|
||||||
"staged", "worktree", or "auto" when unnamed (a caller phrase like
|
"staged", "worktree", or "auto" when unnamed (a caller phrase like
|
||||||
"get_diff --base X" means diff_spec = "X").
|
"get_diff --base X" means diff_spec = "X").
|
||||||
@@ -159,7 +174,7 @@ nodes:
|
|||||||
branch: check_criterion
|
branch: check_criterion
|
||||||
output_key: crit_verdict
|
output_key: crit_verdict
|
||||||
collect_into: crit_verdicts
|
collect_into: crit_verdicts
|
||||||
max_concurrency: 4
|
max_concurrency: 6
|
||||||
next: holistic
|
next: holistic
|
||||||
|
|
||||||
check_criterion:
|
check_criterion:
|
||||||
@@ -190,9 +205,26 @@ nodes:
|
|||||||
confirm the change makes the criterion true, confirm the test targets
|
confirm the change makes the criterion true, confirm the test targets
|
||||||
BEHAVIOR (not implementation echo), use ast_grep for structural claims.
|
BEHAVIOR (not implementation echo), use ast_grep for structural claims.
|
||||||
Watch for gamed evidence: a test weakened/deleted so it passes, hardcoded
|
Watch for gamed evidence: a test weakened/deleted so it passes, hardcoded
|
||||||
values shaped to the criterion's example, TODO/stub presented as done —
|
values shaped to the criterion's example, TODO/stub presented as done, a
|
||||||
|
mock expectation whose count is relaxed (`AnyTimes`, `MinTimes`, ignored
|
||||||
|
request body) AND whose matcher is permissive (`gomock.Any()`,
|
||||||
|
`mock.Anything`) — it proves a call happened, never that it carried the
|
||||||
|
criterion's values, so it is not a proving test —
|
||||||
those are PARTIAL or DIVERGED with the gaming named in the complaint.
|
those are PARTIAL or DIVERGED with the gaming named in the complaint.
|
||||||
|
|
||||||
|
Retry / redelivery / idempotency demands: before you mark a criterion PARTIAL
|
||||||
|
or UNMET for lacking a retry, re-run, or "second attempt emits nothing" spec,
|
||||||
|
classify the handler. STATEFUL — it carries something across attempts (emits
|
||||||
|
events, calls an external system, performs an unconditional write, keeps an
|
||||||
|
in-memory or ledger record) — a retry spec is a legitimate demand. STATELESS —
|
||||||
|
each attempt is a pure function of the request plus a conditional store
|
||||||
|
predicate (`WHERE status = 'pending'`, `ended_at IS NULL`, `INSERT … ON
|
||||||
|
CONFLICT DO NOTHING`) and nothing is emitted or ledgered outside that
|
||||||
|
predicate — then "retry produces no event" is a tautology of the mechanism,
|
||||||
|
not a missing behavior: demand at most one test of the predicate itself,
|
||||||
|
never a retry spec. Name the classification and its file:line evidence in
|
||||||
|
the complaint either way.
|
||||||
|
|
||||||
Execution criteria ("tests pass", "lint clean", "build succeeds") are
|
Execution criteria ("tests pass", "lint clean", "build succeeds") are
|
||||||
judged ONLY against the recorded verification runs in your prompt — you
|
judged ONLY against the recorded verification runs in your prompt — you
|
||||||
have no execution tools BY DESIGN and must never claim to have run
|
have no execution tools BY DESIGN and must never claim to have run
|
||||||
@@ -282,7 +314,10 @@ nodes:
|
|||||||
The checklist (each hit becomes one complaint with kind, file:line
|
The checklist (each hit becomes one complaint with kind, file:line
|
||||||
location, the violation, and the fix):
|
location, the violation, and the fix):
|
||||||
- **Absence** — something the PLAN requires that maps to no criterion and
|
- **Absence** — something the PLAN requires that maps to no criterion and
|
||||||
never showed up in the diff (the dog that didn't bark).
|
never showed up in the diff (the dog that didn't bark). A missing
|
||||||
|
retry/redelivery spec is an absence ONLY for a handler that carries state
|
||||||
|
or side effects across attempts; a handler that is a pure function of the
|
||||||
|
request plus a conditional store predicate owes none.
|
||||||
- **Scope drift / out-of-scope** — the diff touches what the plan's scope
|
- **Scope drift / out-of-scope** — the diff touches what the plan's scope
|
||||||
notes say not to touch, or quietly does extra unplanned work.
|
notes say not to touch, or quietly does extra unplanned work.
|
||||||
- **Interface/approach substitution** — the plan named an interface,
|
- **Interface/approach substitution** — the plan named an interface,
|
||||||
@@ -340,7 +375,7 @@ nodes:
|
|||||||
observations:
|
observations:
|
||||||
type: string
|
type: string
|
||||||
required: [extra_complaints, observations]
|
required: [extra_complaints, observations]
|
||||||
max_iterations: 20
|
max_iterations: 45
|
||||||
max_attempts: 2
|
max_attempts: 2
|
||||||
state_updates:
|
state_updates:
|
||||||
holistic_failure: '{{output}}'
|
holistic_failure: '{{output}}'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ review proceeds fail-closed rather than crashing.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -22,6 +23,22 @@ def load_state():
|
|||||||
|
|
||||||
|
|
||||||
state = 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(
|
proj = os.path.expanduser(
|
||||||
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
|
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
|
||||||
)
|
)
|
||||||
@@ -56,8 +73,13 @@ try:
|
|||||||
if line.startswith("+++ b/")
|
if line.startswith("+++ b/")
|
||||||
})
|
})
|
||||||
out["changed_files"] = files[:300]
|
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:
|
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 "
|
f" (truncated to {MAX_DIFF_CHARS} chars of {len(text)} — use the fs tools "
|
||||||
"to inspect files beyond the cut)"
|
"to inspect files beyond the cut)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ in-script degradation would never fire. Each command gets
|
|||||||
min(PER_COMMAND_TIMEOUT_SECS, remaining budget); commands the deadline
|
min(PER_COMMAND_TIMEOUT_SECS, remaining budget); commands the deadline
|
||||||
leaves no budget for are recorded as {"cmd", "skipped": "deadline"} entries,
|
leaves no budget for are recorded as {"cmd", "skipped": "deadline"} entries,
|
||||||
so the in-script handling stays authoritative. The env var
|
so the in-script handling stays authoritative. The env var
|
||||||
ADVERSARY_RUN_CHECKS_DEADLINE_SECS overrides the deadline (test seam).
|
ADVERSARY_RUN_CHECKS_DEADLINE_SECS overrides the deadline (test seam), and
|
||||||
|
ADVERSARY_STACK_LOCK_PATH overrides the review-stack lock file location
|
||||||
|
(isolation seam — see acquire_stack_lock).
|
||||||
|
|
||||||
Process hygiene: commands run through the shell. On POSIX each command gets
|
Process hygiene: commands run through the shell. On POSIX each command gets
|
||||||
its own session (start_new_session) and a timeout SIGKILLs the whole process
|
its own session (start_new_session) and a timeout SIGKILLs the whole process
|
||||||
@@ -52,10 +54,52 @@ import json
|
|||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
except ImportError: # non-POSIX (Windows): advisory flock unavailable
|
||||||
|
fcntl = None
|
||||||
|
|
||||||
PER_COMMAND_TIMEOUT_SECS = 900
|
PER_COMMAND_TIMEOUT_SECS = 900
|
||||||
TOTAL_DEADLINE_SECS = float(os.environ.get("ADVERSARY_RUN_CHECKS_DEADLINE_SECS") or 3300)
|
TOTAL_DEADLINE_SECS = float(os.environ.get("ADVERSARY_RUN_CHECKS_DEADLINE_SECS") or 3300)
|
||||||
|
STACK_LOCK_PATH = os.environ.get("ADVERSARY_STACK_LOCK_PATH") or os.path.expanduser(
|
||||||
|
"~/.cache/coyote/review-stack.lock"
|
||||||
|
)
|
||||||
|
STACK_LOCK_WAIT_SECS = float(os.environ.get("ADVERSARY_STACK_LOCK_WAIT_SECS") or 1200)
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_stack_lock():
|
||||||
|
"""Serialize with the probe lane's shared-stack work (advisory flock on a
|
||||||
|
well-known path; the probe is instructed to flock the same file around
|
||||||
|
stack boot/teardown). Bounded wait so a hogged lock can never turn into a
|
||||||
|
false red verification: on timeout we proceed WITHOUT the lock and note it
|
||||||
|
on stderr. The fd is held for the process lifetime (released on exit).
|
||||||
|
On non-POSIX platforms (no fcntl) the lock is skipped entirely."""
|
||||||
|
if fcntl is None:
|
||||||
|
sys.stderr.write("WARN: review-stack lock unsupported on this platform; running unlocked\n")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(STACK_LOCK_PATH), exist_ok=True)
|
||||||
|
fd = os.open(STACK_LOCK_PATH, os.O_CREAT | os.O_RDWR)
|
||||||
|
except OSError as e:
|
||||||
|
sys.stderr.write(f"WARN: review-stack lock unavailable ({e}); running unlocked\n")
|
||||||
|
return None
|
||||||
|
limit = time.monotonic() + STACK_LOCK_WAIT_SECS
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
return fd
|
||||||
|
except OSError:
|
||||||
|
if time.monotonic() >= limit:
|
||||||
|
os.close(fd)
|
||||||
|
sys.stderr.write(
|
||||||
|
"WARN: review-stack lock still held after bounded wait — proceeding "
|
||||||
|
"WITHOUT it; shared-stack collisions with the probe lane are possible\n"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
time.sleep(5)
|
||||||
DRAIN_TIMEOUT_SECS = 10
|
DRAIN_TIMEOUT_SECS = 10
|
||||||
TAIL_LINES = 50
|
TAIL_LINES = 50
|
||||||
MAX_TAIL_CHARS = 8000
|
MAX_TAIL_CHARS = 8000
|
||||||
@@ -181,6 +225,7 @@ def main():
|
|||||||
)
|
)
|
||||||
deadline = time.monotonic() + TOTAL_DEADLINE_SECS
|
deadline = time.monotonic() + TOTAL_DEADLINE_SECS
|
||||||
results = []
|
results = []
|
||||||
|
_stack_lock = acquire_stack_lock() if cmds else None # noqa: F841 — held until exit
|
||||||
for cmd in cmds:
|
for cmd in cmds:
|
||||||
remaining = deadline - time.monotonic()
|
remaining = deadline - time.monotonic()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: |
|
|||||||
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and
|
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and
|
||||||
finishes with ONE draft PR (CI checks watched to green) plus tracked follow-up tasks. Task state
|
finishes with ONE draft PR (CI checks watched to green) plus tracked follow-up tasks. Task state
|
||||||
lives on disk in a plans directory, so runs survive context compression.
|
lives on disk in a plans directory, so runs survive context compression.
|
||||||
version: 2.5.0
|
version: 2.8.0
|
||||||
agent_session: temp
|
agent_session: temp
|
||||||
auto_continue: true
|
auto_continue: true
|
||||||
max_auto_continues: 100
|
max_auto_continues: 100
|
||||||
@@ -18,6 +18,7 @@ spawnable_agents:
|
|||||||
- oracle
|
- oracle
|
||||||
- explore
|
- explore
|
||||||
- librarian
|
- librarian
|
||||||
|
- deep-research
|
||||||
- adversary
|
- adversary
|
||||||
- probe
|
- probe
|
||||||
- review-gauntlet
|
- review-gauntlet
|
||||||
@@ -180,7 +181,9 @@ instructions: |
|
|||||||
<label>; authoritative sources preferred; return a distilled, deduplicated checklist"), put the
|
<label>; authoritative sources preferred; return a distilled, deduplicated checklist"), put the
|
||||||
returned checklist through the same accept/drop round with the user, and write the ACCEPTED
|
returned checklist through the same accept/drop round with the user, and write the ACCEPTED
|
||||||
items into the plan — as measurable acceptance criteria on the relevant tasks where possible,
|
items into the plan — as measurable acceptance criteria on the relevant tasks where possible,
|
||||||
otherwise as a checklist under `## Quality bar → Long-tail criteria`.
|
otherwise as a checklist under `## Quality bar → Long-tail criteria`. Spawn checklist
|
||||||
|
librarians with `--variables {"research_depth": "deep"}` — checklist distillation is
|
||||||
|
broad-topic research and the default standard depth (3 searches / 8 fetches) starves it.
|
||||||
|
|
||||||
Ground the breakdown in real code: fan out `explore` agents (load `parallel-research`) across
|
Ground the breakdown in real code: fan out `explore` agents (load `parallel-research`) across
|
||||||
{{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
|
{{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
|
||||||
@@ -340,6 +343,12 @@ instructions: |
|
|||||||
Verification commands go in `--variables` (a JSON-array string), never in the prompt: the
|
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
|
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.
|
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
|
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,
|
fix-and-rerun rules as below (resume the SAME Sisyphus session with the findings verbatim,
|
||||||
@@ -363,6 +372,16 @@ instructions: |
|
|||||||
The direct `adversary`/probe spawns below are the FALLBACK when the gauntlet is not installed;
|
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
|
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.
|
code) is likewise an infrastructure fault to retry, never a divergence.
|
||||||
|
Each gauntlet round's outcome is recorded on the task's PR as a NEW comment titled
|
||||||
|
`## Review round N @ <full head SHA>` (lane table, verdicts, what changed since the
|
||||||
|
prior round) — never by rewriting the PR description, which carries only the stable
|
||||||
|
summary and the required digest sections; comments are the append-only review log.
|
||||||
|
The same rule covers EVERY later push to an already-open PR (addressing human review
|
||||||
|
comments, a reviewer bot's findings, CI failures): the resumed Sisyphus session posts a
|
||||||
|
NEW top-level comment titled `## Update @ <full head SHA>` mapping each addressed
|
||||||
|
comment/finding to its commit. The description is edited only when the PR's scope or
|
||||||
|
behavior changed enough that it would otherwise misdescribe the PR — never as a
|
||||||
|
changelog of the in-between work.
|
||||||
- **Spawn `adversary`** with the diff base and the criteria pasted in:
|
- **Spawn `adversary`** with the diff base and the criteria pasted in:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -577,7 +596,8 @@ instructions: |
|
|||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `sisyphus` | Implement ONE task's code in {{project_dir}} (its own explore/coder/verify/review loop). One per task. |
|
| `sisyphus` | Implement ONE task's code in {{project_dir}} (its own explore/coder/verify/review loop). One per task. |
|
||||||
| `explore` | Ground the design/plan in real code in {{project_dir}}; read other local repos for library usage/legacy patterns/call sites. Fan out in parallel. |
|
| `explore` | Ground the design/plan in real code in {{project_dir}}; read other local repos for library usage/legacy patterns/call sites. Fan out in parallel. |
|
||||||
| `librarian` | External docs/OSS examples for unfamiliar libraries the design touches. |
|
| `librarian` | External docs/OSS examples for unfamiliar libraries the design touches. Design-time/broad topics: pass `--variables {"research_depth": "deep"}` (~3× budgets); default depth is for narrow lookups. One librarian per question beats one broad librarian — deep is for inherently broad questions. |
|
||||||
|
| `deep-research` | Open-ended/exhaustive research that should churn until its critique loop passes (plans sub-questions, deep librarian per question, credibility vetting). Spawn with `--variables {"autonomy": "autonomous"}` — MANDATORY, the supervised default blocks on a human approval gate; raise `reflexion_budget` (default 2) for longer churn. |
|
||||||
| `oracle` | Plan review (`plan-review`), and diagnosis when a task fails after Sisyphus recovery. |
|
| `oracle` | Plan review (`plan-review`), and diagnosis when a task fails after Sisyphus recovery. |
|
||||||
| `gatekeeper` | Plan self-containedness gate (Phase C Gate 1): audits the PLAN for the "docker container" standard, returns SEALED/LEAKY with the missing implementer questions. |
|
| `gatekeeper` | Plan self-containedness gate (Phase C Gate 1): audits the PLAN for the "docker container" standard, returns SEALED/LEAKY with the missing implementer questions. |
|
||||||
| `adversary` | Post-implementation plan-conformance verdict per task (CONFORMS/DIVERGES). |
|
| `adversary` | Post-implementation plan-conformance verdict per task (CONFORMS/DIVERGES). |
|
||||||
@@ -612,6 +632,10 @@ instructions: |
|
|||||||
an unprobeable consumer-facing change is a plan gap to fix, not a checkbox to skip.
|
an unprobeable consumer-facing change is a plan gap to fix, not a checkbox to skip.
|
||||||
- Code comments referencing the plan/design doc/phases/steps/TASK numbers → docs drift, comments
|
- Code comments referencing the plan/design doc/phases/steps/TASK numbers → docs drift, comments
|
||||||
rot; plan references live in commit messages only.
|
rot; plan references live in commit messages only.
|
||||||
|
- Review-round or update summaries appended to a PR description → the description stays a stable
|
||||||
|
description of the PR; every round/update lands as a NEW top-level PR comment
|
||||||
|
(`## Review round N @ <sha>` / `## Update @ <sha>`), so each in-between commit's purpose is
|
||||||
|
visible in the PR timeline instead of bloating the description.
|
||||||
- A `.env` file landing in any repo → only `.env.example` with placeholder keys is committable;
|
- A `.env` file landing in any repo → only `.env.example` with placeholder keys is committable;
|
||||||
`.env` stays gitignored and real values are a follow-up.
|
`.env` stays gitignored and real values are a follow-up.
|
||||||
- Dropping a Sisyphus-reported follow-up (not logged in the task's log.md, not in the PR's
|
- Dropping a Sisyphus-reported follow-up (not logged in the task's log.md, not in the PR's
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ settings:
|
|||||||
|
|
||||||
initial_state:
|
initial_state:
|
||||||
project_dir_in: ''
|
project_dir_in: ''
|
||||||
|
verify_retries: 0
|
||||||
diff_spec: auto
|
diff_spec: auto
|
||||||
intent: ''
|
intent: ''
|
||||||
rigor_in: ''
|
rigor_in: ''
|
||||||
@@ -146,7 +147,7 @@ nodes:
|
|||||||
extra_context:
|
extra_context:
|
||||||
type: string
|
type: string
|
||||||
required: [diff_spec, intent, rigor_in, surfaces_in, project_dir_in, extra_context]
|
required: [diff_spec, intent, rigor_in, surfaces_in, project_dir_in, extra_context]
|
||||||
max_iterations: 3
|
max_iterations: 6
|
||||||
max_attempts: 2
|
max_attempts: 2
|
||||||
state_updates:
|
state_updates:
|
||||||
parse_failure: '{{output}}'
|
parse_failure: '{{output}}'
|
||||||
@@ -227,7 +228,7 @@ nodes:
|
|||||||
type: string
|
type: string
|
||||||
required: [domain, files]
|
required: [domain, files]
|
||||||
required: [groups]
|
required: [groups]
|
||||||
max_iterations: 3
|
max_iterations: 6
|
||||||
max_attempts: 2
|
max_attempts: 2
|
||||||
state_updates:
|
state_updates:
|
||||||
refine_failure: '{{output}}'
|
refine_failure: '{{output}}'
|
||||||
@@ -435,6 +436,13 @@ nodes:
|
|||||||
(cross-domain concerns from the leaders' reports + teammate alerts, or "None"),
|
(cross-domain concerns from the leaders' reports + teammate alerts, or "None"),
|
||||||
`operational_history` (from prior_art_notes; "" when the lane didn't run),
|
`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).
|
`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: |
|
prompt: |
|
||||||
Domain reports ({{file_count}} changed files, grouped into the domain slices below):
|
Domain reports ({{file_count}} changed files, grouped into the domain slices below):
|
||||||
{{domain_reports}}
|
{{domain_reports}}
|
||||||
@@ -522,6 +530,14 @@ nodes:
|
|||||||
state_updates:
|
state_updates:
|
||||||
verifier_output: '{{output}}'
|
verifier_output: '{{output}}'
|
||||||
fallback: verdict
|
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
|
next: verdict
|
||||||
|
|
||||||
verdict:
|
verdict:
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ except Exception as e: # noqa: BLE001
|
|||||||
out["resolved_diff_spec"] = resolved
|
out["resolved_diff_spec"] = resolved
|
||||||
out["changed_files"] = files[:300]
|
out["changed_files"] = files[:300]
|
||||||
out["file_count"] = len(files)
|
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():
|
def diff_text():
|
||||||
|
|||||||
@@ -165,14 +165,45 @@ def main():
|
|||||||
if v.get("id") == "pipeline-fault"
|
if v.get("id") == "pipeline-fault"
|
||||||
or (isinstance(v.get("note"), str) and v["note"].lstrip().startswith("PIPELINE-FAULT:"))
|
or (isinstance(v.get("note"), str) and v["note"].lstrip().startswith("PIPELINE-FAULT:"))
|
||||||
]
|
]
|
||||||
if fv_faults:
|
# parse_fault's sentinel = the verifier verified NOTHING (hard fault, lane-
|
||||||
first = fv_faults[0].get("note")
|
# 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 ""
|
detail = first.strip().replace("\n", " ") if isinstance(first, str) else ""
|
||||||
if len(detail) > MAX_DETAIL_CHARS:
|
if len(detail) > MAX_DETAIL_CHARS:
|
||||||
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
|
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
|
||||||
|
return detail
|
||||||
|
|
||||||
|
soft_notes = []
|
||||||
|
if hard_fv:
|
||||||
faults.append(
|
faults.append(
|
||||||
f"PIPELINE-FAULT: finding verification degraded — {len(fv_faults)} verifier "
|
"PIPELINE-FAULT: finding verification degraded — the verifier verified "
|
||||||
f"verdict(s) carry a pipeline fault ({detail})"
|
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 == [] —
|
# Keyed off synth_failure + a non-empty diff, NEVER off findings == [] —
|
||||||
# a clean review legitimately has zero findings and stays MERGE-READY.
|
# 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;
|
# faults go FIRST and uncapped so the display cap can never hide one;
|
||||||
# the trigger reason keys off the human-facing triggers alone
|
# the trigger reason keys off the human-facing triggers alone
|
||||||
has_triggers = bool(attention)
|
has_triggers = bool(attention)
|
||||||
attention = faults + attention[:5]
|
attention = faults + soft_notes + coverage_notes + attention[:5]
|
||||||
|
|
||||||
reasons = []
|
reasons = []
|
||||||
if counts["🔴"]:
|
if counts["🔴"]:
|
||||||
@@ -200,6 +231,13 @@ def main():
|
|||||||
if faults:
|
if faults:
|
||||||
# CONTRACT: review-gauntlet/verdict_gate.py anchors on this wording
|
# CONTRACT: review-gauntlet/verdict_gate.py anchors on this wording
|
||||||
reasons.append("pipeline fault(s) recorded — degraded run")
|
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:
|
if has_triggers:
|
||||||
reasons.append("always-human trigger(s) fired")
|
reasons.append("always-human trigger(s) fired")
|
||||||
verdict = "NEEDS-HUMAN" if reasons else "MERGE-READY"
|
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}"}))
|
||||||
@@ -39,7 +39,7 @@ settings:
|
|||||||
initial_state:
|
initial_state:
|
||||||
project_dir: ''
|
project_dir: ''
|
||||||
fix_attempts: 0
|
fix_attempts: 0
|
||||||
max_fix_attempts: 3
|
max_fix_attempts: 5
|
||||||
fix_instructions: ''
|
fix_instructions: ''
|
||||||
build_output: ''
|
build_output: ''
|
||||||
tests_output: ''
|
tests_output: ''
|
||||||
@@ -222,7 +222,8 @@ nodes:
|
|||||||
3. While reading it, note the repo's comment register per
|
3. While reading it, note the repo's comment register per
|
||||||
`comment-discipline` (self-documenting / api-documented /
|
`comment-discipline` (self-documenting / api-documented /
|
||||||
comment-heavy) and write comments to match. When the signal is
|
comment-heavy) and write comments to match. When the signal is
|
||||||
weak, write NO comment.
|
weak, write NO comment. Every comment must EARN ITS KEEP — if
|
||||||
|
the code already tells the reader, don't add it.
|
||||||
4. If the change touches boundaries, error paths, jobs, or state
|
4. If the change touches boundaries, error paths, jobs, or state
|
||||||
transitions, also note the logging register per
|
transitions, also note the logging register per
|
||||||
`logging-discipline` (logger, message style, payload vs IDs,
|
`logging-discipline` (logger, message style, payload vs IDs,
|
||||||
@@ -323,7 +324,8 @@ nodes:
|
|||||||
- Suppressed errors (as any, @ts-ignore, #[allow(...)] on unfamiliar
|
- Suppressed errors (as any, @ts-ignore, #[allow(...)] on unfamiliar
|
||||||
lints, empty catch blocks)
|
lints, empty catch blocks)
|
||||||
- Dishonest naming (get_X that mutates, returns wrong type, etc.)
|
- Dishonest naming (get_X that mutates, returns wrong type, etc.)
|
||||||
- Useless comments that restate the code
|
- Useless comments that restate the code, or that otherwise don't earn
|
||||||
|
their keep (the code already tells the reader)
|
||||||
- AI slop (filler prose, multi-paragraph docstrings, defensive
|
- AI slop (filler prose, multi-paragraph docstrings, defensive
|
||||||
handling of impossible cases)
|
handling of impossible cases)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ this agent runs a fixed graph: every request goes through the same
|
|||||||
`plan -> parallel research -> vet -> critique -> synthesize -> verify -> approve`
|
`plan -> parallel research -> vet -> critique -> synthesize -> verify -> approve`
|
||||||
pipeline.
|
pipeline.
|
||||||
|
|
||||||
|
Two variables tune a run (pass via `--variables`): `reflexion_budget`
|
||||||
|
(default 2) caps the automated critique -> re-research passes — raise it
|
||||||
|
to let the run churn until the critique passes — and
|
||||||
|
`autonomy: autonomous` skips the final human approval gate so other
|
||||||
|
agents can spawn this one autonomously. Each sub-question is researched
|
||||||
|
by a spawned `librarian` running at deep budgets.
|
||||||
|
|
||||||
This agent is also the **canonical reference for the Coyote graph
|
This agent is also the **canonical reference for the Coyote graph
|
||||||
system**: it exercises every node type (`script`, `llm`, `rag`, `map`,
|
system**: it exercises every node type (`script`, `llm`, `rag`, `map`,
|
||||||
`agent`, `input`, `approval`, `end`) and both static fan-out and
|
`agent`, `input`, `approval`, `end`) and both static fan-out and
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ description: |
|
|||||||
delegates the final write-up to a focused sub-agent, checks that the
|
delegates the final write-up to a focused sub-agent, checks that the
|
||||||
cited sources are reachable, and gates the result behind human
|
cited sources are reachable, and gates the result behind human
|
||||||
approval. A reviewer's free-form feedback at the approval step feeds
|
approval. A reviewer's free-form feedback at the approval step feeds
|
||||||
back into another research pass.
|
back into another research pass. Each sub-question is researched by a
|
||||||
|
spawned librarian running at deep budgets. Tunable via --variables:
|
||||||
|
reflexion_budget (default 2) caps the automated critique->re-research
|
||||||
|
passes - raise it to let the run churn until the critique passes;
|
||||||
|
autonomy=autonomous skips the human approval gate (REQUIRED when
|
||||||
|
another agent spawns this one).
|
||||||
|
|
||||||
This is the canonical Coyote graph-agent reference: it exercises every
|
This is the canonical Coyote graph-agent reference: it exercises every
|
||||||
node type (script, llm, rag, map, agent, input, approval, end) and
|
node type (script, llm, rag, map, agent, input, approval, end) and
|
||||||
@@ -27,8 +32,16 @@ conversation_starters:
|
|||||||
- 'How does HTTP/3 differ from HTTP/2?'
|
- 'How does HTTP/3 differ from HTTP/2?'
|
||||||
- 'Summarize recent advances in solid-state battery chemistry'
|
- 'Summarize recent advances in solid-state battery chemistry'
|
||||||
|
|
||||||
|
variables:
|
||||||
|
- name: reflexion_budget
|
||||||
|
description: "Max automated critique->re-research passes before synthesis proceeds anyway. Default 2; raise it (4-8) for exhaustive runs that should churn until the critique passes."
|
||||||
|
default: '2'
|
||||||
|
- name: autonomy
|
||||||
|
description: "supervised (default): the finished report stops at the human approval gate. autonomous: skip the gate and return the report directly - REQUIRED when another agent spawns this one (a human gate would block the run)."
|
||||||
|
default: 'supervised'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
max_loop_iterations: 40
|
max_loop_iterations: 120
|
||||||
log_state_snapshots: false
|
log_state_snapshots: false
|
||||||
validate_before_run: true
|
validate_before_run: true
|
||||||
max_concurrency: 4
|
max_concurrency: 4
|
||||||
@@ -142,23 +155,11 @@ nodes:
|
|||||||
|
|
||||||
research_one_question:
|
research_one_question:
|
||||||
id: research_one_question
|
id: research_one_question
|
||||||
type: llm
|
type: agent
|
||||||
instructions: |
|
agent: librarian
|
||||||
You are a web research assistant. Investigate the SINGLE question
|
# The leaf research primitive: triage -> doc+OSS search -> cited
|
||||||
given to you using your tools: search the web, fetch and read
|
# synthesis. `inputs:` overlays the librarian graph's initial state,
|
||||||
pages, and search arXiv for academic sources.
|
# pinning research_depth to deep for every sub-question lane.
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Every factual claim must be backed by a real source you
|
|
||||||
actually retrieved. Never fabricate URLs, page titles,
|
|
||||||
authors, or DOIs.
|
|
||||||
- Prefer primary and authoritative sources over aggregators.
|
|
||||||
- Where sources disagree, report the disagreement rather than
|
|
||||||
papering over it.
|
|
||||||
- Put the URL (or DOI) inline next to each claim it supports.
|
|
||||||
|
|
||||||
Return organized findings in plain text. Do not include
|
|
||||||
meta-commentary about the process.
|
|
||||||
prompt: |
|
prompt: |
|
||||||
Research question: {{question}}
|
Research question: {{question}}
|
||||||
|
|
||||||
@@ -166,16 +167,13 @@ nodes:
|
|||||||
{{local_context}}
|
{{local_context}}
|
||||||
|
|
||||||
{{research_feedback}}
|
{{research_feedback}}
|
||||||
tools:
|
inputs:
|
||||||
- web_search_coyote
|
research_depth: deep
|
||||||
- fetch_url_via_curl
|
timeout: 1900
|
||||||
- search_arxiv
|
|
||||||
- mcp:ddg-search
|
|
||||||
max_iterations: 10
|
|
||||||
max_attempts: 2
|
max_attempts: 2
|
||||||
state_updates:
|
state_updates:
|
||||||
finding: '{{output}}'
|
finding: '{{output}}'
|
||||||
# On failure the engine writes "LLM node ...failed: <chain>" into `finding`
|
# On failure the engine writes "Agent node failed: <chain>" into `finding`
|
||||||
# via the state_updates above BEFORE routing to the fallback. The marker
|
# via the state_updates above BEFORE routing to the fallback. The marker
|
||||||
# is branch-local: a map branch's fallback must stay inside the branch.
|
# is branch-local: a map branch's fallback must stay inside the branch.
|
||||||
fallback: question_fault
|
fallback: question_fault
|
||||||
@@ -312,6 +310,13 @@ nodes:
|
|||||||
id: verify_sources
|
id: verify_sources
|
||||||
type: script
|
type: script
|
||||||
script: scripts/verify_sources.py
|
script: scripts/verify_sources.py
|
||||||
|
next: approval_gate
|
||||||
|
|
||||||
|
approval_gate:
|
||||||
|
id: approval_gate
|
||||||
|
type: script
|
||||||
|
description: Autonomy router - autonomous runs (spawned by another agent) skip the human approval gate and end accepted with the source check + pipeline notes attached; supervised (default) proceeds to the approval node. A crash fails forward via `next` to approve, the safe human-gated direction.
|
||||||
|
script: scripts/approval_gate.py
|
||||||
next: approve
|
next: approve
|
||||||
|
|
||||||
approve:
|
approve:
|
||||||
@@ -348,6 +353,15 @@ nodes:
|
|||||||
type: end
|
type: end
|
||||||
output: '{{report}}{{pipeline_notes}}'
|
output: '{{report}}{{pipeline_notes}}'
|
||||||
|
|
||||||
|
end_autonomous:
|
||||||
|
id: end_autonomous
|
||||||
|
type: end
|
||||||
|
output: |
|
||||||
|
{{report}}
|
||||||
|
|
||||||
|
----
|
||||||
|
{{source_check}}{{pipeline_notes}}
|
||||||
|
|
||||||
end_rejected:
|
end_rejected:
|
||||||
id: end_rejected
|
id: end_rejected
|
||||||
type: end
|
type: end
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Autonomy router for deep-research's approval gate.
|
||||||
|
|
||||||
|
Runs after verify_sources. When the `autonomy` variable is
|
||||||
|
'autonomous' (the run was spawned by another agent — architect or
|
||||||
|
sisyphus research tiers — with no human at the terminal), the report
|
||||||
|
skips the human approval node and ends accepted: the reflexion loop
|
||||||
|
already served as the quality gate, and end_autonomous carries the
|
||||||
|
source check + pipeline notes with the report so the caller sees them.
|
||||||
|
Any other value (default 'supervised') proceeds to the human approval
|
||||||
|
gate unchanged.
|
||||||
|
|
||||||
|
Routing (`_next`):
|
||||||
|
- autonomy == 'autonomous' -> end_autonomous
|
||||||
|
- anything else -> approve
|
||||||
|
|
||||||
|
Fail-safe: a crash here fails FORWARD via the node's `next` to
|
||||||
|
`approve` — the safe, human-gated direction.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
if path := os.environ.get("GRAPH_STATE_FILE"):
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
state = load_state()
|
||||||
|
autonomy = str(state.get("autonomy") or "supervised").strip().lower()
|
||||||
|
target = "end_autonomous" if autonomy == "autonomous" else "approve"
|
||||||
|
print(json.dumps({"_next": target}))
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -4,10 +4,11 @@
|
|||||||
Reached ONLY via research_one_question's `fallback:` route inside the
|
Reached ONLY via research_one_question's `fallback:` route inside the
|
||||||
`research_each_question` map branch (branch-local — a map branch's fallback
|
`research_each_question` map branch (branch-local — a map branch's fallback
|
||||||
must stay inside the branch subgraph). Grounded in engine behavior: on
|
must stay inside the branch subgraph). Grounded in engine behavior: on
|
||||||
llm-node failure the engine writes "LLM node …failed: <chain>" into
|
branch failure the engine writes its failure text into `finding` via the
|
||||||
`finding` via the node's state_updates BEFORE routing to the fallback (on
|
node's state_updates BEFORE routing to the fallback — "Agent node failed:
|
||||||
success the same key holds the researched findings, which never start with
|
<chain>" now that the branch spawns a librarian agent, "LLM node …failed:
|
||||||
the "LLM node" prefix). This node normalizes the failure text into a
|
<chain>" for an llm branch (on success the same key holds the researched
|
||||||
|
findings, which never start with either prefix). This node normalizes the failure text into a
|
||||||
PIPELINE-FAULT finding and the chain ends here — the map collects the fault
|
PIPELINE-FAULT finding and the chain ends here — the map collects the fault
|
||||||
as the lane's `finding`, so one dead research lane never sinks the whole
|
as the lane's `finding`, so one dead research lane never sinks the whole
|
||||||
map. combine_findings later lifts PIPELINE-FAULT findings into
|
map. combine_findings later lifts PIPELINE-FAULT findings into
|
||||||
@@ -53,7 +54,7 @@ def main():
|
|||||||
state = load_state()
|
state = load_state()
|
||||||
raw = state.get("finding")
|
raw = state.get("finding")
|
||||||
detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else ""
|
detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else ""
|
||||||
if not detail.startswith("LLM node"):
|
if not detail.startswith(("LLM node", "Agent node")):
|
||||||
detail = "the research lane died without recording a failure detail"
|
detail = "the research lane died without recording a failure detail"
|
||||||
if len(detail) > MAX_DETAIL_CHARS:
|
if len(detail) > MAX_DETAIL_CHARS:
|
||||||
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
|
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"""Reflexion gate for deep-research.
|
"""Reflexion gate for deep-research.
|
||||||
|
|
||||||
Runs after `critique` has reviewed the current research findings. If the
|
Runs after `critique` has reviewed the current research findings. If the
|
||||||
critique's verdict is REVISE and the reflexion budget is not spent,
|
critique's verdict is REVISE and the reflexion budget (the graph's
|
||||||
|
`reflexion_budget` variable, default 2) is not spent,
|
||||||
loops back to `research` with the critique attached as
|
loops back to `research` with the critique attached as
|
||||||
`research_feedback`, so the retry is informed rather than a blind
|
`research_feedback`, so the retry is informed rather than a blind
|
||||||
re-run. Otherwise it proceeds to `synthesize`.
|
re-run. Otherwise it proceeds to `synthesize`.
|
||||||
@@ -26,9 +27,11 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Automated revision passes allowed. `research` runs at most
|
# Default automated revision passes when the `reflexion_budget` variable
|
||||||
# MAX_REFLEXION_REVISIONS + 1 times per user pass. Bump to allow more.
|
# is absent or malformed. `research` runs at most budget + 1 times per
|
||||||
MAX_REFLEXION_REVISIONS = 2
|
# user pass. Callers raise it via --variables {"reflexion_budget": "6"}
|
||||||
|
# to let the run churn until the critique passes.
|
||||||
|
DEFAULT_REFLEXION_REVISIONS = 2
|
||||||
|
|
||||||
|
|
||||||
def load_state():
|
def load_state():
|
||||||
@@ -69,8 +72,9 @@ def main():
|
|||||||
critique = state.get("critique") or ""
|
critique = state.get("critique") or ""
|
||||||
verdict = parse_verdict(critique)
|
verdict = parse_verdict(critique)
|
||||||
attempts = as_int(state.get("research_attempts"))
|
attempts = as_int(state.get("research_attempts"))
|
||||||
|
budget = as_int(state.get("reflexion_budget"), DEFAULT_REFLEXION_REVISIONS)
|
||||||
|
|
||||||
if verdict == "REVISE" and attempts < MAX_REFLEXION_REVISIONS:
|
if verdict == "REVISE" and attempts < budget:
|
||||||
feedback = (
|
feedback = (
|
||||||
"A reviewer judged the previous research pass incomplete. "
|
"A reviewer judged the previous research pass incomplete. "
|
||||||
"Address every point in the critique below:\n\n" + critique
|
"Address every point in the critique below:\n\n" + critique
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ variables:
|
|||||||
settings:
|
settings:
|
||||||
max_loop_iterations: 8
|
max_loop_iterations: 8
|
||||||
log_state_snapshots: true
|
log_state_snapshots: true
|
||||||
timeout: 900
|
timeout: 3000
|
||||||
|
|
||||||
initial_state:
|
initial_state:
|
||||||
findings_list: []
|
findings_list: []
|
||||||
@@ -174,7 +174,7 @@ nodes:
|
|||||||
- fs_read
|
- fs_read
|
||||||
- fs_cat
|
- fs_cat
|
||||||
- fs_grep
|
- fs_grep
|
||||||
max_iterations: 8
|
max_iterations: 28
|
||||||
max_attempts: 2
|
max_attempts: 2
|
||||||
state_updates:
|
state_updates:
|
||||||
verdict: '{{output}}'
|
verdict: '{{output}}'
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ description: |
|
|||||||
preamble. The "external grep" sibling of explore (which handles
|
preamble. The "external grep" sibling of explore (which handles
|
||||||
internal/codebase grep). Designed to be fanned out 1-3 in parallel by
|
internal/codebase grep). Designed to be fanned out 1-3 in parallel by
|
||||||
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
|
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
|
||||||
involved.
|
involved. Supports research_depth=deep (via --variables) for broad
|
||||||
|
design-time topics: ~3x the search/OSS/fetch budgets of the standard
|
||||||
|
quick-lookup mode.
|
||||||
|
|
||||||
version: '1.0'
|
version: '1.0'
|
||||||
|
|
||||||
global_tools:
|
global_tools:
|
||||||
- web_search_coyote.sh
|
- web_search_coyote.sh
|
||||||
- fetch_url_via_curl.sh
|
- fetch_url_via_curl.sh
|
||||||
|
- search_arxiv.sh
|
||||||
|
|
||||||
mcp_servers:
|
mcp_servers:
|
||||||
- ddg-search
|
- ddg-search
|
||||||
@@ -26,11 +29,14 @@ variables:
|
|||||||
- name: project_dir
|
- name: project_dir
|
||||||
description: Project directory for context (unused in MVP but reserved for future iterations).
|
description: Project directory for context (unused in MVP but reserved for future iterations).
|
||||||
default: '.'
|
default: '.'
|
||||||
|
- name: research_depth
|
||||||
|
description: "standard (default) = quick targeted lookup budgets. deep = design-time research: ~3x search/OSS/fetch budgets for broad topics (best-practice checklists, architecture surveys)."
|
||||||
|
default: 'standard'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
max_loop_iterations: 12
|
max_loop_iterations: 12
|
||||||
log_state_snapshots: true
|
log_state_snapshots: true
|
||||||
timeout: 600
|
timeout: 1800
|
||||||
|
|
||||||
initial_state:
|
initial_state:
|
||||||
language_ecosystem: 'general'
|
language_ecosystem: 'general'
|
||||||
@@ -117,7 +123,7 @@ nodes:
|
|||||||
search:
|
search:
|
||||||
id: search
|
id: search
|
||||||
type: llm
|
type: llm
|
||||||
description: Identify 3-5 authoritative documentation sources via ddg-search.
|
description: Identify authoritative documentation sources via ddg-search (3-5 standard depth, 6-12 deep).
|
||||||
skills_enabled: true
|
skills_enabled: true
|
||||||
enabled_skills:
|
enabled_skills:
|
||||||
- ai-slop-remover
|
- ai-slop-remover
|
||||||
@@ -129,7 +135,10 @@ nodes:
|
|||||||
Priority order:
|
Priority order:
|
||||||
1. Official documentation - PRIORITIZE the hinted doc domains when
|
1. Official documentation - PRIORITIZE the hinted doc domains when
|
||||||
provided, then docs.X.org / readthedocs.io / MDN / vendor docs
|
provided, then docs.X.org / readthedocs.io / MDN / vendor docs
|
||||||
2. Specifications (RFCs, W3C, ECMA, IEEE)
|
2. Specifications (RFCs, W3C, ECMA, IEEE) and academic sources -
|
||||||
|
the search_arxiv tool is available; use it when the topic is
|
||||||
|
academic/research-flavored (arXiv/DOI links count as
|
||||||
|
authoritative)
|
||||||
3. Credible secondary references (PEPs, official blog posts) - only
|
3. Credible secondary references (PEPs, official blog posts) - only
|
||||||
if 1-2 are sparse
|
if 1-2 are sparse
|
||||||
|
|
||||||
@@ -142,14 +151,19 @@ nodes:
|
|||||||
|
|
||||||
## Search budget and fail-fast rules
|
## Search budget and fail-fast rules
|
||||||
|
|
||||||
You have a HARD BUDGET of 3 search calls total. After 3 calls, stop
|
Your budget is set by the `Research depth` line in the prompt:
|
||||||
calling tools and produce your final answer with whatever you have.
|
- standard -> HARD BUDGET of 3 search calls total.
|
||||||
|
- deep -> HARD BUDGET of 10 search calls total; spend the extra
|
||||||
|
calls covering DISTINCT subtopics of the research topic, never
|
||||||
|
re-running near-duplicate queries.
|
||||||
|
After the budget is spent, stop calling tools and produce your final
|
||||||
|
answer with whatever you have.
|
||||||
|
|
||||||
If a search returns "HTTP 202 Accepted", empty results, error messages,
|
If a search returns "HTTP 202 Accepted", empty results, error messages,
|
||||||
or rate-limit warnings: that counts as a used call. Do not retry the
|
or rate-limit warnings: that counts as a used call. Do not retry the
|
||||||
same query - either rephrase OR give up.
|
same query - either rephrase OR give up.
|
||||||
|
|
||||||
If after 3 calls you have NO usable URLs, output exactly:
|
If after spending the budget you have NO usable URLs, output exactly:
|
||||||
|
|
||||||
NO_AUTHORITATIVE_SOURCES_FOUND
|
NO_AUTHORITATIVE_SOURCES_FOUND
|
||||||
Reason: <one line>
|
Reason: <one line>
|
||||||
@@ -168,9 +182,11 @@ nodes:
|
|||||||
URL: <full url>
|
URL: <full url>
|
||||||
...
|
...
|
||||||
|
|
||||||
Output 3-5 source blocks. No prose intro, no closing summary.
|
Output 3-5 source blocks (standard depth) or 6-12 (deep). No prose
|
||||||
|
intro, no closing summary.
|
||||||
prompt: |
|
prompt: |
|
||||||
Research topic: {{initial_prompt}}
|
Research topic: {{initial_prompt}}
|
||||||
|
Research depth: {{research_depth}}
|
||||||
|
|
||||||
Triage hints:
|
Triage hints:
|
||||||
- Language/ecosystem: {{language_ecosystem}}
|
- Language/ecosystem: {{language_ecosystem}}
|
||||||
@@ -183,8 +199,9 @@ nodes:
|
|||||||
pathlib` style queries).
|
pathlib` style queries).
|
||||||
tools:
|
tools:
|
||||||
- mcp:ddg-search
|
- mcp:ddg-search
|
||||||
|
- search_arxiv
|
||||||
- web_search_coyote
|
- web_search_coyote
|
||||||
max_iterations: 15
|
max_iterations: 30
|
||||||
state_updates:
|
state_updates:
|
||||||
search_output: '{{output}}'
|
search_output: '{{output}}'
|
||||||
fallback: synthesize
|
fallback: synthesize
|
||||||
@@ -193,7 +210,7 @@ nodes:
|
|||||||
search_oss:
|
search_oss:
|
||||||
id: search_oss
|
id: search_oss
|
||||||
type: llm
|
type: llm
|
||||||
description: Find 2-3 production OSS examples relevant to the topic via the personal-github MCP.
|
description: Find production OSS examples relevant to the topic via the personal-github MCP (2-3 standard depth, 4-8 deep).
|
||||||
skills_enabled: true
|
skills_enabled: true
|
||||||
enabled_skills:
|
enabled_skills:
|
||||||
- ai-slop-remover
|
- ai-slop-remover
|
||||||
@@ -219,8 +236,12 @@ nodes:
|
|||||||
|
|
||||||
## Search budget and fail-fast rules
|
## Search budget and fail-fast rules
|
||||||
|
|
||||||
HARD BUDGET: 8 tool calls total. After 8 calls, stop and output what
|
Your budget is set by the `Research depth` line in the prompt:
|
||||||
you have - even one or two examples is fine.
|
- standard -> HARD BUDGET of 8 tool calls total; output 2-3 blocks.
|
||||||
|
- deep -> HARD BUDGET of 20 tool calls total; output 4-8 blocks
|
||||||
|
across DIFFERENT repos/approaches, not near-duplicates.
|
||||||
|
After the budget is spent, stop and output what you have - even one or
|
||||||
|
two examples is fine.
|
||||||
|
|
||||||
If you find no production examples, output exactly:
|
If you find no production examples, output exactly:
|
||||||
|
|
||||||
@@ -240,10 +261,12 @@ nodes:
|
|||||||
|
|
||||||
REPO: ...
|
REPO: ...
|
||||||
|
|
||||||
Output 2-3 blocks. The URL should point to a specific file that
|
Output 2-3 blocks (standard depth) or 4-8 (deep). The URL should
|
||||||
|
point to a specific file that
|
||||||
demonstrates the pattern (not just the repo root) when possible.
|
demonstrates the pattern (not just the repo root) when possible.
|
||||||
prompt: |
|
prompt: |
|
||||||
Research topic: {{initial_prompt}}
|
Research topic: {{initial_prompt}}
|
||||||
|
Research depth: {{research_depth}}
|
||||||
|
|
||||||
Triage hints:
|
Triage hints:
|
||||||
- Language/ecosystem: {{language_ecosystem}}
|
- Language/ecosystem: {{language_ecosystem}}
|
||||||
@@ -254,7 +277,7 @@ nodes:
|
|||||||
Filter to {{language_ecosystem}} repositories when the API allows.
|
Filter to {{language_ecosystem}} repositories when the API allows.
|
||||||
tools:
|
tools:
|
||||||
- mcp:personal-github
|
- mcp:personal-github
|
||||||
max_iterations: 15
|
max_iterations: 30
|
||||||
state_updates:
|
state_updates:
|
||||||
oss_output: '{{output}}'
|
oss_output: '{{output}}'
|
||||||
fallback: synthesize
|
fallback: synthesize
|
||||||
@@ -308,8 +331,10 @@ nodes:
|
|||||||
5. If a URL is dead, returns garbage, or is off-topic, note it
|
5. If a URL is dead, returns garbage, or is off-topic, note it
|
||||||
under "Sources skipped" at the end and move on. Do not retry.
|
under "Sources skipped" at the end and move on. Do not retry.
|
||||||
|
|
||||||
Budget: max 8 fetches total (across both source lists). Skip
|
Fetch budget, set by the `Research depth` line in the prompt:
|
||||||
aggressively.
|
- standard -> max 8 fetches total (across both source lists).
|
||||||
|
- deep -> max 25 fetches total. Depth buys MORE SOURCES, never
|
||||||
|
re-fetching dead or off-topic URLs - skip aggressively either way.
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
|
|
||||||
@@ -332,6 +357,7 @@ nodes:
|
|||||||
No flattery, no preamble. Start with `## Findings`.
|
No flattery, no preamble. Start with `## Findings`.
|
||||||
prompt: |
|
prompt: |
|
||||||
Research topic: {{initial_prompt}}
|
Research topic: {{initial_prompt}}
|
||||||
|
Research depth: {{research_depth}}
|
||||||
|
|
||||||
Documentation sources (from doc search branch):
|
Documentation sources (from doc search branch):
|
||||||
{{search_output}}
|
{{search_output}}
|
||||||
@@ -340,7 +366,7 @@ nodes:
|
|||||||
{{oss_output}}
|
{{oss_output}}
|
||||||
tools:
|
tools:
|
||||||
- fetch_url_via_curl
|
- fetch_url_via_curl
|
||||||
max_iterations: 20
|
max_iterations: 45
|
||||||
state_updates:
|
state_updates:
|
||||||
findings: '{{output}}'
|
findings: '{{output}}'
|
||||||
fallback: final_format
|
fallback: final_format
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ instructions: |
|
|||||||
|
|
||||||
## Rules
|
## 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
|
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
|
repo's suite conventions) and throwaway environment scaffolding you tear down. The
|
||||||
implementer owns all fixes.
|
implementer owns all fixes.
|
||||||
@@ -112,7 +120,15 @@ instructions: |
|
|||||||
update — never delete or silence it).
|
update — never delete or silence it).
|
||||||
4. **Clean, local, isolated.** Fresh ephemeral state, mocked externals, no dependence on
|
4. **Clean, local, isolated.** Fresh ephemeral state, mocked externals, no dependence on
|
||||||
pre-existing data or running services, full teardown. Bounded retries for startup only —
|
pre-existing data or running services, full teardown. Bounded retries for startup only —
|
||||||
never to mask a flaky assertion.
|
never to mask a flaky assertion. Teardown MUST NOT depend on a shell trap firing (a
|
||||||
|
killed or timed-out lane leaks containers — it has happened): pick one unique run
|
||||||
|
prefix up front (e.g. `probe-$$`), give EVERY container you create `--name <prefix>-…`
|
||||||
|
and `--label coyote-probe=<prefix>` (compose: `-p <prefix>`), prefer `docker run --rm`,
|
||||||
|
and make the LAST action before emitting your verdict an explicit sweep:
|
||||||
|
`docker ps -aq --filter label=coyote-probe=<prefix> | xargs -r docker rm -f` (compose:
|
||||||
|
`docker compose -p <prefix> down -v`). The label filter keeps the sweep scoped to YOUR
|
||||||
|
resources — rule 0 still forbids touching anyone else's. A leftover you cannot remove
|
||||||
|
is a finding to REPORT in the verdict, never to silence.
|
||||||
5. **Classify every failure** as BUG / EXPECTED-CHANGE / ENV per the skill table. The verdict
|
5. **Classify every failure** as BUG / EXPECTED-CHANGE / ENV per the skill table. The verdict
|
||||||
depends on the classification being honest.
|
depends on the classification being honest.
|
||||||
6. **Committed tests are the deliverable** alongside the verdict: write them where the repo's
|
6. **Committed tests are the deliverable** alongside the verdict: write them where the repo's
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ flowchart TD
|
|||||||
parse -. "fallback" .-> pfault["parse_fault<br/>(script: PIPELINE-FAULT marker)"]
|
parse -. "fallback" .-> pfault["parse_fault<br/>(script: PIPELINE-FAULT marker)"]
|
||||||
pfault --> gate
|
pfault --> gate
|
||||||
signals --> select["select_lanes<br/>(llm: ADDITIVE-only judgment)"]
|
signals --> select["select_lanes<br/>(llm: ADDITIVE-only judgment)"]
|
||||||
select --> build["build_items<br/>(script: rules floor ∪ additions;<br/>forced lanes override exactly)"]
|
select --> build["build_items<br/>(script: rules floor ∪ additions;<br/>forced lanes add)"]
|
||||||
select -. "fallback" .-> dlanes["default_lanes<br/>(script: deterministic degraded<br/>selection — wider, never narrower)"]
|
select -. "fallback" .-> dlanes["default_lanes<br/>(script: deterministic degraded<br/>selection — wider, never narrower)"]
|
||||||
dlanes --> build
|
dlanes --> build
|
||||||
build --> mcr["map: code-review"] & madv["map: adversary"] & msec["map: security"] & mpb["map: probe"]
|
build --> mcr["map: code-review"] & madv["map: adversary"] & msec["map: security"] & mpb["map: probe"]
|
||||||
@@ -155,7 +155,7 @@ Plan / acceptance criteria:
|
|||||||
Local-run recipe / usage suites:
|
Local-run recipe / usage suites:
|
||||||
<how to boot the service locally + where existing suites live — enables the probe lane>
|
<how to boot the service locally + where existing suites live — enables the probe lane>
|
||||||
|
|
||||||
Forced lanes: (optional — e.g. 'adversary, probe' to run exactly those)" \
|
Forced lanes: (optional, ADDITIVE — e.g. 'adversary, probe' run on top of the computed selection; forcing never drops the always-on floor)" \
|
||||||
--variables {"verification_commands": "[\"cargo test --all\", \"cargo clippy -- -D warnings\"]"}
|
--variables {"verification_commands": "[\"cargo test --all\", \"cargo clippy -- -D warnings\"]"}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -210,9 +210,9 @@ verbatim.
|
|||||||
(`'[]'` ⇒ "none declared"); a declaration that is not a JSON array of
|
(`'[]'` ⇒ "none declared"); a declaration that is not a JSON array of
|
||||||
strings executes nothing and records a `PIPELINE-FAULT`, which the
|
strings executes nothing and records a `PIPELINE-FAULT`, which the
|
||||||
adversary lane reports as DIVERGES and the gate as BLOCKED.
|
adversary lane reports as DIVERGES and the gate as BLOCKED.
|
||||||
- Signals failures degrade gracefully: if git can't compute the diff, lane
|
- Signals failures degrade WIDER: if git can't compute the diff, the surface
|
||||||
selection falls back to caller context + forced lanes, and the report notes
|
is unknown — the security lane is added (and probe, when a local-run recipe
|
||||||
it. The auth-path regex deliberately over-fires (`auth(?!or)` matches
|
exists) on top of caller context + forced lanes, and the report notes it. The auth-path regex deliberately over-fires (`auth(?!or)` matches
|
||||||
auth/authn/authz/oauth but not "author") — the safe direction, since signals
|
auth/authn/authz/oauth but not "author") — the safe direction, since signals
|
||||||
only ever ADD the security lane.
|
only ever ADD the security lane.
|
||||||
- Requires the `code-reviewer`, `adversary`, `security-reviewer`, and `probe`
|
- Requires the `code-reviewer`, `adversary`, `security-reviewer`, and `probe`
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ settings:
|
|||||||
timeout: 69600
|
timeout: 69600
|
||||||
|
|
||||||
initial_state:
|
initial_state:
|
||||||
|
retry_notes: []
|
||||||
diff_spec: worktree
|
diff_spec: worktree
|
||||||
plan_context: ''
|
plan_context: ''
|
||||||
rigor: production
|
rigor: production
|
||||||
@@ -106,16 +107,22 @@ nodes:
|
|||||||
You extract the facts of a review request into structured fields.
|
You extract the facts of a review request into structured fields.
|
||||||
EXTRACTION ONLY — do not decide which review lanes should run.
|
EXTRACTION ONLY — do not decide which review lanes should run.
|
||||||
|
|
||||||
- `diff_spec`: the git diff specification the caller named — a ref
|
- `diff_spec`: the git diff specification the caller named, as a BARE
|
||||||
range ("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
|
revision token usable verbatim in `git diff <spec>` — a ref range
|
||||||
"worktree" for uncommitted changes against HEAD. When the caller
|
("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
|
||||||
did not name one, use "worktree".
|
"worktree" for uncommitted changes against HEAD. NEVER prose, a
|
||||||
|
command, or a path: from "run get_diff --base origin/main" extract
|
||||||
|
"origin/main...HEAD"; from "diff origin/main...HEAD in ~/code/x"
|
||||||
|
extract "origin/main...HEAD". When the caller did not name one, use
|
||||||
|
"worktree".
|
||||||
- `project_dir_in`: the repository directory the caller named as holding
|
- `project_dir_in`: the repository directory the caller named as holding
|
||||||
the change (absolute path preferred); empty string when the caller
|
the change (absolute path preferred); empty string when the caller
|
||||||
named none.
|
named none.
|
||||||
- `plan_context`: the plan / spec / acceptance-criteria text the
|
- `plan_context`: the plan / spec / acceptance-criteria text the
|
||||||
implementation must satisfy, verbatim (or a file path the caller
|
implementation must satisfy, copied through VERBATIM — never
|
||||||
pointed at). Empty string when the caller supplied none.
|
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
|
- `rigor`: poc | prototype | production. Default "production" when
|
||||||
not stated.
|
not stated.
|
||||||
- `security_posture`: prototype | standard | hardened. Default
|
- `security_posture`: prototype | standard | hardened. Default
|
||||||
@@ -124,7 +131,8 @@ nodes:
|
|||||||
pointers for black-box probing (how to boot the service, where the
|
pointers for black-box probing (how to boot the service, where the
|
||||||
suites live). Empty string when the caller supplied none.
|
suites live). Empty string when the caller supplied none.
|
||||||
- `forced_lanes`: lanes the caller explicitly demanded (verbatim
|
- `forced_lanes`: lanes the caller explicitly demanded (verbatim
|
||||||
names). Empty array = automatic selection.
|
names). Empty array = automatic selection. Forcing is ADDITIVE
|
||||||
|
downstream: it widens the computed selection, never replaces it.
|
||||||
- `extra_context`: any remaining caller context reviewers should see,
|
- `extra_context`: any remaining caller context reviewers should see,
|
||||||
condensed.
|
condensed.
|
||||||
prompt: |
|
prompt: |
|
||||||
@@ -258,7 +266,7 @@ nodes:
|
|||||||
build_items:
|
build_items:
|
||||||
id: build_items
|
id: build_items
|
||||||
type: script
|
type: script
|
||||||
description: Deterministic lane builder — hard rules are the floor, LLM flags may only add, forced lanes override exactly. Emits one 0-or-1-item list per lane; an empty list skips that lane's map. On a re-run pass (retry_lanes non-empty) it skips selection and emits items only for those lanes, each carrying its attempt number.
|
description: Deterministic lane builder — hard rules are the floor, LLM flags may only add, forced lanes are ADDITIVE (selection only ever widens; unavailable signals widen it too). Emits one 0-or-1-item list per lane; an empty list skips that lane's map. On a re-run pass (retry_lanes non-empty) it skips selection and emits items only for those lanes, each carrying its attempt number.
|
||||||
script: scripts/build_items.py
|
script: scripts/build_items.py
|
||||||
timeout: 30
|
timeout: 30
|
||||||
next: [map_code_review, map_adversary, map_security, map_probe]
|
next: [map_code_review, map_adversary, map_security, map_probe]
|
||||||
@@ -404,6 +412,11 @@ nodes:
|
|||||||
The workspace may contain test files from an aborted earlier attempt of
|
The workspace may contain test files from an aborted earlier attempt of
|
||||||
this lane; reconcile rather than duplicate.
|
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
|
Derive expected behaviors from the plan/contract BEFORE reading
|
||||||
implementation code, run existing suites first, then author spec-first
|
implementation code, run existing suites first, then author spec-first
|
||||||
tests for the uncovered delta. Return your USAGE_PROBE verdict.
|
tests for the uncovered delta. Return your USAGE_PROBE verdict.
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Deterministic lane builder for the review gauntlet.
|
"""Deterministic lane builder for the review gauntlet.
|
||||||
|
|
||||||
Selection = hard rules (the floor) ∪ LLM additions; forced lanes override
|
Selection = hard rules (the floor) ∪ LLM additions ∪ caller-forced lanes.
|
||||||
everything exactly. Emits one 0-or-1-item list per lane: an empty list makes
|
Forcing is ADDITIVE: it can only widen the computed selection — honoring a
|
||||||
that lane's map node run zero branches (map-over-nothing), which IS the
|
forced list as the exact set once dropped the always-on code-review lane in
|
||||||
skip mechanism — no dynamic routing anywhere.
|
a live run. Emits one 0-or-1-item list per lane: an empty list makes that
|
||||||
|
lane's map node run zero branches (map-over-nothing), which IS the skip
|
||||||
|
mechanism — no dynamic routing anywhere.
|
||||||
|
|
||||||
The LLM's add_* flags can only ADD lanes. A flag that is false never
|
The LLM's add_* flags can only ADD lanes. A flag that is false never
|
||||||
removes a lane the hard rules selected.
|
removes a lane the hard rules selected.
|
||||||
|
|
||||||
|
Unavailable diff signals (malformed spec, git failure) mean the surface is
|
||||||
|
UNKNOWN — selection degrades WIDER (security on; probe when a recipe
|
||||||
|
exists), never narrower.
|
||||||
|
|
||||||
A re-run pass (retry_gate emitted `retry_lanes`) skips selection: only the
|
A re-run pass (retry_gate emitted `retry_lanes`) skips selection: only the
|
||||||
named lanes get an item, stamped with their attempt number, and every other
|
named lanes get an item, stamped with their attempt number, and every other
|
||||||
lane maps over nothing — retry_gate restores their earlier reports.
|
lane maps over nothing — retry_gate restores their earlier reports.
|
||||||
@@ -101,10 +107,6 @@ def main():
|
|||||||
(forced.append(canon) if canon else unknown.append(str(lane)))
|
(forced.append(canon) if canon else unknown.append(str(lane)))
|
||||||
|
|
||||||
reasons = []
|
reasons = []
|
||||||
if forced:
|
|
||||||
lanes = set(forced)
|
|
||||||
reasons.append(f"forced lanes honored exactly: {sorted(lanes)}")
|
|
||||||
else:
|
|
||||||
lanes = {"code-review"}
|
lanes = {"code-review"}
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"code-review: always on — the gauntlet is only spawned for non-trivial work"
|
"code-review: always on — the gauntlet is only spawned for non-trivial work"
|
||||||
@@ -128,6 +130,22 @@ def main():
|
|||||||
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists"
|
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Unavailable signals (malformed diff spec, git failure) = surface UNKNOWN.
|
||||||
|
# Degrade WIDER, never narrower — a run without signals once silently
|
||||||
|
# dropped the security and probe lanes.
|
||||||
|
if text("signals_summary") == "signals unavailable":
|
||||||
|
if "security" not in lanes:
|
||||||
|
lanes.add("security")
|
||||||
|
reasons.append(
|
||||||
|
"security: diff signals unavailable — surface unknown, degrading wider"
|
||||||
|
)
|
||||||
|
if "probe" not in lanes and text("probe_context"):
|
||||||
|
lanes.add("probe")
|
||||||
|
reasons.append(
|
||||||
|
"probe: diff signals unavailable and a local-run recipe/suite pointer "
|
||||||
|
"exists — degrading wider"
|
||||||
|
)
|
||||||
|
|
||||||
# LLM judgment is additive-only.
|
# LLM judgment is additive-only.
|
||||||
llm_why = text("lane_reasons")[:300]
|
llm_why = text("lane_reasons")[:300]
|
||||||
for flag, lane in (
|
for flag, lane in (
|
||||||
@@ -152,6 +170,28 @@ def main():
|
|||||||
lanes.add(lane)
|
lanes.add(lane)
|
||||||
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}")
|
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}")
|
||||||
|
|
||||||
|
# Caller-forced lanes are ADDITIVE: they widen the computed selection,
|
||||||
|
# never replace it. (Honoring them as the exact set dropped code-review —
|
||||||
|
# documented as always-on — and adversary in a live run.) Forcing also
|
||||||
|
# bypasses the context guards above: the caller demanded the lane, so it
|
||||||
|
# runs, with the expectation named in the summary.
|
||||||
|
if forced:
|
||||||
|
newly = sorted(set(forced) - lanes)
|
||||||
|
lanes |= set(forced)
|
||||||
|
reasons.append(
|
||||||
|
f"forced lanes (additive): {sorted(set(forced))}"
|
||||||
|
+ (f" — newly added: {newly}" if newly else " — all already selected")
|
||||||
|
)
|
||||||
|
if "probe" in forced and not text("probe_context"):
|
||||||
|
reasons.append(
|
||||||
|
"probe: forced without a local-run recipe/suite pointer — expect INCONCLUSIVE"
|
||||||
|
)
|
||||||
|
if "adversary" in forced and not text("plan_context"):
|
||||||
|
reasons.append(
|
||||||
|
"adversary: forced without spec/plan context — it fails closed "
|
||||||
|
"(DIVERGES) without extractable criteria"
|
||||||
|
)
|
||||||
|
|
||||||
if unknown:
|
if unknown:
|
||||||
reasons.append(f"ignored unknown forced lane name(s): {unknown}")
|
reasons.append(f"ignored unknown forced lane name(s): {unknown}")
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ AND a local-run recipe exists (build_items gates probe the same way — a
|
|||||||
probe without a recipe is INCONCLUSIVE by construction),
|
probe without a recipe is INCONCLUSIVE by construction),
|
||||||
plus security when the attack-surface signals (auth/deps/exec) or a hardened
|
plus security when the attack-surface signals (auth/deps/exec) or a hardened
|
||||||
posture demand it, unioned with any lanes the caller forced (canonicalized
|
posture demand it, unioned with any lanes the caller forced (canonicalized
|
||||||
with the same aliases build_items uses) — which build_items honors exactly,
|
with the same aliases build_items uses) — which build_items honors
|
||||||
so selection degrades WIDER, never narrower. The degradation note goes into
|
ADDITIVELY, so selection degrades WIDER, never narrower. Unavailable diff
|
||||||
|
signals also widen: unknown surface adds security (and probe when a
|
||||||
|
local-run recipe exists). The degradation note goes into
|
||||||
`lanes_degraded`; build_items folds it into lanes_summary so the final
|
`lanes_degraded`; build_items folds it into lanes_summary so the final
|
||||||
report names the degradation.
|
report names the degradation.
|
||||||
|
|
||||||
@@ -72,6 +74,11 @@ def main():
|
|||||||
or state.get("security_posture") == "hardened"
|
or state.get("security_posture") == "hardened"
|
||||||
):
|
):
|
||||||
lanes.add("security")
|
lanes.add("security")
|
||||||
|
# Unavailable signals = surface UNKNOWN → widen (mirrors build_items).
|
||||||
|
if (state.get("signals_summary") or "") == "signals unavailable":
|
||||||
|
lanes.add("security")
|
||||||
|
if isinstance(probe_context, str) and probe_context.strip():
|
||||||
|
lanes.add("probe")
|
||||||
# Caller-forced lanes survive degradation — union, never overwrite.
|
# Caller-forced lanes survive degradation — union, never overwrite.
|
||||||
for lane in state.get("forced_lanes") or []:
|
for lane in state.get("forced_lanes") or []:
|
||||||
if canon := ALIASES.get(str(lane).strip().lower()):
|
if canon := ALIASES.get(str(lane).strip().lower()):
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ hard rules:
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# 69600 (graph timeout) − 2 × 11400 (largest lane envelope) − 1200 (script stages)
|
# 69600 (graph timeout) − 2 × 11400 (largest lane envelope) − 1200 (script stages)
|
||||||
@@ -184,6 +185,26 @@ def main():
|
|||||||
"review_incomplete": incomplete,
|
"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:
|
if retry:
|
||||||
out["_next"] = "build_items"
|
out["_next"] = "build_items"
|
||||||
print(json.dumps(out))
|
print(json.dumps(out))
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
Pure git — no LLM judgment. Every signal is computed from the changed-file
|
Pure git — no LLM judgment. Every signal is computed from the changed-file
|
||||||
list and the added lines of the diff. Failures never sink the gauntlet:
|
list and the added lines of the diff. Failures never sink the gauntlet:
|
||||||
on any error the script reports `signals_error` and leaves the defaults
|
on any error the script reports `signals_error` and marks the signals
|
||||||
(false/empty) in place, which biases lane selection toward the caller's
|
unavailable — downstream (build_items / default_lanes) treats that as
|
||||||
context and forced lanes.
|
UNKNOWN surface and degrades WIDER (security on; probe when a recipe
|
||||||
|
exists), on top of caller context and forced lanes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -26,6 +27,19 @@ proj = os.path.expanduser(
|
|||||||
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
|
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
|
||||||
)
|
)
|
||||||
spec = (state.get("diff_spec") or "worktree").strip()
|
spec = (state.get("diff_spec") or "worktree").strip()
|
||||||
|
spec_raw = spec
|
||||||
|
# The parse stage should emit a bare revision token, but callers write prose
|
||||||
|
# and extraction drifts ("run get_diff --base origin/main" once reached
|
||||||
|
# `git diff` verbatim, killing the signals — and with them the security and
|
||||||
|
# probe lanes). Deterministically recover a usable spec before giving up.
|
||||||
|
_TOKEN = r"[\w./~^@{}-]+"
|
||||||
|
if spec not in ("", "worktree") and not re.fullmatch(
|
||||||
|
rf"{_TOKEN}(\.\.\.?{_TOKEN})?", spec
|
||||||
|
):
|
||||||
|
if m := re.search(rf"({_TOKEN}\.\.\.?{_TOKEN})", spec):
|
||||||
|
spec = m.group(1) # a revision range buried in prose
|
||||||
|
elif m := re.search(rf"--base[= ]({_TOKEN})", spec):
|
||||||
|
spec = f"{m.group(1)}...HEAD" # the get_diff idiom
|
||||||
|
|
||||||
|
|
||||||
def git(*args):
|
def git(*args):
|
||||||
@@ -38,6 +52,25 @@ def git(*args):
|
|||||||
|
|
||||||
|
|
||||||
out = {"project_dir": proj}
|
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
|
# 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.
|
# before the git work so a failed diff still starts the clock.
|
||||||
if not state.get("gauntlet_started_at"):
|
if not state.get("gauntlet_started_at"):
|
||||||
@@ -93,13 +126,18 @@ try:
|
|||||||
),
|
),
|
||||||
"docs_only": bool(files)
|
"docs_only": bool(files)
|
||||||
and all(re.search(r"\.(md|rst|txt|adoc)$", f, re.I) for f in files),
|
and all(re.search(r"\.(md|rst|txt|adoc)$", f, re.I) for f in files),
|
||||||
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})",
|
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})"
|
||||||
|
+ (f" — spec normalized from {spec_raw!r}" if spec != spec_raw else ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as e: # noqa: BLE001 — degraded signals must not sink the gauntlet
|
except Exception as e: # noqa: BLE001 — degraded signals must not sink the gauntlet
|
||||||
|
what = f"diff spec {spec!r}" + (
|
||||||
|
f", normalized from {spec_raw!r}" if spec != spec_raw else ""
|
||||||
|
)
|
||||||
out["signals_error"] = (
|
out["signals_error"] = (
|
||||||
f"NOTE: diff signals could not be computed ({e}); "
|
f"NOTE: diff signals could not be computed ({what}: {e}); surface UNKNOWN — "
|
||||||
"lane selection falls back to caller context and forced lanes."
|
"lane selection degrades WIDER (security lane added; probe eligible when a "
|
||||||
|
"local-run recipe exists), plus caller context and forced lanes."
|
||||||
)
|
)
|
||||||
out["signals_summary"] = "signals unavailable"
|
out["signals_summary"] = "signals unavailable"
|
||||||
|
|
||||||
|
|||||||
@@ -360,6 +360,11 @@ def main():
|
|||||||
+ [f"| {lane} | {status} | {detail} |" for lane, (status, detail) in rows.items()]
|
+ [f"| {lane} | {status} | {detail} |" for lane, (status, detail) in rows.items()]
|
||||||
)
|
)
|
||||||
md += "\n\n## Lane selection\n" + (state.get("lanes_summary") or "(none recorded)")
|
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:
|
if blockers:
|
||||||
md += "\n\n## Blockers\n" + "\n".join(f"- {b}" for b in blockers)
|
md += "\n\n## Blockers\n" + "\n".join(f"- {b}" for b in blockers)
|
||||||
if attention:
|
if attention:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: sisyphus
|
name: sisyphus
|
||||||
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline
|
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline
|
||||||
version: 3.13.0
|
version: 3.17.0
|
||||||
|
|
||||||
agent_session: temp
|
agent_session: temp
|
||||||
auto_continue: true
|
auto_continue: true
|
||||||
@@ -11,6 +11,7 @@ can_spawn_agents: true
|
|||||||
spawnable_agents:
|
spawnable_agents:
|
||||||
- explore
|
- explore
|
||||||
- librarian
|
- librarian
|
||||||
|
- deep-research
|
||||||
- coder
|
- coder
|
||||||
- oracle
|
- oracle
|
||||||
- code-reviewer
|
- code-reviewer
|
||||||
@@ -157,7 +158,8 @@ instructions: |
|
|||||||
| Agent | Use For | Characteristics |
|
| Agent | Use For | Characteristics |
|
||||||
|-------|---------|-----------------|
|
|-------|---------|-----------------|
|
||||||
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 5–15+. |
|
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 5–15+. |
|
||||||
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. |
|
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. Default depth = quick-lookup budgets; for a BROAD topic (design research, best-practice surveys, architecture comparisons) pass `--variables {"research_depth": "deep"}` (~3× search/fetch budgets). Still prefer several narrow librarians over one giant one — deep is for questions that are inherently broad, not a substitute for fan-out. |
|
||||||
|
| `deep-research` | Open-ended investigation that must CHURN until solid — plans sub-questions, fans out a deep librarian per question, vets source credibility, runs a critique→re-research loop | Spawn with `--variables {"autonomy": "autonomous"}` — MANDATORY when spawned as a subagent (the supervised default blocks on a human approval gate). Raise `reflexion_budget` (e.g. "6", default 2) to let it churn longer. Expect hours, not minutes; one per investigation — its internal map is the fan-out. |
|
||||||
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
|
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
|
||||||
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
|
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
|
||||||
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
|
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
|
||||||
@@ -314,6 +316,9 @@ instructions: |
|
|||||||
Diff: <git range like main...HEAD, or 'worktree' for uncommitted changes>
|
Diff: <git range like main...HEAD, or 'worktree' for uncommitted changes>
|
||||||
Rigor: <rigor> Security posture: <posture>
|
Rigor: <rigor> Security posture: <posture>
|
||||||
Plan / acceptance criteria: <pasted verbatim — enables the adversary lane; omit when none>
|
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>"
|
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\"]"}
|
--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>
|
<— the cargo commands above are only an EXAMPLE: replace them with THIS project's exact build/test/lint commands; never guess, and pass "[]" when none apply>
|
||||||
@@ -322,6 +327,12 @@ instructions: |
|
|||||||
Verification commands go in `--variables` (a JSON-array string), never in the prompt: the prompt
|
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
|
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.
|
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
|
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
|
spec/plan is present; security on attack-surface signals; probe on consumer-facing surface with a
|
||||||
@@ -566,7 +577,7 @@ instructions: |
|
|||||||
|
|
||||||
When you write or modify files yourself (rather than delegating to coder):
|
When you write or modify files yourself (rather than delegating to coder):
|
||||||
|
|
||||||
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment.
|
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment. In every register, a comment must EARN ITS KEEP — if the code already tells the reader, don't add it.
|
||||||
- **Calibrate logging before writing.** When the change touches boundaries, error paths, jobs, or state transitions, load `logging-discipline` and note the repo's logging register (logger, message style, payload vs IDs, level semantics) from the same sibling reads; match it. No discernible convention → its best-judgment defaults. Never leave a new error path silently swallowed, and never delete existing log lines as drive-by cleanup.
|
- **Calibrate logging before writing.** When the change touches boundaries, error paths, jobs, or state transitions, load `logging-discipline` and note the repo's logging register (logger, message style, payload vs IDs, level semantics) from the same sibling reads; match it. No discernible convention → its best-judgment defaults. Never leave a new error path silently swallowed, and never delete existing log lines as drive-by cleanup.
|
||||||
- **For editing an existing file**, prefer `fs_patch`. It's a surgical edit that preserves unchanged content. Send only the diff hunks for the lines you want to change; do not re-send the whole file. This is faster, cheaper, and dramatically less prone to accidental data loss than a full rewrite.
|
- **For editing an existing file**, prefer `fs_patch`. It's a surgical edit that preserves unchanged content. Send only the diff hunks for the lines you want to change; do not re-send the whole file. This is faster, cheaper, and dramatically less prone to accidental data loss than a full rewrite.
|
||||||
- **For writing a NEW file or doing a COMPLETE rewrite**, use `fs_write`. Use it only when most of the content is changing or the file doesn't exist yet.
|
- **For writing a NEW file or doing a COMPLETE rewrite**, use `fs_write`. Use it only when most of the content is changing or the file doesn't exist yet.
|
||||||
@@ -691,6 +702,23 @@ instructions: |
|
|||||||
and what changed (including any plan amendment). Zero autonomous decisions → omit the section.
|
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.
|
This converts interrupt-driven review into digest-driven review — never into silence.
|
||||||
|
|
||||||
|
**The PR review log (MANDATORY when you author a PR).** The PR DESCRIPTION is a stable
|
||||||
|
document: what the PR does, how to verify it, and the required digest sections (autonomous
|
||||||
|
decisions, skipped reviews, observability). Every review round's evidence — the gauntlet
|
||||||
|
lane table and verdicts, the gates you ran, rulings recorded that round, and what changed
|
||||||
|
since the previous round — is posted as a NEW PR comment (`gh pr comment`) titled
|
||||||
|
`## Review round N @ <full head SHA>`. NEVER record round evidence by rewriting the
|
||||||
|
description: in-place edits destroy the review history and hide which commits each round
|
||||||
|
actually reviewed. Comments are the append-only review log; the SHA in the title names
|
||||||
|
exactly what was reviewed.
|
||||||
|
The same discipline covers EVERY later push to a PR you (or your run) opened — addressing
|
||||||
|
human review comments, a reviewer bot's findings, CI failures, or any other PR event: post
|
||||||
|
a NEW top-level PR comment titled `## Update @ <full head SHA>` mapping each addressed
|
||||||
|
comment/finding to the commit that addresses it (one line each), plus anything
|
||||||
|
deliberately NOT addressed and why. NEVER append update summaries to the description.
|
||||||
|
Edit the description ONLY when the PR's actual scope or behavior changed enough that it
|
||||||
|
would otherwise misdescribe the PR — it stays a description, never a changelog.
|
||||||
|
|
||||||
**Close the loop.** When a decision DID reach the user (step 4), suggest in your final report
|
**Close the loop.** When a decision DID reach the user (step 4), suggest in your final report
|
||||||
that they record the resolution with `coyote --macro record-ruling` so that class of decision
|
that they record the resolution with `coyote --macro record-ruling` so that class of decision
|
||||||
never asks again.
|
never asks again.
|
||||||
|
|||||||
@@ -149,6 +149,18 @@ mcp_tools: null # Per-server MCP tool allowlists (glob patterns
|
|||||||
# - list_*
|
# - list_*
|
||||||
# slack: []
|
# 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) ----
|
# ---- Auto-Continue (Todo System) ----
|
||||||
# The auto-continue system provides built-in task tracking for improved reliability.
|
# 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
|
# When enabled, the model can create todo lists and the system will automatically
|
||||||
@@ -174,6 +186,7 @@ save_session: null # Controls the persistence of the session. If t
|
|||||||
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
|
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
|
||||||
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
|
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
|
||||||
compression_model: null # Model to use for session compression (e.g. 'openai:gpt-4o-mini'); defaults to the session's current model
|
compression_model: null # Model to use for session compression (e.g. 'openai:gpt-4o-mini'); defaults to the session's current model
|
||||||
|
compression_safety_valve: 0.8 # Force compression when the last real request's prompt size (or the local estimate) reaches this fraction of the model's max_input_tokens, even if compression_threshold would not fire (0 = disabled)
|
||||||
summarization_prompt: null # The text prompt used for creating a concise summary of session messages. If null, uses built-in default
|
summarization_prompt: null # The text prompt used for creating a concise summary of session messages. If null, uses built-in default
|
||||||
summary_context_prompt: null # The text prompt used for including the summary of the entire session as context to the model. If null, uses built-in default
|
summary_context_prompt: null # The text prompt used for including the summary of the entire session as context to the model. If null, uses built-in default
|
||||||
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
|
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
|
||||||
@@ -264,6 +277,7 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons
|
|||||||
# All clients have the following configuration:
|
# All clients have the following configuration:
|
||||||
# - type: xxxx
|
# - type: xxxx
|
||||||
# name: xxxx # Only use it to distinguish clients with the same client type. Optional
|
# name: xxxx # Only use it to distinguish clients with the same client type. Optional
|
||||||
|
# extend_models: true # Merge the models below on top of the catalog models for this client instead of replacing them. Optional, default false
|
||||||
# models:
|
# models:
|
||||||
# - name: xxxx # Chat model
|
# - name: xxxx # Chat model
|
||||||
# max_input_tokens: 100000
|
# max_input_tokens: 100000
|
||||||
@@ -290,4 +304,28 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons
|
|||||||
#
|
#
|
||||||
# The claude, bedrock, and vertexai clients also support:
|
# The claude, bedrock, and vertexai clients also support:
|
||||||
# prompt_cache: true # Enable Anthropic prompt caching (default: true)
|
# 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__
|
__CLIENTS_BLOCK__
|
||||||
@@ -23,10 +23,16 @@ set -e
|
|||||||
# - perplexity:*
|
# - perplexity:*
|
||||||
# - ernie:*
|
# - ernie:*
|
||||||
# - claude:* (Anthropic native web_search server tool)
|
# - claude:* (Anthropic native web_search server tool)
|
||||||
# - openai:gpt-4o-search-preview (and -mini-; requires an api-key openai
|
# - openai:gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4*
|
||||||
# client — the codex OAuth path uses the
|
# OpenAI serves web search through two different mechanisms depending on
|
||||||
# Responses API where this parameter
|
# the wire API the client resolves (the script cannot pick the wire):
|
||||||
# does not exist)
|
# 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 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
|
# @env LLM_OUTPUT=/dev/stdout The output path
|
||||||
|
|
||||||
@@ -34,7 +40,7 @@ set -e
|
|||||||
supports_native_search() {
|
supports_native_search() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
gemini:* | vertexai:gemini-* | perplexity:* | ernie:* | claude:*) return 0 ;;
|
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 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -46,7 +52,8 @@ export_search_patch() {
|
|||||||
COYOTE_PATCH_VERTEXAI_CHAT_COMPLETIONS \
|
COYOTE_PATCH_VERTEXAI_CHAT_COMPLETIONS \
|
||||||
COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS \
|
COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS \
|
||||||
COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS \
|
COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS \
|
||||||
COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS
|
COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS \
|
||||||
|
COYOTE_PATCH_OPENAI_RESPONSES
|
||||||
|
|
||||||
case "${1%%:*}" in
|
case "${1%%:*}" in
|
||||||
gemini)
|
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}]}}}'
|
export COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS='{".*":{"body":{"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}}}'
|
||||||
;;
|
;;
|
||||||
openai)
|
openai)
|
||||||
# Chat Completions native search exists only on the search-preview
|
# Both patches are exported; request patching is wire-aware, so the
|
||||||
# models; the regex scopes the patch so other OpenAI models run
|
# client's resolved wire applies exactly one of them. Each regex
|
||||||
# unpatched instead of erroring on an unsupported parameter.
|
# 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_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
|
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
|
# shellcheck disable=SC2154
|
||||||
run_search() {
|
run_search() {
|
||||||
export_search_patch "$1"
|
export_search_patch "$1"
|
||||||
|
case "$1" in
|
||||||
|
openai:*)
|
||||||
|
run_openai_search "$1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT"
|
coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
|||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Event-logging hook: appends a timestamped snapshot of every COYOTE_* env var
|
||||||
|
# to a log file, so you can see exactly which events fire and what context
|
||||||
|
# each one carries.
|
||||||
|
#
|
||||||
|
# Env vars read:
|
||||||
|
# COYOTE_HOOK_LOG log file to append to
|
||||||
|
# (default: ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log)
|
||||||
|
# COYOTE_* everything coyote sets for the event is dumped verbatim
|
||||||
|
#
|
||||||
|
# Secrets (COYOTE_SECRET_*) are excluded from the snapshot; widening the grep
|
||||||
|
# filter below opts into logging them.
|
||||||
|
#
|
||||||
|
# Wire it up under `hooks:` in your config.yaml (any event works):
|
||||||
|
# hooks:
|
||||||
|
# tool.completed:
|
||||||
|
# - name: log-events
|
||||||
|
# command: ./hooks/log-events.sh
|
||||||
|
#
|
||||||
|
# A relative command path like ./hooks/log-events.sh resolves against the
|
||||||
|
# coyote config directory for global hooks, so this works with the default
|
||||||
|
# hooks dir.
|
||||||
|
#
|
||||||
|
# See https://github.com/Dark-Alex-17/coyote/wiki/Hooks for the full list of
|
||||||
|
# events and env vars. Hooks are fire-and-forget: this script never fails
|
||||||
|
# loudly, and coyote ignores its exit code either way.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
log_file="${COYOTE_HOOK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log}"
|
||||||
|
|
||||||
|
# COYOTE_HOOK_LOG may point into a shared directory: refuse to append through
|
||||||
|
# a symlink someone else planted there.
|
||||||
|
[ -L "$log_file" ] && exit 0
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$(dirname "$log_file")" 2> /dev/null || true
|
||||||
|
{
|
||||||
|
echo "=== $(date -u '+%Y-%m-%dT%H:%M:%SZ') ${COYOTE_EVENT:-unknown}"
|
||||||
|
env | grep '^COYOTE_' | grep -v '^COYOTE_SECRET_' | sort
|
||||||
|
echo
|
||||||
|
} >> "$log_file" 2> /dev/null || true
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Desktop-notification hook: posts a notification describing the event that
|
||||||
|
# fired, falling back to a message on the controlling terminal when no
|
||||||
|
# notifier is present.
|
||||||
|
#
|
||||||
|
# Env vars read (set by coyote when the hook fires):
|
||||||
|
# COYOTE_EVENT dotted event name, e.g. turn.completed
|
||||||
|
# COYOTE_AGENT_NAME agent name, when the event carries one
|
||||||
|
# COYOTE_TOOL_NAME tool name, when the event carries one
|
||||||
|
#
|
||||||
|
# Wire it up under `hooks:` in your config.yaml:
|
||||||
|
# hooks:
|
||||||
|
# turn.completed:
|
||||||
|
# - name: notify
|
||||||
|
# command: ./hooks/notify.sh
|
||||||
|
#
|
||||||
|
# A relative command path like ./hooks/notify.sh resolves against the coyote
|
||||||
|
# config directory for global hooks, so this works with the default hooks dir.
|
||||||
|
#
|
||||||
|
# See https://github.com/Dark-Alex-17/coyote/wiki/Hooks for the full list of
|
||||||
|
# events and env vars. Hooks are fire-and-forget: this script never fails
|
||||||
|
# loudly, and coyote ignores its exit code either way.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
title="coyote"
|
||||||
|
body="${COYOTE_EVENT:-event}"
|
||||||
|
[ -n "${COYOTE_AGENT_NAME:-}" ] && body="$body agent=$COYOTE_AGENT_NAME"
|
||||||
|
[ -n "${COYOTE_TOOL_NAME:-}" ] && body="$body tool=$COYOTE_TOOL_NAME"
|
||||||
|
|
||||||
|
if command -v notify-send > /dev/null 2>&1; then
|
||||||
|
notify-send "$title" "$body" 2> /dev/null || true
|
||||||
|
elif command -v osascript > /dev/null 2>&1; then
|
||||||
|
# $body carries model-influenced values (e.g. tool names); strip quotes and
|
||||||
|
# backslashes so it cannot escape the AppleScript string literal.
|
||||||
|
safe_body=${body//[\"\\]/}
|
||||||
|
osascript -e "display notification \"$safe_body\" with title \"$title\"" 2> /dev/null || true
|
||||||
|
else
|
||||||
|
# coyote discards hook stdout, so aim the fallback at the controlling
|
||||||
|
# terminal when one exists; plain stdout keeps direct/manual runs working.
|
||||||
|
# $body carries model-influenced values: strip control bytes (including
|
||||||
|
# ESC, so ANSI/OSC sequences cannot drive the terminal) before echoing.
|
||||||
|
safe_line="$(printf '%s' "[$title] $body" | tr -d '\000-\010\013\014\016-\037\177')"
|
||||||
|
{ echo "$safe_line" > /dev/tty; } 2> /dev/null || echo "$safe_line"
|
||||||
|
fi
|
||||||
@@ -13,7 +13,7 @@ description: >
|
|||||||
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
||||||
|
|
||||||
sandbox:
|
sandbox:
|
||||||
image: 'darkalex17/coyote:v0.10.2'
|
image: 'darkalex17/coyote:v0.10.3'
|
||||||
entrypoint: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
entrypoint: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ These are review findings that only surface in a diff context, not in a whole-fi
|
|||||||
finding (dead weight or a forgotten wiring step). Used in SOME applicable in-diff sites but
|
finding (dead weight or a forgotten wiring step). Used in SOME applicable in-diff sites but
|
||||||
not others = a finding naming the sites that didn't adopt it — half-adopted artifacts are how
|
not others = a finding naming the sites that didn't adopt it — half-adopted artifacts are how
|
||||||
two mechanisms for the same job end up coexisting forever.
|
two mechanisms for the same job end up coexisting forever.
|
||||||
|
- **"Ignored / redundant request field" claims need a wire-validation check first** — before
|
||||||
|
calling a request/message field ignored, redundant, or dead on some code path because the
|
||||||
|
handler never reads it there, `fs_grep` the boundary contract for that field: proto validation
|
||||||
|
rules (`(validate.rules)`, `buf.validate`), OpenAPI/JSON-schema `required`, struct-tag
|
||||||
|
`required`/`binding`/serde attributes, or a hand-written validator/interceptor. A field the
|
||||||
|
validator MANDATES is load-bearing at the boundary even when the handler ignores it — the
|
||||||
|
honest finding (if any) is "validator requires `<field>` on the `<path>` path that never reads
|
||||||
|
it (`path:line`); relax with `ignore_empty`/`oneof` or document why", never "`<field>` is
|
||||||
|
ignored". No validation found ⇒ the ignored-field finding stands, citing the grep you ran.
|
||||||
- **Version-literal consistency and freshness** — when the diff bumps a version (image tag,
|
- **Version-literal consistency and freshness** — when the diff bumps a version (image tag,
|
||||||
tool version, dependency pin), `fs_grep` the repo for other occurrences of the OLD version
|
tool version, dependency pin), `fs_grep` the repo for other occurrences of the OLD version
|
||||||
string (compose files, workflows, docs, sibling Dockerfiles) — stragglers are findings. For
|
string (compose files, workflows, docs, sibling Dockerfiles) — stragglers are findings. For
|
||||||
@@ -143,6 +152,21 @@ Named adequacy anti-patterns — each is a finding even when coverage looks gree
|
|||||||
- **Leaky fixture** — an integration test against a shared/persistent database inserts fixture
|
- **Leaky fixture** — an integration test against a shared/persistent database inserts fixture
|
||||||
rows directly with no paired cleanup (deferred delete/teardown/transaction rollback). The
|
rows directly with no paired cleanup (deferred delete/teardown/transaction rollback). The
|
||||||
test passes today and leaves a primary-key landmine for the next run.
|
test passes today and leaves a primary-key landmine for the next run.
|
||||||
|
- **Struct-typed args against an inferred schema** — when a struct drives an inferred wire schema
|
||||||
|
(MCP go-sdk, OpenAPI/JSON-schema generators, serde-derived schemas), every field without
|
||||||
|
`omitempty`/pointer/`Option` becomes schema-REQUIRED. Cross-check the optionality the doc
|
||||||
|
strings promise ("X as an alternative to Y") against the tags — a documented-optional field
|
||||||
|
without the omission marker is a live validation bug, not a style nit. And tests that build the
|
||||||
|
request from the typed struct serialize EVERY field (zero values included), so they can never
|
||||||
|
exercise the required-ness check: demand raw-JSON / map-typed tests that OMIT each
|
||||||
|
documented-optional field.
|
||||||
|
- **Relaxed call count with a permissive matcher** — a mock expectation whose COUNT is relaxed
|
||||||
|
(`AnyTimes()`, `MinTimes(1)`, `Maybe()`, "called at least once") leaves the ARGUMENT matcher as
|
||||||
|
the only thing asserted; pair it with `gomock.Any()` / `mock.Anything` / `mock.ANY` /
|
||||||
|
`expect.anything()` / an ignored request body and the expectation proves nothing — a call with
|
||||||
|
the wrong ID, wrong quantity, or wrong principal satisfies it. Flag every relaxed-count
|
||||||
|
expectation whose matcher is permissive; the fix pins the load-bearing fields in the matcher
|
||||||
|
(or exact count + captured args).
|
||||||
|
|
||||||
## 3. Clarity
|
## 3. Clarity
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
description: Calibrate comment density and style to the repository's existing conventions before writing code. Detects the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you already read for pattern matching, or from a declared policy in workspace instructions, then dictates when a comment is warranted. Default when signal is weak - write NO comment. Complements ai-slop-remover (which bans comments that restate code in every register).
|
description: Calibrate comment density and style to the repository's existing conventions before writing code. Detects the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you already read for pattern matching, or from a declared policy in workspace instructions, then dictates when a comment is warranted. Default when signal is weak - write NO comment. Complements ai-slop-remover (which bans comments that restate code in every register).
|
||||||
---
|
---
|
||||||
You are about to write or modify code. LLMs systematically over-comment — narrating every block, restating signatures, banner-ing sections — and that default is wrong in most repositories. Before writing, determine the repo's **comment register** and match it, exactly the way you already match imports, naming, and error handling.
|
You are about to write or modify code. LLMs systematically over-comment — narrating every block, restating signatures, banner-ing sections — and that default is wrong in most repositories. Before writing, determine the repo's **comment register** and match it, exactly the way you already match imports, naming, and error handling. Across every register, one test governs each comment you write: it must **earn its keep**. If the code already tells the reader, the comment doesn't get written — don't add comments you don't need; make the code tell them instead.
|
||||||
|
|
||||||
## Step 0: Check for a declared policy first
|
## Step 0: Check for a declared policy first
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ If no posture is given, assume `standard` and say so in the report.
|
|||||||
8. **Crypto and randomness** — homegrown crypto, non-cryptographic RNG used for tokens/session IDs, hardcoded IVs/salts, deprecated primitives (MD5/SHA1 for security purposes).
|
8. **Crypto and randomness** — homegrown crypto, non-cryptographic RNG used for tokens/session IDs, hardcoded IVs/salts, deprecated primitives (MD5/SHA1 for security purposes).
|
||||||
9. **Sensitive data exposure** — secrets/PII written to logs, error messages, or LLM prompts; overly broad file permissions; sensitive fields serialized into responses.
|
9. **Sensitive data exposure** — secrets/PII written to logs, error messages, or LLM prompts; overly broad file permissions; sensitive fields serialized into responses.
|
||||||
10. **Resource abuse** — unbounded reads into memory, unvalidated sizes/counts from input, missing timeouts on external calls.
|
10. **Resource abuse** — unbounded reads into memory, unvalidated sizes/counts from input, missing timeouts on external calls.
|
||||||
|
11. **Error-text passthrough on a NEW surface** — a new mount, gateway, adapter, or endpoint that
|
||||||
|
writes an upstream/middleware error's text (`err.Error()`, connect/gRPC `Message()`, a wrapped
|
||||||
|
library message) into its own response body. `fs_grep` the new surface's response writers for
|
||||||
|
interpolated error strings. Default expectation: fixed strings per error class; passthrough
|
||||||
|
needs POSITIVE justification (the upstream text is a documented, stable, caller-facing
|
||||||
|
contract). "The same principal already sees this text on another surface" is NOT a clearance
|
||||||
|
— upstream strings are not a stable contract, grow internal detail over time (verifier
|
||||||
|
internals, allow-list names, hostnames), and the new surface may acquire callers with
|
||||||
|
different trust. Medium by default; High when the passed-through source is an authorizer,
|
||||||
|
token verifier, or anything that names internal identifiers.
|
||||||
|
|
||||||
## Ground-truth verification (verify, don't pattern-match)
|
## Ground-truth verification (verify, don't pattern-match)
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ enabled_macros: null # Which macros are invocable by default (no ro
|
|||||||
# Example (comma-separated form):
|
# Example (comma-separated form):
|
||||||
# enabled_macros: generate-commit-message,review
|
# 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) ----
|
# ---- Auto-Continue (Todo System) ----
|
||||||
# The auto-continue system provides built-in task tracking for improved reliability.
|
# 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
|
# 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_base: https://api.openai.com/v1 # Optional
|
||||||
api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||||
organization_id: org-xxx # Optional
|
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
|
# For any platform compatible with OpenAI's API
|
||||||
- type: openai-compatible
|
- type: openai-compatible
|
||||||
@@ -385,6 +400,20 @@ clients:
|
|||||||
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
|
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
|
||||||
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||||
prompt_cache: true # Optional - enable Anthropic prompt caching (default: true)
|
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/
|
# See https://docs.mistral.ai/
|
||||||
- type: openai-compatible
|
- type: openai-compatible
|
||||||
|
|||||||
+1331
-541
File diff suppressed because it is too large.
Load diff
+30
@@ -51,6 +51,10 @@
|
|||||||
max_tokens: null
|
max_tokens: null
|
||||||
temperature: null
|
temperature: null
|
||||||
top_p: null
|
top_p: null
|
||||||
|
responses:
|
||||||
|
body:
|
||||||
|
temperature: null
|
||||||
|
top_p: null
|
||||||
- match: "o3"
|
- match: "o3"
|
||||||
system_prompt_prefix: Formatting re-enabled
|
system_prompt_prefix: Formatting re-enabled
|
||||||
patch:
|
patch:
|
||||||
@@ -58,6 +62,10 @@
|
|||||||
max_tokens: null
|
max_tokens: null
|
||||||
temperature: null
|
temperature: null
|
||||||
top_p: null
|
top_p: null
|
||||||
|
responses:
|
||||||
|
body:
|
||||||
|
temperature: null
|
||||||
|
top_p: null
|
||||||
- match: "o3-mini"
|
- match: "o3-mini"
|
||||||
system_prompt_prefix: Formatting re-enabled
|
system_prompt_prefix: Formatting re-enabled
|
||||||
patch:
|
patch:
|
||||||
@@ -65,6 +73,10 @@
|
|||||||
max_tokens: null
|
max_tokens: null
|
||||||
temperature: null
|
temperature: null
|
||||||
top_p: null
|
top_p: null
|
||||||
|
responses:
|
||||||
|
body:
|
||||||
|
temperature: null
|
||||||
|
top_p: null
|
||||||
models:
|
models:
|
||||||
- name: text-embedding-3-large
|
- name: text-embedding-3-large
|
||||||
type: embedding
|
type: embedding
|
||||||
@@ -94,6 +106,12 @@
|
|||||||
max_tokens: null
|
max_tokens: null
|
||||||
temperature: null
|
temperature: null
|
||||||
top_p: null
|
top_p: null
|
||||||
|
responses:
|
||||||
|
body:
|
||||||
|
temperature: null
|
||||||
|
top_p: null
|
||||||
|
reasoning:
|
||||||
|
effort: high
|
||||||
- name: o3-high
|
- name: o3-high
|
||||||
real_name: o3
|
real_name: o3
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
@@ -108,6 +126,12 @@
|
|||||||
max_tokens: null
|
max_tokens: null
|
||||||
temperature: null
|
temperature: null
|
||||||
top_p: null
|
top_p: null
|
||||||
|
responses:
|
||||||
|
body:
|
||||||
|
temperature: null
|
||||||
|
top_p: null
|
||||||
|
reasoning:
|
||||||
|
effort: high
|
||||||
- name: o3-mini-high
|
- name: o3-mini-high
|
||||||
max_output_tokens: 100000
|
max_output_tokens: 100000
|
||||||
real_name: o3-mini
|
real_name: o3-mini
|
||||||
@@ -123,6 +147,12 @@
|
|||||||
max_tokens: null
|
max_tokens: null
|
||||||
temperature: null
|
temperature: null
|
||||||
top_p: null
|
top_p: null
|
||||||
|
responses:
|
||||||
|
body:
|
||||||
|
temperature: null
|
||||||
|
top_p: null
|
||||||
|
reasoning:
|
||||||
|
effort: high
|
||||||
- name: chat-latest
|
- name: chat-latest
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
|
|||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Entrypoint for the coyote Docker image. Runs under tini:
|
||||||
|
# ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/coyote-entrypoint"]
|
||||||
|
#
|
||||||
|
# tini (PID 1) reaps orphaned processes and forwards signals; coyote is not an
|
||||||
|
# init and does neither.
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
sh | bash | /*) exec "$@" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exec coyote "$@"
|
||||||
+135
-18
@@ -1,7 +1,9 @@
|
|||||||
use crate::client::{ModelType, list_models};
|
use crate::client::{ModelType, list_models};
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
use crate::config::{AppConfig, Config, list_agents_with_descriptions, list_sessions};
|
use crate::config::{
|
||||||
use crate::utils::list_file_names;
|
AppConfig, Config, labeled_session_names, list_agents_with_descriptions, sanitize_display_text,
|
||||||
|
session_scope_dirs,
|
||||||
|
};
|
||||||
use crate::vault::Vault;
|
use crate::vault::Vault;
|
||||||
use clap_complete::{CompletionCandidate, Shell, generate};
|
use clap_complete::{CompletionCandidate, Shell, generate};
|
||||||
use clap_complete_nushell::Nushell;
|
use clap_complete_nushell::Nushell;
|
||||||
@@ -51,12 +53,12 @@ pub(super) fn model_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
|||||||
fn load_app_config_for_completion() -> anyhow::Result<AppConfig> {
|
fn load_app_config_for_completion() -> anyhow::Result<AppConfig> {
|
||||||
let h = tokio::runtime::Handle::try_current().ok();
|
let h = tokio::runtime::Handle::try_current().ok();
|
||||||
let cfg = match h {
|
let cfg = match h {
|
||||||
Some(handle) => {
|
Some(handle) => tokio::task::block_in_place(|| {
|
||||||
tokio::task::block_in_place(|| handle.block_on(Config::load_with_interpolation(true)))?
|
handle.block_on(Config::load_with_interpolation(true, true))
|
||||||
}
|
})?,
|
||||||
None => {
|
None => {
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
rt.block_on(Config::load_with_interpolation(true))?
|
rt.block_on(Config::load_with_interpolation(true, true))?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
AppConfig::from_config(cfg)
|
AppConfig::from_config(cfg)
|
||||||
@@ -115,7 +117,11 @@ pub(super) fn bundle_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_agent_from_args() -> Option<String> {
|
fn extract_agent_from_args() -> Option<String> {
|
||||||
let args: Vec<String> = env::args().collect();
|
extract_agent_from_args_in(env::args())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_agent_from_args_in<I: IntoIterator<Item = String>>(args: I) -> Option<String> {
|
||||||
|
let args: Vec<String> = args.into_iter().collect();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
let arg = &args[i];
|
let arg = &args[i];
|
||||||
@@ -134,19 +140,21 @@ fn extract_agent_from_args() -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||||
let cur = current.to_string_lossy();
|
session_candidates(
|
||||||
|
¤t.to_string_lossy(),
|
||||||
|
extract_agent_from_args().as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let sessions = if let Some(agent_name) = extract_agent_from_args() {
|
fn session_candidates(cur: &str, agent: Option<&str>) -> Vec<CompletionCandidate> {
|
||||||
let sessions_dir = paths::agent_data_dir(&agent_name).join("sessions");
|
let (workspace_dir, global_dir) = session_scope_dirs(agent);
|
||||||
list_file_names(sessions_dir, ".yaml")
|
labeled_session_names(&workspace_dir, &global_dir)
|
||||||
} else {
|
|
||||||
list_sessions()
|
|
||||||
};
|
|
||||||
|
|
||||||
sessions
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.starts_with(&*cur))
|
.filter(|(name, _)| name.starts_with(cur))
|
||||||
.map(CompletionCandidate::new)
|
.map(|(name, scope)| {
|
||||||
|
CompletionCandidate::new(sanitize_display_text(&name))
|
||||||
|
.help(Some(scope.to_string().into()))
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,3 +202,112 @@ pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
|||||||
Err(_) => vec![],
|
Err(_) => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::default_sessions_dir;
|
||||||
|
use crate::testing::{EnvVarGuard, TestConfigDirGuard};
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn seed(dir: &Path, name: &str) {
|
||||||
|
fs::create_dir_all(dir).unwrap();
|
||||||
|
fs::write(dir.join(format!("{name}.yaml")), "model: x\n").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn labeled(candidates: Vec<CompletionCandidate>) -> Vec<(String, Option<String>)> {
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| {
|
||||||
|
(
|
||||||
|
c.get_value().to_string_lossy().into_owned(),
|
||||||
|
c.get_help().map(|h| h.to_string()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn session_completer_labels_scope_and_prefers_workspace() {
|
||||||
|
let _guard = TestConfigDirGuard::new("cli-session-completer");
|
||||||
|
seed(&paths::workspace_sessions_dir(), "both");
|
||||||
|
seed(&default_sessions_dir(), "both");
|
||||||
|
seed(&default_sessions_dir(), "only-global");
|
||||||
|
seed(&paths::workspace_sessions_dir(), "only-ws");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
labeled(session_completer(OsStr::new(""))),
|
||||||
|
vec![
|
||||||
|
("both".to_string(), Some("workspace".to_string())),
|
||||||
|
("only-ws".to_string(), Some("workspace".to_string())),
|
||||||
|
("only-global".to_string(), Some("global".to_string())),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
labeled(session_completer(OsStr::new("on"))),
|
||||||
|
vec![
|
||||||
|
("only-ws".to_string(), Some("workspace".to_string())),
|
||||||
|
("only-global".to_string(), Some("global".to_string())),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn session_candidates_use_the_agent_scope_dirs() {
|
||||||
|
let _guard = TestConfigDirGuard::new("cli-agent-session-completer");
|
||||||
|
let _agent_data_dir = EnvVarGuard::unset("A_DATA_DIR");
|
||||||
|
seed(&paths::workspace_agent_sessions_dir("a"), "both");
|
||||||
|
seed(&paths::agent_sessions_dir("a"), "both");
|
||||||
|
seed(&paths::agent_sessions_dir("a"), "agent-global");
|
||||||
|
seed(&default_sessions_dir(), "plain-global");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
labeled(session_candidates("", Some("a"))),
|
||||||
|
vec![
|
||||||
|
("both".to_string(), Some("workspace".to_string())),
|
||||||
|
("agent-global".to_string(), Some("global".to_string())),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
labeled(session_candidates("", None)),
|
||||||
|
vec![("plain-global".to_string(), Some("global".to_string()))]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_agent_from_args_in_reads_both_flag_forms() {
|
||||||
|
fn args(s: &str) -> Vec<String> {
|
||||||
|
s.split_whitespace().map(str::to_string).collect()
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
extract_agent_from_args_in(args("coyote --agent a --session x")),
|
||||||
|
Some("a".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_agent_from_args_in(args("coyote -a b")),
|
||||||
|
Some("b".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_agent_from_args_in(args("coyote --agent=c")),
|
||||||
|
Some("c".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(extract_agent_from_args_in(args("coyote --agent")), None);
|
||||||
|
assert_eq!(extract_agent_from_args_in(args("coyote --session x")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn config_dir_guard_clears_and_restores_the_sessions_dir_override() {
|
||||||
|
let key = crate::utils::get_env_name("sessions_dir");
|
||||||
|
let junk = std::path::PathBuf::from("/nonexistent/junk-sessions");
|
||||||
|
let _outer = EnvVarGuard::set(&key, &junk);
|
||||||
|
{
|
||||||
|
let guard = TestConfigDirGuard::new("cli-sessions-dir-guard");
|
||||||
|
assert!(default_sessions_dir().starts_with(&guard.path));
|
||||||
|
}
|
||||||
|
assert_eq!(default_sessions_dir(), junk);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -609,6 +609,10 @@ mod tests {
|
|||||||
parse(&["--install-builtins", "mcp_config"]).install_builtins,
|
parse(&["--install-builtins", "mcp_config"]).install_builtins,
|
||||||
Some(AssetCategory::McpConfig)
|
Some(AssetCategory::McpConfig)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--install-builtins", "hooks"]).install_builtins,
|
||||||
|
Some(AssetCategory::Hooks)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -620,12 +624,14 @@ mod tests {
|
|||||||
assert!(category.get_name_and_aliases().any(|n| n == "mcp_config"));
|
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(&"mcp_config"));
|
assert!(!AssetCategory::NAMES.contains(&"mcp_config"));
|
||||||
|
assert!(AssetCategory::NAMES.contains(&"hooks"));
|
||||||
|
|
||||||
let filter = InstallFilter::McpConfig.to_possible_value().unwrap();
|
let filter = InstallFilter::McpConfig.to_possible_value().unwrap();
|
||||||
assert_eq!(filter.get_name(), "mcp-config");
|
assert_eq!(filter.get_name(), "mcp-config");
|
||||||
assert!(filter.get_name_and_aliases().any(|n| n == "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(&"mcp_config"));
|
assert!(!InstallFilter::NAMES.contains(&"mcp_config"));
|
||||||
|
assert!(InstallFilter::NAMES.contains(&"hooks"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -685,6 +691,10 @@ mod tests {
|
|||||||
.filter,
|
.filter,
|
||||||
Some(InstallFilter::McpConfig)
|
Some(InstallFilter::McpConfig)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--install", "https://github.com/x/y", "--filter", "hooks"]).filter,
|
||||||
|
Some(InstallFilter::Hooks)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ use super::*;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct AzureOpenAIConfig {
|
pub struct AzureOpenAIConfig {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-15
@@ -16,7 +16,7 @@ use reqwest::{Client as ReqwestClient, Method, RequestBuilder};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct BedrockConfig {
|
pub struct BedrockConfig {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub access_key_id: Option<String>,
|
pub access_key_id: Option<String>,
|
||||||
@@ -25,6 +25,8 @@ pub struct BedrockConfig {
|
|||||||
pub session_token: Option<String>,
|
pub session_token: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub prompt_cache: Option<bool>,
|
pub prompt_cache: Option<bool>,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
@@ -73,6 +75,7 @@ impl BedrockClient {
|
|||||||
url: _,
|
url: _,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
..
|
||||||
} = request_data;
|
} = request_data;
|
||||||
|
|
||||||
let builder = aws_fetch(
|
let builder = aws_fetch(
|
||||||
@@ -126,6 +129,7 @@ impl BedrockClient {
|
|||||||
url: _,
|
url: _,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
..
|
||||||
} = request_data;
|
} = request_data;
|
||||||
|
|
||||||
let builder = aws_fetch(
|
let builder = aws_fetch(
|
||||||
@@ -421,13 +425,16 @@ fn build_chat_completions_body(
|
|||||||
tool_results,
|
tool_results,
|
||||||
text,
|
text,
|
||||||
sequence,
|
sequence,
|
||||||
|
round_starts,
|
||||||
}) => {
|
}) => {
|
||||||
if !sequence {
|
if !sequence {
|
||||||
let mut assistant_parts = vec![];
|
let mut assistant_parts = vec![];
|
||||||
let mut user_parts = vec![];
|
let mut user_parts = vec![];
|
||||||
for (index, tool_result) in tool_results.iter().enumerate() {
|
for (index, tool_result) in tool_results.iter().enumerate() {
|
||||||
for block in &tool_result.thinking {
|
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() {
|
let round_text = if index == 0 && !text.is_empty() {
|
||||||
Some(text.as_str())
|
Some(text.as_str())
|
||||||
@@ -468,11 +475,15 @@ fn build_chat_completions_body(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// One pair per round: Claude can reuse tool_use IDs across API calls.
|
// One pair per round: Claude can reuse tool_use IDs across API calls.
|
||||||
// A round boundary is detected by the presence of round text, but
|
// Round boundaries are recorded in `round_starts` at merge time.
|
||||||
// rounds where the model emitted only tool calls (no narration)
|
// Splitting per round also keeps the serialized history append-only
|
||||||
// carry no text marker. As a backstop, also split whenever a
|
// across tool-loop iterations, so prompt caching keeps hitting on
|
||||||
// toolUseId would repeat within the current assistant message.
|
// earlier rounds instead of rewriting them every request.
|
||||||
// Converse rejects duplicate toolUseIds in a single message.
|
// Sessions recorded before round tracking carry no `round_starts`;
|
||||||
|
// for those, fall back to the presence of round text, plus an ID
|
||||||
|
// backstop: split whenever a toolUseId would repeat within the
|
||||||
|
// current assistant message. Converse rejects duplicate toolUseIds
|
||||||
|
// in a single message.
|
||||||
let mut messages = vec![];
|
let mut messages = vec![];
|
||||||
let mut assistant_parts: Vec<Value> = vec![];
|
let mut assistant_parts: Vec<Value> = vec![];
|
||||||
let mut user_parts: Vec<Value> = vec![];
|
let mut user_parts: Vec<Value> = vec![];
|
||||||
@@ -483,7 +494,11 @@ fn build_chat_completions_body(
|
|||||||
.id
|
.id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|id| chunk_ids.contains(id));
|
.is_some_and(|id| chunk_ids.contains(id));
|
||||||
if index > 0 && (tool_result.text.is_some() || id_collision) {
|
if index > 0
|
||||||
|
&& (round_starts.contains(&index)
|
||||||
|
|| tool_result.text.is_some()
|
||||||
|
|| id_collision)
|
||||||
|
{
|
||||||
messages.push(
|
messages.push(
|
||||||
json!({ "role": "assistant", "content": assistant_parts }),
|
json!({ "role": "assistant", "content": assistant_parts }),
|
||||||
);
|
);
|
||||||
@@ -496,7 +511,9 @@ fn build_chat_completions_body(
|
|||||||
chunk_ids.insert(id);
|
chunk_ids.insert(id);
|
||||||
}
|
}
|
||||||
for block in &tool_result.thinking {
|
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() {
|
let round_text = if index == 0 && !text.is_empty() {
|
||||||
Some(text.as_str())
|
Some(text.as_str())
|
||||||
@@ -661,23 +678,26 @@ fn bedrock_tool_result_content(output: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse
|
/// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse
|
||||||
/// reasoningContent union so signed blocks replay verbatim.
|
/// reasoningContent union so signed blocks replay verbatim. OpenAI Responses
|
||||||
fn converse_reasoning_block(block: &ThinkingBlock) -> Value {
|
/// 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 {
|
match block {
|
||||||
ThinkingBlock::Thinking {
|
ThinkingBlock::Thinking {
|
||||||
thinking,
|
thinking,
|
||||||
signature,
|
signature,
|
||||||
} => json!({
|
} => Some(json!({
|
||||||
"reasoningContent": {
|
"reasoningContent": {
|
||||||
"reasoningText": {
|
"reasoningText": {
|
||||||
"text": thinking,
|
"text": thinking,
|
||||||
"signature": signature,
|
"signature": signature,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
})),
|
||||||
ThinkingBlock::RedactedThinking { data } => json!({
|
ThinkingBlock::RedactedThinking { data } => Some(json!({
|
||||||
"reasoningContent": { "redactedContent": data }
|
"reasoningContent": { "redactedContent": data }
|
||||||
}),
|
})),
|
||||||
|
ThinkingBlock::Reasoning { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,6 +907,7 @@ mod tests {
|
|||||||
tool_results,
|
tool_results,
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
sequence,
|
sequence,
|
||||||
|
round_starts: vec![],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -903,6 +924,29 @@ mod tests {
|
|||||||
build_body_with(tool_results, "", true)
|
build_body_with(tool_results, "", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_body_rounds(tool_results: Vec<ToolResult>, round_starts: Vec<usize>) -> Value {
|
||||||
|
let data = ChatCompletionsData {
|
||||||
|
messages: vec![
|
||||||
|
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
|
||||||
|
Message::new(
|
||||||
|
MessageRole::Assistant,
|
||||||
|
MessageContent::ToolCalls(MessageContentToolCalls {
|
||||||
|
tool_results,
|
||||||
|
text: String::new(),
|
||||||
|
sequence: true,
|
||||||
|
round_starts,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
functions: None,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
build_chat_completions_body(data, &Model::new("bedrock", "test"), false).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn tool_result_content_block(output: Value) -> Value {
|
fn tool_result_content_block(output: Value) -> Value {
|
||||||
let body = build_body(vec![tool_result(output)]);
|
let body = build_body(vec![tool_result(output)]);
|
||||||
body["messages"][2]["content"][0]["toolResult"]["content"][0].clone()
|
body["messages"][2]["content"][0]["toolResult"]["content"][0].clone()
|
||||||
@@ -1058,6 +1102,9 @@ mod tests {
|
|||||||
assert_unique_tool_use_ids_per_message(&body);
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legacy sessions recorded before round tracking carry no
|
||||||
|
/// `round_starts`; without a text marker or an ID collision their
|
||||||
|
/// textless rounds still serialize merged (the pre-round-tracking shape).
|
||||||
#[test]
|
#[test]
|
||||||
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
|
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
|
||||||
let body = build_body(vec![
|
let body = build_body(vec![
|
||||||
@@ -1072,6 +1119,64 @@ mod tests {
|
|||||||
assert_unique_tool_use_ids_per_message(&body);
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: textless tool-loop rounds must split into one
|
||||||
|
/// assistant/user pair per recorded round so the serialized history stays
|
||||||
|
/// append-only across iterations and prompt caching keeps hitting on
|
||||||
|
/// earlier rounds (mirrors the claude client's round_starts handling).
|
||||||
|
#[test]
|
||||||
|
fn sequence_splits_on_round_starts_without_text() {
|
||||||
|
let body = build_body_rounds(
|
||||||
|
vec![
|
||||||
|
sequence_tool_result("toolu_A", None),
|
||||||
|
sequence_tool_result("toolu_B", None),
|
||||||
|
sequence_tool_result("toolu_C", None),
|
||||||
|
],
|
||||||
|
vec![1, 2],
|
||||||
|
);
|
||||||
|
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
// user + 3 × (assistant, user) — one pair per round.
|
||||||
|
assert_eq!(messages.len(), 7, "body: {body}");
|
||||||
|
for pair in messages[1..].chunks(2) {
|
||||||
|
assert_eq!(pair[0]["role"], "assistant", "body: {body}");
|
||||||
|
assert_eq!(pair[0]["content"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(pair[1]["role"], "user", "body: {body}");
|
||||||
|
}
|
||||||
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The serialized history must be append-only across tool-loop
|
||||||
|
/// iterations: request N+1's messages must start with exactly request N's
|
||||||
|
/// messages, so the prompt-cache prefix keeps matching.
|
||||||
|
#[test]
|
||||||
|
fn round_history_is_append_only_across_iterations() {
|
||||||
|
let two = build_body_rounds(
|
||||||
|
vec![
|
||||||
|
sequence_tool_result("toolu_A", None),
|
||||||
|
sequence_tool_result("toolu_B", None),
|
||||||
|
],
|
||||||
|
vec![1],
|
||||||
|
);
|
||||||
|
let three = build_body_rounds(
|
||||||
|
vec![
|
||||||
|
sequence_tool_result("toolu_A", None),
|
||||||
|
sequence_tool_result("toolu_B", None),
|
||||||
|
sequence_tool_result("toolu_C", None),
|
||||||
|
],
|
||||||
|
vec![1, 2],
|
||||||
|
);
|
||||||
|
|
||||||
|
let two_messages = two["messages"].as_array().unwrap();
|
||||||
|
let three_messages = three["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
&three_messages[..two_messages.len()],
|
||||||
|
&two_messages[..],
|
||||||
|
"two: {two}, three: {three}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_sequence_emits_assistant_user_pair() {
|
fn non_sequence_emits_assistant_user_pair() {
|
||||||
let body = build_body_with(
|
let body = build_body_with(
|
||||||
@@ -1185,6 +1290,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_openai_reasoning_blocks_in_converse_shape() {
|
||||||
|
let reasoning = ToolResult {
|
||||||
|
call: ToolCall::new(
|
||||||
|
"fs_read".into(),
|
||||||
|
json!({"path": "x"}),
|
||||||
|
Some("tool_A".into()),
|
||||||
|
),
|
||||||
|
output: json!("ok"),
|
||||||
|
text: None,
|
||||||
|
thinking: vec![ThinkingBlock::Reasoning {
|
||||||
|
id: "rs_1".into(),
|
||||||
|
summary: json!([{ "type": "summary_text", "text": "thinking" }]),
|
||||||
|
encrypted_content: Some("enc123".into()),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
for sequence in [false, true] {
|
||||||
|
let body = build_body_with(vec![reasoning.clone()], "", sequence);
|
||||||
|
|
||||||
|
let content = body["messages"][1]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(content.len(), 1, "body: {body}");
|
||||||
|
assert!(content[0].get("toolUse").is_some(), "body: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_chat_completions_captures_reasoning_content() {
|
fn extract_chat_completions_captures_reasoning_content() {
|
||||||
let data = json!({
|
let data = json!({
|
||||||
|
|||||||
+61
-1
@@ -572,7 +572,7 @@ pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 {
|
|||||||
kept as f64 / old.len() as f64
|
kept as f64 / old.len() as f64
|
||||||
}
|
}
|
||||||
|
|
||||||
fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
|
pub(crate) fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
|
||||||
ModelData {
|
ModelData {
|
||||||
name: hand.name,
|
name: hand.name,
|
||||||
model_type: hand.model_type,
|
model_type: hand.model_type,
|
||||||
@@ -604,6 +604,23 @@ fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn extend_catalog_models(
|
||||||
|
catalog: &[ModelData],
|
||||||
|
overrides: &[ModelData],
|
||||||
|
) -> Vec<ModelData> {
|
||||||
|
let mut models = catalog.to_vec();
|
||||||
|
for override_model in overrides {
|
||||||
|
match models.iter_mut().find(|model| {
|
||||||
|
model.name == override_model.name && model.model_type == override_model.model_type
|
||||||
|
}) {
|
||||||
|
Some(model) => *model = overlay_hand_owned(override_model.clone(), model.clone()),
|
||||||
|
None => models.push(override_model.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
models
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ProviderQuirks {
|
pub struct ProviderQuirks {
|
||||||
@@ -1724,4 +1741,47 @@ rules:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn typed_model(name: &str, model_type: &str) -> ModelData {
|
||||||
|
ModelData {
|
||||||
|
name: name.to_string(),
|
||||||
|
model_type: model_type.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_catalog_models_merges_collisions_field_wise() {
|
||||||
|
let mut catalog_model = ModelData::new("m");
|
||||||
|
catalog_model.max_input_tokens = Some(100_000);
|
||||||
|
catalog_model.input_price = Some(1.0);
|
||||||
|
catalog_model.supports_vision = true;
|
||||||
|
let mut override_model = ModelData::new("m");
|
||||||
|
override_model.input_price = Some(9.0);
|
||||||
|
|
||||||
|
let merged = extend_catalog_models(&[catalog_model], &[override_model]);
|
||||||
|
assert_eq!(merged.len(), 1);
|
||||||
|
assert_eq!(merged[0].input_price, Some(9.0));
|
||||||
|
assert_eq!(merged[0].max_input_tokens, Some(100_000));
|
||||||
|
assert!(merged[0].supports_vision);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_catalog_models_appends_net_new_after_catalog() {
|
||||||
|
let catalog = [ModelData::new("a"), ModelData::new("b")];
|
||||||
|
let merged = extend_catalog_models(&catalog, &[ModelData::new("c")]);
|
||||||
|
let names: Vec<&str> = merged.iter().map(|model| model.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["a", "b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_catalog_models_collides_on_name_and_type() {
|
||||||
|
let mut catalog_model = typed_model("m", "embedding");
|
||||||
|
catalog_model.default_chunk_size = Some(1500);
|
||||||
|
let merged = extend_catalog_models(&[catalog_model], &[typed_model("m", "reranker")]);
|
||||||
|
assert_eq!(merged.len(), 2);
|
||||||
|
assert_eq!(merged[0].model_type, "embedding");
|
||||||
|
assert_eq!(merged[0].default_chunk_size, Some(1500));
|
||||||
|
assert_eq!(merged[1].model_type, "reranker");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+578
-22
@@ -2,13 +2,12 @@ use std::collections::HashSet;
|
|||||||
use std::mem;
|
use std::mem;
|
||||||
|
|
||||||
use super::access_token::get_access_token;
|
use super::access_token::get_access_token;
|
||||||
use super::claude_oauth::ClaudeOAuthProvider;
|
use super::oauth::{self, OAuthConfig};
|
||||||
use super::oauth::{self, OAuthProvider};
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use crate::utils::strip_think_tag;
|
use crate::utils::strip_think_tag;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -16,14 +15,17 @@ use serde_json::{Value, json};
|
|||||||
const API_BASE: &str = "https://api.anthropic.com/v1";
|
const API_BASE: &str = "https://api.anthropic.com/v1";
|
||||||
const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
|
const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct ClaudeConfig {
|
pub struct ClaudeConfig {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
pub auth: Option<String>,
|
pub auth: Option<String>,
|
||||||
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub prompt_cache: Option<bool>,
|
pub prompt_cache: Option<bool>,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
@@ -71,9 +73,17 @@ async fn prepare_chat_completions(
|
|||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<RequestData> {
|
) -> Result<RequestData> {
|
||||||
let api_base = self_
|
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
|
||||||
.get_api_base()
|
|
||||||
.unwrap_or_else(|_| API_BASE.to_string());
|
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 url = format!("{}/messages", api_base.trim_end_matches('/'));
|
||||||
let body = claude_build_chat_completions_body(
|
let body = claude_build_chat_completions_body(
|
||||||
@@ -86,24 +96,27 @@ async fn prepare_chat_completions(
|
|||||||
|
|
||||||
request_data.header("anthropic-version", "2023-06-01");
|
request_data.header("anthropic-version", "2023-06-01");
|
||||||
|
|
||||||
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
|
|
||||||
|
|
||||||
if uses_oauth {
|
if uses_oauth {
|
||||||
let provider = ClaudeOAuthProvider;
|
let client_name = self_.name();
|
||||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
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 {
|
if !ready {
|
||||||
bail!(
|
bail!(
|
||||||
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
||||||
self_.name(),
|
client_name,
|
||||||
self_.name()
|
client_name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let token = get_access_token(self_.name())?;
|
let token = get_access_token(client_name)?;
|
||||||
request_data.bearer_auth(token);
|
request_data.bearer_auth(token);
|
||||||
for (key, value) in provider.extra_request_headers() {
|
for (key, value) in provider.extra_request_headers() {
|
||||||
request_data.header(key, value);
|
request_data.header(key, value);
|
||||||
}
|
}
|
||||||
|
if uses_stock_provider {
|
||||||
inject_oauth_system_prompt(&mut request_data.body);
|
inject_oauth_system_prompt(&mut request_data.body);
|
||||||
|
}
|
||||||
} else if let Ok(api_key) = self_.get_api_key() {
|
} else if let Ok(api_key) = self_.get_api_key() {
|
||||||
request_data.header("x-api-key", api_key);
|
request_data.header("x-api-key", api_key);
|
||||||
} else {
|
} else {
|
||||||
@@ -117,6 +130,50 @@ async fn prepare_chat_completions(
|
|||||||
Ok(request_data)
|
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
|
/// Anthropic requires OAuth-authenticated requests to include a Claude Code
|
||||||
/// system prompt prefix in order to consider a request body as "valid".
|
/// system prompt prefix in order to consider a request body as "valid".
|
||||||
///
|
///
|
||||||
@@ -346,14 +403,24 @@ pub fn claude_build_chat_completions_body(
|
|||||||
tool_results,
|
tool_results,
|
||||||
text,
|
text,
|
||||||
sequence,
|
sequence,
|
||||||
|
round_starts,
|
||||||
}) => {
|
}) => {
|
||||||
if !sequence {
|
if !sequence {
|
||||||
let mut assistant_parts = vec![];
|
let mut assistant_parts = vec![];
|
||||||
let mut user_parts = vec![];
|
let mut user_parts = vec![];
|
||||||
for (index, tool_result) in tool_results.iter().enumerate() {
|
for (index, tool_result) in tool_results.iter().enumerate() {
|
||||||
for block in &tool_result.thinking {
|
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));
|
assistant_parts.push(json!(block));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let round_text = if index == 0 && !text.is_empty() {
|
let round_text = if index == 0 && !text.is_empty() {
|
||||||
Some(text.as_str())
|
Some(text.as_str())
|
||||||
} else {
|
} else {
|
||||||
@@ -392,14 +459,18 @@ pub fn claude_build_chat_completions_body(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// One pair per round: Claude can reuse tool_use IDs across API calls.
|
// One pair per round: Claude can reuse tool_use IDs across API calls.
|
||||||
// A round boundary is detected by the presence of round text, but
|
// Round boundaries are recorded in `round_starts` at merge time.
|
||||||
// rounds where the model emitted only tool calls (no narration)
|
// Splitting per round also keeps the serialized history append-only
|
||||||
// carry no text marker. As a backstop, also split whenever a
|
// across tool-loop iterations, so prefix-based prompt caching keeps
|
||||||
// tool_use ID would repeat within the current assistant message —
|
// hitting on earlier rounds instead of rewriting them every request.
|
||||||
// the API rejects duplicate tool_use IDs in a single message.
|
// Sessions recorded before round tracking carry no `round_starts`;
|
||||||
|
// for those, fall back to the presence of round text, plus an ID
|
||||||
|
// backstop: split whenever a tool_use ID would repeat within the
|
||||||
|
// current assistant message — the API rejects duplicate tool_use
|
||||||
|
// IDs in a single message.
|
||||||
let mut messages = vec![];
|
let mut messages = vec![];
|
||||||
let mut assistant_parts: Vec<serde_json::Value> = vec![];
|
let mut assistant_parts: Vec<Value> = vec![];
|
||||||
let mut user_parts: Vec<serde_json::Value> = vec![];
|
let mut user_parts: Vec<Value> = vec![];
|
||||||
let mut chunk_ids: HashSet<&str> = HashSet::new();
|
let mut chunk_ids: HashSet<&str> = HashSet::new();
|
||||||
for (index, tool_result) in tool_results.iter().enumerate() {
|
for (index, tool_result) in tool_results.iter().enumerate() {
|
||||||
let id_collision = tool_result
|
let id_collision = tool_result
|
||||||
@@ -407,7 +478,11 @@ pub fn claude_build_chat_completions_body(
|
|||||||
.id
|
.id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|id| chunk_ids.contains(id));
|
.is_some_and(|id| chunk_ids.contains(id));
|
||||||
if index > 0 && (tool_result.text.is_some() || id_collision) {
|
if index > 0
|
||||||
|
&& (round_starts.contains(&index)
|
||||||
|
|| tool_result.text.is_some()
|
||||||
|
|| id_collision)
|
||||||
|
{
|
||||||
messages.push(
|
messages.push(
|
||||||
json!({ "role": "assistant", "content": assistant_parts }),
|
json!({ "role": "assistant", "content": assistant_parts }),
|
||||||
);
|
);
|
||||||
@@ -420,8 +495,17 @@ pub fn claude_build_chat_completions_body(
|
|||||||
chunk_ids.insert(id);
|
chunk_ids.insert(id);
|
||||||
}
|
}
|
||||||
for block in &tool_result.thinking {
|
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));
|
assistant_parts.push(json!(block));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let round_text = if index == 0 && !text.is_empty() {
|
let round_text = if index == 0 && !text.is_empty() {
|
||||||
Some(text.as_str())
|
Some(text.as_str())
|
||||||
} else {
|
} else {
|
||||||
@@ -632,7 +716,11 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::client::access_token::set_access_token;
|
||||||
|
use crate::config::AppConfig;
|
||||||
use crate::function::{FunctionDeclaration, ToolCall, ToolResult};
|
use crate::function::{FunctionDeclaration, ToolCall, ToolResult};
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn tool_result(id: &str, text: Option<&str>) -> ToolResult {
|
fn tool_result(id: &str, text: Option<&str>) -> ToolResult {
|
||||||
ToolResult {
|
ToolResult {
|
||||||
@@ -658,6 +746,7 @@ mod tests {
|
|||||||
tool_results,
|
tool_results,
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
sequence,
|
sequence,
|
||||||
|
round_starts: vec![],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -675,6 +764,30 @@ mod tests {
|
|||||||
build_body_with(tool_results, "", true, prompt_cache)
|
build_body_with(tool_results, "", true, prompt_cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_body_rounds(tool_results: Vec<ToolResult>, round_starts: Vec<usize>) -> Value {
|
||||||
|
let data = ChatCompletionsData {
|
||||||
|
messages: vec![
|
||||||
|
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
|
||||||
|
Message::new(
|
||||||
|
MessageRole::Assistant,
|
||||||
|
MessageContent::ToolCalls(MessageContentToolCalls {
|
||||||
|
tool_results,
|
||||||
|
text: String::new(),
|
||||||
|
sequence: true,
|
||||||
|
round_starts,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
functions: None,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
claude_build_chat_completions_body(data, &Model::new("claude", "claude-test"), false)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn multi_turn_data(functions: Option<Vec<FunctionDeclaration>>) -> ChatCompletionsData {
|
fn multi_turn_data(functions: Option<Vec<FunctionDeclaration>>) -> ChatCompletionsData {
|
||||||
ChatCompletionsData {
|
ChatCompletionsData {
|
||||||
messages: vec![
|
messages: vec![
|
||||||
@@ -746,6 +859,63 @@ mod tests {
|
|||||||
assert_unique_tool_use_ids_per_message(&body);
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: tool-loop rounds where the model emitted no narration text
|
||||||
|
/// (the norm with thinking models) must still serialize as one
|
||||||
|
/// assistant/user pair per round. Collapsing them into one growing pair
|
||||||
|
/// rewrites the whole accumulated history on every request, which defeats
|
||||||
|
/// prefix-based prompt caching (cache reads stall at the system prompt).
|
||||||
|
#[test]
|
||||||
|
fn sequence_splits_on_round_starts_without_text() {
|
||||||
|
let body = build_body_rounds(
|
||||||
|
vec![
|
||||||
|
tool_result("toolu_A", None),
|
||||||
|
tool_result("toolu_B", None),
|
||||||
|
tool_result("toolu_C", None),
|
||||||
|
],
|
||||||
|
vec![1, 2],
|
||||||
|
);
|
||||||
|
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
// user + 3 × (assistant, user) — one pair per round.
|
||||||
|
assert_eq!(messages.len(), 7, "body: {body}");
|
||||||
|
for pair in messages[1..].chunks(2) {
|
||||||
|
assert_eq!(pair[0]["role"], "assistant", "body: {body}");
|
||||||
|
assert_eq!(pair[0]["content"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(pair[1]["role"], "user", "body: {body}");
|
||||||
|
}
|
||||||
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The serialized history must be append-only across tool-loop
|
||||||
|
/// iterations: request N+1's messages must start with exactly request N's
|
||||||
|
/// messages, so the prompt-cache prefix keeps matching and the cache read
|
||||||
|
/// grows monotonically instead of re-writing every round each request.
|
||||||
|
#[test]
|
||||||
|
fn round_history_is_append_only_across_iterations() {
|
||||||
|
let two = build_body_rounds(
|
||||||
|
vec![tool_result("toolu_A", None), tool_result("toolu_B", None)],
|
||||||
|
vec![1],
|
||||||
|
);
|
||||||
|
let three = build_body_rounds(
|
||||||
|
vec![
|
||||||
|
tool_result("toolu_A", None),
|
||||||
|
tool_result("toolu_B", None),
|
||||||
|
tool_result("toolu_C", None),
|
||||||
|
],
|
||||||
|
vec![1, 2],
|
||||||
|
);
|
||||||
|
|
||||||
|
let two_messages = two["messages"].as_array().unwrap();
|
||||||
|
let three_messages = three["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
&three_messages[..two_messages.len()],
|
||||||
|
&two_messages[..],
|
||||||
|
"two: {two}, three: {three}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sequence_splits_on_reused_id_in_textless_round() {
|
fn sequence_splits_on_reused_id_in_textless_round() {
|
||||||
let body = build_body(
|
let body = build_body(
|
||||||
@@ -763,6 +933,9 @@ mod tests {
|
|||||||
assert_unique_tool_use_ids_per_message(&body);
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legacy sessions recorded before round tracking carry no
|
||||||
|
/// `round_starts`; without a text marker or an ID collision their
|
||||||
|
/// textless rounds still serialize merged (the pre-round-tracking shape).
|
||||||
#[test]
|
#[test]
|
||||||
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
|
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
|
||||||
let body = build_body(
|
let body = build_body(
|
||||||
@@ -816,6 +989,48 @@ mod tests {
|
|||||||
assert_eq!(messages.len(), 1, "body: {body}");
|
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]
|
#[test]
|
||||||
fn prompt_cache_places_breakpoints() {
|
fn prompt_cache_places_breakpoints() {
|
||||||
let functions = vec![function_declaration("a"), function_declaration("b")];
|
let functions = vec![function_declaration("a"), function_declaration("b")];
|
||||||
@@ -905,4 +1120,345 @@ mod tests {
|
|||||||
assert_eq!(usage.cache_creation_input_tokens, Some(100));
|
assert_eq!(usage.cache_creation_input_tokens, Some(100));
|
||||||
assert_eq!(usage.cache_read_input_tokens, Some(200));
|
assert_eq!(usage.cache_read_input_tokens, Some(200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn claude_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> ClaudeConfig {
|
||||||
|
ClaudeConfig {
|
||||||
|
name: Some(name.into()),
|
||||||
|
api_base: oauth
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| "https://gateway.example/v1".to_string()),
|
||||||
|
auth: auth.map(str::to_string),
|
||||||
|
oauth: oauth.map(Box::new),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minimal_oauth_config() -> OAuthConfig {
|
||||||
|
serde_yaml::from_str("client_id: gateway\ntoken_url: https://gateway.example/token")
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_client(config: ClaudeConfig, clients: Vec<ClientConfig>) -> ClaudeClient {
|
||||||
|
ClaudeClient {
|
||||||
|
app_config: Arc::new(AppConfig {
|
||||||
|
clients,
|
||||||
|
..AppConfig::default()
|
||||||
|
}),
|
||||||
|
config,
|
||||||
|
model: Model::new("claude", "claude-test"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare(client: &ClaudeClient) -> Result<RequestData> {
|
||||||
|
let data = ChatCompletionsData {
|
||||||
|
messages: vec![Message::new(
|
||||||
|
MessageRole::User,
|
||||||
|
MessageContent::Text("hello".to_string()),
|
||||||
|
)],
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
functions: None,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(prepare_chat_completions(
|
||||||
|
client,
|
||||||
|
&ReqwestClient::new(),
|
||||||
|
data,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_stock_provider_applies_claude_code_spoof() {
|
||||||
|
let name = "claude-gate-stock-test";
|
||||||
|
let config = claude_config(name, Some("oauth"), None);
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
set_access_token(name, "stock-at".into(), Utc::now().timestamp() + 3600, None);
|
||||||
|
|
||||||
|
let request_data = prepare(&client).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request_data
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("Bearer stock-at")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_data
|
||||||
|
.headers
|
||||||
|
.get("anthropic-beta")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("oauth-2025-04-20")
|
||||||
|
);
|
||||||
|
assert_eq!(request_data.body["system"][0]["text"], CLAUDE_CODE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_config_provider_skips_claude_code_spoof() {
|
||||||
|
let name = "claude-gate-gateway-test";
|
||||||
|
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
set_access_token(
|
||||||
|
name,
|
||||||
|
"gateway-at".into(),
|
||||||
|
Utc::now().timestamp() + 3600,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_data = prepare(&client).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request_data
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("Bearer gateway-at")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!request_data.headers.contains_key("anthropic-beta"),
|
||||||
|
"headers: {:?}",
|
||||||
|
request_data.headers
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
request_data.body.get("system").is_none(),
|
||||||
|
"body: {}",
|
||||||
|
request_data.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_key_auth_sets_x_api_key_without_spoof() {
|
||||||
|
let name = "claude-gate-apikey-test";
|
||||||
|
let mut config = claude_config(name, None, None);
|
||||||
|
config.api_key = Some("sk-test".into());
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
|
||||||
|
let request_data = prepare(&client).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request_data.headers.get("x-api-key").map(String::as_str),
|
||||||
|
Some("sk-test")
|
||||||
|
);
|
||||||
|
assert!(!request_data.headers.contains_key("authorization"));
|
||||||
|
assert!(!request_data.headers.contains_key("anthropic-beta"));
|
||||||
|
assert!(
|
||||||
|
request_data.body.get("system").is_none(),
|
||||||
|
"body: {}",
|
||||||
|
request_data.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_block_without_auth_oauth_is_rejected() {
|
||||||
|
let name = "claude-gate-contradiction-test";
|
||||||
|
let mut config = claude_config(name, None, Some(minimal_oauth_config()));
|
||||||
|
config.api_key = Some("sk-test".into());
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
|
||||||
|
let err = match prepare(&client) {
|
||||||
|
Ok(_) => panic!("expected the contradictory config to be rejected"),
|
||||||
|
Err(err) => err.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.contains("has an `oauth:` block configured but `auth: oauth` is not set"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_block_without_api_base_is_rejected() {
|
||||||
|
let name = "claude-gate-apibase-missing-test";
|
||||||
|
let mut config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
|
||||||
|
config.api_base = None;
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
|
||||||
|
let err = resolve_api_base(&client).unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.contains(name) && err.contains("refusing to fall back"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_only_oauth_block_without_api_base_is_rejected() {
|
||||||
|
let name = "claude-gate-catalog-oauth-test";
|
||||||
|
let config = claude_config(name, Some("oauth"), None);
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
let catalog = vec![ProviderModels {
|
||||||
|
provider: name.into(),
|
||||||
|
oauth: Some(minimal_oauth_config()),
|
||||||
|
models: vec![],
|
||||||
|
}];
|
||||||
|
|
||||||
|
let err = resolve_api_base_against(&client, &catalog)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.contains(name) && err.contains("refusing to fall back"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_oauth_block_with_api_key_auth_falls_back_to_stock_api_base() {
|
||||||
|
let name = "claude-gate-catalog-apikey-test";
|
||||||
|
let mut config = claude_config(name, None, None);
|
||||||
|
config.api_key = Some("sk-test".into());
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
let catalog = vec![ProviderModels {
|
||||||
|
provider: name.into(),
|
||||||
|
oauth: Some(minimal_oauth_config()),
|
||||||
|
models: vec![],
|
||||||
|
}];
|
||||||
|
|
||||||
|
let api_base = resolve_api_base_against(&client, &catalog).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(api_base, API_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_mismatch_bails_before_missing_api_base_guard() {
|
||||||
|
let name = "claude-gate-bail-order-test";
|
||||||
|
let mut config = claude_config(name, None, Some(minimal_oauth_config()));
|
||||||
|
config.api_base = None;
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
|
||||||
|
let err = match prepare(&client) {
|
||||||
|
Ok(_) => panic!("expected the contradictory config to be rejected"),
|
||||||
|
Err(err) => err.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.contains("has an `oauth:` block configured but `auth: oauth` is not set")
|
||||||
|
&& !err.contains("refusing to fall back"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_block_with_api_base_resolves_it() {
|
||||||
|
let name = "claude-gate-apibase-set-test";
|
||||||
|
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
|
||||||
|
let api_base = resolve_api_base(&client).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(api_base, "https://gateway.example/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_oauth_block_falls_back_to_stock_api_base() {
|
||||||
|
let name = "claude-gate-apibase-fallback-test";
|
||||||
|
let config = claude_config(name, None, None);
|
||||||
|
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]);
|
||||||
|
|
||||||
|
let api_base = resolve_api_base(&client).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(api_base, API_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_lookup_resolves_entry_matching_client_name() {
|
||||||
|
let name = "claude-gate-lookup-test";
|
||||||
|
// The sibling entry resolves to the stock provider; if the lookup
|
||||||
|
// ignored the name it would apply the Claude Code spoof here.
|
||||||
|
let sibling = claude_config("claude-gate-lookup-sibling-test", Some("oauth"), None);
|
||||||
|
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
|
||||||
|
let client = make_client(
|
||||||
|
config.clone(),
|
||||||
|
vec![
|
||||||
|
ClientConfig::ClaudeConfig(sibling),
|
||||||
|
ClientConfig::ClaudeConfig(config),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
set_access_token(
|
||||||
|
name,
|
||||||
|
"lookup-at".into(),
|
||||||
|
Utc::now().timestamp() + 3600,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_data = prepare(&client).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request_data
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("Bearer lookup-at")
|
||||||
|
);
|
||||||
|
assert!(!request_data.headers.contains_key("anthropic-beta"));
|
||||||
|
assert!(request_data.body.get("system").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_lookup_falls_back_to_default_name_for_unnamed_entry() {
|
||||||
|
// An entry with `name: None` must match a client named "claude" via
|
||||||
|
// the `unwrap_or(ClaudeClient::NAME)` fallback.
|
||||||
|
let sibling = claude_config("claude-gate-fallback-sibling-test", Some("oauth"), None);
|
||||||
|
let mut config = claude_config("ignored", Some("oauth"), Some(minimal_oauth_config()));
|
||||||
|
config.name = None;
|
||||||
|
let client = make_client(
|
||||||
|
config.clone(),
|
||||||
|
vec![
|
||||||
|
ClientConfig::ClaudeConfig(sibling),
|
||||||
|
ClientConfig::ClaudeConfig(config),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
set_access_token(
|
||||||
|
ClaudeClient::NAME,
|
||||||
|
"fallback-at".into(),
|
||||||
|
Utc::now().timestamp() + 3600,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_data = prepare(&client).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request_data
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("Bearer fallback-at")
|
||||||
|
);
|
||||||
|
assert!(!request_data.headers.contains_key("anthropic-beta"));
|
||||||
|
assert!(request_data.body.get("system").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_lookup_errors_when_config_entry_missing() {
|
||||||
|
let config = claude_config("claude-gate-missing-test", Some("oauth"), None);
|
||||||
|
let client = make_client(config, vec![]);
|
||||||
|
|
||||||
|
let err = match prepare(&client) {
|
||||||
|
Ok(_) => panic!("expected the lookup to fail"),
|
||||||
|
Err(err) => err.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.contains("Could not locate ClientConfig entry"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shipped_catalog_resolves_stock_claude_provider() {
|
||||||
|
let cc = ClientConfig::ClaudeConfig(claude_config("claude", Some("oauth"), None));
|
||||||
|
let bundled: Vec<ProviderModels> = serde_yaml::from_str(MODELS_YAML).unwrap();
|
||||||
|
|
||||||
|
let (_, is_stock) = oauth::claude_oauth_provider_for_client(&cc, &bundled);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
is_stock,
|
||||||
|
"bundled models.yaml must not carry a claude oauth block: it would silently drop the Claude Code spoof for stock Pro/Max users"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,8 @@ pub struct CohereConfig {
|
|||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
}
|
}
|
||||||
|
|||||||
+803
-15
@@ -2,6 +2,7 @@ use super::*;
|
|||||||
|
|
||||||
use super::access_token::{distrust_access_token, get_access_token};
|
use super::access_token::{distrust_access_token, get_access_token};
|
||||||
use crate::config::{RenderMode, paths};
|
use crate::config::{RenderMode, paths};
|
||||||
|
use crate::hooks::{self, HookEvent};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{AppConfig, Input, RequestContext},
|
config::{AppConfig, Input, RequestContext},
|
||||||
function::{FunctionDeclaration, ToolCall, ToolResult, eval_tool_calls},
|
function::{FunctionDeclaration, ToolCall, ToolResult, eval_tool_calls},
|
||||||
@@ -20,7 +21,7 @@ use reqwest::{Client as ReqwestClient, RequestBuilder};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
|
||||||
pub const MODELS_YAML: &str = include_str!("../../models.yaml");
|
pub const MODELS_YAML: &str = include_str!("../../models.yaml");
|
||||||
@@ -221,23 +222,39 @@ pub trait Client: Sync + Send {
|
|||||||
request_data.into_builder(client)
|
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) {
|
fn patch_request_data(&self, request_data: &mut RequestData) {
|
||||||
let model_type = self.model().model_type();
|
let model_type = self.model().model_type();
|
||||||
|
let wire = request_data.wire;
|
||||||
if let Some(patch) = self.model().patch() {
|
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());
|
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!(
|
let patch_map = std::env::var(get_env_name(&format!(
|
||||||
"patch_{}_{}",
|
"patch_{}_{api_name}",
|
||||||
self.model().client_name(),
|
self.model().client_name(),
|
||||||
model_type.api_name(),
|
|
||||||
)))
|
)))
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| serde_json::from_str(&v).ok())
|
.and_then(|v| serde_json::from_str(&v).ok())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
self.patch_config()
|
self.patch_config().and_then(|v| match wire {
|
||||||
.and_then(|v| model_type.extract_patch(v))
|
WireApi::Responses => v.responses.clone(),
|
||||||
.cloned()
|
WireApi::Chat => model_type.extract_patch(v).cloned(),
|
||||||
|
})
|
||||||
});
|
});
|
||||||
let patch_map = match patch_map {
|
let patch_map = match patch_map {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
@@ -271,16 +288,26 @@ pub struct ExtraConfig {
|
|||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct RequestPatch {
|
pub struct RequestPatch {
|
||||||
pub chat_completions: Option<ApiPatch>,
|
pub chat_completions: Option<ApiPatch>,
|
||||||
|
pub responses: Option<ApiPatch>,
|
||||||
pub embeddings: Option<ApiPatch>,
|
pub embeddings: Option<ApiPatch>,
|
||||||
pub rerank: Option<ApiPatch>,
|
pub rerank: Option<ApiPatch>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type ApiPatch = IndexMap<String, Value>;
|
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 struct RequestData {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub headers: IndexMap<String, String>,
|
pub headers: IndexMap<String, String>,
|
||||||
pub body: Value,
|
pub body: Value,
|
||||||
|
pub wire: WireApi,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestData {
|
impl RequestData {
|
||||||
@@ -292,9 +319,15 @@ impl RequestData {
|
|||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
headers: Default::default(),
|
headers: Default::default(),
|
||||||
body,
|
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)
|
pub fn bearer_auth<T>(&mut self, auth: T)
|
||||||
where
|
where
|
||||||
T: std::fmt::Display,
|
T: std::fmt::Display,
|
||||||
@@ -312,7 +345,9 @@ impl RequestData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {
|
pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {
|
||||||
let RequestData { url, headers, body } = self;
|
let RequestData {
|
||||||
|
url, headers, body, ..
|
||||||
|
} = self;
|
||||||
debug!("Request {url} {body}");
|
debug!("Request {url} {body}");
|
||||||
|
|
||||||
let mut builder = client.post(url);
|
let mut builder = client.post(url);
|
||||||
@@ -385,6 +420,15 @@ impl TokenUsage {
|
|||||||
&& self.cache_read_input_tokens.is_none()
|
&& self.cache_read_input_tokens.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prompt-side tokens the API reported for one request: fresh input plus
|
||||||
|
/// cache writes and cache reads. The buckets are disjoint (see
|
||||||
|
/// [`Self::cost_usd`]), so the terms sum without adjustment.
|
||||||
|
pub fn total_prompt_tokens(&self) -> u64 {
|
||||||
|
self.input_tokens.unwrap_or(0)
|
||||||
|
+ self.cache_creation_input_tokens.unwrap_or(0)
|
||||||
|
+ self.cache_read_input_tokens.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn accumulate(&mut self, other: &TokenUsage) {
|
pub fn accumulate(&mut self, other: &TokenUsage) {
|
||||||
fn add(acc: &mut Option<u64>, value: Option<u64>) {
|
fn add(acc: &mut Option<u64>, value: Option<u64>) {
|
||||||
if let Some(value) = value {
|
if let Some(value) = value {
|
||||||
@@ -546,6 +590,47 @@ pub async fn create_openai_compatible_client_config(
|
|||||||
Ok(Some((model, clients)))
|
Ok(Some((model, clients)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn llm_request_extras(client: &dyn Client) -> Vec<(&'static str, String)> {
|
||||||
|
vec![
|
||||||
|
("COYOTE_LLM_PROVIDER", client.name().to_string()),
|
||||||
|
("COYOTE_LLM_MODEL", client.model().id()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llm_completed_extras(client: &dyn Client, started_at: Instant) -> Vec<(&'static str, String)> {
|
||||||
|
let mut extras = llm_request_extras(client);
|
||||||
|
extras.push((
|
||||||
|
"COYOTE_LLM_DURATION_MS",
|
||||||
|
started_at.elapsed().as_millis().to_string(),
|
||||||
|
));
|
||||||
|
extras
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llm_failed_extras(
|
||||||
|
client: &dyn Client,
|
||||||
|
err: Option<&anyhow::Error>,
|
||||||
|
aborted: bool,
|
||||||
|
) -> Vec<(&'static str, String)> {
|
||||||
|
let mut extras = llm_request_extras(client);
|
||||||
|
if aborted {
|
||||||
|
extras.push(("COYOTE_LLM_ERROR_KIND", "aborted".to_string()));
|
||||||
|
} else if let Some(api_err) = err.and_then(|err| err.downcast_ref::<ApiStatusError>()) {
|
||||||
|
extras.push(("COYOTE_LLM_ERROR_KIND", "api_error".to_string()));
|
||||||
|
extras.push(("COYOTE_LLM_STATUS", api_err.status.to_string()));
|
||||||
|
} else {
|
||||||
|
extras.push(("COYOTE_LLM_ERROR_KIND", "other".to_string()));
|
||||||
|
}
|
||||||
|
extras.push((
|
||||||
|
"COYOTE_LLM_ERROR",
|
||||||
|
// The immediate error only, matching COYOTE_TOOL_ERROR; the context
|
||||||
|
// chain below it stays out of the env.
|
||||||
|
err.map(|err| err.to_string())
|
||||||
|
.unwrap_or_else(|| "Aborted.".to_string()),
|
||||||
|
));
|
||||||
|
|
||||||
|
extras
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn call_chat_completions(
|
pub async fn call_chat_completions(
|
||||||
input: &Input,
|
input: &Input,
|
||||||
print: bool,
|
print: bool,
|
||||||
@@ -557,15 +642,28 @@ pub async fn call_chat_completions(
|
|||||||
let is_child_agent = ctx.current_depth > 0;
|
let is_child_agent = ctx.current_depth > 0;
|
||||||
let suppress_spinner = is_child_agent || ctx.render_mode == RenderMode::Silent;
|
let suppress_spinner = is_child_agent || ctx.render_mode == RenderMode::Silent;
|
||||||
let spinner_message = if suppress_spinner { "" } else { "Generating" };
|
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(
|
let ret = abortable_run_with_spinner(
|
||||||
client.chat_completions(input.clone()),
|
client.chat_completions(input.clone()),
|
||||||
spinner_message,
|
spinner_message,
|
||||||
abort_signal,
|
abort_signal.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match ret {
|
match ret {
|
||||||
Ok(ret) => {
|
Ok(ret) => {
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::LlmRequestCompleted,
|
||||||
|
ctx,
|
||||||
|
&llm_completed_extras(client, request_started_at),
|
||||||
|
None,
|
||||||
|
);
|
||||||
let ChatCompletionsOutput {
|
let ChatCompletionsOutput {
|
||||||
mut text,
|
mut text,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
@@ -583,7 +681,15 @@ pub async fn call_chat_completions(
|
|||||||
ctx.record_token_usage(usage, client.model());
|
ctx.record_token_usage(usage, client.model());
|
||||||
finish_completion(ctx, text, tool_calls, thinking).await
|
finish_completion(ctx, text, tool_calls, thinking).await
|
||||||
}
|
}
|
||||||
Err(err) => Err(err),
|
Err(err) => {
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::LlmRequestFailed,
|
||||||
|
ctx,
|
||||||
|
&llm_failed_extras(client, Some(&err), abort_signal.aborted()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +706,13 @@ pub async fn call_chat_completions_streaming(
|
|||||||
handler.set_silent(true);
|
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!(
|
let (send_ret, render_ret) = tokio::join!(
|
||||||
client.chat_completions_streaming(input, &mut handler),
|
client.chat_completions_streaming(input, &mut handler),
|
||||||
render_stream(rx, client.app_config(), abort_signal.clone(), silent),
|
render_stream(rx, client.app_config(), abort_signal.clone(), silent),
|
||||||
@@ -615,12 +728,32 @@ pub async fn call_chat_completions_streaming(
|
|||||||
ctx.record_token_usage(usage, client.model());
|
ctx.record_token_usage(usage, client.model());
|
||||||
|
|
||||||
if aborted_ctrld {
|
if aborted_ctrld {
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::LlmRequestFailed,
|
||||||
|
ctx,
|
||||||
|
&llm_failed_extras(client, None, true),
|
||||||
|
None,
|
||||||
|
);
|
||||||
bail!("Aborted.");
|
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 {
|
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() {
|
if !ctx.working_mode.is_repl() || ctx.session.is_none() {
|
||||||
bail!("Aborted.");
|
bail!("Aborted.");
|
||||||
}
|
}
|
||||||
@@ -644,6 +777,12 @@ pub async fn call_chat_completions_streaming(
|
|||||||
|
|
||||||
match send_ret {
|
match send_ret {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::LlmRequestCompleted,
|
||||||
|
ctx,
|
||||||
|
&llm_completed_extras(client, request_started_at),
|
||||||
|
None,
|
||||||
|
);
|
||||||
if !silent && !text.is_empty() && !text.ends_with('\n') {
|
if !silent && !text.is_empty() && !text.ends_with('\n') {
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
@@ -653,6 +792,12 @@ pub async fn call_chat_completions_streaming(
|
|||||||
if !silent && !text.is_empty() {
|
if !silent && !text.is_empty() {
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::LlmRequestFailed,
|
||||||
|
ctx,
|
||||||
|
&llm_failed_extras(client, Some(&err), false),
|
||||||
|
None,
|
||||||
|
);
|
||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -675,10 +820,36 @@ pub async fn call_chat_completions_streaming_quiet(
|
|||||||
ctx: &mut RequestContext,
|
ctx: &mut RequestContext,
|
||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
) -> Result<(String, Vec<ToolResult>)> {
|
) -> 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() {
|
if client.model().no_stream() {
|
||||||
let output = tokio::select! {
|
let output = tokio::select! {
|
||||||
ret = client.chat_completions(input.clone()) => ret?,
|
ret = client.chat_completions(input.clone()) => match ret {
|
||||||
_ = wait_abort_signal(&abort_signal) => bail!("Aborted."),
|
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 {
|
let ChatCompletionsOutput {
|
||||||
text,
|
text,
|
||||||
@@ -688,8 +859,20 @@ pub async fn call_chat_completions_streaming_quiet(
|
|||||||
} = output;
|
} = output;
|
||||||
ctx.record_token_usage(usage, client.model());
|
ctx.record_token_usage(usage, client.model());
|
||||||
if abort_signal.aborted() {
|
if abort_signal.aborted() {
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::LlmRequestFailed,
|
||||||
|
ctx,
|
||||||
|
&llm_failed_extras(client, None, true),
|
||||||
|
None,
|
||||||
|
);
|
||||||
bail!("Aborted.");
|
bail!("Aborted.");
|
||||||
}
|
}
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::LlmRequestCompleted,
|
||||||
|
ctx,
|
||||||
|
&llm_completed_extras(client, request_started_at),
|
||||||
|
None,
|
||||||
|
);
|
||||||
return finish_completion(ctx, text, tool_calls, thinking).await;
|
return finish_completion(ctx, text, tool_calls, thinking).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,10 +888,30 @@ pub async fn call_chat_completions_streaming_quiet(
|
|||||||
ctx.record_token_usage(usage, client.model());
|
ctx.record_token_usage(usage, client.model());
|
||||||
|
|
||||||
if abort_signal.aborted() {
|
if abort_signal.aborted() {
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::LlmRequestFailed,
|
||||||
|
ctx,
|
||||||
|
&llm_failed_extras(client, None, true),
|
||||||
|
None,
|
||||||
|
);
|
||||||
bail!("Aborted.");
|
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
|
finish_completion(ctx, text, tool_calls, thinking).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,6 +1172,7 @@ mod tests {
|
|||||||
|
|
||||||
use super::super::access_token::{is_rejected, set_access_token};
|
use super::super::access_token::{is_rejected, set_access_token};
|
||||||
use crate::config::{AppState, WorkingMode};
|
use crate::config::{AppState, WorkingMode};
|
||||||
|
use anyhow::Error;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
@@ -1185,8 +1389,8 @@ mod tests {
|
|||||||
|
|
||||||
/// Wrapped in `.context(...)` so every test below proves the downcast
|
/// Wrapped in `.context(...)` so every test below proves the downcast
|
||||||
/// works through an anyhow context chain, as in the trait methods.
|
/// works through an anyhow context chain, as in the trait methods.
|
||||||
fn api_status_error(status: u16) -> anyhow::Error {
|
fn api_status_error(status: u16) -> Error {
|
||||||
anyhow::Error::new(ApiStatusError {
|
Error::new(ApiStatusError {
|
||||||
status,
|
status,
|
||||||
message: format!("error (status: {status})"),
|
message: format!("error (status: {status})"),
|
||||||
})
|
})
|
||||||
@@ -1524,4 +1728,588 @@ mod tests {
|
|||||||
assert_eq!(client.streaming_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(client.streaming_calls.load(Ordering::SeqCst), 1);
|
||||||
assert_paced_reply(&text, &results);
|
assert_paced_reply(&text, &results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fake provider whose every request fails with an [`ApiStatusError`],
|
||||||
|
/// wrapped in the same context chain the real trait defaults produce.
|
||||||
|
struct FailingStatusClient {
|
||||||
|
config: AppConfig,
|
||||||
|
model: Model,
|
||||||
|
status: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailingStatusClient {
|
||||||
|
fn new(status: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
config: AppConfig::default(),
|
||||||
|
model: Model::default(),
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error(&self) -> Error {
|
||||||
|
Error::new(ApiStatusError {
|
||||||
|
status: self.status,
|
||||||
|
message: format!("error (status: {})", self.status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Client for FailingStatusClient {
|
||||||
|
fn app_config(&self) -> &AppConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extra_config(&self) -> Option<&ExtraConfig> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_config(&self) -> Option<&RequestPatch> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"failing-status"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model(&self) -> &Model {
|
||||||
|
&self.model
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions_inner(
|
||||||
|
&self,
|
||||||
|
_client: &ReqwestClient,
|
||||||
|
_data: ChatCompletionsData,
|
||||||
|
) -> Result<ChatCompletionsOutput> {
|
||||||
|
Err(self.error())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions_streaming_inner(
|
||||||
|
&self,
|
||||||
|
_client: &ReqwestClient,
|
||||||
|
_handler: &mut SseHandler,
|
||||||
|
_data: ChatCompletionsData,
|
||||||
|
) -> Result<()> {
|
||||||
|
Err(self.error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llm_hooks_map(marker: &str) -> hooks::HooksMap {
|
||||||
|
[
|
||||||
|
"llm.request.started",
|
||||||
|
"llm.request.completed",
|
||||||
|
"llm.request.failed",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| {
|
||||||
|
(
|
||||||
|
event.to_string(),
|
||||||
|
vec![crate::hooks::HookDef {
|
||||||
|
name: marker.to_string(),
|
||||||
|
command: "true".to_string(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llm_hooked_ctx(marker: &str) -> RequestContext {
|
||||||
|
let mut app = AppState::test_default();
|
||||||
|
app.config = Arc::new(AppConfig {
|
||||||
|
hooks: llm_hooks_map(marker),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
|
||||||
|
ctx.render_mode = RenderMode::Silent;
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llm_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
|
||||||
|
hooks::test_sink::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|capture| capture.hook_name == marker)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn plain_transport_fires_llm_request_started_and_completed() {
|
||||||
|
let marker = "llm-hooks-plain-ok-x7x";
|
||||||
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
|
let mut client = paced_client(false);
|
||||||
|
client.nonstream_replies = true;
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
call_chat_completions(
|
||||||
|
&input,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
&client,
|
||||||
|
&mut ctx,
|
||||||
|
create_abort_signal(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let captures = llm_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
|
||||||
|
assert_eq!(
|
||||||
|
captures[0]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_LLM_PROVIDER")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("paced-stream")
|
||||||
|
);
|
||||||
|
assert!(captures[0].envs.contains_key("COYOTE_LLM_MODEL"));
|
||||||
|
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
|
||||||
|
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
|
||||||
|
assert!(!captures[1].envs.contains_key("COYOTE_LLM_ERROR_KIND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn plain_transport_fires_llm_request_failed_with_api_error_kind() {
|
||||||
|
let marker = "llm-hooks-plain-api-err-x7x";
|
||||||
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
|
let client = FailingStatusClient::new(429);
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
let err = call_chat_completions(
|
||||||
|
&input,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
&client,
|
||||||
|
&mut ctx,
|
||||||
|
create_abort_signal(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.downcast_ref::<ApiStatusError>().is_some(),
|
||||||
|
"hooks must not change the error: {err:?}"
|
||||||
|
);
|
||||||
|
let captures = llm_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
|
||||||
|
let failed = &captures[1];
|
||||||
|
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
|
||||||
|
Some("api_error")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
|
||||||
|
Some("429")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_LLM_ERROR").map(String::as_str),
|
||||||
|
Some("Failed to call chat-completions api"),
|
||||||
|
"the env must carry the immediate error only, not the context chain"
|
||||||
|
);
|
||||||
|
assert!(!failed.envs.contains_key("COYOTE_LLM_DURATION_MS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn streaming_transport_fires_llm_request_started_and_completed() {
|
||||||
|
let marker = "llm-hooks-stream-ok-x7x";
|
||||||
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
|
let client = paced_client(false);
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
call_chat_completions_streaming(&input, &client, &mut ctx, create_abort_signal())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let captures = llm_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
|
||||||
|
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
|
||||||
|
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn streaming_transport_abort_fires_llm_request_failed_aborted() {
|
||||||
|
let marker = "llm-hooks-stream-abort-x7x";
|
||||||
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
|
let client = paced_client(true);
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
let trigger = abort.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
|
||||||
|
trigger.set_ctrlc();
|
||||||
|
});
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
let err = call_chat_completions_streaming(&input, &client, &mut ctx, abort)
|
||||||
|
.await
|
||||||
|
.expect_err("an aborted stream must not succeed");
|
||||||
|
|
||||||
|
assert_eq!(err.to_string(), "Aborted.");
|
||||||
|
let captures = llm_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
let failed = &captures[1];
|
||||||
|
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
|
||||||
|
Some("aborted")
|
||||||
|
);
|
||||||
|
assert!(!failed.envs.contains_key("COYOTE_LLM_STATUS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn quiet_transport_fires_llm_request_started_and_completed() {
|
||||||
|
let marker = "llm-hooks-quiet-ok-x7x";
|
||||||
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
|
let client = paced_client(false);
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let captures = llm_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].event, HookEvent::LlmRequestStarted);
|
||||||
|
assert_eq!(
|
||||||
|
captures[0]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_LLM_PROVIDER")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("paced-stream")
|
||||||
|
);
|
||||||
|
assert_eq!(captures[1].event, HookEvent::LlmRequestCompleted);
|
||||||
|
assert!(captures[1].envs.contains_key("COYOTE_LLM_DURATION_MS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn quiet_transport_abort_fires_llm_request_failed_aborted() {
|
||||||
|
let marker = "llm-hooks-quiet-abort-x7x";
|
||||||
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
|
let client = paced_client(true);
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
let trigger = abort.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
|
||||||
|
trigger.set_ctrlc();
|
||||||
|
});
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
let err = call_chat_completions_streaming_quiet(&input, &client, &mut ctx, abort)
|
||||||
|
.await
|
||||||
|
.expect_err("an aborted stream must not succeed");
|
||||||
|
|
||||||
|
assert_eq!(err.to_string(), "Aborted.");
|
||||||
|
let captures = llm_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
let failed = &captures[1];
|
||||||
|
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
|
||||||
|
Some("aborted")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_LLM_ERROR").map(String::as_str),
|
||||||
|
Some("Aborted.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn quiet_transport_fires_llm_request_failed_with_api_error_kind() {
|
||||||
|
let marker = "llm-hooks-quiet-api-err-x7x";
|
||||||
|
let mut ctx = llm_hooked_ctx(marker);
|
||||||
|
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||||
|
let client = FailingStatusClient::new(500);
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
let err =
|
||||||
|
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.downcast_ref::<ApiStatusError>().is_some());
|
||||||
|
let captures = llm_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
let failed = &captures[1];
|
||||||
|
assert_eq!(failed.event, HookEvent::LlmRequestFailed);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_LLM_ERROR_KIND").map(String::as_str),
|
||||||
|
Some("api_error")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
|
||||||
|
Some("500")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal client for exercising `patch_request_data`: only the model
|
||||||
|
/// and the client-level patch config matter.
|
||||||
|
struct PatchProbeClient {
|
||||||
|
config: AppConfig,
|
||||||
|
model: Model,
|
||||||
|
patch: Option<RequestPatch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Client for PatchProbeClient {
|
||||||
|
fn app_config(&self) -> &AppConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extra_config(&self) -> Option<&ExtraConfig> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_config(&self) -> Option<&RequestPatch> {
|
||||||
|
self.patch.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.model.client_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model(&self) -> &Model {
|
||||||
|
&self.model
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions_inner(
|
||||||
|
&self,
|
||||||
|
_client: &ReqwestClient,
|
||||||
|
_data: ChatCompletionsData,
|
||||||
|
) -> Result<ChatCompletionsOutput> {
|
||||||
|
bail!("the patch probe makes no requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions_streaming_inner(
|
||||||
|
&self,
|
||||||
|
_client: &ReqwestClient,
|
||||||
|
_handler: &mut SseHandler,
|
||||||
|
_data: ChatCompletionsData,
|
||||||
|
) -> Result<()> {
|
||||||
|
bail!("the patch probe makes no requests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_probe(
|
||||||
|
client_name: &str,
|
||||||
|
model_patch: Option<Value>,
|
||||||
|
patch: Option<RequestPatch>,
|
||||||
|
) -> PatchProbeClient {
|
||||||
|
let mut data = ModelData::new("gpt-test");
|
||||||
|
data.patch = model_patch;
|
||||||
|
PatchProbeClient {
|
||||||
|
config: AppConfig::default(),
|
||||||
|
model: Model::from_config(client_name, &[data]).remove(0),
|
||||||
|
patch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_patch(json: Value) -> RequestPatch {
|
||||||
|
serde_json::from_value(json).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wire_request(wire: WireApi) -> RequestData {
|
||||||
|
RequestData::new("https://example.test/v1", json!({"model": "gpt-test"})).wire(wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_patch_accepts_a_responses_section() {
|
||||||
|
let patch = client_patch(json!({
|
||||||
|
"responses": {".*": {"body": {"store": true}}},
|
||||||
|
"chat_completions": {".*": {"body": {"temperature": 0.5}}},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert!(patch.responses.is_some());
|
||||||
|
assert!(patch.chat_completions.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_map_request_patch_responses_section_is_rejected() {
|
||||||
|
let err = serde_json::from_value::<RequestPatch>(json!({"responses": "bogus"}))
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert!(err.contains("expected a map"), "unexpected error: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_client_patch_never_touches_the_responses_wire() {
|
||||||
|
let client = patch_probe(
|
||||||
|
"patchprobechatiso",
|
||||||
|
None,
|
||||||
|
Some(client_patch(json!({
|
||||||
|
"chat_completions": {".*": {"body": {"temperature": 0.5}}}
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
let mut request_data = wire_request(WireApi::Responses);
|
||||||
|
let before = request_data.body.clone();
|
||||||
|
|
||||||
|
client.patch_request_data(&mut request_data);
|
||||||
|
|
||||||
|
assert_eq!(request_data.body, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_client_patch_never_touches_the_chat_wire() {
|
||||||
|
let patch = Some(client_patch(json!({
|
||||||
|
"responses": {".*": {"body": {"store": true}}}
|
||||||
|
})));
|
||||||
|
let client = patch_probe("patchproberespiso", None, patch);
|
||||||
|
|
||||||
|
let mut chat_request = wire_request(WireApi::Chat);
|
||||||
|
let before = chat_request.body.clone();
|
||||||
|
client.patch_request_data(&mut chat_request);
|
||||||
|
assert_eq!(chat_request.body, before);
|
||||||
|
|
||||||
|
let mut responses_request = wire_request(WireApi::Responses);
|
||||||
|
client.patch_request_data(&mut responses_request);
|
||||||
|
assert_eq!(responses_request.body["store"], json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_shaped_model_patch_never_touches_the_responses_wire() {
|
||||||
|
let client = patch_probe(
|
||||||
|
"patchprobemodeliso",
|
||||||
|
Some(json!({"body": {"reasoning_effort": "high"}})),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let mut request_data = wire_request(WireApi::Responses);
|
||||||
|
let before = request_data.body.clone();
|
||||||
|
|
||||||
|
client.patch_request_data(&mut request_data);
|
||||||
|
|
||||||
|
assert_eq!(request_data.body, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_patch_responses_subkey_is_selected_per_wire() {
|
||||||
|
let model_patch = json!({
|
||||||
|
"body": {"temperature": 0.2},
|
||||||
|
"responses": {"body": {"store": true}},
|
||||||
|
});
|
||||||
|
let client = patch_probe("patchprobesubkey", Some(model_patch), None);
|
||||||
|
|
||||||
|
let mut responses_request = wire_request(WireApi::Responses);
|
||||||
|
client.patch_request_data(&mut responses_request);
|
||||||
|
assert_eq!(responses_request.body["store"], json!(true));
|
||||||
|
assert!(responses_request.body.get("temperature").is_none());
|
||||||
|
|
||||||
|
let mut chat_request = wire_request(WireApi::Chat);
|
||||||
|
client.patch_request_data(&mut chat_request);
|
||||||
|
assert_eq!(chat_request.body["temperature"], json!(0.2));
|
||||||
|
assert!(chat_request.body.get("store").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pins the silent-ignore contract for a malformed `responses` sub-key:
|
||||||
|
/// model patches are untyped JSON maps and the whole patch layer is
|
||||||
|
/// deliberately lenient — `apply_patch` only reads object-shaped
|
||||||
|
/// `url`/`body`/`headers` keys — so a non-object `responses` sub-key
|
||||||
|
/// degrades to a no-op on the Responses wire (never a request-time
|
||||||
|
/// error, never a partial merge) and stays inert on the Chat wire,
|
||||||
|
/// where the patch's top-level keys still apply normally.
|
||||||
|
#[test]
|
||||||
|
fn malformed_model_patch_responses_subkey_is_silently_ignored() {
|
||||||
|
for malformed in [json!("oops"), json!([{"body": {"store": true}}])] {
|
||||||
|
let model_patch = json!({
|
||||||
|
"body": {"temperature": 0.2},
|
||||||
|
"responses": malformed,
|
||||||
|
});
|
||||||
|
let client = patch_probe("patchprobemalformed", Some(model_patch), None);
|
||||||
|
|
||||||
|
let mut responses_request = wire_request(WireApi::Responses);
|
||||||
|
let before_url = responses_request.url.clone();
|
||||||
|
let before_body = responses_request.body.clone();
|
||||||
|
let before_headers = responses_request.headers.clone();
|
||||||
|
client.patch_request_data(&mut responses_request);
|
||||||
|
assert_eq!(responses_request.url, before_url);
|
||||||
|
assert_eq!(responses_request.body, before_body);
|
||||||
|
assert_eq!(responses_request.headers, before_headers);
|
||||||
|
|
||||||
|
let mut chat_request = wire_request(WireApi::Chat);
|
||||||
|
client.patch_request_data(&mut chat_request);
|
||||||
|
assert_eq!(chat_request.body["temperature"], json!(0.2));
|
||||||
|
assert!(chat_request.body.get("store").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_patch_override_is_selected_per_wire() {
|
||||||
|
// Unique client name so the env vars cannot race parallel tests.
|
||||||
|
let client_name = "patchprobeenvwire";
|
||||||
|
let responses_env = get_env_name(&format!("patch_{client_name}_responses"));
|
||||||
|
let chat_env = get_env_name(&format!("patch_{client_name}_chat_completions"));
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(&responses_env, r#"{".*": {"body": {"store": true}}}"#);
|
||||||
|
std::env::set_var(&chat_env, r#"{".*": {"body": {"temperature": 0.7}}}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = patch_probe(client_name, None, None);
|
||||||
|
let mut responses_request = wire_request(WireApi::Responses);
|
||||||
|
client.patch_request_data(&mut responses_request);
|
||||||
|
let mut chat_request = wire_request(WireApi::Chat);
|
||||||
|
client.patch_request_data(&mut chat_request);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var(&responses_env);
|
||||||
|
std::env::remove_var(&chat_env);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(responses_request.body["store"], json!(true));
|
||||||
|
assert!(responses_request.body.get("temperature").is_none());
|
||||||
|
assert_eq!(chat_request.body["temperature"], json!(0.7));
|
||||||
|
assert!(chat_request.body.get("store").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_wire_patching_is_unchanged_by_wire_awareness() {
|
||||||
|
let client = patch_probe(
|
||||||
|
"patchproberegress",
|
||||||
|
Some(json!({"body": {"max_tokens": null}, "headers": {"x-model": "m"}})),
|
||||||
|
Some(client_patch(json!({
|
||||||
|
"chat_completions": {".*": {
|
||||||
|
"url": "https://patched.example/v1/chat/completions",
|
||||||
|
"body": {"temperature": 0.9},
|
||||||
|
}}
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
let mut request_data = RequestData::new(
|
||||||
|
"https://example.test/v1/chat/completions",
|
||||||
|
json!({"model": "gpt-test", "max_tokens": 100}),
|
||||||
|
);
|
||||||
|
request_data.bearer_auth("sk-test");
|
||||||
|
|
||||||
|
client.patch_request_data(&mut request_data);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request_data.url,
|
||||||
|
"https://patched.example/v1/chat/completions"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_data.body,
|
||||||
|
json!({"model": "gpt-test", "temperature": 0.9})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_data.headers.get("x-model").map(String::as_str),
|
||||||
|
Some("m")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_data
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("Bearer sk-test")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+387
-15
@@ -1,10 +1,10 @@
|
|||||||
use super::access_token::get_access_token;
|
use super::access_token::get_access_token;
|
||||||
use super::gemini_oauth::GeminiOAuthProvider;
|
use super::gemini_oauth::GeminiOAuthProvider;
|
||||||
use super::oauth;
|
use super::oauth::{self, OAuthConfig, OAuthProvider};
|
||||||
use super::vertexai::*;
|
use super::vertexai::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -17,8 +17,11 @@ pub struct GeminiConfig {
|
|||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
pub auth: Option<String>,
|
pub auth: Option<String>,
|
||||||
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
}
|
}
|
||||||
@@ -85,9 +88,17 @@ async fn prepare_chat_completions(
|
|||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<RequestData> {
|
) -> Result<RequestData> {
|
||||||
let api_base = self_
|
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
|
||||||
.get_api_base()
|
|
||||||
.unwrap_or_else(|_| API_BASE.to_string());
|
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 {
|
let func = match data.stream {
|
||||||
true => "streamGenerateContent",
|
true => "streamGenerateContent",
|
||||||
@@ -104,11 +115,9 @@ async fn prepare_chat_completions(
|
|||||||
let body = gemini_build_chat_completions_body(data, &self_.model)?;
|
let body = gemini_build_chat_completions_body(data, &self_.model)?;
|
||||||
let mut request_data = RequestData::new(url, body);
|
let mut request_data = RequestData::new(url, body);
|
||||||
|
|
||||||
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
|
|
||||||
|
|
||||||
if uses_oauth {
|
if uses_oauth {
|
||||||
let provider = GeminiOAuthProvider;
|
let (provider, _) = resolve_oauth_provider(self_)?;
|
||||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
|
||||||
if !ready {
|
if !ready {
|
||||||
bail!(
|
bail!(
|
||||||
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
||||||
@@ -118,6 +127,9 @@ async fn prepare_chat_completions(
|
|||||||
}
|
}
|
||||||
let token = get_access_token(self_.name())?;
|
let token = get_access_token(self_.name())?;
|
||||||
request_data.bearer_auth(token);
|
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() {
|
} else if let Ok(api_key) = self_.get_api_key() {
|
||||||
request_data.header("x-goog-api-key", api_key);
|
request_data.header("x-goog-api-key", api_key);
|
||||||
} else {
|
} else {
|
||||||
@@ -136,12 +148,18 @@ async fn prepare_embeddings(
|
|||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: &EmbeddingsData,
|
data: &EmbeddingsData,
|
||||||
) -> Result<RequestData> {
|
) -> 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");
|
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 {
|
let url = if uses_oauth {
|
||||||
format!(
|
format!(
|
||||||
"{}/models/{}:batchEmbedContents",
|
"{}/models/{}:batchEmbedContents",
|
||||||
@@ -177,8 +195,8 @@ async fn prepare_embeddings(
|
|||||||
let mut request_data = RequestData::new(url, body);
|
let mut request_data = RequestData::new(url, body);
|
||||||
|
|
||||||
if uses_oauth {
|
if uses_oauth {
|
||||||
let provider = GeminiOAuthProvider;
|
let (provider, _) = resolve_oauth_provider(self_)?;
|
||||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
|
||||||
if !ready {
|
if !ready {
|
||||||
bail!(
|
bail!(
|
||||||
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
||||||
@@ -188,11 +206,72 @@ async fn prepare_embeddings(
|
|||||||
}
|
}
|
||||||
let token = get_access_token(self_.name())?;
|
let token = get_access_token(self_.name())?;
|
||||||
request_data.bearer_auth(token);
|
request_data.bearer_auth(token);
|
||||||
|
for (key, value) in provider.extra_request_headers() {
|
||||||
|
request_data.header(key, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(request_data)
|
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> {
|
async fn embeddings(builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {
|
||||||
let res = builder.send().await?;
|
let res = builder.send().await?;
|
||||||
let status = res.status();
|
let status = res.status();
|
||||||
@@ -219,3 +298,296 @@ struct EmbeddingsResBody {
|
|||||||
struct EmbeddingsResBodyEmbedding {
|
struct EmbeddingsResBodyEmbedding {
|
||||||
values: Vec<f32>,
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-5
@@ -51,15 +51,24 @@ macro_rules! register_client {
|
|||||||
|
|
||||||
pub fn list_models(local_config: &$config) -> Vec<Model> {
|
pub fn list_models(local_config: &$config) -> Vec<Model> {
|
||||||
let client_name = Self::name(local_config);
|
let client_name = Self::name(local_config);
|
||||||
if local_config.models.is_empty() {
|
let find_catalog = || $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
|
||||||
if let Some(v) = $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
|
|
||||||
v.provider == $name ||
|
v.provider == $name ||
|
||||||
($name == OpenAICompatibleClient::NAME
|
($name == OpenAICompatibleClient::NAME
|
||||||
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
|
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
|
||||||
}) {
|
});
|
||||||
return Model::from_config(client_name, &v.models);
|
if local_config.models.is_empty() {
|
||||||
|
match find_catalog() {
|
||||||
|
Some(v) => Model::from_config(client_name, &v.models),
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
} else if local_config.extend_models {
|
||||||
|
match find_catalog() {
|
||||||
|
Some(v) => Model::from_config(
|
||||||
|
client_name,
|
||||||
|
&$crate::client::catalog::extend_catalog_models(&v.models, &local_config.models),
|
||||||
|
),
|
||||||
|
None => Model::from_config(client_name, &local_config.models),
|
||||||
}
|
}
|
||||||
vec![]
|
|
||||||
} else {
|
} else {
|
||||||
Model::from_config(client_name, &local_config.models)
|
Model::from_config(client_name, &local_config.models)
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-5
@@ -188,15 +188,27 @@ pub struct ImageUrl {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An extended-thinking block returned by Anthropic-protocol models.
|
/// An extended-thinking block returned by Anthropic-protocol models or an
|
||||||
/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`)
|
/// 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
|
/// 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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ThinkingBlock {
|
pub enum ThinkingBlock {
|
||||||
Thinking { thinking: String, signature: String },
|
Thinking {
|
||||||
RedactedThinking { data: String },
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@@ -204,6 +216,16 @@ pub struct MessageContentToolCalls {
|
|||||||
pub tool_results: Vec<ToolResult>,
|
pub tool_results: Vec<ToolResult>,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub sequence: bool,
|
pub sequence: bool,
|
||||||
|
/// Indices into `tool_results` where a new tool-call round begins (the
|
||||||
|
/// first round implicitly starts at index 0). Recorded by `merge()` on
|
||||||
|
/// every merge, so serializers can emit one assistant/user pair per
|
||||||
|
/// round even when the model produced no narration text for a round.
|
||||||
|
/// Keeping each round in its own message pair makes the request history
|
||||||
|
/// append-only across tool-loop iterations, which prefix-based prompt
|
||||||
|
/// caches require to produce growing cache reads instead of rewriting
|
||||||
|
/// the whole accumulated history on every request.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub round_starts: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageContentToolCalls {
|
impl MessageContentToolCalls {
|
||||||
@@ -212,6 +234,7 @@ impl MessageContentToolCalls {
|
|||||||
tool_results,
|
tool_results,
|
||||||
text,
|
text,
|
||||||
sequence: false,
|
sequence: false,
|
||||||
|
round_starts: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +244,9 @@ impl MessageContentToolCalls {
|
|||||||
{
|
{
|
||||||
first.text = Some(text);
|
first.text = Some(text);
|
||||||
}
|
}
|
||||||
|
if !self.tool_results.is_empty() && !tool_results.is_empty() {
|
||||||
|
self.round_starts.push(self.tool_results.len());
|
||||||
|
}
|
||||||
self.tool_results.extend(tool_results);
|
self.tool_results.extend(tool_results);
|
||||||
self.sequence = true;
|
self.sequence = true;
|
||||||
}
|
}
|
||||||
@@ -263,3 +289,139 @@ pub fn extract_system_message(messages: &mut Vec<Message>) -> Option<String> {
|
|||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_session_thinking_blocks_still_deserialize() {
|
||||||
|
let json = json!([
|
||||||
|
{ "type": "thinking", "thinking": "hmm", "signature": "sig123" },
|
||||||
|
{ "type": "redacted_thinking", "data": "b64data" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let blocks: Vec<ThinkingBlock> = serde_json::from_value(json.clone()).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(&blocks[0], ThinkingBlock::Thinking { thinking, signature } if thinking == "hmm" && signature == "sig123")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(&blocks[1], ThinkingBlock::RedactedThinking { data } if data == "b64data")
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::to_value(&blocks).unwrap(), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_block_round_trips() {
|
||||||
|
let json = json!({
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_1",
|
||||||
|
"summary": [{ "type": "summary_text", "text": "thinking about it" }],
|
||||||
|
"encrypted_content": "enc123",
|
||||||
|
});
|
||||||
|
|
||||||
|
let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(&block, ThinkingBlock::Reasoning { id, encrypted_content, .. } if id == "rs_1" && encrypted_content.as_deref() == Some("enc123"))
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::to_value(&block).unwrap(), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_block_without_encrypted_content_round_trips() {
|
||||||
|
let json = json!({
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_1",
|
||||||
|
"summary": [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(&block, ThinkingBlock::Reasoning { encrypted_content, .. } if encrypted_content.is_none())
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::to_value(&block).unwrap(), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thinking_blocks_round_trip_through_yaml() {
|
||||||
|
let json = json!([
|
||||||
|
{ "type": "thinking", "thinking": "hmm", "signature": "sig123" },
|
||||||
|
{ "type": "redacted_thinking", "data": "b64data" },
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_1",
|
||||||
|
"summary": [{ "type": "summary_text", "text": "thinking about it" }],
|
||||||
|
"encrypted_content": "enc123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_2",
|
||||||
|
"summary": [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let blocks: Vec<ThinkingBlock> = serde_json::from_value(json.clone()).unwrap();
|
||||||
|
|
||||||
|
let yaml = serde_yaml::to_string(&blocks).unwrap();
|
||||||
|
assert!(!yaml.contains("encrypted_content: null"));
|
||||||
|
|
||||||
|
let restored: Vec<ThinkingBlock> = serde_yaml::from_str(&yaml).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(&restored).unwrap(), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session files recorded before round tracking carry no `round_starts`
|
||||||
|
/// key — they must deserialize to an empty default, and an empty
|
||||||
|
/// `round_starts` must be skipped on serialize so new files keep the old
|
||||||
|
/// shape until a merge actually records a round boundary.
|
||||||
|
#[test]
|
||||||
|
fn tool_calls_round_starts_defaults_and_skips_when_empty() {
|
||||||
|
let json = json!({
|
||||||
|
"tool_results": [],
|
||||||
|
"text": "t",
|
||||||
|
"sequence": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed: MessageContentToolCalls = serde_json::from_value(json).unwrap();
|
||||||
|
assert!(parsed.round_starts.is_empty());
|
||||||
|
|
||||||
|
let serialized = serde_json::to_value(&parsed).unwrap();
|
||||||
|
assert!(
|
||||||
|
serialized.get("round_starts").is_none(),
|
||||||
|
"serialized: {serialized}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_calls_round_starts_round_trip_through_yaml() {
|
||||||
|
let mut tool_calls = MessageContentToolCalls::new(vec![], "t".to_string());
|
||||||
|
tool_calls.round_starts = vec![1, 3];
|
||||||
|
|
||||||
|
let yaml = serde_yaml::to_string(&tool_calls).unwrap();
|
||||||
|
let restored: MessageContentToolCalls = serde_yaml::from_str(&yaml).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(restored.round_starts, vec![1, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each `merge()` call is one tool-loop round: the boundary index must be
|
||||||
|
/// recorded even when the model produced no narration text for the round.
|
||||||
|
#[test]
|
||||||
|
fn merge_records_round_start_per_call() {
|
||||||
|
let tool_result = |id: &str| {
|
||||||
|
crate::function::ToolResult::new(
|
||||||
|
crate::function::ToolCall::new("t".into(), json!({}), Some(id.to_string())),
|
||||||
|
json!("ok"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut tool_calls = MessageContentToolCalls::new(vec![tool_result("a")], String::new());
|
||||||
|
|
||||||
|
tool_calls.merge(vec![tool_result("b"), tool_result("c")], String::new());
|
||||||
|
tool_calls.merge(vec![tool_result("d")], String::new());
|
||||||
|
|
||||||
|
assert!(tool_calls.sequence);
|
||||||
|
assert_eq!(tool_calls.round_starts, vec![1, 3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -536,4 +536,117 @@ reasoning_levels: [none, low, medium, high]
|
|||||||
|
|
||||||
assert_eq!(data.default_reasoning_effort.as_deref(), Some("none"));
|
assert_eq!(data.default_reasoning_effort.as_deref(), Some("none"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shipped_catalog_carries_no_credentials_and_only_intended_oauth_defaults() {
|
||||||
|
assert!(
|
||||||
|
!super::super::common::MODELS_YAML.contains("api_key"),
|
||||||
|
"the shipped models.yaml must never carry an api key: the model catalog is credential-free by policy"
|
||||||
|
);
|
||||||
|
|
||||||
|
let bundled: Vec<ProviderModels> =
|
||||||
|
serde_yaml::from_str(super::super::common::MODELS_YAML).unwrap();
|
||||||
|
let with_oauth: Vec<&str> = bundled
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.oauth.is_some())
|
||||||
|
.map(|p| p.provider.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
with_oauth,
|
||||||
|
["xai"],
|
||||||
|
"oauth blocks in the shipped models.yaml are limited to providers with no built-in stock oauth path (currently only xai's bundled defaults); adding one for claude/openai/gemini would silently swap stock oauth users onto a non-stock provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_models_replace_catalog_by_default() {
|
||||||
|
let ClientConfig::ClaudeConfig(config) = serde_yaml::from_str::<ClientConfig>(
|
||||||
|
"type: claude\nmodels:\n - name: test-replace-model\n",
|
||||||
|
)
|
||||||
|
.unwrap() else {
|
||||||
|
panic!("expected a claude config");
|
||||||
|
};
|
||||||
|
assert!(!config.extend_models);
|
||||||
|
|
||||||
|
let models = crate::client::ClaudeClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), 1);
|
||||||
|
assert_eq!(models[0].name(), "test-replace-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_models_extend_catalog_when_flag_set() {
|
||||||
|
let ClientConfig::ClaudeConfig(config) = serde_yaml::from_str::<ClientConfig>(
|
||||||
|
"type: claude\nextend_models: true\nmodels:\n - name: test-extra-model\n",
|
||||||
|
)
|
||||||
|
.unwrap() else {
|
||||||
|
panic!("expected a claude config");
|
||||||
|
};
|
||||||
|
|
||||||
|
let catalog_len = crate::client::ALL_PROVIDER_MODELS
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.provider == "claude")
|
||||||
|
.map(|v| v.models.len())
|
||||||
|
.unwrap();
|
||||||
|
let models = crate::client::ClaudeClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), catalog_len + 1);
|
||||||
|
assert_eq!(models.last().unwrap().name(), "test-extra-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_models_with_empty_models_returns_catalog_exactly() {
|
||||||
|
let ClientConfig::ClaudeConfig(config) =
|
||||||
|
serde_yaml::from_str::<ClientConfig>("type: claude\nextend_models: true\n").unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected a claude config");
|
||||||
|
};
|
||||||
|
|
||||||
|
let catalog = crate::client::ALL_PROVIDER_MODELS
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.provider == "claude")
|
||||||
|
.unwrap();
|
||||||
|
let models = crate::client::ClaudeClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), catalog.models.len());
|
||||||
|
for (model, catalog_model) in models.iter().zip(catalog.models.iter()) {
|
||||||
|
assert_eq!(model.name(), catalog_model.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_models_without_catalog_match_returns_config_models_only() {
|
||||||
|
let ClientConfig::OpenAICompatibleConfig(config) = serde_yaml::from_str::<ClientConfig>(
|
||||||
|
"type: openai-compatible\nname: no-such-provider-xyz\nextend_models: true\nmodels:\n - name: test-only-model\n",
|
||||||
|
)
|
||||||
|
.unwrap() else {
|
||||||
|
panic!("expected an openai-compatible config");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!crate::client::ALL_PROVIDER_MODELS
|
||||||
|
.iter()
|
||||||
|
.any(|v| "no-such-provider-xyz".starts_with(v.provider.as_str())),
|
||||||
|
"test premise: the client name must not prefix-match any catalog provider"
|
||||||
|
);
|
||||||
|
let models = crate::client::OpenAICompatibleClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), 1);
|
||||||
|
assert_eq!(models[0].name(), "test-only-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_models_extends_catalog_via_provider_name_prefix_match() {
|
||||||
|
let ClientConfig::OpenAICompatibleConfig(config) = serde_yaml::from_str::<ClientConfig>(
|
||||||
|
"type: openai-compatible\nname: openrouter-custom\nextend_models: true\nmodels:\n - name: test-net-new-model\n",
|
||||||
|
)
|
||||||
|
.unwrap() else {
|
||||||
|
panic!("expected an openai-compatible config");
|
||||||
|
};
|
||||||
|
|
||||||
|
let catalog_len = crate::client::ALL_PROVIDER_MODELS
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.provider == "openrouter")
|
||||||
|
.map(|v| v.models.len())
|
||||||
|
.unwrap();
|
||||||
|
let models = crate::client::OpenAICompatibleClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), catalog_len + 1);
|
||||||
|
assert_eq!(models.last().unwrap().name(), "test-net-new-model");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+608
-26
@@ -1,5 +1,8 @@
|
|||||||
use super::access_token::{clear_rejected, is_rejected, is_valid_access_token, set_access_token};
|
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_compatible_oauth::OpenAICompatibleOAuthProvider;
|
||||||
|
use super::openai_oauth::OpenAIOAuthProvider;
|
||||||
use super::{ClientConfig, ProviderModels};
|
use super::{ClientConfig, ProviderModels};
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||||
@@ -211,6 +214,9 @@ pub trait OAuthProvider: Send + Sync {
|
|||||||
fn use_pkce_in_device_flow(&self) -> bool {
|
fn use_pkce_in_device_flow(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
fn requires_issuer_stamp(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -221,6 +227,10 @@ pub struct OAuthTokens {
|
|||||||
pub expires_at: i64,
|
pub expires_at: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub account_id: Option<String>,
|
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);
|
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,
|
refresh_token,
|
||||||
expires_at,
|
expires_at,
|
||||||
account_id,
|
account_id,
|
||||||
|
issuer: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
save_oauth_tokens(client_name, &tokens)?;
|
store_tokens(client_name, provider, tokens)?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Successfully authenticated client '{}' with {} via OAuth. Tokens saved.",
|
"Successfully authenticated client '{}' with {} via OAuth. Tokens saved.",
|
||||||
@@ -430,8 +441,9 @@ async fn run_client_credentials_flow(
|
|||||||
refresh_token: None,
|
refresh_token: None,
|
||||||
expires_at,
|
expires_at,
|
||||||
account_id: provider.extract_account_id(&response),
|
account_id: provider.extract_account_id(&response),
|
||||||
|
issuer: None,
|
||||||
};
|
};
|
||||||
save_oauth_tokens(client_name, &tokens)?;
|
store_tokens(client_name, provider, tokens)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -597,8 +609,9 @@ async fn run_device_code_flow(provider: &dyn OAuthProvider, client_name: &str) -
|
|||||||
refresh_token,
|
refresh_token,
|
||||||
expires_at,
|
expires_at,
|
||||||
account_id,
|
account_id,
|
||||||
|
issuer: None,
|
||||||
};
|
};
|
||||||
save_oauth_tokens(client_name, &tokens)?;
|
store_tokens(client_name, provider, tokens)?;
|
||||||
println!(
|
println!(
|
||||||
"Successfully authenticated client '{}' with {} via OAuth (device_code). Tokens saved.",
|
"Successfully authenticated client '{}' with {} via OAuth (device_code). Tokens saved.",
|
||||||
client_name,
|
client_name,
|
||||||
@@ -637,12 +650,22 @@ pub fn load_oauth_tokens(client_name: &str) -> Option<OAuthTokens> {
|
|||||||
serde_json::from_str(&content).ok()
|
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);
|
let path = paths::token_file(client_name);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(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.
|
// Write-then-rename so a crash mid-write never truncates the live token file.
|
||||||
let mut tmp = path.clone().into_os_string();
|
let mut tmp = path.clone().into_os_string();
|
||||||
tmp.push(".tmp");
|
tmp.push(".tmp");
|
||||||
@@ -660,6 +683,35 @@ fn save_oauth_tokens(client_name: &str, tokens: &OAuthTokens) -> Result<()> {
|
|||||||
Ok(())
|
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 {
|
pub(crate) fn token_response_keys(response: &Value) -> String {
|
||||||
match response.as_object() {
|
match response.as_object() {
|
||||||
Some(map) => {
|
Some(map) => {
|
||||||
@@ -764,9 +816,10 @@ pub async fn refresh_oauth_token(
|
|||||||
refresh_token,
|
refresh_token,
|
||||||
expires_at,
|
expires_at,
|
||||||
account_id,
|
account_id,
|
||||||
|
issuer: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
save_oauth_tokens(client_name, &new_tokens)?;
|
save_oauth_tokens(client_name, provider, &new_tokens)?;
|
||||||
|
|
||||||
Ok(new_tokens)
|
Ok(new_tokens)
|
||||||
}
|
}
|
||||||
@@ -799,6 +852,10 @@ pub async fn prepare_oauth_access_token(
|
|||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if !issuer_matches(&tokens, provider) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
let tokens = if Utc::now().timestamp() >= tokens.expires_at
|
let tokens = if Utc::now().timestamp() >= tokens.expires_at
|
||||||
|| is_rejected(client_name, &tokens.access_token)
|
|| is_rejected(client_name, &tokens.access_token)
|
||||||
{
|
{
|
||||||
@@ -817,6 +874,10 @@ pub async fn prepare_oauth_access_token(
|
|||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if !issuer_matches(&tokens, provider) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
if Utc::now().timestamp() >= tokens.expires_at
|
if Utc::now().timestamp() >= tokens.expires_at
|
||||||
|| is_rejected(client_name, &tokens.access_token)
|
|| 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>> {
|
pub fn get_oauth_provider(provider_type: &str) -> Option<Box<dyn OAuthProvider>> {
|
||||||
match provider_type {
|
match provider_type {
|
||||||
"claude" => Some(Box::new(super::claude_oauth::ClaudeOAuthProvider)),
|
"claude" => Some(Box::new(ClaudeOAuthProvider)),
|
||||||
"gemini" => Some(Box::new(super::gemini_oauth::GeminiOAuthProvider)),
|
"gemini" => Some(Box::new(GeminiOAuthProvider)),
|
||||||
"openai" => Some(Box::new(super::openai_oauth::OpenAIOAuthProvider)),
|
"openai" => Some(Box::new(OpenAIOAuthProvider)),
|
||||||
_ => None,
|
_ => 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(
|
pub fn get_oauth_provider_for_client(
|
||||||
client_config: &ClientConfig,
|
client_config: &ClientConfig,
|
||||||
all_provider_models: &[ProviderModels],
|
all_provider_models: &[ProviderModels],
|
||||||
@@ -1015,23 +1148,29 @@ pub fn get_oauth_provider_for_client(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match client_config {
|
match client_config {
|
||||||
ClientConfig::OpenAICompatibleConfig(c) => {
|
ClientConfig::OpenAICompatibleConfig(_) => {
|
||||||
let base = all_provider_models
|
let merged = config_oauth_for_client(client_config, 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),
|
|
||||||
};
|
|
||||||
Some(Box::new(OpenAICompatibleOAuthProvider {
|
Some(Box::new(OpenAICompatibleOAuthProvider {
|
||||||
config: merged,
|
config: merged,
|
||||||
client_name: client_name.to_string(),
|
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),
|
_ => get_oauth_provider(provider_type),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1236,9 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::client::access_token::{distrust_access_token, get_access_token};
|
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::openai_compatible::OpenAICompatibleConfig;
|
||||||
use crate::client::{ModelData, ProviderModels};
|
use crate::client::{ModelData, ProviderModels};
|
||||||
use crate::utils::get_env_name;
|
use crate::utils::get_env_name;
|
||||||
@@ -1315,12 +1457,48 @@ echo_pkce_in_token_exchange: true
|
|||||||
ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig {
|
ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig {
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
api_base: Some("https://api.example/v1".into()),
|
api_base: Some("https://api.example/v1".into()),
|
||||||
api_key: None,
|
|
||||||
auth: auth.map(str::to_string),
|
auth: auth.map(str::to_string),
|
||||||
oauth: oauth.map(Box::new),
|
oauth: oauth.map(Box::new),
|
||||||
models: vec![],
|
..Default::default()
|
||||||
patch: None,
|
})
|
||||||
extra: None,
|
}
|
||||||
|
|
||||||
|
fn make_claude_client(
|
||||||
|
name: &str,
|
||||||
|
auth: Option<&str>,
|
||||||
|
oauth: Option<OAuthConfig>,
|
||||||
|
) -> ClientConfig {
|
||||||
|
ClientConfig::ClaudeConfig(ClaudeConfig {
|
||||||
|
name: Some(name.into()),
|
||||||
|
auth: auth.map(str::to_string),
|
||||||
|
oauth: oauth.map(Box::new),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_openai_client(
|
||||||
|
name: &str,
|
||||||
|
auth: Option<&str>,
|
||||||
|
oauth: Option<OAuthConfig>,
|
||||||
|
) -> ClientConfig {
|
||||||
|
ClientConfig::OpenAIConfig(OpenAIConfig {
|
||||||
|
name: Some(name.into()),
|
||||||
|
auth: auth.map(str::to_string),
|
||||||
|
oauth: oauth.map(Box::new),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_gemini_client(
|
||||||
|
name: &str,
|
||||||
|
auth: Option<&str>,
|
||||||
|
oauth: Option<OAuthConfig>,
|
||||||
|
) -> ClientConfig {
|
||||||
|
ClientConfig::GeminiConfig(GeminiConfig {
|
||||||
|
name: Some(name.into()),
|
||||||
|
auth: auth.map(str::to_string),
|
||||||
|
oauth: oauth.map(Box::new),
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,6 +1564,173 @@ echo_pkce_in_token_exchange: true
|
|||||||
assert!(get_oauth_provider_for_client(&cc, &models).is_none());
|
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]
|
#[test]
|
||||||
fn openai_compatible_provider_joins_scopes_with_spaces() {
|
fn openai_compatible_provider_joins_scopes_with_spaces() {
|
||||||
let mut cfg = base_config();
|
let mut cfg = base_config();
|
||||||
@@ -1394,6 +1739,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(provider.scopes(), "one two three");
|
assert_eq!(provider.scopes(), "one two three");
|
||||||
@@ -1408,6 +1754,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1425,6 +1772,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(provider.uses_localhost_redirect());
|
assert!(provider.uses_localhost_redirect());
|
||||||
@@ -1436,6 +1784,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: base_config(),
|
config: base_config(),
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(provider.extra_token_params().is_empty());
|
assert!(provider.extra_token_params().is_empty());
|
||||||
@@ -1546,6 +1895,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1559,6 +1909,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: base_config(),
|
config: base_config(),
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(provider.device_authorization_url().is_none());
|
assert!(provider.device_authorization_url().is_none());
|
||||||
@@ -1569,6 +1920,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: base_config(),
|
config: base_config(),
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(!provider.use_pkce_in_device_flow());
|
assert!(!provider.use_pkce_in_device_flow());
|
||||||
@@ -1582,6 +1934,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(provider.use_pkce_in_device_flow());
|
assert!(provider.use_pkce_in_device_flow());
|
||||||
@@ -1596,6 +1949,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(provider.resource(), Some("https://rs.example/"));
|
assert_eq!(provider.resource(), Some("https://rs.example/"));
|
||||||
@@ -1655,6 +2009,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(provider.fixed_redirect_uri().is_none());
|
assert!(provider.fixed_redirect_uri().is_none());
|
||||||
@@ -1669,6 +2024,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1686,6 +2042,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1815,6 +2172,7 @@ extra_token_params:
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_token_request(
|
let request = build_token_request(
|
||||||
@@ -1967,6 +2325,7 @@ extra_token_params:
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: base_config(),
|
config: base_config(),
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_token_request(
|
let request = build_token_request(
|
||||||
@@ -1991,6 +2350,7 @@ extra_token_params:
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = build_authorize_url(
|
let url = build_authorize_url(
|
||||||
@@ -2010,6 +2370,7 @@ extra_token_params:
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: base_config(),
|
config: base_config(),
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = build_authorize_url(
|
let url = build_authorize_url(
|
||||||
@@ -2032,6 +2393,7 @@ extra_token_params:
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = build_authorize_url(
|
let url = build_authorize_url(
|
||||||
@@ -2062,6 +2424,7 @@ extra_token_params:
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let params = build_device_authorization_params(&provider, "a b", None);
|
let params = build_device_authorization_params(&provider, "a b", None);
|
||||||
@@ -2076,6 +2439,7 @@ extra_token_params:
|
|||||||
let provider = OpenAICompatibleOAuthProvider {
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
config: base_config(),
|
config: base_config(),
|
||||||
client_name: "test".into(),
|
client_name: "test".into(),
|
||||||
|
requires_issuer_stamp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let params = build_device_authorization_params(&provider, "a b", Some("challenge"));
|
let params = build_device_authorization_params(&provider, "a b", Some("challenge"));
|
||||||
@@ -2094,9 +2458,10 @@ extra_token_params:
|
|||||||
refresh_token: Some("rt-456".into()),
|
refresh_token: Some("rt-456".into()),
|
||||||
expires_at: 1234567890,
|
expires_at: 1234567890,
|
||||||
account_id: Some("acct-789".into()),
|
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();
|
let loaded = load_oauth_tokens("atomic-test").unwrap();
|
||||||
assert_eq!(loaded.access_token, "at-123");
|
assert_eq!(loaded.access_token, "at-123");
|
||||||
@@ -2130,11 +2495,13 @@ extra_token_params:
|
|||||||
let expires_at = Utc::now().timestamp() + 3600;
|
let expires_at = Utc::now().timestamp() + 3600;
|
||||||
save_oauth_tokens(
|
save_oauth_tokens(
|
||||||
client_name,
|
client_name,
|
||||||
|
&ResourceStubProvider,
|
||||||
&OAuthTokens {
|
&OAuthTokens {
|
||||||
access_token: "rejected-at".into(),
|
access_token: "rejected-at".into(),
|
||||||
refresh_token: None,
|
refresh_token: None,
|
||||||
expires_at,
|
expires_at,
|
||||||
account_id: None,
|
account_id: None,
|
||||||
|
issuer: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2169,11 +2536,13 @@ extra_token_params:
|
|||||||
assert!(distrust_access_token(client_name, "rejected-at"));
|
assert!(distrust_access_token(client_name, "rejected-at"));
|
||||||
save_oauth_tokens(
|
save_oauth_tokens(
|
||||||
client_name,
|
client_name,
|
||||||
|
&ResourceStubProvider,
|
||||||
&OAuthTokens {
|
&OAuthTokens {
|
||||||
access_token: "fresh-at".into(),
|
access_token: "fresh-at".into(),
|
||||||
refresh_token: None,
|
refresh_token: None,
|
||||||
expires_at,
|
expires_at,
|
||||||
account_id: None,
|
account_id: None,
|
||||||
|
issuer: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2198,6 +2567,219 @@ extra_token_params:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn store_tokens_caches_token_and_clears_rejection() {
|
||||||
|
with_temp_cache(|| {
|
||||||
|
let client_name = "store-tokens-test";
|
||||||
|
let expires_at = Utc::now().timestamp() + 3600;
|
||||||
|
set_access_token(client_name, "old-at".into(), expires_at, None);
|
||||||
|
assert!(distrust_access_token(client_name, "old-at"));
|
||||||
|
|
||||||
|
store_tokens(
|
||||||
|
client_name,
|
||||||
|
&ResourceStubProvider,
|
||||||
|
OAuthTokens {
|
||||||
|
access_token: "fresh-at".into(),
|
||||||
|
refresh_token: None,
|
||||||
|
expires_at,
|
||||||
|
account_id: None,
|
||||||
|
issuer: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(get_access_token(client_name).unwrap(), "fresh-at");
|
||||||
|
assert!(is_valid_access_token(client_name));
|
||||||
|
assert!(
|
||||||
|
!is_rejected(client_name, "old-at"),
|
||||||
|
"rejection marker not cleared by store_tokens"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_raw_token_file(client_name: &str, json: &str) {
|
||||||
|
let path = paths::token_file(client_name);
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(path, json).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn save_oauth_tokens_stamps_issuer_fingerprint() {
|
||||||
|
with_temp_cache(|| {
|
||||||
|
save_oauth_tokens(
|
||||||
|
"issuer-stamp-test",
|
||||||
|
&ResourceStubProvider,
|
||||||
|
&OAuthTokens {
|
||||||
|
access_token: "at".into(),
|
||||||
|
refresh_token: None,
|
||||||
|
expires_at: 1234567890,
|
||||||
|
account_id: None,
|
||||||
|
issuer: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loaded = load_oauth_tokens("issuer-stamp-test").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
loaded.issuer.as_deref(),
|
||||||
|
Some("https://as.example/token|stub-client")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn prepare_rejects_unexpired_tokens_with_mismatched_issuer() {
|
||||||
|
with_temp_cache(|| {
|
||||||
|
let client_name = "issuer-mismatch-valid-test";
|
||||||
|
let expires_at = Utc::now().timestamp() + 3600;
|
||||||
|
write_raw_token_file(
|
||||||
|
client_name,
|
||||||
|
&format!(
|
||||||
|
r#"{{"access_token":"foreign-at","expires_at":{expires_at},"issuer":"https://old.example/token|old-client"}}"#
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ready = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(prepare_oauth_access_token(
|
||||||
|
&ReqwestClient::new(),
|
||||||
|
&ResourceStubProvider,
|
||||||
|
client_name,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!ready);
|
||||||
|
assert!(
|
||||||
|
get_access_token(client_name).is_err(),
|
||||||
|
"foreign token must not enter the cache"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn prepare_rejects_expired_mismatched_issuer_without_refreshing() {
|
||||||
|
with_temp_cache(|| {
|
||||||
|
let client_name = "issuer-mismatch-expired-test";
|
||||||
|
write_raw_token_file(
|
||||||
|
client_name,
|
||||||
|
r#"{"access_token":"foreign-at","refresh_token":"foreign-rt","expires_at":0,"issuer":"https://old.example/token|old-client"}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A refresh attempt would POST to the stub's token_url and fail
|
||||||
|
// with a transport error; Ok(false) proves the refresh token was
|
||||||
|
// never forwarded.
|
||||||
|
let ready = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(prepare_oauth_access_token(
|
||||||
|
&ReqwestClient::new(),
|
||||||
|
&ResourceStubProvider,
|
||||||
|
client_name,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!ready);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn prepare_accepts_legacy_tokens_without_issuer() {
|
||||||
|
with_temp_cache(|| {
|
||||||
|
let client_name = "issuer-legacy-test";
|
||||||
|
let expires_at = Utc::now().timestamp() + 3600;
|
||||||
|
write_raw_token_file(
|
||||||
|
client_name,
|
||||||
|
&format!(r#"{{"access_token":"legacy-at","expires_at":{expires_at}}}"#),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ready = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(prepare_oauth_access_token(
|
||||||
|
&ReqwestClient::new(),
|
||||||
|
&ResourceStubProvider,
|
||||||
|
client_name,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(ready);
|
||||||
|
assert_eq!(get_access_token(client_name).unwrap(), "legacy-at");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn prepare_rejects_legacy_tokens_for_claude_config_provider() {
|
||||||
|
with_temp_cache(|| {
|
||||||
|
let client_name = "issuer-legacy-claude-config-test";
|
||||||
|
let user = empty_user_override("gateway-id", "https://gateway.example/token");
|
||||||
|
let cc = make_claude_client(client_name, Some("oauth"), Some(user));
|
||||||
|
let (provider, _) = claude_oauth_provider_for_client(&cc, &[]);
|
||||||
|
write_raw_token_file(
|
||||||
|
client_name,
|
||||||
|
r#"{"access_token":"legacy-at","refresh_token":"legacy-rt","expires_at":0}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Legacy files predate config-driven claude oauth, so they are
|
||||||
|
// Anthropic-issued; Ok(false) proves neither the access token
|
||||||
|
// nor the refresh token reaches the configured endpoint.
|
||||||
|
let ready = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(prepare_oauth_access_token(
|
||||||
|
&ReqwestClient::new(),
|
||||||
|
&*provider,
|
||||||
|
client_name,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!ready);
|
||||||
|
assert!(
|
||||||
|
get_access_token(client_name).is_err(),
|
||||||
|
"legacy token must not enter the cache"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn prepare_accepts_legacy_tokens_for_stock_claude_provider() {
|
||||||
|
with_temp_cache(|| {
|
||||||
|
let client_name = "issuer-legacy-claude-stock-test";
|
||||||
|
let cc = make_claude_client(client_name, Some("oauth"), None);
|
||||||
|
let (provider, _) = claude_oauth_provider_for_client(&cc, &[]);
|
||||||
|
let expires_at = Utc::now().timestamp() + 3600;
|
||||||
|
write_raw_token_file(
|
||||||
|
client_name,
|
||||||
|
&format!(r#"{{"access_token":"legacy-at","expires_at":{expires_at}}}"#),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ready = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(prepare_oauth_access_token(
|
||||||
|
&ReqwestClient::new(),
|
||||||
|
&*provider,
|
||||||
|
client_name,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(ready);
|
||||||
|
assert_eq!(get_access_token(client_name).unwrap(), "legacy-at");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_refresh_response_invalid_grant_redacts_and_prompts_reauth() {
|
fn parse_refresh_response_invalid_grant_redacts_and_prompts_reauth() {
|
||||||
let response = serde_json::json!({
|
let response = serde_json::json!({
|
||||||
|
|||||||
+1423
-80
File diff suppressed because it is too large.
Load diff
@@ -9,15 +9,18 @@ use serde::Deserialize;
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use oauth::OAuthConfig;
|
use oauth::OAuthConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct OpenAICompatibleConfig {
|
pub struct OpenAICompatibleConfig {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
pub auth: Option<String>,
|
pub auth: Option<String>,
|
||||||
pub oauth: Option<Box<OAuthConfig>>,
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
|
pub wire_api: Option<WireApi>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
}
|
}
|
||||||
@@ -42,10 +45,13 @@ impl Client for OpenAICompatibleClient {
|
|||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<ChatCompletionsOutput> {
|
) -> 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);
|
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(
|
async fn chat_completions_streaming_inner(
|
||||||
@@ -54,11 +60,16 @@ impl Client for OpenAICompatibleClient {
|
|||||||
handler: &mut SseHandler,
|
handler: &mut SseHandler,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<()> {
|
) -> 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);
|
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
|
openai_chat_completions_streaming(builder, handler, self.model()).await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn embeddings_inner(
|
async fn embeddings_inner(
|
||||||
&self,
|
&self,
|
||||||
@@ -87,15 +98,22 @@ async fn prepare_chat_completions(
|
|||||||
self_: &OpenAICompatibleClient,
|
self_: &OpenAICompatibleClient,
|
||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
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 api_base = get_api_base_ext(self_)?;
|
||||||
let url = format!("{api_base}/chat/completions");
|
let url = match wire {
|
||||||
let body = openai_build_chat_completions_body(data, &self_.model);
|
WireApi::Responses => format!("{api_base}/responses"),
|
||||||
let mut request_data = RequestData::new(url, body);
|
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?;
|
apply_auth(self_, client, &mut request_data).await?;
|
||||||
|
|
||||||
Ok(request_data)
|
Ok((request_data, wire))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_embeddings(
|
async fn prepare_embeddings(
|
||||||
@@ -246,3 +264,133 @@ pub fn generic_build_rerank_body(data: &RerankData, model: &Model) -> Value {
|
|||||||
}
|
}
|
||||||
body
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn compat_config(wire_api: Option<WireApi>) -> OpenAICompatibleConfig {
|
||||||
|
OpenAICompatibleConfig {
|
||||||
|
name: Some("compat-test".into()),
|
||||||
|
api_base: Some("https://compat.example/v1".into()),
|
||||||
|
api_key: Some("sk-test".into()),
|
||||||
|
wire_api,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_client(config: OpenAICompatibleConfig) -> OpenAICompatibleClient {
|
||||||
|
OpenAICompatibleClient {
|
||||||
|
app_config: Arc::new(AppConfig::default()),
|
||||||
|
config,
|
||||||
|
model: Model::new("compat-test", "gpt-test"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare(
|
||||||
|
client: &OpenAICompatibleClient,
|
||||||
|
stream: bool,
|
||||||
|
) -> Result<(RequestData, WireApi)> {
|
||||||
|
let data = ChatCompletionsData {
|
||||||
|
messages: vec![Message::new(
|
||||||
|
MessageRole::User,
|
||||||
|
MessageContent::Text("hello".to_string()),
|
||||||
|
)],
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
functions: None,
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(prepare_chat_completions(
|
||||||
|
client,
|
||||||
|
&ReqwestClient::new(),
|
||||||
|
data,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_api_deserializes_on_openai_compatible_config() {
|
||||||
|
let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: responses").unwrap();
|
||||||
|
assert_eq!(config.wire_api, Some(WireApi::Responses));
|
||||||
|
|
||||||
|
let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: chat").unwrap();
|
||||||
|
assert_eq!(config.wire_api, Some(WireApi::Chat));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bogus_wire_api_is_a_config_error() {
|
||||||
|
let err = serde_yaml::from_str::<OpenAICompatibleConfig>("wire_api: bogus")
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.contains("responses") && err.contains("chat"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_wire_is_chat() {
|
||||||
|
let client = make_client(compat_config(None));
|
||||||
|
|
||||||
|
let (request_data, wire) = prepare(&client, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(wire, WireApi::Chat);
|
||||||
|
assert_eq!(
|
||||||
|
request_data.url,
|
||||||
|
"https://compat.example/v1/chat/completions"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
request_data.body.get("messages").is_some(),
|
||||||
|
"body: {}",
|
||||||
|
request_data.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_responses_wire_posts_a_responses_body_to_the_responses_endpoint() {
|
||||||
|
let client = make_client(compat_config(Some(WireApi::Responses)));
|
||||||
|
|
||||||
|
let (request_data, wire) = prepare(&client, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(wire, WireApi::Responses);
|
||||||
|
assert_eq!(request_data.url, "https://compat.example/v1/responses");
|
||||||
|
assert_eq!(request_data.body["store"], json!(false));
|
||||||
|
assert!(
|
||||||
|
request_data.body.get("input").is_some() && request_data.body.get("messages").is_none(),
|
||||||
|
"body: {}",
|
||||||
|
request_data.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_responses_wire_forks_the_streaming_path_too() {
|
||||||
|
let client = make_client(compat_config(Some(WireApi::Responses)));
|
||||||
|
|
||||||
|
let (request_data, wire) = prepare(&client, true).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(wire, WireApi::Responses);
|
||||||
|
assert_eq!(request_data.url, "https://compat.example/v1/responses");
|
||||||
|
assert_eq!(request_data.body["stream"], json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_chat_wire_keeps_the_chat_endpoint() {
|
||||||
|
let client = make_client(compat_config(Some(WireApi::Chat)));
|
||||||
|
|
||||||
|
let (request_data, wire) = prepare(&client, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(wire, WireApi::Chat);
|
||||||
|
assert_eq!(
|
||||||
|
request_data.url,
|
||||||
|
"https://compat.example/v1/chat/completions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use super::oauth::{OAuthConfig, OAuthFlow, OAuthProvider, TokenRequestFormat};
|
|||||||
pub struct OpenAICompatibleOAuthProvider {
|
pub struct OpenAICompatibleOAuthProvider {
|
||||||
pub config: OAuthConfig,
|
pub config: OAuthConfig,
|
||||||
pub client_name: String,
|
pub client_name: String,
|
||||||
|
pub requires_issuer_stamp: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_loopback_uri(uri: &str) -> bool {
|
fn is_loopback_uri(uri: &str) -> bool {
|
||||||
@@ -122,4 +123,8 @@ impl OAuthProvider for OpenAICompatibleOAuthProvider {
|
|||||||
fn use_pkce_in_device_flow(&self) -> bool {
|
fn use_pkce_in_device_flow(&self) -> bool {
|
||||||
self.config.use_pkce_in_device_flow
|
self.config.use_pkce_in_device_flow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_issuer_stamp(&self) -> bool {
|
||||||
|
self.requires_issuer_stamp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +215,14 @@ impl SseHandler {
|
|||||||
!self.buffer.is_empty() || !self.tool_calls.is_empty() || !self.thinking.is_empty()
|
!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 {
|
pub fn abort(&self) -> AbortSignal {
|
||||||
self.abort_signal.clone()
|
self.abort_signal.clone()
|
||||||
}
|
}
|
||||||
@@ -544,6 +552,23 @@ mod tests {
|
|||||||
assert!(handler.has_received_content());
|
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
|
/// Claude splits usage across events: `message_start` carries the input
|
||||||
/// and cache counts with a provisional output count, `message_delta`
|
/// and cache counts with a provisional output count, `message_delta`
|
||||||
/// carries only the final output count. Later non-None fields must win.
|
/// carries only the final output count. Later non-None fields must win.
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub struct VertexAIConfig {
|
|||||||
pub adc_file: Option<String>,
|
pub adc_file: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub prompt_cache: Option<bool>,
|
pub prompt_cache: Option<bool>,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
@@ -600,6 +602,7 @@ mod tests {
|
|||||||
tool_results: vec![],
|
tool_results: vec![],
|
||||||
text: "leftover text".to_string(),
|
text: "leftover text".to_string(),
|
||||||
sequence: false,
|
sequence: false,
|
||||||
|
round_starts: vec![],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+510
-19
@@ -1,5 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use crate::hooks::{HooksMap, RagSyncHooks};
|
||||||
use crate::{
|
use crate::{
|
||||||
client::Model,
|
client::Model,
|
||||||
config::memory,
|
config::memory,
|
||||||
@@ -12,11 +13,14 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::rag_cache::RagKey;
|
use super::rag_cache::RagKey;
|
||||||
|
use crate::config::builtin_manifest;
|
||||||
|
use crate::config::conflict::{self, InstallMode, StickyMode};
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
use crate::config::prompts::{
|
use crate::config::prompts::{
|
||||||
DEFAULT_JOB_INSTRUCTIONS, DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS,
|
DEFAULT_JOB_INSTRUCTIONS, DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS,
|
||||||
DEFAULT_TODO_INSTRUCTIONS, DEFAULT_USER_INTERACTION_INSTRUCTIONS,
|
DEFAULT_TODO_INSTRUCTIONS, DEFAULT_USER_INTERACTION_INSTRUCTIONS,
|
||||||
};
|
};
|
||||||
|
use crate::function::write_file_atomic;
|
||||||
use crate::graph::types::RagNode;
|
use crate::graph::types::RagNode;
|
||||||
use crate::graph::{Graph, GraphParser, NodeType};
|
use crate::graph::{Graph, GraphParser, NodeType};
|
||||||
use crate::mcp::McpServerFeatures;
|
use crate::mcp::McpServerFeatures;
|
||||||
@@ -27,7 +31,8 @@ use fancy_regex::Captures;
|
|||||||
use inquire::{Text, validator::Validation};
|
use inquire::{Text, validator::Validation};
|
||||||
use rust_embed::Embed;
|
use rust_embed::Embed;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{env, ffi::OsStr, path::Path};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::{env, path::Path};
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME: &str = "rag";
|
const DEFAULT_AGENT_NAME: &str = "rag";
|
||||||
|
|
||||||
@@ -46,6 +51,115 @@ pub type AgentVariables = IndexMap<String, String>;
|
|||||||
#[folder = "assets/agents/"]
|
#[folder = "assets/agents/"]
|
||||||
struct AgentAssets;
|
struct AgentAssets;
|
||||||
|
|
||||||
|
/// Splits an embedded asset path of the form `<agent>/hooks/<file>` into
|
||||||
|
/// its agent and hook-file names. Only direct children of `hooks/` count:
|
||||||
|
/// they are the only shape the builtin manifest tracks.
|
||||||
|
fn parse_direct_hook_path(path: &str) -> Option<(&str, &str)> {
|
||||||
|
let (agent, rest) = path.split_once('/')?;
|
||||||
|
let hook = direct_hook_name(rest)?;
|
||||||
|
Some((agent, hook))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_hook_name(rest: &str) -> Option<&str> {
|
||||||
|
let name = rest.strip_prefix("hooks/")?;
|
||||||
|
(!name.is_empty() && !name.contains('/')).then_some(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs one bundled agent's hook scripts under `<agent>/hooks/` and
|
||||||
|
/// reconciles that directory through its builtin manifest, mirroring the
|
||||||
|
/// role-side hook installer.
|
||||||
|
pub(crate) fn install_and_reconcile_agent_hooks(
|
||||||
|
agent: &str,
|
||||||
|
shipped: &[(String, String)],
|
||||||
|
mode: InstallMode,
|
||||||
|
sticky: &mut StickyMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
let dir = paths::agents_data_dir().join(agent).join("hooks");
|
||||||
|
let mut written = BTreeSet::new();
|
||||||
|
for (name, content) in shipped {
|
||||||
|
let path = dir.join(name);
|
||||||
|
if path.exists()
|
||||||
|
&& !conflict::should_replace_existing(&path, content, "agents", mode, sticky)?
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
"Agent hook file already exists, skipping: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ensure_parent_exists(&path)?;
|
||||||
|
info!("Creating agent hook file: {}", path.display());
|
||||||
|
write_file_atomic(&path, content, None)?;
|
||||||
|
set_executable_bit_if_script(&path)?;
|
||||||
|
written.insert(name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let names: BTreeSet<String> = shipped.iter().map(|(name, _)| name.clone()).collect();
|
||||||
|
// Reconciliation is best-effort housekeeping: a failure here must not
|
||||||
|
// abort the install, matching the role-side policy.
|
||||||
|
if let Err(err) = builtin_manifest::reconcile_builtin_dir(&dir, &names, &written) {
|
||||||
|
warn!("Failed to reconcile builtin hooks for agent '{agent}': {err}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An agent the embed stopped bundling is never visited by the per-agent
|
||||||
|
/// reconcile loop, so its installed hooks and manifest would orphan forever.
|
||||||
|
/// Any agent directory that carries a builtin hooks manifest but is absent
|
||||||
|
/// from the current embed gets its manifest-owned hooks removed, each
|
||||||
|
/// deletion logged individually by the reconcile. Only the installer writes
|
||||||
|
/// manifests, but a manual copy of a builtin agent directory carries the
|
||||||
|
/// hidden manifest along: files it lists are treated as installer-owned and
|
||||||
|
/// swept, while files absent from the manifest always survive.
|
||||||
|
/// Best-effort: a failure here must not abort the install.
|
||||||
|
fn sweep_removed_agent_hooks(bundled_files: &HashMap<String, HashSet<String>>) {
|
||||||
|
let entries = match read_dir(paths::agents_data_dir()) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to scan agents dir for removed-agent hooks: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if bundled_files.contains_key(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A symlinked agent dir is user-arranged: never follow it into a
|
||||||
|
// hooks tree that lives somewhere else.
|
||||||
|
if !entry
|
||||||
|
.path()
|
||||||
|
.symlink_metadata()
|
||||||
|
.is_ok_and(|metadata| metadata.is_dir())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let hooks_dir = entry.path().join("hooks");
|
||||||
|
if !hooks_dir
|
||||||
|
.join(builtin_manifest::BUILTIN_MANIFEST_FILE)
|
||||||
|
.is_file()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Removing hooks shipped by removed built-in agent '{name}' in {}",
|
||||||
|
hooks_dir.display()
|
||||||
|
);
|
||||||
|
if let Err(err) =
|
||||||
|
builtin_manifest::reconcile_builtin_dir(&hooks_dir, &BTreeSet::new(), &BTreeSet::new())
|
||||||
|
{
|
||||||
|
warn!("Failed to reconcile builtin hooks for removed agent '{name}': {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// remove_dir only succeeds on an empty directory, so a user file
|
||||||
|
// left behind keeps the directory in place.
|
||||||
|
let _ = fs::remove_dir(&hooks_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Agent {
|
pub struct Agent {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -64,27 +178,32 @@ pub struct Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
pub fn install_builtin_agents(force: bool) -> Result<()> {
|
pub fn install_builtin_agents(mode: InstallMode) -> Result<()> {
|
||||||
info!(
|
info!(
|
||||||
"Installing built-in agents in {}",
|
"Installing built-in agents in {}",
|
||||||
paths::agents_data_dir().display()
|
paths::agents_data_dir().display()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
let mut written_hooks: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||||
for file in AgentAssets::iter() {
|
for file in AgentAssets::iter() {
|
||||||
debug!("Processing agent file: {}", file.as_ref());
|
debug!("Processing agent file: {}", file.as_ref());
|
||||||
|
|
||||||
let embedded_file = AgentAssets::get(&file)
|
let embedded_file = AgentAssets::get(&file)
|
||||||
.ok_or_else(|| anyhow!("Failed to load embedded agent file: {}", file.as_ref()))?;
|
.ok_or_else(|| anyhow!("Failed to load embedded agent file: {}", file.as_ref()))?;
|
||||||
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
let content = std::str::from_utf8(&embedded_file.data)
|
||||||
|
.expect("bundled agent asset is not valid UTF-8");
|
||||||
let file_path = paths::agents_data_dir().join(file.as_ref());
|
let file_path = paths::agents_data_dir().join(file.as_ref());
|
||||||
let file_extension = file_path
|
|
||||||
.extension()
|
|
||||||
.and_then(OsStr::to_str)
|
|
||||||
.map(|s| s.to_lowercase());
|
|
||||||
#[cfg_attr(not(unix), expect(unused))]
|
|
||||||
let is_script = matches!(file_extension.as_deref(), Some("sh") | Some("py"));
|
|
||||||
|
|
||||||
if file_path.exists() && !force {
|
if file_path.exists()
|
||||||
|
&& !conflict::should_replace_existing(
|
||||||
|
&file_path,
|
||||||
|
content,
|
||||||
|
"agents",
|
||||||
|
mode,
|
||||||
|
&mut sticky,
|
||||||
|
)?
|
||||||
|
{
|
||||||
debug!(
|
debug!(
|
||||||
"Agent file already exists, skipping: {}",
|
"Agent file already exists, skipping: {}",
|
||||||
file_path.display()
|
file_path.display()
|
||||||
@@ -96,11 +215,12 @@ impl Agent {
|
|||||||
info!("Creating agent file: {}", file_path.display());
|
info!("Creating agent file: {}", file_path.display());
|
||||||
let mut agent_file = File::create(&file_path)?;
|
let mut agent_file = File::create(&file_path)?;
|
||||||
agent_file.write_all(content.as_bytes())?;
|
agent_file.write_all(content.as_bytes())?;
|
||||||
|
set_executable_bit_if_script(&file_path)?;
|
||||||
#[cfg(unix)]
|
if let Some((agent, hook)) = parse_direct_hook_path(file.as_ref()) {
|
||||||
if is_script {
|
written_hooks
|
||||||
use std::{fs, os::unix::fs::PermissionsExt};
|
.entry(agent.to_string())
|
||||||
fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755))?;
|
.or_default()
|
||||||
|
.insert(hook.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +254,53 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hook filenames are open-ended (unlike AGENT_DEFINITION_FILES), so
|
||||||
|
// each bundled agent's hooks/ directory reconciles through its
|
||||||
|
// builtin manifest: only files the installer previously shipped are
|
||||||
|
// removal candidates, and user-created files in the same directory
|
||||||
|
// are never touched. Only direct children of hooks/ are tracked;
|
||||||
|
// nested shipped files install but are not reconciled.
|
||||||
|
for (agent, files) in &bundled_files {
|
||||||
|
let shipped: BTreeSet<String> = files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|rest| direct_hook_name(rest))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect();
|
||||||
|
let written = written_hooks.remove(agent.as_str()).unwrap_or_default();
|
||||||
|
let hooks_dir = paths::agents_data_dir().join(agent).join("hooks");
|
||||||
|
if let Err(err) =
|
||||||
|
builtin_manifest::reconcile_builtin_dir(&hooks_dir, &shipped, &written)
|
||||||
|
{
|
||||||
|
warn!("Failed to reconcile builtin hooks for agent '{agent}': {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep_removed_agent_hooks(&bundled_files);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_builtin_agent_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> {
|
||||||
|
let mut shipped: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
|
||||||
|
for file in AgentAssets::iter() {
|
||||||
|
let Some((agent, rest)) = file.as_ref().split_once('/') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let hooks = shipped.entry(agent.to_string()).or_default();
|
||||||
|
let Some(hook) = direct_hook_name(rest) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let embedded = AgentAssets::get(&file)
|
||||||
|
.ok_or_else(|| anyhow!("Failed to load embedded agent file: {}", file.as_ref()))?;
|
||||||
|
let content = std::str::from_utf8(&embedded.data)
|
||||||
|
.expect("bundled agent hook asset is not valid UTF-8");
|
||||||
|
hooks.push((hook.to_string(), content.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (agent, files) in &shipped {
|
||||||
|
install_and_reconcile_agent_hooks(agent, files, mode, sticky)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +356,13 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let rag_sync_hooks = RagSyncHooks::resolve_for_agent(
|
||||||
|
&app.hooks,
|
||||||
|
&agent_config.global_hooks,
|
||||||
|
&agent_config.hooks,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
|
||||||
let rag = if rag_path.exists() {
|
let rag = if rag_path.exists() {
|
||||||
let key = RagKey::Agent(name.to_string());
|
let key = RagKey::Agent(name.to_string());
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
@@ -221,6 +395,7 @@ impl Agent {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let rag_path_clone = rag_path.clone();
|
let rag_path_clone = rag_path.clone();
|
||||||
let abort = abort_signal.clone();
|
let abort = abort_signal.clone();
|
||||||
|
let sync_hooks = rag_sync_hooks.clone();
|
||||||
let rag = app_state
|
let rag = app_state
|
||||||
.rag_cache
|
.rag_cache
|
||||||
.load_with(key, || async move {
|
.load_with(key, || async move {
|
||||||
@@ -231,6 +406,7 @@ impl Agent {
|
|||||||
&document_paths,
|
&document_paths,
|
||||||
abort,
|
abort,
|
||||||
true,
|
true,
|
||||||
|
sync_hooks,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -254,6 +430,7 @@ impl Agent {
|
|||||||
&loaders,
|
&loaders,
|
||||||
info_flag,
|
info_flag,
|
||||||
abort_signal.clone(),
|
abort_signal.clone(),
|
||||||
|
&rag_sync_hooks,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
@@ -639,6 +816,14 @@ impl Agent {
|
|||||||
self.config.compression_model.as_deref()
|
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 {
|
pub fn is_dynamic_instructions(&self) -> bool {
|
||||||
self.config.dynamic_instructions
|
self.config.dynamic_instructions
|
||||||
}
|
}
|
||||||
@@ -848,6 +1033,10 @@ pub struct AgentConfig {
|
|||||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_tools: Vec<String>,
|
pub global_tools: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hooks: HooksMap,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_hooks: Vec<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub skills_enabled: Option<bool>,
|
pub skills_enabled: Option<bool>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -917,6 +1106,8 @@ impl AgentConfig {
|
|||||||
reasoning_effort: graph.reasoning_effort.clone(),
|
reasoning_effort: graph.reasoning_effort.clone(),
|
||||||
description: graph.description.clone(),
|
description: graph.description.clone(),
|
||||||
global_tools: graph.global_tools.clone(),
|
global_tools: graph.global_tools.clone(),
|
||||||
|
hooks: graph.hooks.clone(),
|
||||||
|
global_hooks: graph.global_hooks.clone(),
|
||||||
mcp_servers: graph.mcp_servers.clone(),
|
mcp_servers: graph.mcp_servers.clone(),
|
||||||
mcp_tools: graph.mcp_tools.clone(),
|
mcp_tools: graph.mcp_tools.clone(),
|
||||||
skills_enabled: graph.skills_enabled,
|
skills_enabled: graph.skills_enabled,
|
||||||
@@ -960,10 +1151,21 @@ impl AgentConfig {
|
|||||||
if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) {
|
if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) {
|
||||||
self.reasoning_effort = v;
|
self.reasoning_effort = v;
|
||||||
}
|
}
|
||||||
if let Ok(v) = env::var(with_prefix("global_tools"))
|
if let Ok(v) = env::var(with_prefix("global_tools")) {
|
||||||
&& let Ok(v) = serde_json::from_str(&v)
|
match serde_json::from_str(&v) {
|
||||||
{
|
Ok(v) => self.global_tools = v,
|
||||||
self.global_tools = v;
|
Err(err) => {
|
||||||
|
debug!("Ignoring malformed global_tools env override for agent '{name}': {err}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(v) = env::var(with_prefix("global_hooks")) {
|
||||||
|
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"))
|
if let Ok(v) = env::var(with_prefix("mcp_servers"))
|
||||||
&& let Ok(v) = serde_json::from_str(&v)
|
&& let Ok(v) = serde_json::from_str(&v)
|
||||||
@@ -1111,6 +1313,7 @@ async fn init_graph_rags(
|
|||||||
loaders: &HashMap<String, String>,
|
loaders: &HashMap<String, String>,
|
||||||
info_flag: bool,
|
info_flag: bool,
|
||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
|
sync_hooks: &RagSyncHooks,
|
||||||
) -> Result<HashMap<String, Arc<Rag>>> {
|
) -> Result<HashMap<String, Arc<Rag>>> {
|
||||||
let mut rags = HashMap::new();
|
let mut rags = HashMap::new();
|
||||||
if info_flag {
|
if info_flag {
|
||||||
@@ -1187,6 +1390,7 @@ async fn init_graph_rags(
|
|||||||
let path_clone = rag_path.clone();
|
let path_clone = rag_path.clone();
|
||||||
let name_clone = node_id.clone();
|
let name_clone = node_id.clone();
|
||||||
let abort = abort_signal.clone();
|
let abort = abort_signal.clone();
|
||||||
|
let sync_hooks = sync_hooks.clone();
|
||||||
app_state
|
app_state
|
||||||
.rag_cache
|
.rag_cache
|
||||||
.load_with(key, || async move {
|
.load_with(key, || async move {
|
||||||
@@ -1197,6 +1401,7 @@ async fn init_graph_rags(
|
|||||||
&document_paths,
|
&document_paths,
|
||||||
&config,
|
&config,
|
||||||
abort,
|
abort,
|
||||||
|
sync_hooks,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -1294,6 +1499,17 @@ pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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]
|
#[test]
|
||||||
fn agent_config_parses_from_yaml() {
|
fn agent_config_parses_from_yaml() {
|
||||||
@@ -1336,6 +1552,119 @@ variables:
|
|||||||
assert_eq!(config.variables[0].name, "username");
|
assert_eq!(config.variables[0].name, "username");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_config_hooks_and_global_hooks_round_trip() {
|
||||||
|
let yaml = "\
|
||||||
|
name: hooked
|
||||||
|
instructions: hi
|
||||||
|
hooks:
|
||||||
|
tool.started:
|
||||||
|
- name: notify
|
||||||
|
command: ./hooks/notify.sh
|
||||||
|
global_hooks:
|
||||||
|
- tool.started.notify
|
||||||
|
";
|
||||||
|
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.hooks["tool.started"][0].name, "notify");
|
||||||
|
assert_eq!(config.hooks["tool.started"][0].command, "./hooks/notify.sh");
|
||||||
|
assert_eq!(config.global_hooks, vec!["tool.started.notify"]);
|
||||||
|
|
||||||
|
let serialized = serde_yaml::to_string(&config).unwrap();
|
||||||
|
let reparsed: AgentConfig = serde_yaml::from_str(&serialized).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reparsed.hooks, config.hooks);
|
||||||
|
assert_eq!(reparsed.global_hooks, config.global_hooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_envs_overrides_global_hooks() {
|
||||||
|
let yaml = "name: hooks-env-probe\ninstructions: hi\nglobal_hooks:\n - initial.hook\n";
|
||||||
|
let mut config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let env_name = normalize_env_name("hooks-env-probe_global_hooks");
|
||||||
|
let prev = env::var_os(&env_name);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::set_var(
|
||||||
|
&env_name,
|
||||||
|
r#"["tool.started.notify","turn.completed.webhook"]"#,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
config.load_envs(&AppConfig::default());
|
||||||
|
assert_eq!(
|
||||||
|
config.global_hooks,
|
||||||
|
vec!["tool.started.notify", "turn.completed.webhook"]
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe { env::set_var(&env_name, "not json") };
|
||||||
|
config.load_envs(&AppConfig::default());
|
||||||
|
assert_eq!(
|
||||||
|
config.global_hooks,
|
||||||
|
vec!["tool.started.notify", "turn.completed.webhook"]
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match prev {
|
||||||
|
Some(v) => env::set_var(&env_name, v),
|
||||||
|
None => env::remove_var(&env_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn load_envs_ignores_malformed_global_tools_and_logs_debug() {
|
||||||
|
testing::install_log_collector();
|
||||||
|
let name = "gt-malformed-envtest";
|
||||||
|
let yaml = format!("name: {name}\ninstructions: hi\nglobal_tools:\n - keep_tool.sh\n");
|
||||||
|
let mut config: AgentConfig = serde_yaml::from_str(&yaml).unwrap();
|
||||||
|
let env_name = normalize_env_name(&format!("{name}_global_tools"));
|
||||||
|
let _guard = testing::EnvVarGuard::set(&env_name, "{not-json");
|
||||||
|
|
||||||
|
config.load_envs(&AppConfig::default());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.global_tools,
|
||||||
|
vec!["keep_tool.sh"],
|
||||||
|
"a malformed override must leave the existing value untouched"
|
||||||
|
);
|
||||||
|
let debugs = testing::debug_snapshot();
|
||||||
|
assert!(
|
||||||
|
debugs.iter().any(|message| {
|
||||||
|
message.contains("Ignoring malformed global_tools env override")
|
||||||
|
&& message.contains(name)
|
||||||
|
}),
|
||||||
|
"expected a debug log for the malformed override: {debugs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_graph_carries_hooks_and_global_hooks() {
|
||||||
|
let yaml = "\
|
||||||
|
name: g
|
||||||
|
start: e
|
||||||
|
hooks:
|
||||||
|
turn.completed:
|
||||||
|
- name: webhook
|
||||||
|
command: curl -s https://example.com/hook
|
||||||
|
global_hooks:
|
||||||
|
- turn.completed.webhook
|
||||||
|
nodes:
|
||||||
|
e:
|
||||||
|
id: e
|
||||||
|
type: end
|
||||||
|
output: done
|
||||||
|
";
|
||||||
|
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
let config = AgentConfig::from_graph("g", &graph);
|
||||||
|
|
||||||
|
assert_eq!(config.hooks, graph.hooks);
|
||||||
|
assert_eq!(config.global_hooks, graph.global_hooks);
|
||||||
|
assert_eq!(config.hooks["turn.completed"][0].name, "webhook");
|
||||||
|
assert_eq!(config.global_hooks, vec!["turn.completed.webhook"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agent_config_defaults() {
|
fn agent_config_defaults() {
|
||||||
let yaml = "name: minimal\ninstructions: hi\n";
|
let yaml = "name: minimal\ninstructions: hi\n";
|
||||||
@@ -1350,6 +1679,8 @@ variables:
|
|||||||
assert_eq!(config.escalation_timeout, 0);
|
assert_eq!(config.escalation_timeout, 0);
|
||||||
assert!(config.mcp_servers.is_empty());
|
assert!(config.mcp_servers.is_empty());
|
||||||
assert!(config.global_tools.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.conversation_starters.is_empty());
|
||||||
assert!(config.variables.is_empty());
|
assert!(config.variables.is_empty());
|
||||||
assert!(config.model_id.is_none());
|
assert!(config.model_id.is_none());
|
||||||
@@ -1731,4 +2062,164 @@ nodes: {}
|
|||||||
);
|
);
|
||||||
assert_eq!(output, expected);
|
assert_eq!(output, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use crate::testing::TestConfigDirGuard;
|
||||||
|
|
||||||
|
fn fixture(entries: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|(name, content)| (name.to_string(), content.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn agent_hooks_prompt_mode_asks_per_conflict_and_honors_sticky() {
|
||||||
|
use crate::config::conflict::prompt_script;
|
||||||
|
|
||||||
|
let _guard = TestConfigDirGuard::new("agent-hooks-prompt");
|
||||||
|
let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]);
|
||||||
|
let dir = paths::agents_data_dir().join("probe").join("hooks");
|
||||||
|
create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("a.sh"), "local a").unwrap();
|
||||||
|
fs::write(dir.join("b.sh"), "local b").unwrap();
|
||||||
|
|
||||||
|
// First conflict answered per-file, second by the sticky replace-all.
|
||||||
|
let script = prompt_script::install(&["replace-all"]);
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 1);
|
||||||
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
|
||||||
|
assert_eq!(read_to_string(dir.join("b.sh")).unwrap(), "new content\n");
|
||||||
|
assert_eq!(sticky, StickyMode::ReplaceAll);
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"a.sh\nb.sh\n"
|
||||||
|
);
|
||||||
|
drop(script);
|
||||||
|
|
||||||
|
// A sticky mode carried in from an earlier location suppresses all
|
||||||
|
// prompting: `--install-builtins hooks` shares one sticky scope
|
||||||
|
// across the global, role, and agent hook locations.
|
||||||
|
fs::write(dir.join("a.sh"), "local again").unwrap();
|
||||||
|
let script = prompt_script::install(&[]);
|
||||||
|
let mut sticky = StickyMode::KeepAll;
|
||||||
|
install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "local again");
|
||||||
|
drop(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn agent_hooks_reconcile_adds_updates_and_drops_within_a_shipped_agent() {
|
||||||
|
let _guard = TestConfigDirGuard::new("agent-hooks-reconcile");
|
||||||
|
let dir = paths::agents_data_dir().join("probe").join("hooks");
|
||||||
|
|
||||||
|
install_and_reconcile_agent_hooks(
|
||||||
|
"probe",
|
||||||
|
&fixture(&[("keep.sh", "v1\n"), ("drop.sh", "#!/bin/sh\n")]),
|
||||||
|
InstallMode::Skip,
|
||||||
|
&mut StickyMode::None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
|
install_and_reconcile_agent_hooks(
|
||||||
|
"probe",
|
||||||
|
&fixture(&[("keep.sh", "v2\n"), ("added.sh", "#!/bin/sh\n")]),
|
||||||
|
InstallMode::Force,
|
||||||
|
&mut StickyMode::None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(dir.join("keep.sh")).unwrap(),
|
||||||
|
"v2\n",
|
||||||
|
"a still-shipped hook updates under Force"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(dir.join("added.sh")).unwrap(),
|
||||||
|
"#!/bin/sh\n",
|
||||||
|
"a newly shipped hook is added"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!dir.join("drop.sh").exists(),
|
||||||
|
"a dropped hook reconciles away"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(dir.join("user.sh")).unwrap(),
|
||||||
|
"user-owned",
|
||||||
|
"user files in the same dir survive"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"added.sh\nkeep.sh\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn install_builtin_agent_hooks_reconciles_hookless_bundled_agents() {
|
||||||
|
let _guard = TestConfigDirGuard::new("agent-hooks-hookless");
|
||||||
|
let agent = AgentAssets::iter()
|
||||||
|
.filter_map(|file| {
|
||||||
|
file.as_ref()
|
||||||
|
.split_once('/')
|
||||||
|
.map(|(agent, _)| agent.to_string())
|
||||||
|
})
|
||||||
|
.find(|agent| {
|
||||||
|
!AgentAssets::iter().any(|file| {
|
||||||
|
parse_direct_hook_path(file.as_ref())
|
||||||
|
.is_some_and(|(hook_agent, _)| hook_agent == agent)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.expect("at least one bundled agent ships no hooks");
|
||||||
|
let dir = paths::agents_data_dir().join(&agent).join("hooks");
|
||||||
|
// A manifest-owned hook left over from a release that shipped it.
|
||||||
|
install_and_reconcile_agent_hooks(
|
||||||
|
&agent,
|
||||||
|
&fixture(&[("stale.sh", "#!/bin/sh\n")]),
|
||||||
|
InstallMode::Skip,
|
||||||
|
&mut StickyMode::None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
|
Agent::install_builtin_agent_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!dir.join("stale.sh").exists(),
|
||||||
|
"a still-bundled agent with no hook assets must reconcile its hooks dir"
|
||||||
|
);
|
||||||
|
assert_eq!(read_to_string(dir.join("user.sh")).unwrap(), "user-owned");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn sweep_removed_agent_hooks_never_follows_symlinked_agent_dirs() {
|
||||||
|
let guard = TestConfigDirGuard::new("agent-sweep-symlink");
|
||||||
|
let real = guard.path.join("agent-elsewhere");
|
||||||
|
let hooks = real.join("hooks");
|
||||||
|
create_dir_all(&hooks).unwrap();
|
||||||
|
fs::write(
|
||||||
|
hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE),
|
||||||
|
"shipped.sh\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(hooks.join("shipped.sh"), "stale").unwrap();
|
||||||
|
create_dir_all(paths::agents_data_dir()).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&real, paths::agents_data_dir().join("linked")).unwrap();
|
||||||
|
|
||||||
|
sweep_removed_agent_hooks(&HashMap::new());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
hooks.join("shipped.sh").exists(),
|
||||||
|
"the sweep must never follow a symlinked agent dir"
|
||||||
|
);
|
||||||
|
assert!(hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::client::{ClientConfig, Model, ModelType, list_models};
|
use crate::client::{ClientConfig, Model, ModelType, list_models};
|
||||||
|
use crate::hooks::HooksMap;
|
||||||
use crate::render::{MarkdownRender, RenderOptions};
|
use crate::render::{MarkdownRender, RenderOptions};
|
||||||
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, drain_stale_tty_input, get_env_name};
|
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 enabled_mcp_servers: Option<Vec<String>>,
|
||||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub hooks: HooksMap,
|
||||||
|
|
||||||
pub auto_continue: bool,
|
pub auto_continue: bool,
|
||||||
pub max_auto_continues: usize,
|
pub max_auto_continues: usize,
|
||||||
pub inject_todo_instructions: bool,
|
pub inject_todo_instructions: bool,
|
||||||
@@ -67,6 +71,7 @@ pub struct AppConfig {
|
|||||||
pub compression_threshold: usize,
|
pub compression_threshold: usize,
|
||||||
pub compression_keep_last: usize,
|
pub compression_keep_last: usize,
|
||||||
pub compression_model: Option<String>,
|
pub compression_model: Option<String>,
|
||||||
|
pub compression_safety_valve: f32,
|
||||||
pub summarization_prompt: Option<String>,
|
pub summarization_prompt: Option<String>,
|
||||||
pub summary_context_prompt: Option<String>,
|
pub summary_context_prompt: Option<String>,
|
||||||
pub max_tool_result_chars: Option<usize>,
|
pub max_tool_result_chars: Option<usize>,
|
||||||
@@ -143,6 +148,8 @@ impl Default for AppConfig {
|
|||||||
enabled_mcp_servers: None,
|
enabled_mcp_servers: None,
|
||||||
mcp_tools: None,
|
mcp_tools: None,
|
||||||
|
|
||||||
|
hooks: Default::default(),
|
||||||
|
|
||||||
auto_continue: false,
|
auto_continue: false,
|
||||||
max_auto_continues: 10,
|
max_auto_continues: 10,
|
||||||
inject_todo_instructions: true,
|
inject_todo_instructions: true,
|
||||||
@@ -158,6 +165,7 @@ impl Default for AppConfig {
|
|||||||
compression_threshold: 4000,
|
compression_threshold: 4000,
|
||||||
compression_keep_last: 0,
|
compression_keep_last: 0,
|
||||||
compression_model: None,
|
compression_model: None,
|
||||||
|
compression_safety_valve: 0.8,
|
||||||
summarization_prompt: None,
|
summarization_prompt: None,
|
||||||
summary_context_prompt: None,
|
summary_context_prompt: None,
|
||||||
max_tool_result_chars: None,
|
max_tool_result_chars: None,
|
||||||
@@ -204,6 +212,18 @@ impl Default for AppConfig {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn from_config(config: super::Config) -> Result<Self> {
|
pub fn from_config(config: super::Config) -> Result<Self> {
|
||||||
|
Self::from_config_impl(config, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::from_config`], but tolerates a config with no resolvable
|
||||||
|
/// model: `model_id` stays empty instead of failing with "No available
|
||||||
|
/// model". For inspection readouts (--info, --list-*, --mcp-list,
|
||||||
|
/// --list-secrets), which render model fields as absent/default.
|
||||||
|
pub fn from_config_lenient(config: super::Config) -> Result<Self> {
|
||||||
|
Self::from_config_impl(config, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_config_impl(config: super::Config, require_model: bool) -> Result<Self> {
|
||||||
let mut app_config = Self {
|
let mut app_config = Self {
|
||||||
model_id: config.model_id,
|
model_id: config.model_id,
|
||||||
temperature: config.temperature,
|
temperature: config.temperature,
|
||||||
@@ -235,6 +255,8 @@ impl AppConfig {
|
|||||||
enabled_mcp_servers: config.enabled_mcp_servers,
|
enabled_mcp_servers: config.enabled_mcp_servers,
|
||||||
mcp_tools: config.mcp_tools,
|
mcp_tools: config.mcp_tools,
|
||||||
|
|
||||||
|
hooks: config.hooks,
|
||||||
|
|
||||||
auto_continue: config.auto_continue,
|
auto_continue: config.auto_continue,
|
||||||
max_auto_continues: config.max_auto_continues,
|
max_auto_continues: config.max_auto_continues,
|
||||||
inject_todo_instructions: config.inject_todo_instructions,
|
inject_todo_instructions: config.inject_todo_instructions,
|
||||||
@@ -250,6 +272,7 @@ impl AppConfig {
|
|||||||
compression_threshold: config.compression_threshold,
|
compression_threshold: config.compression_threshold,
|
||||||
compression_keep_last: config.compression_keep_last,
|
compression_keep_last: config.compression_keep_last,
|
||||||
compression_model: config.compression_model,
|
compression_model: config.compression_model,
|
||||||
|
compression_safety_valve: config.compression_safety_valve,
|
||||||
summarization_prompt: config.summarization_prompt,
|
summarization_prompt: config.summarization_prompt,
|
||||||
summary_context_prompt: config.summary_context_prompt,
|
summary_context_prompt: config.summary_context_prompt,
|
||||||
max_tool_result_chars: config.max_tool_result_chars,
|
max_tool_result_chars: config.max_tool_result_chars,
|
||||||
@@ -298,7 +321,13 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
app_config.setup_document_loaders();
|
app_config.setup_document_loaders();
|
||||||
app_config.setup_user_agent();
|
app_config.setup_user_agent();
|
||||||
|
if require_model {
|
||||||
app_config.resolve_model()?;
|
app_config.resolve_model()?;
|
||||||
|
} else {
|
||||||
|
// Model-less is fine here: model_id stays empty and inspection
|
||||||
|
// callers substitute Model::default().
|
||||||
|
let _ = app_config.resolve_model();
|
||||||
|
}
|
||||||
app_config.validate_reasoning_effort()?;
|
app_config.validate_reasoning_effort()?;
|
||||||
Ok(app_config)
|
Ok(app_config)
|
||||||
}
|
}
|
||||||
@@ -572,6 +601,15 @@ impl AppConfig {
|
|||||||
self.enabled_mcp_servers = v.map(|raw| super::csv_to_vec(&raw));
|
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")) {
|
if let Some(v) = super::read_env_value::<String>(&get_env_name("repl_prelude")) {
|
||||||
self.repl_prelude = v;
|
self.repl_prelude = v;
|
||||||
}
|
}
|
||||||
@@ -788,6 +826,77 @@ mod tests {
|
|||||||
assert_eq!(app.clients.len(), 1);
|
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]
|
#[test]
|
||||||
fn from_config_copies_mapping_fields() {
|
fn from_config_copies_mapping_fields() {
|
||||||
let mut cfg = Config {
|
let mut cfg = Config {
|
||||||
@@ -1181,6 +1290,18 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_config_errors_when_no_model_available() {
|
||||||
|
let result = AppConfig::from_config(Config::default());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_config_lenient_tolerates_missing_model() {
|
||||||
|
let app = AppConfig::from_config_lenient(Config::default()).unwrap();
|
||||||
|
assert!(app.model_id.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_model_keeps_explicit_model_id() {
|
fn resolve_model_keeps_explicit_model_id() {
|
||||||
let mut app = AppConfig {
|
let mut app = AppConfig {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ impl AppState {
|
|||||||
log_path: Option<PathBuf>,
|
log_path: Option<PathBuf>,
|
||||||
start_mcp_servers: bool,
|
start_mcp_servers: bool,
|
||||||
lenient_vault: bool,
|
lenient_vault: bool,
|
||||||
|
inspection: bool,
|
||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let vault = if lenient_vault {
|
let vault = if lenient_vault {
|
||||||
@@ -74,7 +75,11 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut functions = Functions::init(config.visible_tools.as_deref())?;
|
let mut functions = if inspection {
|
||||||
|
Functions::default()
|
||||||
|
} else {
|
||||||
|
Functions::init(config.visible_tools.as_deref())?
|
||||||
|
};
|
||||||
if !mcp_registry.is_empty() && config.mcp_server_support {
|
if !mcp_registry.is_empty() && config.mcp_server_support {
|
||||||
functions.append_mcp_meta_functions(mcp_registry.server_features());
|
functions.append_mcp_meta_functions(mcp_registry.server_features());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
//! Per-directory tracking of files the builtin installer shipped, so a later
|
||||||
|
//! release that stops shipping an asset can remove the stale copy without
|
||||||
|
//! ever touching user-created files that live in the same directory.
|
||||||
|
//!
|
||||||
|
//! Unlike agent definition files, hook filenames are open-ended: no closed
|
||||||
|
//! candidate list can distinguish "previously bundled" from "user-authored".
|
||||||
|
//! The manifest records exactly what the installer shipped; anything absent
|
||||||
|
//! from it is user-owned and untouchable.
|
||||||
|
|
||||||
|
use crate::config::ensure_parent_exists;
|
||||||
|
use crate::function::write_file_atomic;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(crate) const BUILTIN_MANIFEST_FILE: &str = ".builtin-manifest";
|
||||||
|
|
||||||
|
pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
|
||||||
|
name.trim_end_matches(['.', ' '])
|
||||||
|
.to_uppercase()
|
||||||
|
.eq_ignore_ascii_case(BUILTIN_MANIFEST_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NTFS 8.3 short names (`BUILTI~1`, `builti~1.sh`) can alias any long
|
||||||
|
/// filename on the same volume, including the manifest itself, so an entry
|
||||||
|
/// shaped like one is never accepted: it could direct a deletion at a file
|
||||||
|
/// the manifest never named.
|
||||||
|
fn is_ntfs_short_name_alias(name: &str) -> bool {
|
||||||
|
let stem = name.split('.').next().unwrap_or(name);
|
||||||
|
let Some((base, digits)) = stem.rsplit_once('~') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!base.is_empty()
|
||||||
|
&& base.len() <= 8
|
||||||
|
&& base.chars().all(|c| c.is_ascii_alphanumeric() || c == '~')
|
||||||
|
&& !digits.is_empty()
|
||||||
|
&& digits.chars().all(|c| c.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciles `dir` against the currently shipped set of builtin filenames:
|
||||||
|
/// a file is removed ONLY if it appears in the previous manifest AND is
|
||||||
|
/// absent from `shipped`. The manifest is then rewritten to the names this
|
||||||
|
/// installer actually owns: files written this run (`written`), plus shipped
|
||||||
|
/// names it already owned, plus stale files whose removal failed (kept so a
|
||||||
|
/// later run retries). A shipped name the installer declined to write (e.g., a
|
||||||
|
/// pre-existing user file with a colliding name) is never claimed.
|
||||||
|
///
|
||||||
|
/// Failure modes degrade toward keeping files: a missing or unreadable
|
||||||
|
/// manifest yields no deletions, and entries that are not plain filenames
|
||||||
|
/// are ignored.
|
||||||
|
pub(crate) fn reconcile_builtin_dir(
|
||||||
|
dir: &Path,
|
||||||
|
shipped: &BTreeSet<String>,
|
||||||
|
written: &BTreeSet<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let previous = read_manifest(dir);
|
||||||
|
let mut retained: BTreeSet<String> = written.clone();
|
||||||
|
retained.extend(shipped.intersection(&previous).cloned());
|
||||||
|
for name in previous.difference(shipped) {
|
||||||
|
let path = dir.join(name);
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Removing stale builtin file no longer shipped: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
if let Err(err) = fs::remove_file(&path) {
|
||||||
|
warn!(
|
||||||
|
"Failed to remove stale builtin file {}: {err}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
retained.insert(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write_manifest(dir, &retained)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_manifest(dir: &Path) -> BTreeSet<String> {
|
||||||
|
let path = dir.join(BUILTIN_MANIFEST_FILE);
|
||||||
|
let content = match fs::read_to_string(&path) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
"No readable builtin manifest at {} ({err}); skipping stale-file removal",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return BTreeSet::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| {
|
||||||
|
if line.is_empty() || is_builtin_manifest_name(line) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !is_plain_file_name(line) || is_ntfs_short_name_alias(line) {
|
||||||
|
debug!(
|
||||||
|
"Ignoring suspicious builtin manifest entry in {}: {line:?}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest entries name direct children of the manifest's directory and
|
||||||
|
/// nothing else; anything path-like is rejected so a corrupted or crafted
|
||||||
|
/// manifest can never direct a deletion outside that directory. The same
|
||||||
|
/// component rules the bundle store applies to recorded paths are reused so
|
||||||
|
/// Windows drive prefixes (`C:x`), ADS colons, trailing dots/spaces, and
|
||||||
|
/// reserved device names are rejected too.
|
||||||
|
fn is_plain_file_name(name: &str) -> bool {
|
||||||
|
!name.contains(['/', '\\'])
|
||||||
|
&& name != "."
|
||||||
|
&& name != ".."
|
||||||
|
&& super::install_remote::is_safe_component(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_manifest(dir: &Path, names: &BTreeSet<String>) -> Result<()> {
|
||||||
|
let path = dir.join(BUILTIN_MANIFEST_FILE);
|
||||||
|
if names.is_empty() {
|
||||||
|
if path.is_file() {
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.with_context(|| format!("failed to remove builtin manifest {}", path.display()))?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut content = names.iter().cloned().collect::<Vec<_>>().join("\n");
|
||||||
|
content.push('\n');
|
||||||
|
ensure_parent_exists(&path)?;
|
||||||
|
write_file_atomic(&path, &content, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::utils;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn fresh_dir(label: &str) -> PathBuf {
|
||||||
|
let dir = utils::temp_file(label, "");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shipped(names: &[&str]) -> BTreeSet<String> {
|
||||||
|
names.iter().map(|n| n.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removes_only_manifest_listed_files_absent_from_shipped() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-stale-");
|
||||||
|
fs::write(dir.join(BUILTIN_MANIFEST_FILE), "old.sh\nkept.sh\n").unwrap();
|
||||||
|
fs::write(dir.join("old.sh"), "stale").unwrap();
|
||||||
|
fs::write(dir.join("kept.sh"), "current").unwrap();
|
||||||
|
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &shipped(&["kept.sh"]), &shipped(&[])).unwrap();
|
||||||
|
|
||||||
|
assert!(!dir.join("old.sh").exists(), "stale bundled file removed");
|
||||||
|
assert!(dir.join("kept.sh").exists(), "still-shipped file kept");
|
||||||
|
assert!(dir.join("user.sh").exists(), "unlisted user file untouched");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"kept.sh\n"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_manifest_deletes_nothing_and_writes_shipped_set() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-missing-");
|
||||||
|
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &shipped(&["new.sh"]), &shipped(&["new.sh"])).unwrap();
|
||||||
|
|
||||||
|
assert!(dir.join("user.sh").exists());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"new.sh\n"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shipped name the installer skipped (pre-existing user file with a
|
||||||
|
/// colliding name) is never claimed by the manifest, so a later release
|
||||||
|
/// dropping that hook can never delete the user's file.
|
||||||
|
#[test]
|
||||||
|
fn skipped_preexisting_user_file_is_never_claimed() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-unclaimed-");
|
||||||
|
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &shipped(&["user.sh"]), &shipped(&[])).unwrap();
|
||||||
|
assert!(
|
||||||
|
!dir.join(BUILTIN_MANIFEST_FILE).exists(),
|
||||||
|
"nothing was written this run and nothing was owned before"
|
||||||
|
);
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &shipped(&[]), &shipped(&[])).unwrap();
|
||||||
|
assert!(
|
||||||
|
dir.join("user.sh").exists(),
|
||||||
|
"the unclaimed user file survives the hook being dropped"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspicious_manifest_entries_never_direct_deletions() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-suspicious-");
|
||||||
|
let outside = fresh_dir("builtin-manifest-outside-");
|
||||||
|
let escape = format!(
|
||||||
|
"../{}/victim.sh",
|
||||||
|
outside.file_name().unwrap().to_string_lossy()
|
||||||
|
);
|
||||||
|
fs::write(
|
||||||
|
dir.join(BUILTIN_MANIFEST_FILE),
|
||||||
|
format!("{escape}\nsub/nested.sh\nC:victim.sh\ntrailing.\n..\n.\n\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(outside.join("victim.sh"), "keep").unwrap();
|
||||||
|
fs::create_dir_all(dir.join("sub")).unwrap();
|
||||||
|
fs::write(dir.join("sub/nested.sh"), "keep").unwrap();
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap();
|
||||||
|
|
||||||
|
assert!(outside.join("victim.sh").exists());
|
||||||
|
assert!(dir.join("sub/nested.sh").exists());
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
let _ = fs::remove_dir_all(&outside);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreadable_manifest_deletes_nothing() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-unreadable-");
|
||||||
|
fs::write(dir.join(BUILTIN_MANIFEST_FILE), [0xff, 0xfe, 0x00]).unwrap();
|
||||||
|
fs::write(dir.join("old.sh"), "keep").unwrap();
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &shipped(&["new.sh"]), &shipped(&["new.sh"])).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dir.join("old.sh").exists(),
|
||||||
|
"a bad manifest must fail safe toward keeping files"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"new.sh\n",
|
||||||
|
"manifest self-heals to the current shipped set"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_shipped_set_removes_manifest_and_creates_nothing() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-empty-");
|
||||||
|
fs::write(dir.join(BUILTIN_MANIFEST_FILE), "old.sh\n").unwrap();
|
||||||
|
fs::write(dir.join("old.sh"), "stale").unwrap();
|
||||||
|
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap();
|
||||||
|
|
||||||
|
assert!(!dir.join("old.sh").exists());
|
||||||
|
assert!(dir.join("user.sh").exists());
|
||||||
|
assert!(!dir.join(BUILTIN_MANIFEST_FILE).exists());
|
||||||
|
|
||||||
|
let absent = dir.join("never-created");
|
||||||
|
reconcile_builtin_dir(&absent, &BTreeSet::new(), &BTreeSet::new()).unwrap();
|
||||||
|
assert!(!absent.exists(), "no directory is created for an empty set");
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_listing_itself_never_deletes_it() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-self-");
|
||||||
|
fs::write(
|
||||||
|
dir.join(BUILTIN_MANIFEST_FILE),
|
||||||
|
format!("{BUILTIN_MANIFEST_FILE}\n.BUILTIN-MANIFEST\nkept.sh\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(dir.join("kept.sh"), "current").unwrap();
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &shipped(&["kept.sh"]), &shipped(&[])).unwrap();
|
||||||
|
|
||||||
|
assert!(dir.join(BUILTIN_MANIFEST_FILE).exists());
|
||||||
|
assert!(dir.join("kept.sh").exists());
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_name_matcher_covers_filesystem_aliases() {
|
||||||
|
assert!(is_builtin_manifest_name(".builtin-manifest"));
|
||||||
|
assert!(is_builtin_manifest_name(".BUILTIN-MANIFEST"));
|
||||||
|
assert!(is_builtin_manifest_name(".Builtin-Manifest."));
|
||||||
|
assert!(is_builtin_manifest_name(".builtin-manifest "));
|
||||||
|
assert!(is_builtin_manifest_name(".builtin-manifeſt"));
|
||||||
|
assert!(!is_builtin_manifest_name("builtin-manifest"));
|
||||||
|
assert!(!is_builtin_manifest_name(".builtin-manifesto"));
|
||||||
|
assert!(!is_builtin_manifest_name("notify.sh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ntfs_short_name_aliases_are_detected() {
|
||||||
|
assert!(is_ntfs_short_name_alias("BUILTI~1"));
|
||||||
|
assert!(is_ntfs_short_name_alias("builti~1.sh"));
|
||||||
|
assert!(is_ntfs_short_name_alias("NOTIFY~12"));
|
||||||
|
assert!(is_ntfs_short_name_alias("A~1"));
|
||||||
|
assert!(!is_ntfs_short_name_alias("notify.sh"));
|
||||||
|
assert!(!is_ntfs_short_name_alias("my~hook.sh"));
|
||||||
|
assert!(!is_ntfs_short_name_alias("~1"));
|
||||||
|
assert!(!is_ntfs_short_name_alias("way-too-long~1"));
|
||||||
|
assert!(!is_ntfs_short_name_alias(".builtin-manifest"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This fixture is unrealizable on NTFS: `.builtin-manifest` gets an
|
||||||
|
/// auto-generated 8.3 short name of exactly this shape (`BUILTI~1`), so
|
||||||
|
/// writing `dir/BUILTI~1` opens the manifest through its alias and
|
||||||
|
/// clobbers it instead of creating a distinct file — the precise
|
||||||
|
/// collision [`is_ntfs_short_name_alias`] defends against. Only
|
||||||
|
/// filesystems without 8.3 aliasing can host alias-shaped names as real,
|
||||||
|
/// distinct files; Windows coverage lives in
|
||||||
|
/// `ntfs_short_name_entries_never_accepted_on_windows` below.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn ntfs_short_name_entries_never_direct_deletions() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-shortname-");
|
||||||
|
fs::write(dir.join(BUILTIN_MANIFEST_FILE), "BUILTI~1\nbuilti~1.sh\n").unwrap();
|
||||||
|
fs::write(dir.join("BUILTI~1"), "keep").unwrap();
|
||||||
|
fs::write(dir.join("builti~1.sh"), "keep").unwrap();
|
||||||
|
|
||||||
|
reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dir.join("BUILTI~1").exists() && dir.join("builti~1.sh").exists(),
|
||||||
|
"8.3-alias-shaped manifest entries must never be removal candidates"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows counterpart of `ntfs_short_name_entries_never_direct_deletions`:
|
||||||
|
/// alias-shaped files cannot exist as distinct fixtures here, so the guard
|
||||||
|
/// invariant is proven through the manifest filter instead. Only entries
|
||||||
|
/// accepted into the previous-manifest set can become removal candidates
|
||||||
|
/// (`previous.difference(shipped)`) or be claimed as owned
|
||||||
|
/// (`shipped ∩ previous`); listing the alias names in `shipped` makes the
|
||||||
|
/// second path observable in the rewritten manifest — a broken guard
|
||||||
|
/// would claim them. Where the volume generates 8.3 short names, the
|
||||||
|
/// self-collision that makes the unix fixture unrealizable is also
|
||||||
|
/// asserted directly; where generation is disabled, the alias simply
|
||||||
|
/// never materializes and the invariant assertions stand on their own.
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn ntfs_short_name_entries_never_accepted_on_windows() {
|
||||||
|
let dir = fresh_dir("builtin-manifest-shortname-win-");
|
||||||
|
fs::write(
|
||||||
|
dir.join(BUILTIN_MANIFEST_FILE),
|
||||||
|
"BUILTI~1\nbuilti~1.sh\nkept.sh\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(dir.join("kept.sh"), "current").unwrap();
|
||||||
|
|
||||||
|
let alias = dir.join("BUILTI~1");
|
||||||
|
if alias.exists() {
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&alias).unwrap(),
|
||||||
|
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"the 8.3 alias opens the manifest itself, not a distinct file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcile_builtin_dir(
|
||||||
|
&dir,
|
||||||
|
&shipped(&["BUILTI~1", "builti~1.sh", "kept.sh"]),
|
||||||
|
&shipped(&[]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(dir.join("kept.sh").exists(), "real shipped file untouched");
|
||||||
|
assert!(
|
||||||
|
dir.join(BUILTIN_MANIFEST_FILE).exists(),
|
||||||
|
"manifest survives reconciliation"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"kept.sh\n",
|
||||||
|
"alias-shaped entries are never accepted: neither removal candidates nor claimed"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1183,6 +1183,13 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(resolved.name, "y/mcp_config");
|
assert_eq!(resolved.name, "y/mcp_config");
|
||||||
assert_eq!(resolved.qualified_from.as_deref(), Some("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]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
//! Shared keep/replace conflict resolution for installers that must not
|
||||||
|
//! silently clobber locally modified files. The bundle installer and the
|
||||||
|
//! builtin hook installers all funnel per-file conflicts through
|
||||||
|
//! [`resolve_conflict`], so the prompt wording and the sticky
|
||||||
|
//! keep-all/replace-all semantics are identical everywhere, including when
|
||||||
|
//! one sticky scope spans several install locations in a single run.
|
||||||
|
|
||||||
|
use crate::utils::IS_STDOUT_TERMINAL;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use inquire::Select;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum InstallMode {
|
||||||
|
Skip,
|
||||||
|
Force,
|
||||||
|
Prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `keep-all`/`replace-all` answer outlives the file it was given for:
|
||||||
|
/// the caller threads one value through every subsequent conflict in the
|
||||||
|
/// same run.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) enum StickyMode {
|
||||||
|
None,
|
||||||
|
KeepAll,
|
||||||
|
ReplaceAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ConflictAction {
|
||||||
|
Keep,
|
||||||
|
Replace,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum NonInteractive {
|
||||||
|
Bail,
|
||||||
|
Keep,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_replace_existing(
|
||||||
|
dst: &Path,
|
||||||
|
content: &str,
|
||||||
|
category: &str,
|
||||||
|
mode: InstallMode,
|
||||||
|
sticky: &mut StickyMode,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match mode {
|
||||||
|
InstallMode::Skip => Ok(false),
|
||||||
|
InstallMode::Force => Ok(true),
|
||||||
|
InstallMode::Prompt => {
|
||||||
|
if fs::read(dst).is_ok_and(|local| local == content.as_bytes()) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
match resolve_conflict(dst, category, sticky, NonInteractive::Keep)? {
|
||||||
|
ConflictAction::Keep => Ok(false),
|
||||||
|
ConflictAction::Replace => Ok(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_conflict(
|
||||||
|
dst: &Path,
|
||||||
|
category: &str,
|
||||||
|
sticky: &mut StickyMode,
|
||||||
|
non_interactive: NonInteractive,
|
||||||
|
) -> Result<ConflictAction> {
|
||||||
|
match *sticky {
|
||||||
|
StickyMode::KeepAll => return Ok(ConflictAction::Keep),
|
||||||
|
StickyMode::ReplaceAll => return Ok(ConflictAction::Replace),
|
||||||
|
StickyMode::None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_interactive() {
|
||||||
|
match non_interactive {
|
||||||
|
NonInteractive::Keep => return Ok(ConflictAction::Keep),
|
||||||
|
NonInteractive::Bail => bail!(
|
||||||
|
"Refusing to overwrite local file {} non-interactively. \
|
||||||
|
Re-run in a terminal, with --install-force (installs), \
|
||||||
|
or with --yes (updates).",
|
||||||
|
dst.display()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = format!("Conflict at {} (category: {category})", dst.display());
|
||||||
|
let choice = select_choice(&prompt)?;
|
||||||
|
|
||||||
|
match choice.as_str() {
|
||||||
|
"keep" => Ok(ConflictAction::Keep),
|
||||||
|
"replace" => Ok(ConflictAction::Replace),
|
||||||
|
"keep-all" => {
|
||||||
|
*sticky = StickyMode::KeepAll;
|
||||||
|
Ok(ConflictAction::Keep)
|
||||||
|
}
|
||||||
|
"replace-all" => {
|
||||||
|
*sticky = StickyMode::ReplaceAll;
|
||||||
|
Ok(ConflictAction::Replace)
|
||||||
|
}
|
||||||
|
"abort" => bail!("Install aborted by user at conflict resolution."),
|
||||||
|
_ => unreachable!("inquire::Select returned an unexpected option"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_interactive() -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(forced) = prompt_script::forced_tty() {
|
||||||
|
return forced;
|
||||||
|
}
|
||||||
|
*IS_STDOUT_TERMINAL
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_choice(prompt: &str) -> Result<String> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if prompt_script::active() {
|
||||||
|
return Ok(prompt_script::next_answer(prompt));
|
||||||
|
}
|
||||||
|
Select::new(
|
||||||
|
prompt,
|
||||||
|
vec!["keep", "replace", "keep-all", "replace-all", "abort"],
|
||||||
|
)
|
||||||
|
.prompt()
|
||||||
|
.map(str::to_string)
|
||||||
|
.with_context(|| "failed to read conflict choice")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod prompt_script {
|
||||||
|
//! Scripted stand-in for the interactive conflict prompt.
|
||||||
|
//! `inquire::Select` cannot run under the test harness, so while a guard
|
||||||
|
//! is installed the resolver takes its terminal answer from the guard and
|
||||||
|
//! serves each prompt from a queue of scripted answers, counting every
|
||||||
|
//! prompt asked. A forced-terminal guard with an empty queue pins the
|
||||||
|
//! "never prompts" invariant: any prompt panics and the counter stays 0.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
const INACTIVE: usize = 0;
|
||||||
|
const TTY: usize = 1;
|
||||||
|
const NON_TTY: usize = 2;
|
||||||
|
|
||||||
|
static STATE: AtomicUsize = AtomicUsize::new(INACTIVE);
|
||||||
|
static ASKED: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
// A test that panics while holding the queue poisons its mutex; every
|
||||||
|
// lock site recovers via `into_inner` so later tests keep working.
|
||||||
|
static ANSWERS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// Installs the script with a forced terminal until the guard drops.
|
||||||
|
/// The script is process-global, so tests using it must serialize
|
||||||
|
/// (`#[serial]`). Answers are consumed front to back; a prompt beyond
|
||||||
|
/// the scripted answers panics.
|
||||||
|
#[must_use]
|
||||||
|
pub fn install(answers: &[&str]) -> ScriptGuard {
|
||||||
|
install_with_state(TTY, answers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the script with the terminal forced absent, for pinning the
|
||||||
|
/// non-interactive behavior regardless of where the tests actually run.
|
||||||
|
#[must_use]
|
||||||
|
pub fn install_non_interactive() -> ScriptGuard {
|
||||||
|
install_with_state(NON_TTY, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_with_state(state: usize, answers: &[&str]) -> ScriptGuard {
|
||||||
|
*ANSWERS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
|
||||||
|
answers.iter().map(|answer| answer.to_string()).collect();
|
||||||
|
ASKED.store(0, Ordering::SeqCst);
|
||||||
|
STATE.store(state, Ordering::SeqCst);
|
||||||
|
ScriptGuard
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ScriptGuard;
|
||||||
|
|
||||||
|
impl Drop for ScriptGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
STATE.store(INACTIVE, Ordering::SeqCst);
|
||||||
|
ANSWERS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of prompts asked since the script was installed.
|
||||||
|
pub fn prompts_asked() -> usize {
|
||||||
|
ASKED.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn active() -> bool {
|
||||||
|
STATE.load(Ordering::SeqCst) != INACTIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn forced_tty() -> Option<bool> {
|
||||||
|
match STATE.load(Ordering::SeqCst) {
|
||||||
|
TTY => Some(true),
|
||||||
|
NON_TTY => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn next_answer(prompt: &str) -> String {
|
||||||
|
ASKED.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let mut answers = ANSWERS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
assert!(
|
||||||
|
!answers.is_empty(),
|
||||||
|
"conflict prompt asked with no scripted answer left: {prompt}"
|
||||||
|
);
|
||||||
|
answers.remove(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn dst() -> PathBuf {
|
||||||
|
PathBuf::from("/tmp/coyote-conflict-tests/example.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sticky_modes_short_circuit_without_prompting() {
|
||||||
|
let mut sticky = StickyMode::KeepAll;
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap(),
|
||||||
|
ConflictAction::Keep
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut sticky = StickyMode::ReplaceAll;
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap(),
|
||||||
|
ConflictAction::Replace
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn non_interactive_bail_keeps_the_bundle_error_shape() {
|
||||||
|
let _script = prompt_script::install_non_interactive();
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
|
||||||
|
let err =
|
||||||
|
resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("Refusing to overwrite local file"),
|
||||||
|
"got: {err}"
|
||||||
|
);
|
||||||
|
assert!(err.to_string().contains("example.yaml"), "got: {err}");
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn non_interactive_keep_resolves_without_error_or_prompt() {
|
||||||
|
let _script = prompt_script::install_non_interactive();
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
|
||||||
|
let action = resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(action, ConflictAction::Keep));
|
||||||
|
assert_eq!(sticky, StickyMode::None);
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn scripted_answers_drive_actions_and_sticky_transitions() {
|
||||||
|
let _script = prompt_script::install(&["keep", "replace", "replace-all"]);
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(),
|
||||||
|
ConflictAction::Keep
|
||||||
|
));
|
||||||
|
assert_eq!(sticky, StickyMode::None);
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(),
|
||||||
|
ConflictAction::Replace
|
||||||
|
));
|
||||||
|
assert_eq!(sticky, StickyMode::None);
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(),
|
||||||
|
ConflictAction::Replace
|
||||||
|
));
|
||||||
|
assert_eq!(sticky, StickyMode::ReplaceAll);
|
||||||
|
// The sticky answer now short-circuits: no further prompt is asked.
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(),
|
||||||
|
ConflictAction::Replace
|
||||||
|
));
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn abort_answer_bails_with_the_bundle_abort_message() {
|
||||||
|
let _script = prompt_script::install(&["abort"]);
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
|
||||||
|
let err = resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("Install aborted by user at conflict resolution."),
|
||||||
|
"got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn should_replace_existing_skips_identical_content_without_prompting() {
|
||||||
|
let _script = prompt_script::install(&[]);
|
||||||
|
let dir = crate::utils::temp_file("conflict-identical-", "");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("hook.sh");
|
||||||
|
fs::write(&path, "same content").unwrap();
|
||||||
|
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
let replace = should_replace_existing(
|
||||||
|
&path,
|
||||||
|
"same content",
|
||||||
|
"hooks",
|
||||||
|
InstallMode::Prompt,
|
||||||
|
&mut sticky,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
assert!(!replace);
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn should_replace_existing_never_prompts_in_skip_or_force_mode() {
|
||||||
|
let _script = prompt_script::install(&[]);
|
||||||
|
let dir = crate::utils::temp_file("conflict-modes-", "");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("hook.sh");
|
||||||
|
fs::write(&path, "local edit").unwrap();
|
||||||
|
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
let skip =
|
||||||
|
should_replace_existing(&path, "shipped", "hooks", InstallMode::Skip, &mut sticky);
|
||||||
|
let force =
|
||||||
|
should_replace_existing(&path, "shipped", "hooks", InstallMode::Force, &mut sticky);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
assert!(!skip.unwrap());
|
||||||
|
assert!(force.unwrap());
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,34 @@ impl Input {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An input carrying only `text`: no session history, no RAG, and no
|
||||||
|
/// function declarations. For auxiliary LLM calls (such as session
|
||||||
|
/// summarization) whose requests must not grow with the session.
|
||||||
|
pub fn detached_text(ctx: &RequestContext, text: &str) -> Self {
|
||||||
|
let mut role = Role::new("", "");
|
||||||
|
role.set_model(ctx.current_model().clone());
|
||||||
|
Self {
|
||||||
|
app_config: Arc::clone(&ctx.app.config),
|
||||||
|
stream_enabled: ctx.app.config.stream,
|
||||||
|
session: None,
|
||||||
|
rag: None,
|
||||||
|
functions: None,
|
||||||
|
text: text.to_string(),
|
||||||
|
raw: (text.to_string(), vec![]),
|
||||||
|
patched_text: None,
|
||||||
|
last_reply: None,
|
||||||
|
continue_output: None,
|
||||||
|
regenerate: false,
|
||||||
|
medias: Default::default(),
|
||||||
|
data_urls: Default::default(),
|
||||||
|
tool_calls: None,
|
||||||
|
role,
|
||||||
|
rag_name: None,
|
||||||
|
with_session: false,
|
||||||
|
with_agent: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn from_files(
|
pub async fn from_files(
|
||||||
ctx: &RequestContext,
|
ctx: &RequestContext,
|
||||||
raw_text: &str,
|
raw_text: &str,
|
||||||
@@ -329,6 +357,14 @@ impl Input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-captures `ctx.session` in place of the snapshot taken at
|
||||||
|
/// construction. Session compression mutates the ctx's session, not the
|
||||||
|
/// snapshot, so an input that outlives a compression must refresh before
|
||||||
|
/// building its next request or it re-sends the pre-compression history.
|
||||||
|
pub fn refresh_session(&mut self, ctx: &RequestContext) {
|
||||||
|
self.session = ctx.session.clone();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_mut<'a>(&self, session: &'a mut Option<Session>) -> Option<&'a mut Session> {
|
pub fn session_mut<'a>(&self, session: &'a mut Option<Session>) -> Option<&'a mut Session> {
|
||||||
if self.with_session {
|
if self.with_session {
|
||||||
session.as_mut()
|
session.as_mut()
|
||||||
@@ -660,6 +696,60 @@ mod tests {
|
|||||||
assert!(!with_session);
|
assert!(!with_session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detached_text_input_carries_no_session_history_or_functions() {
|
||||||
|
let mut ctx = create_test_ctx();
|
||||||
|
ctx.session = Some(Session::default());
|
||||||
|
let earlier = Input::from_str(&ctx, "earlier question", None).unwrap();
|
||||||
|
ctx.session
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.add_message(&earlier, "earlier answer")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let detached = Input::detached_text(&ctx, "summarize the above");
|
||||||
|
let messages = detached.build_messages().unwrap();
|
||||||
|
assert_eq!(messages.len(), 1, "must contain only the detached text");
|
||||||
|
assert!(messages[0].role.is_user());
|
||||||
|
assert_eq!(messages[0].content.to_text(), "summarize the above");
|
||||||
|
assert!(detached.declared_function_names().is_empty());
|
||||||
|
assert!(!detached.with_session());
|
||||||
|
assert!(!detached.with_agent());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_session_rebuilds_from_compressed_session() {
|
||||||
|
let mut ctx = create_test_ctx();
|
||||||
|
ctx.session = Some(Session::default());
|
||||||
|
for i in 0..4 {
|
||||||
|
let question =
|
||||||
|
Input::from_str(&ctx, &format!("question {i}: {}", "x".repeat(500)), None).unwrap();
|
||||||
|
ctx.session
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.add_message(&question, &"y".repeat(500))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut input = Input::from_str(&ctx, "retry me", None).unwrap();
|
||||||
|
let stale = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
|
||||||
|
|
||||||
|
ctx.session.as_mut().unwrap().compress("recap".into(), 1);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&input.build_messages().unwrap()).unwrap(),
|
||||||
|
stale,
|
||||||
|
"the snapshot captured at construction must be untouched by compression"
|
||||||
|
);
|
||||||
|
|
||||||
|
input.refresh_session(&ctx);
|
||||||
|
let refreshed = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
|
||||||
|
assert!(
|
||||||
|
refreshed.len() < stale.len(),
|
||||||
|
"a refreshed input must build a strictly smaller request after compression"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_paths_detects_last_reply_syntax() {
|
fn resolve_paths_detects_last_reply_syntax() {
|
||||||
let loaders = HashMap::new();
|
let loaders = HashMap::new();
|
||||||
@@ -1023,6 +1113,7 @@ mod tests {
|
|||||||
assert!(!tool_calls.sequence);
|
assert!(!tool_calls.sequence);
|
||||||
assert_eq!(tool_calls.tool_results.len(), 1);
|
assert_eq!(tool_calls.tool_results.len(), 1);
|
||||||
assert!(tool_calls.tool_results[0].text.is_none());
|
assert!(tool_calls.tool_results[0].text.is_none());
|
||||||
|
assert!(tool_calls.round_starts.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1044,6 +1135,29 @@ mod tests {
|
|||||||
tool_calls.tool_results[1].text,
|
tool_calls.tool_results[1].text,
|
||||||
Some("second text".to_string())
|
Some("second text".to_string())
|
||||||
);
|
);
|
||||||
|
assert_eq!(tool_calls.round_starts, vec![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: rounds where the model emitted only tool calls (no
|
||||||
|
/// narration text) carry no text marker, so `round_starts` is the only
|
||||||
|
/// record of the round boundary — it must be set on every merge.
|
||||||
|
#[test]
|
||||||
|
fn merge_tool_results_records_round_starts_for_textless_rounds() {
|
||||||
|
let ctx = create_test_ctx();
|
||||||
|
let input = Input::from_str(&ctx, "test", None)
|
||||||
|
.unwrap()
|
||||||
|
.merge_tool_results(String::new(), vec![tool_result("id-1", "ok")])
|
||||||
|
.merge_tool_results(
|
||||||
|
String::new(),
|
||||||
|
vec![tool_result("id-2", "ok2"), tool_result("id-3", "ok3")],
|
||||||
|
)
|
||||||
|
.merge_tool_results(String::new(), vec![tool_result("id-4", "ok4")]);
|
||||||
|
|
||||||
|
let tool_calls = input.tool_calls().as_ref().unwrap();
|
||||||
|
assert!(tool_calls.sequence);
|
||||||
|
assert_eq!(tool_calls.tool_results.len(), 4);
|
||||||
|
assert!(tool_calls.tool_results.iter().all(|t| t.text.is_none()));
|
||||||
|
assert_eq!(tool_calls.round_starts, vec![1, 3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+343
-85
@@ -2,9 +2,11 @@ use super::bundles::{
|
|||||||
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes,
|
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes,
|
||||||
hash_file,
|
hash_file,
|
||||||
};
|
};
|
||||||
use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths};
|
use crate::config::builtin_manifest::is_builtin_manifest_name;
|
||||||
#[cfg(not(windows))]
|
use crate::config::conflict::{ConflictAction, NonInteractive, StickyMode, resolve_conflict};
|
||||||
use crate::function::Language;
|
use crate::config::{
|
||||||
|
AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths, set_executable_bit_if_script,
|
||||||
|
};
|
||||||
use crate::mcp::{McpServer, McpServersConfig};
|
use crate::mcp::{McpServer, McpServersConfig};
|
||||||
use crate::utils;
|
use crate::utils;
|
||||||
use crate::utils::IS_STDOUT_TERMINAL;
|
use crate::utils::IS_STDOUT_TERMINAL;
|
||||||
@@ -32,7 +34,7 @@ pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool)
|
|||||||
if layout.is_empty() {
|
if layout.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
"No recognized assets found in {git_url}. Expected one or more of: \
|
"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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -805,7 +807,7 @@ fn is_safe_relative_path(path: &str) -> bool {
|
|||||||
/// Rejects names Windows refuses or silently rewrites (alternate data stream
|
/// Rejects names Windows refuses or silently rewrites (alternate data stream
|
||||||
/// colons, reserved device names, trailing dots or spaces) so a recorded path
|
/// colons, reserved device names, trailing dots or spaces) so a recorded path
|
||||||
/// denotes the same regular file on every platform.
|
/// 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.contains(':')
|
||||||
&& !name.ends_with('.')
|
&& !name.ends_with('.')
|
||||||
&& !name.ends_with(' ')
|
&& !name.ends_with(' ')
|
||||||
@@ -1270,6 +1272,7 @@ struct RemoteLayout {
|
|||||||
roles: Option<PathBuf>,
|
roles: Option<PathBuf>,
|
||||||
skills: Option<PathBuf>,
|
skills: Option<PathBuf>,
|
||||||
macros: Option<PathBuf>,
|
macros: Option<PathBuf>,
|
||||||
|
hooks: Option<PathBuf>,
|
||||||
functions_tools: Option<PathBuf>,
|
functions_tools: Option<PathBuf>,
|
||||||
mcp_json: Option<PathBuf>,
|
mcp_json: Option<PathBuf>,
|
||||||
manifest: Option<BundleManifest>,
|
manifest: Option<BundleManifest>,
|
||||||
@@ -1282,6 +1285,7 @@ impl RemoteLayout {
|
|||||||
&& self.roles.is_none()
|
&& self.roles.is_none()
|
||||||
&& self.skills.is_none()
|
&& self.skills.is_none()
|
||||||
&& self.macros.is_none()
|
&& self.macros.is_none()
|
||||||
|
&& self.hooks.is_none()
|
||||||
&& self.functions_tools.is_none()
|
&& self.functions_tools.is_none()
|
||||||
&& self.mcp_json.is_none()
|
&& self.mcp_json.is_none()
|
||||||
}
|
}
|
||||||
@@ -1313,6 +1317,11 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
|
|||||||
layout.macros = Some(macros);
|
layout.macros = Some(macros);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hooks = root.join("hooks");
|
||||||
|
if hooks.is_dir() {
|
||||||
|
layout.hooks = Some(hooks);
|
||||||
|
}
|
||||||
|
|
||||||
let root_mcp = root.join("mcp.json");
|
let root_mcp = root.join("mcp.json");
|
||||||
if root_mcp.is_file() {
|
if root_mcp.is_file() {
|
||||||
layout.mcp_json = Some(root_mcp);
|
layout.mcp_json = Some(root_mcp);
|
||||||
@@ -1540,6 +1549,10 @@ fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> Remo
|
|||||||
functions_tools: layout.functions_tools.take(),
|
functions_tools: layout.functions_tools.take(),
|
||||||
..base
|
..base
|
||||||
},
|
},
|
||||||
|
InstallFilter::Hooks => RemoteLayout {
|
||||||
|
hooks: layout.hooks.take(),
|
||||||
|
..base
|
||||||
|
},
|
||||||
InstallFilter::McpConfig => RemoteLayout {
|
InstallFilter::McpConfig => RemoteLayout {
|
||||||
mcp_json: layout.mcp_json.take(),
|
mcp_json: layout.mcp_json.take(),
|
||||||
..base
|
..base
|
||||||
@@ -1591,6 +1604,7 @@ enum TopCategory {
|
|||||||
Roles,
|
Roles,
|
||||||
Skills,
|
Skills,
|
||||||
Macros,
|
Macros,
|
||||||
|
Hooks,
|
||||||
FunctionsTools,
|
FunctionsTools,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1601,6 +1615,7 @@ impl TopCategory {
|
|||||||
TopCategory::Roles => "roles",
|
TopCategory::Roles => "roles",
|
||||||
TopCategory::Skills => "skills",
|
TopCategory::Skills => "skills",
|
||||||
TopCategory::Macros => "macros",
|
TopCategory::Macros => "macros",
|
||||||
|
TopCategory::Hooks => "hooks",
|
||||||
TopCategory::FunctionsTools => "functions/tools",
|
TopCategory::FunctionsTools => "functions/tools",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1619,6 +1634,8 @@ enum PlannedKind {
|
|||||||
struct PlannedFile {
|
struct PlannedFile {
|
||||||
src: PathBuf,
|
src: PathBuf,
|
||||||
dst: PathBuf,
|
dst: PathBuf,
|
||||||
|
/// Path relative to the category's source directory in the bundle.
|
||||||
|
rel: PathBuf,
|
||||||
kind: PlannedKind,
|
kind: PlannedKind,
|
||||||
top_category: TopCategory,
|
top_category: TopCategory,
|
||||||
}
|
}
|
||||||
@@ -1660,6 +1677,9 @@ fn plan_changes(layout: &RemoteLayout) -> Result<InstallPlan> {
|
|||||||
&mut files,
|
&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 {
|
if let Some(src_dir) = &layout.functions_tools {
|
||||||
plan_dir_into(
|
plan_dir_into(
|
||||||
src_dir,
|
src_dir,
|
||||||
@@ -1686,7 +1706,28 @@ fn plan_dir_into(
|
|||||||
for src in walk_files(src_dir)? {
|
for src in walk_files(src_dir)? {
|
||||||
let rel = src
|
let rel = src
|
||||||
.strip_prefix(src_dir)
|
.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 {
|
if category == TopCategory::Skills {
|
||||||
let skill_name = rel
|
let skill_name = rel
|
||||||
@@ -1707,11 +1748,12 @@ fn plan_dir_into(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dst = dst_dir.join(rel);
|
let dst = dst_dir.join(&rel);
|
||||||
let kind = classify_file(&src, &dst)?;
|
let kind = classify_file(&src, &dst)?;
|
||||||
out.push(PlannedFile {
|
out.push(PlannedFile {
|
||||||
src,
|
src,
|
||||||
dst,
|
dst,
|
||||||
|
rel,
|
||||||
kind,
|
kind,
|
||||||
top_category: category,
|
top_category: category,
|
||||||
});
|
});
|
||||||
@@ -1790,6 +1832,7 @@ fn print_plan_summary(plan: &InstallPlan) {
|
|||||||
TopCategory::Roles,
|
TopCategory::Roles,
|
||||||
TopCategory::Skills,
|
TopCategory::Skills,
|
||||||
TopCategory::Macros,
|
TopCategory::Macros,
|
||||||
|
TopCategory::Hooks,
|
||||||
TopCategory::FunctionsTools,
|
TopCategory::FunctionsTools,
|
||||||
] {
|
] {
|
||||||
let new_ = count_kind(plan, cat, PlannedKind::New);
|
let new_ = count_kind(plan, cat, PlannedKind::New);
|
||||||
@@ -1808,6 +1851,37 @@ fn print_plan_summary(plan: &InstallPlan) {
|
|||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for line in hook_script_lines(plan) {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundle-shipped hook scripts execute on the user's machine once wired into
|
||||||
|
/// config, so every planned file landing in a hook location is called out
|
||||||
|
/// individually. Visibility only: installing them needs no extra approval.
|
||||||
|
fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
|
||||||
|
plan.files
|
||||||
|
.iter()
|
||||||
|
.filter(|planned| is_hook_location(planned))
|
||||||
|
.map(|planned| {
|
||||||
|
let rel = planned.rel.to_string_lossy().replace('\\', "/");
|
||||||
|
format!(
|
||||||
|
"bundle defines hook script: {}/{rel}",
|
||||||
|
planned.top_category.label()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hook_location(planned: &PlannedFile) -> bool {
|
||||||
|
let components: Vec<_> = planned.rel.components().collect();
|
||||||
|
match planned.top_category {
|
||||||
|
TopCategory::Hooks => true,
|
||||||
|
TopCategory::Agents => components.len() == 3 && components[1].as_os_str() == "hooks",
|
||||||
|
TopCategory::Roles => components.len() == 2 && components[0].as_os_str() == "hooks",
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize {
|
fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize {
|
||||||
@@ -1817,18 +1891,6 @@ fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
enum StickyMode {
|
|
||||||
None,
|
|
||||||
KeepAll,
|
|
||||||
ReplaceAll,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ConflictAction {
|
|
||||||
Keep,
|
|
||||||
Replace,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ApplyReport {
|
struct ApplyReport {
|
||||||
new_count: usize,
|
new_count: usize,
|
||||||
@@ -1868,7 +1930,12 @@ fn apply_plan(
|
|||||||
record_written_file(store, bundle, planned, FileAction::Replaced)?;
|
record_written_file(store, bundle, planned, FileAction::Replaced)?;
|
||||||
report.refreshed_count += 1;
|
report.refreshed_count += 1;
|
||||||
}
|
}
|
||||||
PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? {
|
PlannedKind::Conflict => match resolve_conflict(
|
||||||
|
&planned.dst,
|
||||||
|
planned.top_category.label(),
|
||||||
|
&mut sticky,
|
||||||
|
NonInteractive::Bail,
|
||||||
|
)? {
|
||||||
ConflictAction::Keep => report.kept_count += 1,
|
ConflictAction::Keep => report.kept_count += 1,
|
||||||
ConflictAction::Replace => {
|
ConflictAction::Replace => {
|
||||||
write_file(&planned.src, &planned.dst)?;
|
write_file(&planned.src, &planned.dst)?;
|
||||||
@@ -1968,50 +2035,6 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo
|
|||||||
store.record_mcp_servers(bundle, entries)
|
store.record_mcp_servers(bundle, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_conflict(planned: &PlannedFile, sticky: &mut StickyMode) -> Result<ConflictAction> {
|
|
||||||
match *sticky {
|
|
||||||
StickyMode::KeepAll => return Ok(ConflictAction::Keep),
|
|
||||||
StickyMode::ReplaceAll => return Ok(ConflictAction::Replace),
|
|
||||||
StickyMode::None => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !*IS_STDOUT_TERMINAL {
|
|
||||||
bail!(
|
|
||||||
"Refusing to overwrite local file {} non-interactively. \
|
|
||||||
Re-run in a terminal, with --install-force (installs), \
|
|
||||||
or with --yes (updates).",
|
|
||||||
planned.dst.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prompt = format!(
|
|
||||||
"Conflict at {} (category: {})",
|
|
||||||
planned.dst.display(),
|
|
||||||
planned.top_category.label()
|
|
||||||
);
|
|
||||||
let choice = Select::new(
|
|
||||||
&prompt,
|
|
||||||
vec!["keep", "replace", "keep-all", "replace-all", "abort"],
|
|
||||||
)
|
|
||||||
.prompt()
|
|
||||||
.with_context(|| "failed to read conflict choice")?;
|
|
||||||
|
|
||||||
match choice {
|
|
||||||
"keep" => Ok(ConflictAction::Keep),
|
|
||||||
"replace" => Ok(ConflictAction::Replace),
|
|
||||||
"keep-all" => {
|
|
||||||
*sticky = StickyMode::KeepAll;
|
|
||||||
Ok(ConflictAction::Keep)
|
|
||||||
}
|
|
||||||
"replace-all" => {
|
|
||||||
*sticky = StickyMode::ReplaceAll;
|
|
||||||
Ok(ConflictAction::Replace)
|
|
||||||
}
|
|
||||||
"abort" => bail!("Install aborted by user at conflict resolution."),
|
|
||||||
_ => unreachable!("inquire::Select returned an unexpected option"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_file(src: &Path, dst: &Path) -> Result<()> {
|
fn write_file(src: &Path, dst: &Path) -> Result<()> {
|
||||||
if let Some(parent) = dst.parent() {
|
if let Some(parent) = dst.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
@@ -2023,26 +2046,6 @@ fn write_file(src: &Path, dst: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn set_executable_bit_if_script(path: &Path) -> Result<()> {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
let Some(ext) = path.extension().and_then(OsStr::to_str) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if Language::from_extension(ext) == Language::Unsupported {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
|
||||||
.with_context(|| format!("chmod {}", path.display()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn set_executable_bit_if_script(_path: &Path) -> Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct McpMergeReport {
|
struct McpMergeReport {
|
||||||
added: Vec<String>,
|
added: Vec<String>,
|
||||||
@@ -2349,6 +2352,8 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config;
|
||||||
|
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
|
||||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||||
use crate::utils::get_env_name;
|
use crate::utils::get_env_name;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
@@ -2551,6 +2556,7 @@ mod tests {
|
|||||||
roles: Some(PathBuf::from("r")),
|
roles: Some(PathBuf::from("r")),
|
||||||
skills: Some(PathBuf::from("s")),
|
skills: Some(PathBuf::from("s")),
|
||||||
macros: Some(PathBuf::from("m")),
|
macros: Some(PathBuf::from("m")),
|
||||||
|
hooks: Some(PathBuf::from("h")),
|
||||||
functions_tools: Some(PathBuf::from("f")),
|
functions_tools: Some(PathBuf::from("f")),
|
||||||
mcp_json: Some(PathBuf::from("j")),
|
mcp_json: Some(PathBuf::from("j")),
|
||||||
..RemoteLayout::default()
|
..RemoteLayout::default()
|
||||||
@@ -2560,6 +2566,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(out.agents.is_some() && out.roles.is_some() && out.skills.is_some());
|
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.macros.is_some() && out.functions_tools.is_some() && out.mcp_json.is_some());
|
||||||
|
assert!(out.hooks.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2638,6 +2645,37 @@ mod tests {
|
|||||||
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
|
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]
|
#[test]
|
||||||
fn walk_files_skips_dot_git_and_collects_regular_files() {
|
fn walk_files_skips_dot_git_and_collects_regular_files() {
|
||||||
let root = fresh_temp_dir("walk-test-");
|
let root = fresh_temp_dir("walk-test-");
|
||||||
@@ -2684,6 +2722,7 @@ mod tests {
|
|||||||
fs::create_dir_all(root.join("roles")).unwrap();
|
fs::create_dir_all(root.join("roles")).unwrap();
|
||||||
fs::create_dir_all(root.join("skills")).unwrap();
|
fs::create_dir_all(root.join("skills")).unwrap();
|
||||||
fs::create_dir_all(root.join("macros")).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();
|
fs::create_dir_all(root.join("functions/tools")).unwrap();
|
||||||
touch(&root.join("functions/mcp.json"));
|
touch(&root.join("functions/mcp.json"));
|
||||||
touch(&root.join("README.md"));
|
touch(&root.join("README.md"));
|
||||||
@@ -2693,11 +2732,29 @@ mod tests {
|
|||||||
assert!(layout.roles.is_some());
|
assert!(layout.roles.is_some());
|
||||||
assert!(layout.skills.is_some());
|
assert!(layout.skills.is_some());
|
||||||
assert!(layout.macros.is_some());
|
assert!(layout.macros.is_some());
|
||||||
|
assert!(layout.hooks.is_some());
|
||||||
assert!(layout.functions_tools.is_some());
|
assert!(layout.functions_tools.is_some());
|
||||||
assert!(layout.mcp_json.is_some());
|
assert!(layout.mcp_json.is_some());
|
||||||
let _ = fs::remove_dir_all(&root);
|
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]
|
#[test]
|
||||||
fn scan_remote_layout_finds_root_mcp_json() {
|
fn scan_remote_layout_finds_root_mcp_json() {
|
||||||
let root = fresh_temp_dir("scan-root-mcp-test-");
|
let root = fresh_temp_dir("scan-root-mcp-test-");
|
||||||
@@ -2752,6 +2809,76 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_dir_into_skips_bundle_shipped_builtin_manifests() {
|
||||||
|
let root = fresh_temp_dir("plan-manifest-skip-");
|
||||||
|
let src_dir = root.join("hooks");
|
||||||
|
touch(&src_dir.join("notify.sh"));
|
||||||
|
touch(&src_dir.join(BUILTIN_MANIFEST_FILE));
|
||||||
|
touch(&src_dir.join(format!("sub/{BUILTIN_MANIFEST_FILE}")));
|
||||||
|
// Case and trailing-dot variants alias the real manifest path on
|
||||||
|
// case-insensitive filesystems; they live in separate subdirs so this
|
||||||
|
// fixture also builds on such filesystems without name collisions.
|
||||||
|
touch(&src_dir.join("sub2/.BUILTIN-MANIFEST"));
|
||||||
|
touch(&src_dir.join("sub3/.Builtin-Manifest."));
|
||||||
|
// A DIRECTORY of the manifest name would occupy the manifest path and
|
||||||
|
// disable reconciliation; a Unicode case-fold alias (U+017F ſ → s)
|
||||||
|
// aliases it on case-insensitive APFS/NTFS volumes.
|
||||||
|
touch(&src_dir.join("sub4/.builtin-manifest/payload.sh"));
|
||||||
|
touch(&src_dir.join("sub5/.builtin-manifeſt"));
|
||||||
|
let dst_dir = root.join("dst");
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
plan_dir_into(&src_dir, &dst_dir, TopCategory::Hooks, &mut files).unwrap();
|
||||||
|
|
||||||
|
let rels: Vec<&Path> = files.iter().map(|p| p.rel.as_path()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
rels,
|
||||||
|
vec![Path::new("notify.sh")],
|
||||||
|
"a bundle must never install a builtin manifest: it would hand the \
|
||||||
|
bundle control over builtin stale-file deletion"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hook_script_lines_cover_every_hooks_dir_and_nothing_else() {
|
||||||
|
let file = |rel: &str, cat: TopCategory| PlannedFile {
|
||||||
|
src: PathBuf::from("src").join(rel),
|
||||||
|
dst: PathBuf::from("dst").join(rel),
|
||||||
|
rel: PathBuf::from(rel),
|
||||||
|
kind: PlannedKind::New,
|
||||||
|
top_category: cat,
|
||||||
|
};
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: vec![
|
||||||
|
file("notify.sh", TopCategory::Hooks),
|
||||||
|
file("x/hooks/pre.sh", TopCategory::Agents),
|
||||||
|
file("hooks/role-hook.sh", TopCategory::Roles),
|
||||||
|
file("x/config.yaml", TopCategory::Agents),
|
||||||
|
file("reviewer.md", TopCategory::Roles),
|
||||||
|
file("hello.yaml", TopCategory::Macros),
|
||||||
|
file("x/hooks/nested/deep.sh", TopCategory::Agents),
|
||||||
|
file("x/sub/hooks/pre.sh", TopCategory::Agents),
|
||||||
|
file("sub/hooks/extra.sh", TopCategory::Roles),
|
||||||
|
file("my-skill/hooks/tool.sh", TopCategory::Skills),
|
||||||
|
file("my-macro/hooks/tool.sh", TopCategory::Macros),
|
||||||
|
],
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines = hook_script_lines(&plan);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
vec![
|
||||||
|
"bundle defines hook script: hooks/notify.sh",
|
||||||
|
"bundle defines hook script: agents/x/hooks/pre.sh",
|
||||||
|
"bundle defines hook script: roles/hooks/role-hook.sh",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_file_new_when_dst_missing() {
|
fn classify_file_new_when_dst_missing() {
|
||||||
let dir = fresh_temp_dir("classify-new-");
|
let dir = fresh_temp_dir("classify-new-");
|
||||||
@@ -3420,6 +3547,91 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&src_root);
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn install_remote_without_manifest_uses_repo_slug_and_short_sha_version() {
|
fn install_remote_without_manifest_uses_repo_slug_and_short_sha_version() {
|
||||||
@@ -3467,12 +3679,14 @@ mod tests {
|
|||||||
PlannedFile {
|
PlannedFile {
|
||||||
src: src_new,
|
src: src_new,
|
||||||
dst: dst_new.clone(),
|
dst: dst_new.clone(),
|
||||||
|
rel: PathBuf::from("new.yaml"),
|
||||||
kind: PlannedKind::New,
|
kind: PlannedKind::New,
|
||||||
top_category: TopCategory::Macros,
|
top_category: TopCategory::Macros,
|
||||||
},
|
},
|
||||||
PlannedFile {
|
PlannedFile {
|
||||||
src: src_conflict,
|
src: src_conflict,
|
||||||
dst: dst_conflict.clone(),
|
dst: dst_conflict.clone(),
|
||||||
|
rel: PathBuf::from("conflict.yaml"),
|
||||||
kind: PlannedKind::Conflict,
|
kind: PlannedKind::Conflict,
|
||||||
top_category: TopCategory::Macros,
|
top_category: TopCategory::Macros,
|
||||||
},
|
},
|
||||||
@@ -3525,6 +3739,7 @@ mod tests {
|
|||||||
files: vec![PlannedFile {
|
files: vec![PlannedFile {
|
||||||
src,
|
src,
|
||||||
dst: dst.clone(),
|
dst: dst.clone(),
|
||||||
|
rel: PathBuf::from("owned.yaml"),
|
||||||
kind: PlannedKind::Conflict,
|
kind: PlannedKind::Conflict,
|
||||||
top_category: TopCategory::Macros,
|
top_category: TopCategory::Macros,
|
||||||
}],
|
}],
|
||||||
@@ -3656,12 +3871,50 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let _guard = TestVaultConfigGuard::new("prov-builtin");
|
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!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
|
||||||
assert!(!paths::installed_bundles_file().exists());
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn update_silently_refreshes_owned_unmodified_files() {
|
fn update_silently_refreshes_owned_unmodified_files() {
|
||||||
@@ -3798,6 +4051,7 @@ mod tests {
|
|||||||
files: vec![PlannedFile {
|
files: vec![PlannedFile {
|
||||||
src,
|
src,
|
||||||
dst: dst.clone(),
|
dst: dst.clone(),
|
||||||
|
rel: PathBuf::from("owned.yaml"),
|
||||||
kind: PlannedKind::Conflict,
|
kind: PlannedKind::Conflict,
|
||||||
top_category: TopCategory::Macros,
|
top_category: TopCategory::Macros,
|
||||||
}],
|
}],
|
||||||
@@ -3844,6 +4098,7 @@ mod tests {
|
|||||||
files: vec![PlannedFile {
|
files: vec![PlannedFile {
|
||||||
src,
|
src,
|
||||||
dst: dst.clone(),
|
dst: dst.clone(),
|
||||||
|
rel: PathBuf::from("owned.yaml"),
|
||||||
kind: PlannedKind::Conflict,
|
kind: PlannedKind::Conflict,
|
||||||
top_category: TopCategory::Macros,
|
top_category: TopCategory::Macros,
|
||||||
}],
|
}],
|
||||||
@@ -3923,6 +4178,7 @@ mod tests {
|
|||||||
files: vec![PlannedFile {
|
files: vec![PlannedFile {
|
||||||
src,
|
src,
|
||||||
dst: dst.clone(),
|
dst: dst.clone(),
|
||||||
|
rel: PathBuf::from("shared.yaml"),
|
||||||
kind: PlannedKind::Conflict,
|
kind: PlannedKind::Conflict,
|
||||||
top_category: TopCategory::Macros,
|
top_category: TopCategory::Macros,
|
||||||
}],
|
}],
|
||||||
@@ -4195,6 +4451,7 @@ mod tests {
|
|||||||
files: vec![PlannedFile {
|
files: vec![PlannedFile {
|
||||||
src: dir.join("bundle-src/agents/foo/graph.yaml"),
|
src: dir.join("bundle-src/agents/foo/graph.yaml"),
|
||||||
dst: dir.join("agents/foo/graph.yaml"),
|
dst: dir.join("agents/foo/graph.yaml"),
|
||||||
|
rel: PathBuf::from("foo/graph.yaml"),
|
||||||
kind: PlannedKind::New,
|
kind: PlannedKind::New,
|
||||||
top_category: TopCategory::Agents,
|
top_category: TopCategory::Agents,
|
||||||
}],
|
}],
|
||||||
@@ -4237,6 +4494,7 @@ mod tests {
|
|||||||
files: vec![PlannedFile {
|
files: vec![PlannedFile {
|
||||||
src: dir.join("bundle-src/agents/foo/graph.yaml"),
|
src: dir.join("bundle-src/agents/foo/graph.yaml"),
|
||||||
dst: dir.join("agents/foo/graph.yaml"),
|
dst: dir.join("agents/foo/graph.yaml"),
|
||||||
|
rel: PathBuf::from("foo/graph.yaml"),
|
||||||
kind: PlannedKind::New,
|
kind: PlannedKind::New,
|
||||||
top_category: TopCategory::Agents,
|
top_category: TopCategory::Agents,
|
||||||
}],
|
}],
|
||||||
|
|||||||
+14
-50
@@ -83,12 +83,17 @@ pub async fn macro_execute(
|
|||||||
.bootstrap_tools(app.as_ref(), true, abort_signal.clone())
|
.bootstrap_tools(app.as_ref(), true, abort_signal.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let result = async {
|
||||||
for step in ¯o_value.steps {
|
for step in ¯o_value.steps {
|
||||||
let command = Macro::interpolate_command(step, &variables);
|
let command = Macro::interpolate_command(step, &variables);
|
||||||
println!(">> {}", multiline_text(&command));
|
println!(">> {}", multiline_text(&command));
|
||||||
run_repl_command(&mut macro_ctx, abort_signal.clone(), &command).await?;
|
run_repl_command(&mut macro_ctx, abort_signal.clone(), &command).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
macro_ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MacroModeGuard<'a> {
|
struct MacroModeGuard<'a> {
|
||||||
@@ -167,7 +172,8 @@ impl Macro {
|
|||||||
debug!("Processing macro file: {}", file.as_ref());
|
debug!("Processing macro file: {}", file.as_ref());
|
||||||
let embedded_file = MacroAssets::get(&file)
|
let embedded_file = MacroAssets::get(&file)
|
||||||
.ok_or_else(|| anyhow!("Failed to load embedded macro file: {}", file.as_ref()))?;
|
.ok_or_else(|| anyhow!("Failed to load embedded macro file: {}", file.as_ref()))?;
|
||||||
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
let content = std::str::from_utf8(&embedded_file.data)
|
||||||
|
.expect("bundled macro asset is not valid UTF-8");
|
||||||
let file_path = paths::macros_dir().join(file.as_ref());
|
let file_path = paths::macros_dir().join(file.as_ref());
|
||||||
|
|
||||||
if file_path.exists() && !force {
|
if file_path.exists() && !force {
|
||||||
@@ -350,56 +356,14 @@ fn default_true() -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{AppState, Session, WorkingMode};
|
use crate::config::{AppState, Session, WorkingMode};
|
||||||
|
use crate::testing::TestConfigDirGuard;
|
||||||
use crate::utils::{create_abort_signal, get_env_name};
|
use crate::utils::{create_abort_signal, get_env_name};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::fs::{create_dir_all, remove_dir_all, write};
|
use std::fs::{create_dir_all, remove_dir_all, write};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use std::{env, str};
|
use std::{env, str};
|
||||||
|
|
||||||
struct TestConfigDirGuard {
|
|
||||||
key: String,
|
|
||||||
previous: Option<std::ffi::OsString>,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestConfigDirGuard {
|
|
||||||
fn new() -> Self {
|
|
||||||
let key = get_env_name("config_dir");
|
|
||||||
let previous = env::var_os(&key);
|
|
||||||
let unique = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let path = env::temp_dir().join(format!("coyote-macros-tests-{unique}"));
|
|
||||||
create_dir_all(&path).unwrap();
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&key, &path);
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
key,
|
|
||||||
previous,
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestConfigDirGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Some(previous) = &self.previous {
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&self.key, previous);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unsafe {
|
|
||||||
env::remove_var(&self.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_ctx() -> RequestContext {
|
fn test_ctx() -> RequestContext {
|
||||||
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
||||||
}
|
}
|
||||||
@@ -974,7 +938,7 @@ variables:
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn non_isolated_steps_run_on_live_ctx_and_mutations_persist() {
|
fn non_isolated_steps_run_on_live_ctx_and_mutations_persist() {
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("macros-tests");
|
||||||
write_macro_file(
|
write_macro_file(
|
||||||
"live-macro",
|
"live-macro",
|
||||||
"isolated: false\nsteps:\n - \".set temperature 0.42\"\n",
|
"isolated: false\nsteps:\n - \".set temperature 0.42\"\n",
|
||||||
@@ -1006,7 +970,7 @@ variables:
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn non_isolated_step_failure_aborts_and_restores_flag_and_mode() {
|
fn non_isolated_step_failure_aborts_and_restores_flag_and_mode() {
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("macros-tests");
|
||||||
write_macro_file(
|
write_macro_file(
|
||||||
"fail-macro",
|
"fail-macro",
|
||||||
"isolated: false\nsteps:\n - \".set temperature 0.9\"\n - \".update\"\n - \".set temperature 0.1\"\n",
|
"isolated: false\nsteps:\n - \".set temperature 0.9\"\n - \".update\"\n - \".set temperature 0.1\"\n",
|
||||||
@@ -1057,7 +1021,7 @@ variables:
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn non_isolated_macro_step_invoking_macro_is_rejected() {
|
fn non_isolated_macro_step_invoking_macro_is_rejected() {
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("macros-tests");
|
||||||
write_macro_file(
|
write_macro_file(
|
||||||
"outer-macro",
|
"outer-macro",
|
||||||
"isolated: false\nsteps:\n - \".inner-macro\"\n",
|
"isolated: false\nsteps:\n - \".inner-macro\"\n",
|
||||||
@@ -1093,7 +1057,7 @@ variables:
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn isolated_macro_step_runs_non_isolated_macro_inline_on_fork() {
|
fn isolated_macro_step_runs_non_isolated_macro_inline_on_fork() {
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("macros-tests");
|
||||||
write_macro_file("iso-outer-macro", "steps:\n - \".inner-macro\"\n");
|
write_macro_file("iso-outer-macro", "steps:\n - \".inner-macro\"\n");
|
||||||
write_macro_file(
|
write_macro_file(
|
||||||
"inner-macro",
|
"inner-macro",
|
||||||
@@ -1122,7 +1086,7 @@ variables:
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn isolated_macro_still_forks_and_leaves_live_ctx_untouched() {
|
fn isolated_macro_still_forks_and_leaves_live_ctx_untouched() {
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("macros-tests");
|
||||||
write_macro_file("iso-macro", "steps:\n - \".set temperature 0.77\"\n");
|
write_macro_file("iso-macro", "steps:\n - \".set temperature 0.77\"\n");
|
||||||
let mut ctx = test_ctx();
|
let mut ctx = test_ctx();
|
||||||
ctx.session = Some(Session::default());
|
ctx.session = Some(Session::default());
|
||||||
@@ -1152,7 +1116,7 @@ variables:
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn guard_restores_prior_flag_values_after_inline_run() {
|
fn guard_restores_prior_flag_values_after_inline_run() {
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("macros-tests");
|
||||||
write_macro_file(
|
write_macro_file(
|
||||||
"inner-macro",
|
"inner-macro",
|
||||||
"isolated: false\nsteps:\n - \".set temperature 0.11\"\n",
|
"isolated: false\nsteps:\n - \".set temperature 0.11\"\n",
|
||||||
|
|||||||
+247
-28
@@ -1,5 +1,6 @@
|
|||||||
|
use crate::hooks::McpServerHooks;
|
||||||
use crate::mcp::{
|
use crate::mcp::{
|
||||||
ConnectedServer, JsonField, McpAuthRequired, McpServer, McpTransportType,
|
ConnectedServer, HttpAuth, JsonField, McpAuthRequired, McpServer, McpTransportType,
|
||||||
is_auth_required_error, resolve_http_auth, spawn_mcp_server,
|
is_auth_required_error, resolve_http_auth, spawn_mcp_server,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,11 +81,6 @@ pub struct McpFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl McpFactory {
|
impl McpFactory {
|
||||||
pub fn try_get_active(&self, key: &McpServerKey) -> Option<Arc<ConnectedServer>> {
|
|
||||||
let map = self.active.lock();
|
|
||||||
map.get(key).and_then(|weak| weak.upgrade())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert_active(&self, key: McpServerKey, handle: &Arc<ConnectedServer>) {
|
pub fn insert_active(&self, key: McpServerKey, handle: &Arc<ConnectedServer>) {
|
||||||
let mut map = self.active.lock();
|
let mut map = self.active.lock();
|
||||||
map.insert(key, Arc::downgrade(handle));
|
map.insert(key, Arc::downgrade(handle));
|
||||||
@@ -95,15 +91,47 @@ impl McpFactory {
|
|||||||
name: &str,
|
name: &str,
|
||||||
spec: &McpServer,
|
spec: &McpServer,
|
||||||
log_path: Option<&Path>,
|
log_path: Option<&Path>,
|
||||||
|
hooks: &McpServerHooks,
|
||||||
) -> Result<Arc<ConnectedServer>> {
|
) -> Result<Arc<ConnectedServer>> {
|
||||||
let key = McpServerKey::from_spec(name, spec);
|
let key = McpServerKey::from_spec(name, spec);
|
||||||
|
|
||||||
if let Some(existing) = self.try_get_active(&key) {
|
let reconnect = {
|
||||||
|
let map = self.active.lock();
|
||||||
|
if let Some(existing) = map.get(&key).and_then(|weak| weak.upgrade()) {
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
|
map.contains_key(&key)
|
||||||
|
};
|
||||||
|
|
||||||
|
let transport = transport_label(&spec.transport_type);
|
||||||
|
let handle = match spawn_server(name, spec, log_path).await {
|
||||||
|
Ok(handle) => handle,
|
||||||
|
Err(e) => {
|
||||||
|
hooks.fire_failed(name, transport, &e, is_auth_required_error(&e));
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.insert_active(key, &handle);
|
||||||
|
hooks.fire_connected(name, transport, reconnect);
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_label(transport: &McpTransportType) -> &'static str {
|
||||||
|
match transport {
|
||||||
|
McpTransportType::Stdio => "stdio",
|
||||||
|
McpTransportType::Http => "http",
|
||||||
|
McpTransportType::Sse => "sse",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_server(
|
||||||
|
name: &str,
|
||||||
|
spec: &McpServer,
|
||||||
|
log_path: Option<&Path>,
|
||||||
|
) -> Result<Arc<ConnectedServer>> {
|
||||||
let (auth, auth_reason) = resolve_http_auth(name, spec).await;
|
let (auth, auth_reason) = resolve_http_auth(name, spec).await;
|
||||||
let handle = spawn_mcp_server(spec, log_path, auth).await.map_err(|e| {
|
spawn_transport(spec, log_path, auth).await.map_err(|e| {
|
||||||
if is_auth_required_error(&e) {
|
if is_auth_required_error(&e) {
|
||||||
e.context(McpAuthRequired {
|
e.context(McpAuthRequired {
|
||||||
server: name.to_string(),
|
server: name.to_string(),
|
||||||
@@ -112,17 +140,39 @@ impl McpFactory {
|
|||||||
} else {
|
} else {
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
})?;
|
})
|
||||||
self.insert_active(key, &handle);
|
|
||||||
Ok(handle)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn spawn_transport(
|
||||||
|
spec: &McpServer,
|
||||||
|
log_path: Option<&Path>,
|
||||||
|
auth: HttpAuth,
|
||||||
|
) -> Result<Arc<ConnectedServer>> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(result) = STUB_SPAWNS.lock().pop() {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
spawn_mcp_server(spec, log_path, auth).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Injected spawn outcomes, popped instead of spawning a real transport:
|
||||||
|
/// nothing in a test environment speaks MCP over stdio or HTTP, so the
|
||||||
|
/// connect path is exercised with fixture handles. Empty means spawn for
|
||||||
|
/// real.
|
||||||
|
#[cfg(test)]
|
||||||
|
static STUB_SPAWNS: Mutex<Vec<Result<Arc<ConnectedServer>>>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::test_fixtures::FixtureServer;
|
||||||
|
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
|
||||||
|
use crate::hooks::{HookDef, HooksMap, test_sink};
|
||||||
use crate::mcp::{JsonField, McpServer, McpTransportType};
|
use crate::mcp::{JsonField, McpServer, McpTransportType};
|
||||||
|
use anyhow::anyhow;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
|
use rmcp::ServiceExt;
|
||||||
|
use serial_test::serial;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn stdio_spec(
|
fn stdio_spec(
|
||||||
@@ -299,26 +349,195 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn factory_try_get_active_returns_none_when_empty() {
|
|
||||||
let factory = McpFactory::default();
|
|
||||||
let spec = stdio_spec("cmd", None, None);
|
|
||||||
let key = McpServerKey::from_spec("s", &spec);
|
|
||||||
assert!(factory.try_get_active(&key).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn factory_try_get_active_returns_none_for_unknown_key() {
|
|
||||||
let factory = McpFactory::default();
|
|
||||||
let spec = stdio_spec("cmd", None, None);
|
|
||||||
let key = McpServerKey::from_spec("s", &spec);
|
|
||||||
assert!(factory.try_get_active(&key).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn factory_default_has_empty_active_map() {
|
fn factory_default_has_empty_active_map() {
|
||||||
let factory = McpFactory::default();
|
let factory = McpFactory::default();
|
||||||
let map = factory.active.lock();
|
let map = factory.active.lock();
|
||||||
assert!(map.is_empty());
|
assert!(map.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears injected spawn outcomes on drop so a panicking test cannot
|
||||||
|
/// leak stubs into later tests.
|
||||||
|
struct StubSpawnsGuard;
|
||||||
|
|
||||||
|
impl StubSpawnsGuard {
|
||||||
|
fn push(result: Result<Arc<ConnectedServer>>) -> Self {
|
||||||
|
STUB_SPAWNS.lock().push(result);
|
||||||
|
StubSpawnsGuard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StubSpawnsGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
STUB_SPAWNS.lock().clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-process client/server pair; the returned server half must stay
|
||||||
|
/// alive for the client handle to keep working.
|
||||||
|
async fn fixture_handle() -> (Arc<ConnectedServer>, impl Sized) {
|
||||||
|
let (client_io, server_io) = tokio::io::duplex(4096);
|
||||||
|
let (server, client) = tokio::join!(
|
||||||
|
FixtureServer::default().serve(server_io),
|
||||||
|
().serve(client_io)
|
||||||
|
);
|
||||||
|
(Arc::new(client.unwrap()), server.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ctx_with_mcp_hooks(marker: &str) -> RequestContext {
|
||||||
|
let mut hooks_map = HooksMap::default();
|
||||||
|
for event in ["mcp.server.connected", "mcp.server.failed"] {
|
||||||
|
hooks_map.insert(
|
||||||
|
event.to_string(),
|
||||||
|
vec![HookDef {
|
||||||
|
name: format!("{marker}-{event}"),
|
||||||
|
command: "true".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut app = AppState::test_default();
|
||||||
|
app.config = Arc::new(AppConfig {
|
||||||
|
hooks: hooks_map,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_captures(marker: &str) -> Vec<test_sink::Capture> {
|
||||||
|
test_sink::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|capture| capture.hook_name.starts_with(marker))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn acquire_fires_connected_only_on_real_spawns_and_marks_reconnects() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "mcp-spawn-k7d";
|
||||||
|
let ctx = ctx_with_mcp_hooks(marker);
|
||||||
|
let hooks = McpServerHooks::resolve(&ctx);
|
||||||
|
let factory = McpFactory::default();
|
||||||
|
let spec = stdio_spec("fixture-server-cmd", None, None);
|
||||||
|
|
||||||
|
let (first, _first_server) = fixture_handle().await;
|
||||||
|
let stub = StubSpawnsGuard::push(Ok(first.clone()));
|
||||||
|
let connected = factory.acquire("srv", &spec, None, &hooks).await.unwrap();
|
||||||
|
drop(stub);
|
||||||
|
assert!(Arc::ptr_eq(&connected, &first));
|
||||||
|
let captures = mcp_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 1, "{captures:?}");
|
||||||
|
let capture = &captures[0];
|
||||||
|
assert_eq!(capture.hook_name, format!("{marker}-mcp.server.connected"));
|
||||||
|
assert_eq!(
|
||||||
|
capture.envs.get("COYOTE_MCP_SERVER").map(String::as_str),
|
||||||
|
Some("srv")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
capture.envs.get("COYOTE_MCP_TRANSPORT").map(String::as_str),
|
||||||
|
Some("stdio")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!capture.envs.contains_key("COYOTE_MCP_RECONNECT"),
|
||||||
|
"a first connect must not be marked as a reconnect"
|
||||||
|
);
|
||||||
|
|
||||||
|
let reused = factory.acquire("srv", &spec, None, &hooks).await.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(&reused, &first));
|
||||||
|
assert_eq!(
|
||||||
|
mcp_captures(marker).len(),
|
||||||
|
1,
|
||||||
|
"reusing a live server must fire nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every holder dropped: the weak in the factory dies, and the next
|
||||||
|
// acquire is a real spawn again — now marked as a reconnect.
|
||||||
|
drop(connected);
|
||||||
|
drop(reused);
|
||||||
|
drop(first);
|
||||||
|
let (second, _second_server) = fixture_handle().await;
|
||||||
|
let _stub = StubSpawnsGuard::push(Ok(second.clone()));
|
||||||
|
let respawned = factory.acquire("srv", &spec, None, &hooks).await.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(&respawned, &second));
|
||||||
|
let captures = mcp_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(
|
||||||
|
captures[1]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_MCP_RECONNECT")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("true")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn acquire_spawn_failure_fires_failed_with_the_error() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "mcp-fail-v2q";
|
||||||
|
let ctx = ctx_with_mcp_hooks(marker);
|
||||||
|
let hooks = McpServerHooks::resolve(&ctx);
|
||||||
|
let factory = McpFactory::default();
|
||||||
|
// No stub: the nonexistent binary drives the real spawn-error path.
|
||||||
|
let spec = stdio_spec("coyote-nonexistent-mcp-binary-v2q", None, None);
|
||||||
|
|
||||||
|
let result = factory.acquire("bad-srv", &spec, None, &hooks).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let captures = mcp_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 1, "{captures:?}");
|
||||||
|
let capture = &captures[0];
|
||||||
|
assert_eq!(capture.hook_name, format!("{marker}-mcp.server.failed"));
|
||||||
|
assert_eq!(
|
||||||
|
capture.envs.get("COYOTE_MCP_SERVER").map(String::as_str),
|
||||||
|
Some("bad-srv")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
capture.envs.get("COYOTE_MCP_TRANSPORT").map(String::as_str),
|
||||||
|
Some("stdio")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
capture
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_ERROR")
|
||||||
|
.is_some_and(|error| !error.is_empty())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!capture.envs.contains_key("COYOTE_MCP_AUTH_REQUIRED"),
|
||||||
|
"a plain spawn failure must not be flagged as auth-required"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn acquire_auth_failure_sets_the_auth_required_flag() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "mcp-auth-h4n";
|
||||||
|
let ctx = ctx_with_mcp_hooks(marker);
|
||||||
|
let hooks = McpServerHooks::resolve(&ctx);
|
||||||
|
let factory = McpFactory::default();
|
||||||
|
let spec = stdio_spec("oauth-cmd", None, None);
|
||||||
|
let _stub = StubSpawnsGuard::push(Err(anyhow!("Auth required: no stored token")));
|
||||||
|
|
||||||
|
let err = factory
|
||||||
|
.acquire("oauth-srv", &spec, None, &hooks)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.downcast_ref::<McpAuthRequired>().is_some(),
|
||||||
|
"the auth error must be wrapped in McpAuthRequired context"
|
||||||
|
);
|
||||||
|
let captures = mcp_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 1, "{captures:?}");
|
||||||
|
let capture = &captures[0];
|
||||||
|
assert_eq!(capture.hook_name, format!("{marker}-mcp.server.failed"));
|
||||||
|
assert_eq!(
|
||||||
|
capture
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_MCP_AUTH_REQUIRED")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("true")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+321
-25
@@ -1,7 +1,9 @@
|
|||||||
mod agent;
|
mod agent;
|
||||||
mod app_config;
|
mod app_config;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
|
pub(crate) mod builtin_manifest;
|
||||||
mod bundles;
|
mod bundles;
|
||||||
|
pub(crate) mod conflict;
|
||||||
mod input;
|
mod input;
|
||||||
mod install_remote;
|
mod install_remote;
|
||||||
pub(crate) mod instructions;
|
pub(crate) mod instructions;
|
||||||
@@ -36,6 +38,7 @@ pub use self::app_config::AppConfig;
|
|||||||
pub use self::app_state::AppState;
|
pub use self::app_state::AppState;
|
||||||
pub(crate) use self::bundles::installed_bundle_names;
|
pub(crate) use self::bundles::installed_bundle_names;
|
||||||
pub use self::bundles::list_installed_bundles;
|
pub use self::bundles::list_installed_bundles;
|
||||||
|
use self::conflict::{InstallMode, StickyMode};
|
||||||
pub use self::input::Input;
|
pub use self::input::Input;
|
||||||
pub use self::install_remote::{
|
pub use self::install_remote::{
|
||||||
DEFAULT_GIT_HOST, install_or_update, install_or_update_from_repl_args, uninstall_bundle,
|
DEFAULT_GIT_HOST, install_or_update, install_or_update_from_repl_args, uninstall_bundle,
|
||||||
@@ -55,7 +58,7 @@ pub use self::request_context::{
|
|||||||
pub use self::role::{
|
pub use self::role::{
|
||||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
||||||
};
|
};
|
||||||
pub use self::session::Session;
|
pub use self::session::{Session, SessionScope, labeled_session_names, session_scope_dirs};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::skill::Skill;
|
pub use self::skill::Skill;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -75,12 +78,14 @@ use crate::client::{
|
|||||||
oauth, set_client_models_config,
|
oauth, set_client_models_config,
|
||||||
};
|
};
|
||||||
use crate::function::{FunctionDeclaration, Functions};
|
use crate::function::{FunctionDeclaration, Functions};
|
||||||
|
use crate::hooks::HooksMap;
|
||||||
use crate::rag::Rag;
|
use crate::rag::Rag;
|
||||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||||
use crate::utils::*;
|
use crate::utils::*;
|
||||||
pub use macros::macro_execute;
|
pub use macros::macro_execute;
|
||||||
|
|
||||||
use crate::config::macros::Macro;
|
use crate::config::macros::Macro;
|
||||||
|
use crate::hooks;
|
||||||
use crate::vault::{
|
use crate::vault::{
|
||||||
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice,
|
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice,
|
||||||
};
|
};
|
||||||
@@ -162,6 +167,7 @@ const AGENT_GRAPH_FILE_NAME: &str = "graph.yaml";
|
|||||||
const ROLES_DIR_NAME: &str = "roles";
|
const ROLES_DIR_NAME: &str = "roles";
|
||||||
const SKILLS_DIR_NAME: &str = "skills";
|
const SKILLS_DIR_NAME: &str = "skills";
|
||||||
const MACROS_DIR_NAME: &str = "macros";
|
const MACROS_DIR_NAME: &str = "macros";
|
||||||
|
const HOOKS_DIR_NAME: &str = "hooks";
|
||||||
const ENV_FILE_NAME: &str = ".env";
|
const ENV_FILE_NAME: &str = ".env";
|
||||||
const MESSAGES_FILE_NAME: &str = "messages.md";
|
const MESSAGES_FILE_NAME: &str = "messages.md";
|
||||||
const SESSIONS_DIR_NAME: &str = "sessions";
|
const SESSIONS_DIR_NAME: &str = "sessions";
|
||||||
@@ -177,7 +183,7 @@ const MCP_FILE_NAME: &str = "mcp.json";
|
|||||||
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
|
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
|
||||||
const MEMORY_DIR_NAME: &str = "memory";
|
const MEMORY_DIR_NAME: &str = "memory";
|
||||||
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
||||||
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
|
pub(crate) const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
|
||||||
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
||||||
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||||
@@ -262,6 +268,9 @@ pub struct Config {
|
|||||||
pub enabled_mcp_servers: Option<Vec<String>>,
|
pub enabled_mcp_servers: Option<Vec<String>>,
|
||||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub hooks: HooksMap,
|
||||||
|
|
||||||
pub auto_continue: bool,
|
pub auto_continue: bool,
|
||||||
pub max_auto_continues: usize,
|
pub max_auto_continues: usize,
|
||||||
pub inject_todo_instructions: bool,
|
pub inject_todo_instructions: bool,
|
||||||
@@ -277,6 +286,7 @@ pub struct Config {
|
|||||||
pub compression_threshold: usize,
|
pub compression_threshold: usize,
|
||||||
pub compression_keep_last: usize,
|
pub compression_keep_last: usize,
|
||||||
pub compression_model: Option<String>,
|
pub compression_model: Option<String>,
|
||||||
|
pub compression_safety_valve: f32,
|
||||||
pub summarization_prompt: Option<String>,
|
pub summarization_prompt: Option<String>,
|
||||||
pub summary_context_prompt: Option<String>,
|
pub summary_context_prompt: Option<String>,
|
||||||
pub max_tool_result_chars: Option<usize>,
|
pub max_tool_result_chars: Option<usize>,
|
||||||
@@ -350,6 +360,8 @@ impl Default for Config {
|
|||||||
enabled_mcp_servers: None,
|
enabled_mcp_servers: None,
|
||||||
mcp_tools: None,
|
mcp_tools: None,
|
||||||
|
|
||||||
|
hooks: Default::default(),
|
||||||
|
|
||||||
auto_continue: false,
|
auto_continue: false,
|
||||||
max_auto_continues: 10,
|
max_auto_continues: 10,
|
||||||
inject_todo_instructions: true,
|
inject_todo_instructions: true,
|
||||||
@@ -365,6 +377,7 @@ impl Default for Config {
|
|||||||
compression_threshold: 4000,
|
compression_threshold: 4000,
|
||||||
compression_keep_last: 0,
|
compression_keep_last: 0,
|
||||||
compression_model: None,
|
compression_model: None,
|
||||||
|
compression_safety_valve: 0.8,
|
||||||
summarization_prompt: None,
|
summarization_prompt: None,
|
||||||
summary_context_prompt: None,
|
summary_context_prompt: None,
|
||||||
max_tool_result_chars: None,
|
max_tool_result_chars: None,
|
||||||
@@ -408,10 +421,13 @@ impl Default for Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn install_builtins() -> Result<()> {
|
pub fn install_builtins() -> Result<()> {
|
||||||
|
hooks::sweep_stale_payload_files();
|
||||||
Functions::install_builtin_global_tools(false)?;
|
Functions::install_builtin_global_tools(false)?;
|
||||||
Agent::install_builtin_agents(false)?;
|
Agent::install_builtin_agents(InstallMode::Skip)?;
|
||||||
Macro::install_macros(false)?;
|
Macro::install_macros(false)?;
|
||||||
Skill::install_builtin_skills(false)?;
|
Skill::install_builtin_skills(false)?;
|
||||||
|
hooks::install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None)?;
|
||||||
|
Role::install_builtin_role_hooks(InstallMode::Skip, &mut StickyMode::None)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,12 +437,20 @@ pub enum AssetCategory {
|
|||||||
Macros,
|
Macros,
|
||||||
Functions,
|
Functions,
|
||||||
Skills,
|
Skills,
|
||||||
|
Hooks,
|
||||||
#[value(name = "mcp-config", alias = "mcp_config")]
|
#[value(name = "mcp-config", alias = "mcp_config")]
|
||||||
McpConfig,
|
McpConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AssetCategory {
|
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> {
|
pub fn parse(name: &str) -> Option<Self> {
|
||||||
match name {
|
match name {
|
||||||
@@ -434,6 +458,7 @@ impl AssetCategory {
|
|||||||
"macros" => Some(Self::Macros),
|
"macros" => Some(Self::Macros),
|
||||||
"functions" => Some(Self::Functions),
|
"functions" => Some(Self::Functions),
|
||||||
"skills" => Some(Self::Skills),
|
"skills" => Some(Self::Skills),
|
||||||
|
"hooks" => Some(Self::Hooks),
|
||||||
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -453,17 +478,19 @@ pub enum InstallFilter {
|
|||||||
Skills,
|
Skills,
|
||||||
Macros,
|
Macros,
|
||||||
Functions,
|
Functions,
|
||||||
|
Hooks,
|
||||||
#[value(name = "mcp-config", alias = "mcp_config")]
|
#[value(name = "mcp-config", alias = "mcp_config")]
|
||||||
McpConfig,
|
McpConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstallFilter {
|
impl InstallFilter {
|
||||||
pub const NAMES: [&'static str; 6] = [
|
pub const NAMES: [&'static str; 7] = [
|
||||||
"agents",
|
"agents",
|
||||||
"roles",
|
"roles",
|
||||||
"skills",
|
"skills",
|
||||||
"macros",
|
"macros",
|
||||||
"functions",
|
"functions",
|
||||||
|
"hooks",
|
||||||
"mcp-config",
|
"mcp-config",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -474,6 +501,7 @@ impl InstallFilter {
|
|||||||
"skills" => Some(Self::Skills),
|
"skills" => Some(Self::Skills),
|
||||||
"macros" => Some(Self::Macros),
|
"macros" => Some(Self::Macros),
|
||||||
"functions" => Some(Self::Functions),
|
"functions" => Some(Self::Functions),
|
||||||
|
"hooks" => Some(Self::Hooks),
|
||||||
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -486,6 +514,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
|
|||||||
AssetCategory::Macros => ("macros", paths::macros_dir()),
|
AssetCategory::Macros => ("macros", paths::macros_dir()),
|
||||||
AssetCategory::Functions => ("functions", paths::functions_dir()),
|
AssetCategory::Functions => ("functions", paths::functions_dir()),
|
||||||
AssetCategory::Skills => ("skills", paths::skills_dir()),
|
AssetCategory::Skills => ("skills", paths::skills_dir()),
|
||||||
|
AssetCategory::Hooks => ("hooks", paths::hooks_dir()),
|
||||||
AssetCategory::McpConfig => ("MCP config", paths::mcp_config_file()),
|
AssetCategory::McpConfig => ("MCP config", paths::mcp_config_file()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -495,10 +524,16 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match category {
|
match category {
|
||||||
AssetCategory::Agents => Agent::install_builtin_agents(true)?,
|
AssetCategory::Agents => Agent::install_builtin_agents(InstallMode::Force)?,
|
||||||
AssetCategory::Macros => Macro::install_macros(true)?,
|
AssetCategory::Macros => Macro::install_macros(true)?,
|
||||||
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
|
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
|
||||||
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
||||||
|
AssetCategory::Hooks => {
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
hooks::install_builtin_hooks(InstallMode::Prompt, &mut sticky)?;
|
||||||
|
Role::install_builtin_role_hooks(InstallMode::Prompt, &mut sticky)?;
|
||||||
|
Agent::install_builtin_agent_hooks(InstallMode::Prompt, &mut sticky)?;
|
||||||
|
}
|
||||||
AssetCategory::McpConfig => Functions::install_mcp_config()?,
|
AssetCategory::McpConfig => Functions::install_mcp_config()?,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,6 +554,14 @@ fn confirm_asset_overwrite(category: AssetCategory, label: &str, target: &Path)
|
|||||||
references) are left untouched.",
|
references) are left untouched.",
|
||||||
target.display()
|
target.display()
|
||||||
),
|
),
|
||||||
|
AssetCategory::Hooks => format!(
|
||||||
|
"Refreshing bundled hooks installs any missing bundled hook \
|
||||||
|
scripts in {}, the roles hooks directory, and each bundled \
|
||||||
|
agent's hooks directory. Where a local copy differs from the \
|
||||||
|
bundled version you are asked per file whether to keep or \
|
||||||
|
replace it; non-interactive runs keep local files.",
|
||||||
|
target.display()
|
||||||
|
),
|
||||||
_ => format!(
|
_ => format!(
|
||||||
"Reinstalling bundled {label} overwrites every bundled {label} in \
|
"Reinstalling bundled {label} overwrites every bundled {label} in \
|
||||||
{} with this build's packaged versions. Local changes to bundled \
|
{} with this build's packaged versions. Local changes to bundled \
|
||||||
@@ -540,10 +583,6 @@ pub fn default_sessions_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_sessions() -> Vec<String> {
|
|
||||||
list_file_names(default_sessions_dir(), ".yaml")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refreshes the models override. With a URL, mirrors a full models.yaml from
|
/// Refreshes the models override. With a URL, mirrors a full models.yaml from
|
||||||
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
|
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
|
||||||
/// APIs merged with the embedded quirks.
|
/// APIs merged with the embedded quirks.
|
||||||
@@ -704,11 +743,17 @@ pub fn maybe_spawn_models_refresh(app: &AppConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub async fn load_with_interpolation(info_flag: bool) -> Result<Self> {
|
pub async fn load_with_interpolation(
|
||||||
|
skip_interpolation: bool,
|
||||||
|
inspection: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
let config_path = paths::config_file();
|
let config_path = paths::config_file();
|
||||||
|
|
||||||
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
|
if inspection {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
create_config_file(&config_path).await?;
|
create_config_file(&config_path).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,6 +768,9 @@ impl Config {
|
|||||||
{
|
{
|
||||||
Some(v) => (Self::load_dynamic(&v)?, String::new()),
|
Some(v) => (Self::load_dynamic(&v)?, String::new()),
|
||||||
None => {
|
None => {
|
||||||
|
if inspection {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
if *IS_STDOUT_TERMINAL {
|
if *IS_STDOUT_TERMINAL {
|
||||||
create_config_file(&config_path).await?;
|
create_config_file(&config_path).await?;
|
||||||
}
|
}
|
||||||
@@ -733,7 +781,7 @@ impl Config {
|
|||||||
Self::load_from_file(&config_path)?
|
Self::load_from_file(&config_path)?
|
||||||
};
|
};
|
||||||
|
|
||||||
if info_flag {
|
if skip_interpolation {
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,6 +1143,32 @@ pub(crate) fn ensure_parent_exists(path: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one exec-bit policy for every hook/agent installer: a file is
|
||||||
|
/// executable iff `Language::from_extension` recognizes its extension.
|
||||||
|
/// Users may keep non-executable support files in hooks directories, so
|
||||||
|
/// installers must write plain and then apply this predicate — never
|
||||||
|
/// blanket-0755.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub(crate) fn set_executable_bit_if_script(path: &Path) -> Result<()> {
|
||||||
|
use crate::function::Language;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let Some(ext) = path.extension().and_then(std::ffi::OsStr::to_str) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if Language::from_extension(ext) == Language::Unsupported {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
||||||
|
.with_context(|| format!("chmod {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub(crate) fn set_executable_bit_if_script(_path: &Path) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_env_value<T>(key: &str) -> Option<Option<T>>
|
fn read_env_value<T>(key: &str) -> Option<Option<T>>
|
||||||
where
|
where
|
||||||
T: std::str::FromStr,
|
T: std::str::FromStr,
|
||||||
@@ -1303,6 +1377,7 @@ clients:
|
|||||||
assert!(cfg.mcp_server_support);
|
assert!(cfg.mcp_server_support);
|
||||||
assert_eq!(cfg.compression_threshold, 4000);
|
assert_eq!(cfg.compression_threshold, 4000);
|
||||||
assert!(cfg.compression_model.is_none());
|
assert!(cfg.compression_model.is_none());
|
||||||
|
assert_eq!(cfg.compression_safety_valve, 0.8);
|
||||||
assert_eq!(cfg.rag_top_k, 5);
|
assert_eq!(cfg.rag_top_k, 5);
|
||||||
assert!(cfg.save_shell_history);
|
assert!(cfg.save_shell_history);
|
||||||
assert_eq!(cfg.keybindings, "emacs");
|
assert_eq!(cfg.keybindings, "emacs");
|
||||||
@@ -1343,6 +1418,7 @@ clients:
|
|||||||
assert!(cfg.mapping_tools.is_empty());
|
assert!(cfg.mapping_tools.is_empty());
|
||||||
assert!(cfg.document_loaders.is_empty());
|
assert!(cfg.document_loaders.is_empty());
|
||||||
assert_eq!(cfg.compression_threshold, 4000);
|
assert_eq!(cfg.compression_threshold, 4000);
|
||||||
|
assert_eq!(cfg.compression_safety_valve, 0.8);
|
||||||
assert!(cfg.theme.is_none());
|
assert!(cfg.theme.is_none());
|
||||||
assert_eq!(cfg.clients.len(), 1);
|
assert_eq!(cfg.clients.len(), 1);
|
||||||
}
|
}
|
||||||
@@ -1393,6 +1469,59 @@ clients:
|
|||||||
assert!(!CONFIG_TEMPLATE.contains("escalation_timeout"));
|
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]
|
#[test]
|
||||||
fn config_enabled_macros_empty_string_is_some_empty() {
|
fn config_enabled_macros_empty_string_is_some_empty() {
|
||||||
let cfg: Config = serde_yaml::from_str("enabled_macros: \"\"").unwrap();
|
let cfg: Config = serde_yaml::from_str("enabled_macros: \"\"").unwrap();
|
||||||
@@ -1515,12 +1644,12 @@ clients:
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time;
|
use std::time;
|
||||||
|
|
||||||
let unique = time::SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(time::UNIX_EPOCH)
|
.duration_since(time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
|
let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
|
||||||
fs::create_dir_all(&tmp_dir).unwrap();
|
create_dir_all(&tmp_dir).unwrap();
|
||||||
let config_path = tmp_dir.join("config.yaml");
|
let config_path = tmp_dir.join("config.yaml");
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -1530,25 +1659,25 @@ clients:
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let config_env = get_env_name("config_file");
|
let config_env = get_env_name("config_file");
|
||||||
let prev_config = std::env::var_os(&config_env);
|
let prev_config = env::var_os(&config_env);
|
||||||
let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG);
|
let prev_sandbox = env::var_os(SANDBOX_ENV_FLAG);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var(&config_env, &config_path);
|
env::set_var(&config_env, &config_path);
|
||||||
std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1");
|
env::set_var(SANDBOX_ENV_FLAG, "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = Config::load_with_interpolation(false).await;
|
let result = Config::load_with_interpolation(false, false).await;
|
||||||
let (_, raw) = Config::load_from_file(&config_path).unwrap();
|
let (_, raw) = Config::load_from_file(&config_path).unwrap();
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
match prev_config {
|
match prev_config {
|
||||||
Some(v) => std::env::set_var(&config_env, v),
|
Some(v) => env::set_var(&config_env, v),
|
||||||
None => std::env::remove_var(&config_env),
|
None => env::remove_var(&config_env),
|
||||||
}
|
}
|
||||||
match prev_sandbox {
|
match prev_sandbox {
|
||||||
Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v),
|
Some(v) => env::set_var(SANDBOX_ENV_FLAG, v),
|
||||||
None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG),
|
None => env::remove_var(SANDBOX_ENV_FLAG),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = fs::remove_dir_all(&tmp_dir);
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
@@ -1562,6 +1691,60 @@ clients:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn info_flag_with_missing_config_skips_wizard_and_writes_nothing() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let tmp_dir = env::temp_dir().join(format!("coyote-info-bypass-{unique}"));
|
||||||
|
create_dir_all(&tmp_dir).unwrap();
|
||||||
|
let _config_dir = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &tmp_dir);
|
||||||
|
let _config_file = crate::testing::EnvVarGuard::unset(get_env_name("config_file"));
|
||||||
|
let _sandbox = crate::testing::EnvVarGuard::unset(SANDBOX_ENV_FLAG);
|
||||||
|
let _provider = crate::testing::EnvVarGuard::unset(get_env_name("provider"));
|
||||||
|
let _platform = crate::testing::EnvVarGuard::unset(get_env_name("platform"));
|
||||||
|
|
||||||
|
let result = Config::load_with_interpolation(true, true).await;
|
||||||
|
|
||||||
|
let leftover: Vec<_> = read_dir(&tmp_dir)
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().file_name())
|
||||||
|
.collect();
|
||||||
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
|
result.expect("inspection flags must load a default config without a config file");
|
||||||
|
assert!(
|
||||||
|
leftover.is_empty(),
|
||||||
|
"inspection flags must not write anything: {leftover:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn sandbox_info_flag_with_missing_config_writes_nothing() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-info-bypass-{unique}"));
|
||||||
|
create_dir_all(&tmp_dir).unwrap();
|
||||||
|
let config_path = tmp_dir.join("config.yaml");
|
||||||
|
let _config_file =
|
||||||
|
crate::testing::EnvVarGuard::set(get_env_name("config_file"), &config_path);
|
||||||
|
let _sandbox = crate::testing::EnvVarGuard::set(SANDBOX_ENV_FLAG, "1");
|
||||||
|
|
||||||
|
let result = Config::load_with_interpolation(true, true).await;
|
||||||
|
|
||||||
|
let created = config_path.exists();
|
||||||
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
|
result.expect("inspection flags must load a default config in sandbox mode too");
|
||||||
|
assert!(
|
||||||
|
!created,
|
||||||
|
"inspection flags must not create the sandbox config file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unloadable_override_is_stale() {
|
fn unloadable_override_is_stale() {
|
||||||
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
|
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
|
||||||
@@ -1641,7 +1824,7 @@ clients:
|
|||||||
let written = write_models_override(vec![entry()]).unwrap();
|
let written = write_models_override(vec![entry()]).unwrap();
|
||||||
assert_eq!(written, path);
|
assert_eq!(written, path);
|
||||||
let mut tmp_name = path.as_os_str().to_os_string();
|
let mut tmp_name = path.as_os_str().to_os_string();
|
||||||
tmp_name.push(format!(".{}.tmp", std::process::id()));
|
tmp_name.push(format!(".{}.tmp", process::id()));
|
||||||
assert!(!PathBuf::from(tmp_name).exists());
|
assert!(!PathBuf::from(tmp_name).exists());
|
||||||
let loaded = paths::local_models_override().unwrap();
|
let loaded = paths::local_models_override().unwrap();
|
||||||
assert_eq!(loaded.len(), 1);
|
assert_eq!(loaded.len(), 1);
|
||||||
@@ -1659,4 +1842,117 @@ clients:
|
|||||||
std::panic::resume_unwind(panic);
|
std::panic::resume_unwind(panic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use crate::testing::TestConfigDirGuard;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn install_assets_hooks_shares_one_sticky_scope_across_all_three_locations() {
|
||||||
|
use crate::config::conflict::prompt_script;
|
||||||
|
|
||||||
|
let _guard = TestConfigDirGuard::new("hooks-three-locations");
|
||||||
|
let notify = paths::hooks_dir().join("notify.sh");
|
||||||
|
let role_dir = paths::roles_dir().join("hooks");
|
||||||
|
let agent_dir = paths::agents_data_dir().join("probe").join("hooks");
|
||||||
|
let shipped = vec![("hook.sh".to_string(), "shipped\n".to_string())];
|
||||||
|
|
||||||
|
// Drives the exact three installers `install_assets(Hooks)` runs,
|
||||||
|
// threading one sticky scope through all of them.
|
||||||
|
let refresh = |sticky: &mut StickyMode| {
|
||||||
|
hooks::install_builtin_hooks(InstallMode::Prompt, sticky).unwrap();
|
||||||
|
role::install_and_reconcile_role_hooks(
|
||||||
|
&role_dir,
|
||||||
|
&shipped,
|
||||||
|
InstallMode::Prompt,
|
||||||
|
sticky,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
agent::install_and_reconcile_agent_hooks(
|
||||||
|
"probe",
|
||||||
|
&shipped,
|
||||||
|
InstallMode::Prompt,
|
||||||
|
sticky,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
};
|
||||||
|
let modify_all = || {
|
||||||
|
fs::write(¬ify, "local global").unwrap();
|
||||||
|
fs::write(role_dir.join("hook.sh"), "local role").unwrap();
|
||||||
|
fs::write(agent_dir.join("hook.sh"), "local agent").unwrap();
|
||||||
|
};
|
||||||
|
|
||||||
|
let script = prompt_script::install(&[]);
|
||||||
|
refresh(&mut StickyMode::None);
|
||||||
|
assert_eq!(
|
||||||
|
prompt_script::prompts_asked(),
|
||||||
|
0,
|
||||||
|
"a clean install asks nothing"
|
||||||
|
);
|
||||||
|
drop(script);
|
||||||
|
|
||||||
|
// One replace-all at the first (global) conflict must silently
|
||||||
|
// replace the role and agent conflicts too.
|
||||||
|
modify_all();
|
||||||
|
let script = prompt_script::install(&["replace-all"]);
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
refresh(&mut sticky);
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 1);
|
||||||
|
assert_eq!(sticky, StickyMode::ReplaceAll);
|
||||||
|
assert!(
|
||||||
|
read_to_string(¬ify)
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("#!/usr/bin/env bash")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(role_dir.join("hook.sh")).unwrap(),
|
||||||
|
"shipped\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(agent_dir.join("hook.sh")).unwrap(),
|
||||||
|
"shipped\n"
|
||||||
|
);
|
||||||
|
drop(script);
|
||||||
|
|
||||||
|
// And one keep-all must keep every location's local edit.
|
||||||
|
modify_all();
|
||||||
|
let script = prompt_script::install(&["keep-all"]);
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
refresh(&mut sticky);
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 1);
|
||||||
|
assert_eq!(sticky, StickyMode::KeepAll);
|
||||||
|
assert_eq!(read_to_string(¬ify).unwrap(), "local global");
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(role_dir.join("hook.sh")).unwrap(),
|
||||||
|
"local role"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(agent_dir.join("hook.sh")).unwrap(),
|
||||||
|
"local agent"
|
||||||
|
);
|
||||||
|
drop(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn install_builtins_never_prompts_and_keeps_local_hook_edits() {
|
||||||
|
use crate::config::conflict::prompt_script;
|
||||||
|
|
||||||
|
let _guard = TestConfigDirGuard::new("install-builtins-startup");
|
||||||
|
install_builtins().unwrap();
|
||||||
|
let notify = paths::hooks_dir().join("notify.sh");
|
||||||
|
fs::write(¬ify, "# local edit").unwrap();
|
||||||
|
|
||||||
|
// A forced terminal with no scripted answers: any prompt would panic
|
||||||
|
// and the counter would move.
|
||||||
|
let script = prompt_script::install(&[]);
|
||||||
|
install_builtins().unwrap();
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
assert_eq!(read_to_string(¬ify).unwrap(), "# local edit");
|
||||||
|
drop(script);
|
||||||
|
|
||||||
|
let _script = prompt_script::install_non_interactive();
|
||||||
|
install_builtins().unwrap();
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
assert_eq!(read_to_string(¬ify).unwrap(), "# local edit");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+64
-2
@@ -2,10 +2,10 @@ use super::role::Role;
|
|||||||
use super::{
|
use super::{
|
||||||
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
|
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,
|
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,
|
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
|
||||||
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
|
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
|
||||||
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
SBX_MIXIN_KITS_DIR_NAME, SESSIONS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
||||||
};
|
};
|
||||||
use crate::client::ProviderModels;
|
use crate::client::ProviderModels;
|
||||||
use crate::config::REPL_HISTORY_DIR_NAME;
|
use crate::config::REPL_HISTORY_DIR_NAME;
|
||||||
@@ -184,6 +184,13 @@ pub fn role_file(name: &str) -> PathBuf {
|
|||||||
roles_dir().join(format!("{name}.md"))
|
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 {
|
pub fn skills_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("skills_dir")) {
|
match env::var(get_env_name("skills_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
@@ -222,6 +229,17 @@ pub fn workspace_macros_dir() -> PathBuf {
|
|||||||
workspace_config_dir().join(MACROS_DIR_NAME)
|
workspace_config_dir().join(MACROS_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn workspace_sessions_dir() -> PathBuf {
|
||||||
|
workspace_config_dir().join(SESSIONS_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_agent_sessions_dir(name: &str) -> PathBuf {
|
||||||
|
workspace_config_dir()
|
||||||
|
.join(AGENTS_DIR_NAME)
|
||||||
|
.join(name)
|
||||||
|
.join(SESSIONS_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
|
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
|
||||||
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
|
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
|
||||||
}
|
}
|
||||||
@@ -325,6 +343,10 @@ pub fn agent_data_dir(name: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn agent_sessions_dir(name: &str) -> PathBuf {
|
||||||
|
agent_data_dir(name).join(SESSIONS_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
|
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
|
||||||
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
|
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
|
||||||
}
|
}
|
||||||
@@ -847,6 +869,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn workspace_session_dirs_live_under_workspace_config_dir() {
|
||||||
|
with_workspace_dir(|_, ws_dir| {
|
||||||
|
assert_eq!(workspace_sessions_dir(), ws_dir.join(SESSIONS_DIR_NAME));
|
||||||
|
assert_eq!(
|
||||||
|
workspace_agent_sessions_dir("helper"),
|
||||||
|
ws_dir
|
||||||
|
.join(AGENTS_DIR_NAME)
|
||||||
|
.join("helper")
|
||||||
|
.join(SESSIONS_DIR_NAME)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod user_mcp_resolution {
|
mod user_mcp_resolution {
|
||||||
@@ -947,6 +984,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn hooks_dir_reflects_env_var_state() {
|
||||||
|
let env_name = get_env_name("hooks_dir");
|
||||||
|
let prev = env::var_os(&env_name);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::remove_var(&env_name);
|
||||||
|
}
|
||||||
|
assert_eq!(hooks_dir(), config_dir().join("hooks"));
|
||||||
|
|
||||||
|
let probe = PathBuf::from("/tmp/coyote-hooks-dir-probe");
|
||||||
|
unsafe {
|
||||||
|
env::set_var(&env_name, &probe);
|
||||||
|
}
|
||||||
|
assert_eq!(hooks_dir(), probe);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match prev {
|
||||||
|
Some(v) => env::set_var(&env_name, v),
|
||||||
|
None => env::remove_var(&env_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_skills_skips_invalid_directory_names() {
|
fn list_skills_skips_invalid_directory_names() {
|
||||||
let unique = time::SystemTime::now()
|
let unique = time::SystemTime::now()
|
||||||
|
|||||||
@@ -47,3 +47,61 @@ impl RagCache {
|
|||||||
Ok(arc)
|
Ok(arc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::hooks::test_sink;
|
||||||
|
use crate::rag::RagData;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// A cache hit must never reach the loader: the loader closure is where
|
||||||
|
/// a real RAG build (and its `rag.sync.*` hook dispatch) happens, so
|
||||||
|
/// returning the cached entry is what keeps warm-cache lookups silent.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn cache_hit_returns_the_cached_rag_without_running_the_loader() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
test_sink::drain();
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!("coyote-rag-cache-hit-{unique}"));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("kb.yaml");
|
||||||
|
let data = RagData {
|
||||||
|
driver: "yaml".to_string(),
|
||||||
|
// The only embedding model resolvable in tests; see
|
||||||
|
// `seed_model_registries` in the request_context tests.
|
||||||
|
embedding_model: "test-seeded:test-embedder".to_string(),
|
||||||
|
chunk_size: 1000,
|
||||||
|
chunk_overlap: 100,
|
||||||
|
top_k: 5,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::fs::write(&path, serde_yaml::to_string(&data).unwrap()).unwrap();
|
||||||
|
let rag = Arc::new(Rag::load(&AppConfig::default(), "kb", &path).await.unwrap());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
|
||||||
|
let cache = RagCache::default();
|
||||||
|
let key = RagKey::Named("kb".to_string());
|
||||||
|
cache.insert(key.clone(), &rag);
|
||||||
|
|
||||||
|
let loaded = cache
|
||||||
|
.load_with(key, || async {
|
||||||
|
Err(anyhow!("a cache hit must not rebuild"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(Arc::ptr_eq(&loaded, &rag));
|
||||||
|
assert!(
|
||||||
|
test_sink::drain().is_empty(),
|
||||||
|
"a cache hit must dispatch no hooks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2712
-136
File diff suppressed because it is too large.
Load diff
+337
-1
@@ -1,12 +1,18 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use crate::client::{Message, MessageContent, MessageRole, Model};
|
use crate::client::{Message, MessageContent, MessageRole, Model};
|
||||||
|
use crate::config::builtin_manifest;
|
||||||
|
use crate::config::conflict::{self, InstallMode, StickyMode};
|
||||||
|
use crate::function::write_file_atomic;
|
||||||
|
use crate::hooks::HooksMap;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use fancy_regex::Regex;
|
use fancy_regex::Regex;
|
||||||
use rust_embed::Embed;
|
use rust_embed::Embed;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
pub const SHELL_ROLE: &str = "shell";
|
pub const SHELL_ROLE: &str = "shell";
|
||||||
@@ -99,6 +105,8 @@ pub struct Role {
|
|||||||
skill_instructions: Option<String>,
|
skill_instructions: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
memory: Option<bool>,
|
memory: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
hooks: Option<HooksMap>,
|
||||||
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
model: Model,
|
model: Model,
|
||||||
@@ -161,6 +169,7 @@ impl Role {
|
|||||||
role.skill_instructions = value.as_str().map(|v| v.to_string())
|
role.skill_instructions = value.as_str().map(|v| v.to_string())
|
||||||
}
|
}
|
||||||
"memory" => role.memory = value.as_bool(),
|
"memory" => role.memory = value.as_bool(),
|
||||||
|
"hooks" => role.hooks = parse_hooks_map(value),
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +180,8 @@ impl Role {
|
|||||||
pub fn builtin(name: &str) -> Result<Self> {
|
pub fn builtin(name: &str) -> Result<Self> {
|
||||||
let content = RolesAsset::get(&format!("{name}.md"))
|
let content = RolesAsset::get(&format!("{name}.md"))
|
||||||
.ok_or_else(|| anyhow!("Unknown role `{name}`"))?;
|
.ok_or_else(|| anyhow!("Unknown role `{name}`"))?;
|
||||||
let content = unsafe { std::str::from_utf8_unchecked(&content.data) };
|
let content =
|
||||||
|
std::str::from_utf8(&content.data).expect("bundled role asset is not valid UTF-8");
|
||||||
Ok(Role::new(name, content))
|
Ok(Role::new(name, content))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +191,27 @@ impl Role {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Installs builtin role hook scripts into `<roles_dir>/hooks/` and
|
||||||
|
/// reconciles ones a release stopped shipping. This is the only builtin
|
||||||
|
/// install machinery roles have: general role installation deliberately
|
||||||
|
/// does not exist (builtin roles load straight from the embed).
|
||||||
|
pub fn install_builtin_role_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> {
|
||||||
|
let shipped: Vec<(String, String)> = RolesAsset::iter()
|
||||||
|
.filter_map(|file| {
|
||||||
|
let name = file.as_ref().strip_prefix("hooks/")?;
|
||||||
|
if name.is_empty() || name.contains('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let embedded = RolesAsset::get(file.as_ref())?;
|
||||||
|
let content = std::str::from_utf8(&embedded.data)
|
||||||
|
.expect("bundled role hook asset is not valid UTF-8");
|
||||||
|
Some((name.to_string(), content.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(&paths::roles_dir().join("hooks"), &shipped, mode, sticky)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn has_args(&self) -> bool {
|
pub fn has_args(&self) -> bool {
|
||||||
self.name.contains('#')
|
self.name.contains('#')
|
||||||
}
|
}
|
||||||
@@ -248,6 +279,10 @@ impl Role {
|
|||||||
if let Some(memory) = self.memory {
|
if let Some(memory) = self.memory {
|
||||||
metadata.push(format!("memory: {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() {
|
if metadata.is_empty() {
|
||||||
format!("{}\n", self.prompt)
|
format!("{}\n", self.prompt)
|
||||||
} else if self.prompt.is_empty() {
|
} else if self.prompt.is_empty() {
|
||||||
@@ -380,6 +415,10 @@ impl Role {
|
|||||||
self.memory
|
self.memory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn hooks(&self) -> Option<&HooksMap> {
|
||||||
|
self.hooks.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn skills_enabled(&self) -> Option<bool> {
|
pub fn skills_enabled(&self) -> Option<bool> {
|
||||||
self.skills_enabled
|
self.skills_enabled
|
||||||
}
|
}
|
||||||
@@ -544,6 +583,16 @@ fn parse_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
|
|||||||
Some(mcp_tools)
|
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)>) {
|
fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
|
||||||
let mut text = prompt;
|
let mut text = prompt;
|
||||||
let mut search_input = true;
|
let mut search_input = true;
|
||||||
@@ -592,6 +641,50 @@ fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
|
|||||||
(prompt, vec![])
|
(prompt, vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only direct children of the hooks/ directory are supported: they are the
|
||||||
|
/// only shape the builtin manifest tracks, so nested asset paths are skipped
|
||||||
|
/// by the caller rather than installed unreconciled.
|
||||||
|
pub(crate) fn install_and_reconcile_role_hooks(
|
||||||
|
dir: &Path,
|
||||||
|
shipped: &[(String, String)],
|
||||||
|
mode: InstallMode,
|
||||||
|
sticky: &mut StickyMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !shipped.is_empty() {
|
||||||
|
info!("Installing built-in role hooks in {}", dir.display());
|
||||||
|
}
|
||||||
|
let mut written = BTreeSet::new();
|
||||||
|
for (name, content) in shipped {
|
||||||
|
let path = dir.join(name);
|
||||||
|
if path.exists()
|
||||||
|
&& !conflict::should_replace_existing(&path, content, "roles", mode, sticky)?
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
"Role hook file already exists, skipping: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ensure_parent_exists(&path)?;
|
||||||
|
info!("Creating role hook file: {}", path.display());
|
||||||
|
write_file_atomic(&path, content, None)?;
|
||||||
|
set_executable_bit_if_script(&path)?;
|
||||||
|
written.insert(name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let names: BTreeSet<String> = shipped.iter().map(|(name, _)| name.clone()).collect();
|
||||||
|
// Reconciliation is best-effort housekeeping: a failure here must not
|
||||||
|
// abort startup (install_builtins), matching the agent-side policy.
|
||||||
|
if let Err(err) = builtin_manifest::reconcile_builtin_dir(dir, &names, &written) {
|
||||||
|
warn!(
|
||||||
|
"Failed to reconcile builtin role hooks in {}: {err}",
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -641,6 +734,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_hooks_round_trip_through_export() {
|
||||||
|
let content = "---\nhooks:\n turn.completed:\n - name: notify\n command: ./hooks/notify.sh\n---\nPrompt";
|
||||||
|
|
||||||
|
let role = Role::new("test", content);
|
||||||
|
|
||||||
|
let hooks = role.hooks().expect("hooks should be parsed");
|
||||||
|
assert_eq!(hooks["turn.completed"][0].name, "notify");
|
||||||
|
assert_eq!(hooks["turn.completed"][0].command, "./hooks/notify.sh");
|
||||||
|
|
||||||
|
let exported = role.export();
|
||||||
|
assert!(exported.contains("hooks:"));
|
||||||
|
|
||||||
|
let reparsed = Role::new("test", &exported);
|
||||||
|
assert_eq!(reparsed.hooks(), role.hooks());
|
||||||
|
assert_eq!(reparsed.prompt(), "Prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_without_hooks_has_none_and_export_omits_key() {
|
||||||
|
let role = Role::new("test", "---\nmemory: true\n---\nPrompt");
|
||||||
|
|
||||||
|
assert!(role.hooks().is_none());
|
||||||
|
assert!(!role.export().contains("hooks:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_malformed_hooks_degrades_to_none() {
|
||||||
|
let content = "---\nhooks: [not, a, map]\n---\nPrompt";
|
||||||
|
|
||||||
|
let role = Role::new("test", content);
|
||||||
|
|
||||||
|
assert!(role.hooks().is_none());
|
||||||
|
assert_eq!(role.prompt(), "Prompt");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn role_new_no_metadata_has_none_fields() {
|
fn role_new_no_metadata_has_none_fields() {
|
||||||
let role = Role::new("test", "Just a prompt");
|
let role = Role::new("test", "Just a prompt");
|
||||||
@@ -909,4 +1038,211 @@ Input 1
|
|||||||
|
|
||||||
assert_eq!(parse_structure_prompt(prompt), (prompt, vec![]));
|
assert_eq!(parse_structure_prompt(prompt), (prompt, vec![]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fixture(entries: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|(name, content)| (name.to_string(), content.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hooks_fixture_dir(label: &str) -> PathBuf {
|
||||||
|
let dir = temp_file(label, "");
|
||||||
|
create_dir_all(&dir).unwrap();
|
||||||
|
dir.join("hooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_hooks_install_writes_scripts_and_manifest() {
|
||||||
|
let dir = hooks_fixture_dir("role-hooks-install-");
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(
|
||||||
|
&dir,
|
||||||
|
&fixture(&[("a.sh", "#!/bin/sh\n")]),
|
||||||
|
InstallMode::Skip,
|
||||||
|
&mut StickyMode::None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n");
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"a.sh\n"
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = fs::metadata(dir.join("a.sh")).unwrap().permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o755, "role hook scripts install executable");
|
||||||
|
let manifest_mode = fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(
|
||||||
|
manifest_mode & 0o111,
|
||||||
|
0,
|
||||||
|
"the manifest must never be executable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The installer writes plain and then applies the shared exec-bit
|
||||||
|
/// predicate: users may keep non-executable support files in hooks
|
||||||
|
/// directories, so only recognized script extensions get 0755.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn role_hooks_install_leaves_non_script_files_non_executable() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = hooks_fixture_dir("role-hooks-nonscript-");
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(
|
||||||
|
&dir,
|
||||||
|
&fixture(&[("README.md", "docs\n"), ("run.sh", "#!/bin/sh\n")]),
|
||||||
|
InstallMode::Skip,
|
||||||
|
&mut StickyMode::None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let readme_mode = fs::metadata(dir.join("README.md"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(
|
||||||
|
readme_mode & 0o111,
|
||||||
|
0,
|
||||||
|
"a non-script file in a hooks dir must stay non-executable"
|
||||||
|
);
|
||||||
|
let script_mode = fs::metadata(dir.join("run.sh"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(script_mode & 0o777, 0o755);
|
||||||
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_hooks_install_overwrites_only_with_force() {
|
||||||
|
let dir = hooks_fixture_dir("role-hooks-force-");
|
||||||
|
let shipped = fixture(&[("a.sh", "new content\n")]);
|
||||||
|
create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Skip, &mut StickyMode::None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL");
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Force, &mut StickyMode::None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
|
||||||
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn role_hooks_prompt_mode_asks_per_conflict_and_honors_sticky() {
|
||||||
|
use crate::config::conflict::prompt_script;
|
||||||
|
|
||||||
|
let dir = hooks_fixture_dir("role-hooks-prompt-");
|
||||||
|
let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]);
|
||||||
|
create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("a.sh"), "local a").unwrap();
|
||||||
|
fs::write(dir.join("b.sh"), "local b").unwrap();
|
||||||
|
|
||||||
|
// First conflict answered per-file, second by the sticky replace-all.
|
||||||
|
let script = prompt_script::install(&["replace-all"]);
|
||||||
|
let mut sticky = StickyMode::None;
|
||||||
|
install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Prompt, &mut sticky).unwrap();
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 1);
|
||||||
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
|
||||||
|
assert_eq!(read_to_string(dir.join("b.sh")).unwrap(), "new content\n");
|
||||||
|
assert_eq!(sticky, StickyMode::ReplaceAll);
|
||||||
|
drop(script);
|
||||||
|
|
||||||
|
// A sticky mode carried in from an earlier location suppresses all
|
||||||
|
// prompting: `--install-builtins hooks` shares one sticky scope
|
||||||
|
// across the global, role, and agent hook locations.
|
||||||
|
fs::write(dir.join("a.sh"), "local again").unwrap();
|
||||||
|
let script = prompt_script::install(&[]);
|
||||||
|
let mut sticky = StickyMode::KeepAll;
|
||||||
|
install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Prompt, &mut sticky).unwrap();
|
||||||
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "local again");
|
||||||
|
drop(script);
|
||||||
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_hooks_reconcile_removes_dropped_hook_and_keeps_user_files() {
|
||||||
|
let dir = hooks_fixture_dir("role-hooks-reconcile-");
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(
|
||||||
|
&dir,
|
||||||
|
&fixture(&[("keep.sh", "#!/bin/sh\n"), ("drop.sh", "#!/bin/sh\n")]),
|
||||||
|
InstallMode::Skip,
|
||||||
|
&mut StickyMode::None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(
|
||||||
|
&dir,
|
||||||
|
&fixture(&[("keep.sh", "#!/bin/sh\n")]),
|
||||||
|
InstallMode::Skip,
|
||||||
|
&mut StickyMode::None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!dir.join("drop.sh").exists(),
|
||||||
|
"dropped builtin hook removed"
|
||||||
|
);
|
||||||
|
assert!(dir.join("keep.sh").exists());
|
||||||
|
assert!(dir.join("user.sh").exists(), "user file survives reconcile");
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||||
|
"keep.sh\n"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_hooks_never_claim_a_preexisting_user_file() {
|
||||||
|
let dir = hooks_fixture_dir("role-hooks-unclaimed-");
|
||||||
|
create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("a.sh"), "user-owned").unwrap();
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(
|
||||||
|
&dir,
|
||||||
|
&fixture(&[("a.sh", "#!/bin/sh\n")]),
|
||||||
|
InstallMode::Skip,
|
||||||
|
&mut StickyMode::None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists(),
|
||||||
|
"a skipped pre-existing file must not be claimed by the manifest"
|
||||||
|
);
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(&dir, &[], InstallMode::Skip, &mut StickyMode::None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_to_string(dir.join("a.sh")).unwrap(),
|
||||||
|
"user-owned",
|
||||||
|
"the user file survives the hook being dropped from the embed"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_hooks_empty_shipped_set_creates_nothing() {
|
||||||
|
let dir = hooks_fixture_dir("role-hooks-empty-");
|
||||||
|
|
||||||
|
install_and_reconcile_role_hooks(&dir, &[], InstallMode::Skip, &mut StickyMode::None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!dir.exists(), "an empty shipped set must not create hooks/");
|
||||||
|
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+392
-12
@@ -3,6 +3,7 @@ use super::todo::TodoList;
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use crate::client::{Message, MessageContent, MessageRole, TokenUsage};
|
use crate::client::{Message, MessageContent, MessageRole, TokenUsage};
|
||||||
|
use crate::hooks::HooksMap;
|
||||||
use crate::render::MarkdownRender;
|
use crate::render::MarkdownRender;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
@@ -11,8 +12,10 @@ use inquire::{Confirm, Text, validator::Validation};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::{read_to_string, write};
|
use std::fmt;
|
||||||
use std::path::Path;
|
use std::fs::{OpenOptions, read_to_string, remove_file, rename};
|
||||||
|
use std::io::{self, Write as _};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
|
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
|
||||||
@@ -28,6 +31,62 @@ fn cost_is_zero(v: &f64) -> bool {
|
|||||||
*v == 0.0
|
*v == 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum SessionScope {
|
||||||
|
#[default]
|
||||||
|
Global,
|
||||||
|
Workspace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SessionScope {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
SessionScope::Global => write!(f, "global"),
|
||||||
|
SessionScope::Workspace => write!(f, "workspace"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_scope_dirs(agent: Option<&str>) -> (PathBuf, PathBuf) {
|
||||||
|
match agent {
|
||||||
|
Some(agent) => (
|
||||||
|
paths::workspace_agent_sessions_dir(agent),
|
||||||
|
paths::agent_sessions_dir(agent),
|
||||||
|
),
|
||||||
|
None => (paths::workspace_sessions_dir(), default_sessions_dir()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session names across both scope dirs, workspace first, deduped by name so
|
||||||
|
/// a workspace session shadows a global one with the same name.
|
||||||
|
pub fn labeled_session_names(
|
||||||
|
workspace_dir: &Path,
|
||||||
|
global_dir: &Path,
|
||||||
|
) -> Vec<(String, SessionScope)> {
|
||||||
|
let mut values = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for (dir, scope) in [
|
||||||
|
(workspace_dir, SessionScope::Workspace),
|
||||||
|
(global_dir, SessionScope::Global),
|
||||||
|
] {
|
||||||
|
for name in list_file_names(dir, ".yaml") {
|
||||||
|
if seen.insert(name.clone()) {
|
||||||
|
values.push((name, scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merged_session_names(workspace_dir: &Path, global_dir: &Path) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = labeled_session_names(workspace_dir, global_dir)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
#[serde(rename(serialize = "model", deserialize = "model"))]
|
#[serde(rename(serialize = "model", deserialize = "model"))]
|
||||||
@@ -113,10 +172,14 @@ pub struct Session {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
role_prompt: String,
|
role_prompt: String,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
role_hooks: Option<HooksMap>,
|
||||||
|
#[serde(skip)]
|
||||||
name: String,
|
name: String,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
scope: SessionScope,
|
||||||
|
#[serde(skip)]
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
save_session_this_time: bool,
|
save_session_this_time: bool,
|
||||||
@@ -170,6 +233,7 @@ impl Session {
|
|||||||
app: &AppConfig,
|
app: &AppConfig,
|
||||||
name: &str,
|
name: &str,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
scope: SessionScope,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let content = read_to_string(path)
|
let content = read_to_string(path)
|
||||||
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
|
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
|
||||||
@@ -177,6 +241,7 @@ impl Session {
|
|||||||
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
|
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
|
||||||
|
|
||||||
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
|
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
|
||||||
|
session.scope = scope;
|
||||||
|
|
||||||
if let Some(autoname) = name.strip_prefix("_/") {
|
if let Some(autoname) = name.strip_prefix("_/") {
|
||||||
session.name = TEMP_SESSION_NAME.to_string();
|
session.name = TEMP_SESSION_NAME.to_string();
|
||||||
@@ -193,6 +258,7 @@ impl Session {
|
|||||||
&& let Ok(role) = ctx.retrieve_role(app, role_name)
|
&& let Ok(role) = ctx.retrieve_role(app, role_name)
|
||||||
{
|
{
|
||||||
session.role_prompt = role.prompt().to_string();
|
session.role_prompt = role.prompt().to_string();
|
||||||
|
session.role_hooks = role.hooks().cloned();
|
||||||
}
|
}
|
||||||
|
|
||||||
session.update_tokens();
|
session.update_tokens();
|
||||||
@@ -246,6 +312,14 @@ impl Session {
|
|||||||
self.name = name;
|
self.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn scope(&self) -> SessionScope {
|
||||||
|
self.scope
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_scope(&mut self, scope: SessionScope) {
|
||||||
|
self.scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_autoname(&mut self) {
|
pub fn clear_autoname(&mut self) {
|
||||||
self.autoname = None;
|
self.autoname = None;
|
||||||
}
|
}
|
||||||
@@ -254,6 +328,13 @@ impl Session {
|
|||||||
self.role_name.as_deref()
|
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 {
|
pub fn dirty(&self) -> bool {
|
||||||
self.dirty
|
self.dirty
|
||||||
}
|
}
|
||||||
@@ -302,6 +383,7 @@ impl Session {
|
|||||||
pub fn export(&self) -> Result<String> {
|
pub fn export(&self) -> Result<String> {
|
||||||
let mut data = json!({
|
let mut data = json!({
|
||||||
"path": self.path,
|
"path": self.path,
|
||||||
|
"scope": self.scope.to_string(),
|
||||||
"model": self.model().id(),
|
"model": self.model().id(),
|
||||||
});
|
});
|
||||||
if let Some(temperature) = self.temperature() {
|
if let Some(temperature) = self.temperature() {
|
||||||
@@ -392,6 +474,7 @@ impl Session {
|
|||||||
if let Some(path) = &self.path {
|
if let Some(path) = &self.path {
|
||||||
items.push(("path", path.to_string()));
|
items.push(("path", path.to_string()));
|
||||||
}
|
}
|
||||||
|
items.push(("scope", self.scope.to_string()));
|
||||||
|
|
||||||
if let Some(autoname) = self.autoname() {
|
if let Some(autoname) = self.autoname() {
|
||||||
items.push(("autoname", autoname.to_string()));
|
items.push(("autoname", autoname.to_string()));
|
||||||
@@ -561,6 +644,7 @@ impl Session {
|
|||||||
self.model = role.model().clone();
|
self.model = role.model().clone();
|
||||||
self.role_name = convert_option_string(role.name());
|
self.role_name = convert_option_string(role.name());
|
||||||
self.role_prompt = role.prompt().to_string();
|
self.role_prompt = role.prompt().to_string();
|
||||||
|
self.role_hooks = role.hooks().cloned();
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
self.update_tokens();
|
self.update_tokens();
|
||||||
}
|
}
|
||||||
@@ -568,11 +652,13 @@ impl Session {
|
|||||||
pub fn clear_role(&mut self) {
|
pub fn clear_role(&mut self) {
|
||||||
self.role_name = None;
|
self.role_name = None;
|
||||||
self.role_prompt.clear();
|
self.role_prompt.clear();
|
||||||
|
self.role_hooks = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync_agent(&mut self, agent: &Agent) {
|
pub fn sync_agent(&mut self, agent: &Agent) {
|
||||||
self.role_name = None;
|
self.role_name = None;
|
||||||
self.role_prompt = agent.interpolated_instructions();
|
self.role_prompt = agent.interpolated_instructions();
|
||||||
|
self.role_hooks = None;
|
||||||
self.agent_variables = agent.variables().clone();
|
self.agent_variables = agent.variables().clone();
|
||||||
self.agent_instructions = self.role_prompt.clone();
|
self.agent_instructions = self.role_prompt.clone();
|
||||||
if let Some(threshold) = agent.compression_threshold() {
|
if let Some(threshold) = agent.compression_threshold() {
|
||||||
@@ -705,17 +791,50 @@ impl Session {
|
|||||||
self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1)
|
self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimated tokens of the messages that compression would actually fold
|
/// Whether the prompt is close enough to the model's hard input limit
|
||||||
/// away: everything except the leading system message (whose content is
|
/// that compression must run regardless of how `compression_threshold`
|
||||||
/// carried into the summary message) and the `keep_last` tail (kept verbatim).
|
/// is configured. Sizes the prompt as the larger of the API-reported
|
||||||
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
|
/// measurement from the last request that reported usage and the local
|
||||||
|
/// estimate, because the estimate can run well below the provider's
|
||||||
|
/// real count.
|
||||||
|
pub fn safety_valve_triggered(
|
||||||
|
&self,
|
||||||
|
valve: f32,
|
||||||
|
keep_last: usize,
|
||||||
|
last_prompt_tokens: Option<usize>,
|
||||||
|
) -> bool {
|
||||||
|
if self.compressing || !valve.is_finite() || valve <= 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(max_input_tokens) = self.model().max_input_tokens() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// With nothing to fold away, compressing is a no-op; firing anyway
|
||||||
|
// would spin compress attempts while a single in-flight turn holds
|
||||||
|
// all the tokens.
|
||||||
|
if self.reclaimable_tokens(keep_last) == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
last_prompt_tokens.unwrap_or(0).max(self.tokens())
|
||||||
|
>= (valve * max_input_tokens as f32) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The messages that compression would actually fold away: everything
|
||||||
|
/// except the leading system message (whose content is carried into the
|
||||||
|
/// summary message) and the `keep_last` tail (kept verbatim).
|
||||||
|
pub fn foldable_messages(&self, keep_last: usize) -> &[Message] {
|
||||||
let start = usize::from(
|
let start = usize::from(
|
||||||
self.messages
|
self.messages
|
||||||
.first()
|
.first()
|
||||||
.is_some_and(|v| v.role == MessageRole::System),
|
.is_some_and(|v| v.role == MessageRole::System),
|
||||||
);
|
);
|
||||||
let end = self.messages.len().saturating_sub(keep_last).max(start);
|
let end = self.messages.len().saturating_sub(keep_last).max(start);
|
||||||
self.model().total_tokens(&self.messages[start..end])
|
&self.messages[start..end]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimated tokens of the messages compression would fold away.
|
||||||
|
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
|
||||||
|
self.model().total_tokens(self.foldable_messages(keep_last))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compressing(&self) -> bool {
|
pub fn compressing(&self) -> bool {
|
||||||
@@ -839,17 +958,52 @@ impl Session {
|
|||||||
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
|
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
|
||||||
ensure_parent_exists(session_path)?;
|
ensure_parent_exists(session_path)?;
|
||||||
|
|
||||||
self.path = Some(session_path.display().to_string());
|
|
||||||
|
|
||||||
let content = serde_yaml::to_string(&self)
|
let content = serde_yaml::to_string(&self)
|
||||||
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
|
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
|
||||||
write(session_path, content).with_context(|| {
|
// Write through a sibling temp file and rename it over the target so a
|
||||||
|
// symlink planted at the session path is replaced, never followed. The
|
||||||
|
// temp file is opened with create_new so a symlink planted at the temp
|
||||||
|
// path is not followed either; a stale entry there is removed and the
|
||||||
|
// open retried once.
|
||||||
|
let mut tmp_name = session_path.as_os_str().to_os_string();
|
||||||
|
tmp_name.push(format!(".{}.tmp", process::id()));
|
||||||
|
let tmp_path = PathBuf::from(tmp_name);
|
||||||
|
let write_context = || {
|
||||||
format!(
|
format!(
|
||||||
"Failed to write session '{}' to '{}'",
|
"Failed to write session '{}' to '{}'",
|
||||||
self.name,
|
self.name,
|
||||||
session_path.display()
|
session_path.display()
|
||||||
)
|
)
|
||||||
})?;
|
};
|
||||||
|
let open_tmp = || {
|
||||||
|
OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&tmp_path)
|
||||||
|
};
|
||||||
|
let mut tmp_file = match open_tmp() {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||||
|
remove_file(&tmp_path).with_context(write_context)?;
|
||||||
|
open_tmp().with_context(write_context)?
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error).with_context(write_context),
|
||||||
|
};
|
||||||
|
if let Err(error) = tmp_file
|
||||||
|
.write_all(content.as_bytes())
|
||||||
|
.and_then(|_| tmp_file.flush())
|
||||||
|
{
|
||||||
|
drop(tmp_file);
|
||||||
|
let _ = remove_file(&tmp_path);
|
||||||
|
return Err(error).with_context(write_context);
|
||||||
|
}
|
||||||
|
drop(tmp_file);
|
||||||
|
if let Err(error) = rename(&tmp_path, session_path) {
|
||||||
|
let _ = remove_file(&tmp_path);
|
||||||
|
return Err(error).with_context(write_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.path = Some(session_path.display().to_string());
|
||||||
|
|
||||||
if is_repl {
|
if is_repl {
|
||||||
println!("✓ Saved the session to '{}'.", session_path.display());
|
println!("✓ Saved the session to '{}'.", session_path.display());
|
||||||
@@ -1095,7 +1249,7 @@ impl AutoName {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, TokenUsage,
|
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, ModelData, TokenUsage,
|
||||||
};
|
};
|
||||||
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
|
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
|
||||||
use crate::function::{Functions, ToolCall, ToolResult};
|
use crate::function::{Functions, ToolCall, ToolResult};
|
||||||
@@ -1111,6 +1265,25 @@ mod tests {
|
|||||||
assert!(!session.dirty());
|
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) {
|
fn push_interrupted_turn(session: &mut Session) {
|
||||||
session.messages.push(Message::new(
|
session.messages.push(Message::new(
|
||||||
MessageRole::User,
|
MessageRole::User,
|
||||||
@@ -1233,6 +1406,47 @@ mod tests {
|
|||||||
assert!(!yaml.contains("token_usage"));
|
assert!(!yaml.contains("token_usage"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_role_hooks_is_not_serialized() {
|
||||||
|
let content = "---\nhooks:\n turn.completed:\n - name: role-probe\n command: role-cmd\n---\nPrompt";
|
||||||
|
let mut session = Session::default();
|
||||||
|
session.set_role(Role::new("hooked", content));
|
||||||
|
assert!(session.role_hooks().is_some());
|
||||||
|
|
||||||
|
let yaml = serde_yaml::to_string(&session).unwrap();
|
||||||
|
assert!(
|
||||||
|
!yaml.contains("role_hooks"),
|
||||||
|
"the role-hooks snapshot must never reach session YAML"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!yaml.contains("role-cmd"),
|
||||||
|
"no hook command text may leak into session YAML under any key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_scope_is_not_serialized() {
|
||||||
|
let mut session = Session::default();
|
||||||
|
session.set_scope(SessionScope::Workspace);
|
||||||
|
|
||||||
|
let yaml = serde_yaml::to_string(&session).unwrap();
|
||||||
|
|
||||||
|
assert!(!yaml.contains("scope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_deserialization_ignores_injected_role_hooks() {
|
||||||
|
// A session file is attacker-writable data: a `role_hooks:` key
|
||||||
|
// crafted into it must never deserialize into executable hook
|
||||||
|
// config. Hooks are only ever re-read from the role file itself.
|
||||||
|
let yaml = "model: ''\nmessages: []\nrole_hooks:\n session.started:\n - name: injected\n command: touch /tmp/pwned\n";
|
||||||
|
let session: Session = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(
|
||||||
|
session.role_hooks().is_none(),
|
||||||
|
"injected role_hooks in session YAML must be ignored"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_cost_accumulates_and_survives_yaml_round_trip() {
|
fn session_cost_accumulates_and_survives_yaml_round_trip() {
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
@@ -1259,8 +1473,10 @@ mod tests {
|
|||||||
fn session_export_includes_real_usage_only_when_reported() {
|
fn session_export_includes_real_usage_only_when_reported() {
|
||||||
let empty = Session::default().export().unwrap();
|
let empty = Session::default().export().unwrap();
|
||||||
assert!(!empty.contains("real_input_tokens"));
|
assert!(!empty.contains("real_input_tokens"));
|
||||||
|
assert!(empty.contains("scope: global"));
|
||||||
|
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
|
session.set_scope(SessionScope::Workspace);
|
||||||
session.accumulate_token_usage(&TokenUsage {
|
session.accumulate_token_usage(&TokenUsage {
|
||||||
input_tokens: Some(5),
|
input_tokens: Some(5),
|
||||||
output_tokens: Some(7),
|
output_tokens: Some(7),
|
||||||
@@ -1268,12 +1484,97 @@ mod tests {
|
|||||||
cache_read_input_tokens: Some(3),
|
cache_read_input_tokens: Some(3),
|
||||||
});
|
});
|
||||||
let exported = session.export().unwrap();
|
let exported = session.export().unwrap();
|
||||||
|
assert!(exported.contains("scope: workspace"));
|
||||||
assert!(exported.contains("real_input_tokens: 5"));
|
assert!(exported.contains("real_input_tokens: 5"));
|
||||||
assert!(exported.contains("real_output_tokens: 7"));
|
assert!(exported.contains("real_output_tokens: 7"));
|
||||||
assert!(exported.contains("cache_read_tokens: 3"));
|
assert!(exported.contains("cache_read_tokens: 3"));
|
||||||
assert!(!exported.contains("cache_creation_tokens"));
|
assert!(!exported.contains("cache_creation_tokens"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rendered_lines(session: &Session) -> Vec<String> {
|
||||||
|
let options = crate::render::RenderOptions {
|
||||||
|
raw_markdown: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut render = MarkdownRender::init(options).unwrap();
|
||||||
|
session
|
||||||
|
.render(&mut render, &None)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_render_reports_scope_line() {
|
||||||
|
let mut session = Session::default();
|
||||||
|
assert!(
|
||||||
|
rendered_lines(&session)
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.starts_with("scope") && line.ends_with("global"))
|
||||||
|
);
|
||||||
|
|
||||||
|
session.set_scope(SessionScope::Workspace);
|
||||||
|
assert!(
|
||||||
|
rendered_lines(&session)
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.starts_with("scope") && line.ends_with("workspace"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn session_save_replaces_a_symlink_instead_of_following_it() {
|
||||||
|
let unique = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!("coyote-session-save-symlink-{unique}"));
|
||||||
|
let sessions_dir = dir.join("sessions");
|
||||||
|
std::fs::create_dir_all(&sessions_dir).unwrap();
|
||||||
|
let target = dir.join("target.txt");
|
||||||
|
std::fs::write(&target, "untouched").unwrap();
|
||||||
|
let link = sessions_dir.join("link.yaml");
|
||||||
|
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let mut session = Session::default();
|
||||||
|
session.save("link", &link, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_to_string(&target).unwrap(), "untouched");
|
||||||
|
assert!(link.symlink_metadata().unwrap().is_file());
|
||||||
|
assert!(read_to_string(&link).unwrap().contains("model:"));
|
||||||
|
assert_eq!(session.name(), "link");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn session_save_does_not_follow_a_symlink_planted_at_the_temp_path() {
|
||||||
|
let unique = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!("coyote-session-save-tmp-symlink-{unique}"));
|
||||||
|
let sessions_dir = dir.join("sessions");
|
||||||
|
std::fs::create_dir_all(&sessions_dir).unwrap();
|
||||||
|
let target = dir.join("target.txt");
|
||||||
|
std::fs::write(&target, "untouched").unwrap();
|
||||||
|
let destination = sessions_dir.join("notes.yaml");
|
||||||
|
let mut tmp_name = destination.as_os_str().to_os_string();
|
||||||
|
tmp_name.push(format!(".{}.tmp", process::id()));
|
||||||
|
let tmp_path = PathBuf::from(tmp_name);
|
||||||
|
std::os::unix::fs::symlink(&target, &tmp_path).unwrap();
|
||||||
|
|
||||||
|
let mut session = Session::default();
|
||||||
|
session.save("notes", &destination, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_to_string(&target).unwrap(), "untouched");
|
||||||
|
assert!(destination.symlink_metadata().unwrap().is_file());
|
||||||
|
assert!(read_to_string(&destination).unwrap().contains("model:"));
|
||||||
|
assert!(!tmp_path.exists(), "temp file must not be left behind");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_todo_state_survives_yaml_round_trip() {
|
fn session_todo_state_survives_yaml_round_trip() {
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
@@ -1514,6 +1815,85 @@ mod tests {
|
|||||||
assert!(session.needs_compression(100, 1));
|
assert!(session.needs_compression(100, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session with a system message, a large user message, and a small
|
||||||
|
/// assistant reply, on a model with the given input limit. With
|
||||||
|
/// `keep_last = 1` the user message is reclaimable; with `keep_last = 2`
|
||||||
|
/// nothing is.
|
||||||
|
fn valve_session(max_input_tokens: Option<usize>) -> Session {
|
||||||
|
let mut session = Session::default();
|
||||||
|
let mut data = ModelData::new("valve-model");
|
||||||
|
data.max_input_tokens = max_input_tokens;
|
||||||
|
session.set_model(Model::from_config("provider", &[data]).remove(0));
|
||||||
|
session.messages.push(Message::new(
|
||||||
|
MessageRole::System,
|
||||||
|
MessageContent::Text("x".repeat(400)),
|
||||||
|
));
|
||||||
|
session.messages.push(Message::new(
|
||||||
|
MessageRole::User,
|
||||||
|
MessageContent::Text("y".repeat(4000)),
|
||||||
|
));
|
||||||
|
session.messages.push(Message::new(
|
||||||
|
MessageRole::Assistant,
|
||||||
|
MessageContent::Text("ok".to_string()),
|
||||||
|
));
|
||||||
|
session.update_tokens();
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_fires_from_estimate() {
|
||||||
|
let session = valve_session(Some(1000));
|
||||||
|
|
||||||
|
assert!(session.tokens() >= 800);
|
||||||
|
assert!(session.safety_valve_triggered(0.8, 1, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_fires_from_real_usage_over_estimate() {
|
||||||
|
let session = valve_session(Some(1_000_000));
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 1, None));
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 1, Some(700_000)));
|
||||||
|
assert!(session.safety_valve_triggered(0.8, 1, Some(800_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_disabled_when_zero() {
|
||||||
|
let session = valve_session(Some(1000));
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.0, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_disabled_when_nan() {
|
||||||
|
let session = valve_session(Some(1000));
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(f32::NAN, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_respects_compressing() {
|
||||||
|
let mut session = valve_session(Some(1000));
|
||||||
|
session.set_compressing(true);
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_requires_reclaimable_tokens() {
|
||||||
|
let session = valve_session(Some(1000));
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 2, Some(1_000_000)));
|
||||||
|
assert!(session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_requires_model_max_input_tokens() {
|
||||||
|
let session = valve_session(None);
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_set_compressing_flag() {
|
fn session_set_compressing_flag() {
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
|
|||||||
+2
-1
@@ -100,7 +100,8 @@ impl Skill {
|
|||||||
|
|
||||||
let embedded_file = SkillsAsset::get(&file)
|
let embedded_file = SkillsAsset::get(&file)
|
||||||
.ok_or_else(|| anyhow!("Failed to load embedded skill file: {}", file.as_ref()))?;
|
.ok_or_else(|| anyhow!("Failed to load embedded skill file: {}", file.as_ref()))?;
|
||||||
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
let content = std::str::from_utf8(&embedded_file.data)
|
||||||
|
.expect("bundled skill asset is not valid UTF-8");
|
||||||
let file_path = paths::skills_dir().join(file.as_ref());
|
let file_path = paths::skills_dir().join(file.as_ref());
|
||||||
|
|
||||||
if file_path.exists() && !force {
|
if file_path.exists() && !force {
|
||||||
|
|||||||
@@ -918,7 +918,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::mcp_tool_policy::LayerSource;
|
use crate::config::mcp_tool_policy::LayerSource;
|
||||||
use crate::function::ToolCall;
|
use crate::function::ToolCall;
|
||||||
use crate::testing::{install_warn_collector, warn_messages};
|
use crate::testing::{install_log_collector, warn_snapshot};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1083,7 +1083,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn catalog_items_warns_when_resource_listing_fails() {
|
async fn catalog_items_warns_when_resource_listing_fails() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let fixture = FixtureServer {
|
let fixture = FixtureServer {
|
||||||
resources_capability: true,
|
resources_capability: true,
|
||||||
fail_resource_listings: true,
|
fail_resource_listings: true,
|
||||||
@@ -1093,7 +1093,7 @@ mod tests {
|
|||||||
|
|
||||||
runtime.catalog_items("fixture").await.unwrap();
|
runtime.catalog_items("fixture").await.unwrap();
|
||||||
|
|
||||||
let messages = warn_messages().lock().unwrap();
|
let messages = warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
messages
|
messages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1547,7 +1547,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn prompt_catalog_degrades_when_one_server_fails() {
|
async fn prompt_catalog_degrades_when_one_server_fails() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let healthy = FixtureServer {
|
let healthy = FixtureServer {
|
||||||
prompts_capability: true,
|
prompts_capability: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1564,7 +1564,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(items.len(), 1);
|
assert_eq!(items.len(), 1);
|
||||||
assert_eq!(items[0].server, "fixture");
|
assert_eq!(items[0].server, "fixture");
|
||||||
let messages = warn_messages().lock().unwrap();
|
let messages = warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
messages
|
messages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1794,7 +1794,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn catalog_items_warns_on_dead_context_patterns() {
|
async fn catalog_items_warns_on_dead_context_patterns() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let (mut runtime, _server) = fixture_runtime(FixtureServer::default()).await;
|
let (mut runtime, _server) = fixture_runtime(FixtureServer::default()).await;
|
||||||
let mut filter = single_layer_filter(LayerSource::Global, &["*"]);
|
let mut filter = single_layer_filter(LayerSource::Global, &["*"]);
|
||||||
filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]);
|
filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]);
|
||||||
@@ -1802,7 +1802,7 @@ mod tests {
|
|||||||
|
|
||||||
runtime.catalog_items("fixture").await.unwrap();
|
runtime.catalog_items("fixture").await.unwrap();
|
||||||
|
|
||||||
let messages = warn_messages().lock().unwrap();
|
let messages = warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
messages.iter().any(|msg| msg.contains("'zzz_*'")
|
messages.iter().any(|msg| msg.contains("'zzz_*'")
|
||||||
&& msg.contains("session (.set)")
|
&& msg.contains("session (.set)")
|
||||||
|
|||||||
+361
-1
@@ -6,6 +6,7 @@ use crate::config::{
|
|||||||
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
|
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
|
||||||
list_agents_with_descriptions, load_agent_variables,
|
list_agents_with_descriptions, load_agent_variables,
|
||||||
};
|
};
|
||||||
|
use crate::hooks::{self, HookEvent, ResolvedHook};
|
||||||
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
|
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
|
||||||
use crate::supervisor::notification::agent_notification;
|
use crate::supervisor::notification::agent_notification;
|
||||||
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
|
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
|
||||||
@@ -15,6 +16,7 @@ use crate::graph;
|
|||||||
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
|
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use hooks::McpServerHooks;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
@@ -960,10 +962,11 @@ async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[Stri
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mcp_hooks = McpServerHooks::resolve(ctx);
|
||||||
for (id, spec) in server_specs {
|
for (id, spec) in server_specs {
|
||||||
let handle = app
|
let handle = app
|
||||||
.mcp_factory
|
.mcp_factory
|
||||||
.acquire(&id, &spec, app.mcp_log_path.as_deref())
|
.acquire(&id, &spec, app.mcp_log_path.as_deref(), &mcp_hooks)
|
||||||
.await?;
|
.await?;
|
||||||
ctx.tool_scope.mcp_runtime.insert(id, handle);
|
ctx.tool_scope.mcp_runtime.insert(id, handle);
|
||||||
}
|
}
|
||||||
@@ -996,6 +999,60 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-resolved `agent.completed`/`agent.interrupted`/`agent.failed`
|
||||||
|
/// snapshots for a spawned agent's result arms, captured from the child
|
||||||
|
/// context before the spawn task takes ownership of it. Resolving against
|
||||||
|
/// the child keeps global hooks gated by the child's own whitelist, the
|
||||||
|
/// same gate every other event fired from the child's context goes through.
|
||||||
|
struct SpawnResultHooks {
|
||||||
|
completed: Vec<ResolvedHook>,
|
||||||
|
failed: Vec<ResolvedHook>,
|
||||||
|
interrupted: Vec<ResolvedHook>,
|
||||||
|
session_name: Option<String>,
|
||||||
|
agent_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpawnResultHooks {
|
||||||
|
fn resolve(child_ctx: &RequestContext, agent_id: &str, agent_name: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
completed: child_ctx.resolved_hooks(HookEvent::AgentCompleted),
|
||||||
|
failed: child_ctx.resolved_hooks(HookEvent::AgentFailed),
|
||||||
|
interrupted: child_ctx.resolved_hooks(HookEvent::AgentInterrupted),
|
||||||
|
session_name: child_ctx
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.map(|session| session.name().to_string()),
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
agent_name: agent_name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire(self, error: Option<&str>, child_abort: &AbortSignal) {
|
||||||
|
let mut extras = vec![
|
||||||
|
("COYOTE_AGENT_ID", self.agent_id),
|
||||||
|
("COYOTE_AGENT_NAME", self.agent_name.clone()),
|
||||||
|
];
|
||||||
|
let (event, resolved) = match error {
|
||||||
|
None => (HookEvent::AgentCompleted, self.completed),
|
||||||
|
Some(_) if child_abort.aborted_ctrlc() => {
|
||||||
|
(HookEvent::AgentInterrupted, self.interrupted)
|
||||||
|
}
|
||||||
|
Some(error) => {
|
||||||
|
extras.push(("COYOTE_AGENT_ERROR", error.to_string()));
|
||||||
|
(HookEvent::AgentFailed, self.failed)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
hooks::fire_resolved(
|
||||||
|
event,
|
||||||
|
resolved,
|
||||||
|
hooks::base_envs_parts(event, self.session_name.as_deref(), Some(&self.agent_name)),
|
||||||
|
&extras,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||||
let agent_name = args
|
let agent_name = args
|
||||||
.get("agent")
|
.get("agent")
|
||||||
@@ -1149,9 +1206,23 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
|||||||
|
|
||||||
debug!("Spawning child agent '{agent_name}' as '{agent_id}'");
|
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_id = agent_id.clone();
|
||||||
let spawn_agent_name = agent_name.clone();
|
let spawn_agent_name = agent_name.clone();
|
||||||
let spawn_abort = child_abort.clone();
|
let spawn_abort = child_abort.clone();
|
||||||
|
let hook_abort = child_abort.clone();
|
||||||
let spawn_notifications = Arc::clone(&ctx.notification_queue);
|
let spawn_notifications = Arc::clone(&ctx.notification_queue);
|
||||||
let child_supervisor = child_ctx.supervisor.clone();
|
let child_supervisor = child_ctx.supervisor.clone();
|
||||||
|
|
||||||
@@ -1173,6 +1244,10 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let success = agent_result.exit_status == AgentExitStatus::Completed;
|
let success = agent_result.exit_status == AgentExitStatus::Completed;
|
||||||
|
match &agent_result.exit_status {
|
||||||
|
AgentExitStatus::Completed => result_hooks.fire(None, &hook_abort),
|
||||||
|
AgentExitStatus::Failed(e) => result_hooks.fire(Some(e), &hook_abort),
|
||||||
|
}
|
||||||
spawn_notifications.push(agent_notification(
|
spawn_notifications.push(agent_notification(
|
||||||
&agent_result.id,
|
&agent_result.id,
|
||||||
&agent_result.agent_name,
|
&agent_result.agent_name,
|
||||||
@@ -1703,6 +1778,18 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result<Val
|
|||||||
let from_agent = request.from_agent_name.clone();
|
let from_agent = request.from_agent_name.clone();
|
||||||
let question = request.question.clone();
|
let question = request.question.clone();
|
||||||
let _ = request.reply_tx.send(reply.to_string());
|
let _ = request.reply_tx.send(reply.to_string());
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::EscalationAnswered,
|
||||||
|
ctx,
|
||||||
|
&[
|
||||||
|
("COYOTE_ESCALATION_ID", escalation_id.to_string()),
|
||||||
|
("COYOTE_ESCALATION_FROM_AGENT_ID", request.from_agent_id),
|
||||||
|
("COYOTE_ESCALATION_FROM_AGENT_NAME", from_agent.clone()),
|
||||||
|
("COYOTE_ESCALATION_QUESTION", question.clone()),
|
||||||
|
("COYOTE_ESCALATION_REPLY", reply.to_string()),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"message": format!("Reply sent to agent '{from_agent}' for escalation '{escalation_id}'"),
|
"message": format!("Reply sent to agent '{from_agent}' for escalation '{escalation_id}'"),
|
||||||
@@ -2154,6 +2241,206 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A graph agent whose graph.yaml wires `agent.*` hooks at its top
|
||||||
|
/// level. With `failing`, the start node references a script that does
|
||||||
|
/// not exist, so the child run fails at runtime — after the spawn seam
|
||||||
|
/// has already fired `agent.started`.
|
||||||
|
fn write_graph_agent_with_hooks(agent_name: &str, marker: &str, failing: bool) {
|
||||||
|
let agent_dir = paths::agent_data_dir(agent_name);
|
||||||
|
create_dir_all(&agent_dir).unwrap();
|
||||||
|
let (start, nodes) = if failing {
|
||||||
|
(
|
||||||
|
"work",
|
||||||
|
" work:\n type: script\n script: missing.sh\n next: done\n done:\n type: end\n output: done\n",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
("done", " done:\n type: end\n output: done\n")
|
||||||
|
};
|
||||||
|
write(
|
||||||
|
agent_dir.join("graph.yaml"),
|
||||||
|
format!(
|
||||||
|
"name: {agent_name}\n\
|
||||||
|
start: {start}\n\
|
||||||
|
settings:\n\
|
||||||
|
\x20 validate_before_run: false\n\
|
||||||
|
hooks:\n\
|
||||||
|
\x20 agent.started:\n\
|
||||||
|
\x20 - name: {marker}_started\n\
|
||||||
|
\x20 command: \"true\"\n\
|
||||||
|
\x20 agent.completed:\n\
|
||||||
|
\x20 - name: {marker}_completed\n\
|
||||||
|
\x20 command: \"true\"\n\
|
||||||
|
\x20 agent.interrupted:\n\
|
||||||
|
\x20 - name: {marker}_interrupted\n\
|
||||||
|
\x20 command: \"true\"\n\
|
||||||
|
\x20 agent.failed:\n\
|
||||||
|
\x20 - name: {marker}_failed\n\
|
||||||
|
\x20 command: \"true\"\n\
|
||||||
|
nodes:\n\
|
||||||
|
{nodes}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
|
||||||
|
hooks::test_sink::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|capture| capture.hook_name == name)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns an agent through the supervisor seam and awaits its spawn
|
||||||
|
/// task, returning the spawn id. The result arms fire their hooks
|
||||||
|
/// before that task returns, so the sink is complete afterwards.
|
||||||
|
fn spawn_and_collect(agent_name: &str) -> String {
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
|
let spawned = handle_spawn(&mut ctx, &json!({"agent": agent_name, "prompt": "hi"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spawned["status"], "ok", "{spawned}");
|
||||||
|
let id = spawned["id"].as_str().unwrap().to_string();
|
||||||
|
let handle = ctx
|
||||||
|
.supervisor
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.write()
|
||||||
|
.take(&id)
|
||||||
|
.expect("spawned agent must be registered");
|
||||||
|
handle
|
||||||
|
.join_handle
|
||||||
|
.await
|
||||||
|
.expect("spawn task must not panic")
|
||||||
|
.expect("spawn task must return an AgentResult");
|
||||||
|
id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn spawned_agent_success_fires_started_and_completed_with_matching_id() {
|
||||||
|
let _guard = TestConfigDirGuard::new();
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let agent_name = unique_agent_name("test_hook_spawn_ok");
|
||||||
|
write_graph_agent_with_hooks(&agent_name, "t037_spawn_ok", false);
|
||||||
|
|
||||||
|
let id = spawn_and_collect(&agent_name);
|
||||||
|
|
||||||
|
let started = hook_captures_named("t037_spawn_ok_started");
|
||||||
|
assert_eq!(started.len(), 1, "{started:?}");
|
||||||
|
assert_eq!(started[0].envs["COYOTE_EVENT"], "agent.started");
|
||||||
|
assert_eq!(started[0].envs["COYOTE_AGENT_ID"], id);
|
||||||
|
assert_eq!(started[0].envs["COYOTE_AGENT_NAME"], agent_name);
|
||||||
|
|
||||||
|
let completed = hook_captures_named("t037_spawn_ok_completed");
|
||||||
|
assert_eq!(completed.len(), 1, "{completed:?}");
|
||||||
|
assert_eq!(completed[0].envs["COYOTE_EVENT"], "agent.completed");
|
||||||
|
assert_eq!(completed[0].envs["COYOTE_AGENT_ID"], id);
|
||||||
|
assert_eq!(completed[0].envs["COYOTE_AGENT_NAME"], agent_name);
|
||||||
|
|
||||||
|
assert!(hook_captures_named("t037_spawn_ok_failed").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn spawned_agent_failure_fires_started_and_failed_with_matching_id() {
|
||||||
|
let _guard = TestConfigDirGuard::new();
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let agent_name = unique_agent_name("test_hook_spawn_fail");
|
||||||
|
write_graph_agent_with_hooks(&agent_name, "t037_spawn_fail", true);
|
||||||
|
|
||||||
|
let id = spawn_and_collect(&agent_name);
|
||||||
|
|
||||||
|
let started = hook_captures_named("t037_spawn_fail_started");
|
||||||
|
assert_eq!(started.len(), 1, "{started:?}");
|
||||||
|
assert_eq!(started[0].envs["COYOTE_AGENT_ID"], id);
|
||||||
|
|
||||||
|
let failed = hook_captures_named("t037_spawn_fail_failed");
|
||||||
|
assert_eq!(failed.len(), 1, "{failed:?}");
|
||||||
|
assert_eq!(failed[0].envs["COYOTE_EVENT"], "agent.failed");
|
||||||
|
assert_eq!(failed[0].envs["COYOTE_AGENT_ID"], id);
|
||||||
|
assert!(
|
||||||
|
!failed[0].envs["COYOTE_AGENT_ERROR"].is_empty(),
|
||||||
|
"a failed spawn must carry its error"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(hook_captures_named("t037_spawn_fail_completed").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn cancelled_spawned_agent_fires_interrupted_not_failed() {
|
||||||
|
let _guard = TestConfigDirGuard::new();
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let agent_name = unique_agent_name("test_hook_spawn_cancel");
|
||||||
|
write_graph_agent_with_hooks(&agent_name, "t049_spawn_cancel", true);
|
||||||
|
|
||||||
|
let id = run_async(async {
|
||||||
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
|
let spawned = handle_spawn(&mut ctx, &json!({"agent": agent_name, "prompt": "hi"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spawned["status"], "ok", "{spawned}");
|
||||||
|
let id = spawned["id"].as_str().unwrap().to_string();
|
||||||
|
let handle = ctx
|
||||||
|
.supervisor
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.write()
|
||||||
|
.take(&id)
|
||||||
|
.expect("spawned agent must be registered");
|
||||||
|
// The `agent__cancel` signal, fired before the spawn task runs:
|
||||||
|
// on this current-thread runtime the task only progresses once
|
||||||
|
// awaited, so its failure arm observes the ctrl-c deterministically.
|
||||||
|
handle.abort_signal.set_ctrlc();
|
||||||
|
handle
|
||||||
|
.join_handle
|
||||||
|
.await
|
||||||
|
.expect("spawn task must not panic")
|
||||||
|
.expect("spawn task must return an AgentResult");
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
let interrupted = hook_captures_named("t049_spawn_cancel_interrupted");
|
||||||
|
assert_eq!(interrupted.len(), 1, "{interrupted:?}");
|
||||||
|
assert_eq!(interrupted[0].envs["COYOTE_EVENT"], "agent.interrupted");
|
||||||
|
assert_eq!(interrupted[0].envs["COYOTE_AGENT_ID"], id);
|
||||||
|
assert_eq!(interrupted[0].envs["COYOTE_AGENT_NAME"], agent_name);
|
||||||
|
assert!(
|
||||||
|
!interrupted[0].envs.contains_key("COYOTE_AGENT_ERROR"),
|
||||||
|
"an interruption is not a failure and carries no error env"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(hook_captures_named("t049_spawn_cancel_failed").is_empty());
|
||||||
|
assert!(hook_captures_named("t049_spawn_cancel_completed").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_event_base_envs_carry_optional_names_only_when_present() {
|
||||||
|
let envs = hooks::base_envs_parts(HookEvent::AgentCompleted, None, None);
|
||||||
|
let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect();
|
||||||
|
assert!(keys.contains(&"COYOTE_EVENT"));
|
||||||
|
assert!(keys.contains(&"COYOTE_CONFIG_DIR"));
|
||||||
|
assert!(keys.contains(&"COYOTE_EVENT_TIMESTAMP"));
|
||||||
|
assert!(!keys.contains(&"COYOTE_SESSION_ID"));
|
||||||
|
assert!(!keys.contains(&"COYOTE_AGENT_NAME"));
|
||||||
|
assert!(
|
||||||
|
envs.iter()
|
||||||
|
.any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed")
|
||||||
|
);
|
||||||
|
|
||||||
|
let envs = hooks::base_envs_parts(HookEvent::AgentFailed, Some("sess"), Some("bot"));
|
||||||
|
assert!(
|
||||||
|
envs.iter()
|
||||||
|
.any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
envs.iter()
|
||||||
|
.any(|(key, value)| key == "COYOTE_AGENT_NAME" && value == "bot")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn run_agent_for_graph_error(
|
fn run_agent_for_graph_error(
|
||||||
agent_name: &str,
|
agent_name: &str,
|
||||||
graph_inputs: Option<HashMap<String, Value>>,
|
graph_inputs: Option<HashMap<String, Value>>,
|
||||||
@@ -3107,6 +3394,79 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `escalation.answered` belongs to the successful take -> reply arm
|
||||||
|
/// alone: exactly once per escalation, and never from the failure arms
|
||||||
|
/// (unknown id, already-taken id).
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn reply_escalation_fires_answered_once_and_never_on_failure_arms() {
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let marker = "t049_answered_once";
|
||||||
|
let mut hooks_map = hooks::HooksMap::default();
|
||||||
|
hooks_map.insert(
|
||||||
|
"escalation.answered".to_string(),
|
||||||
|
vec![hooks::HookDef {
|
||||||
|
name: marker.to_string(),
|
||||||
|
command: "true".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let mut app = AppState::test_default();
|
||||||
|
app.config = Arc::new(AppConfig {
|
||||||
|
hooks: hooks_map,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
|
||||||
|
let queue = Arc::new(EscalationQueue::new());
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
queue.submit(EscalationRequest {
|
||||||
|
id: "esc_once".into(),
|
||||||
|
from_agent_id: "a1".into(),
|
||||||
|
from_agent_name: "explore".into(),
|
||||||
|
question: "[input] What do?".into(),
|
||||||
|
options: None,
|
||||||
|
reply_tx: tx,
|
||||||
|
});
|
||||||
|
ctx.escalation_queue = Some(queue);
|
||||||
|
|
||||||
|
// Unknown id: an error result, and no event.
|
||||||
|
let miss = handle_reply_escalation(
|
||||||
|
&mut ctx,
|
||||||
|
&json!({"escalation_id": "missing", "reply": "nope"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(miss["status"], "error");
|
||||||
|
assert!(
|
||||||
|
hook_captures_named(marker).is_empty(),
|
||||||
|
"a failed reply must not fire escalation.answered"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The successful take -> reply arm fires it exactly once.
|
||||||
|
let ok = handle_reply_escalation(
|
||||||
|
&mut ctx,
|
||||||
|
&json!({"escalation_id": "esc_once", "reply": "do X"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ok["status"], "ok");
|
||||||
|
assert_eq!(rx.blocking_recv().unwrap(), "do X");
|
||||||
|
let answered = hook_captures_named(marker);
|
||||||
|
assert_eq!(answered.len(), 1, "{answered:?}");
|
||||||
|
assert_eq!(answered[0].envs["COYOTE_ESCALATION_ID"], "esc_once");
|
||||||
|
assert_eq!(answered[0].envs["COYOTE_ESCALATION_REPLY"], "do X");
|
||||||
|
|
||||||
|
// Replying again to the same id hits the already-taken arm: still one.
|
||||||
|
let again = handle_reply_escalation(
|
||||||
|
&mut ctx,
|
||||||
|
&json!({"escalation_id": "esc_once", "reply": "again"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(again["status"], "error");
|
||||||
|
assert_eq!(
|
||||||
|
hook_captures_named(marker).len(),
|
||||||
|
1,
|
||||||
|
"a second reply must not re-fire escalation.answered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_task_create_simple() {
|
fn handle_task_create_simple() {
|
||||||
let mut ctx = ctx_with_supervisor(4, 3);
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::config::{
|
|||||||
McpRuntime, RequestContext, effective_max_concurrent_jobs, jobs_enabled, paths,
|
McpRuntime, RequestContext, effective_max_concurrent_jobs, jobs_enabled, paths,
|
||||||
};
|
};
|
||||||
use crate::graph;
|
use crate::graph;
|
||||||
|
use crate::hooks::{self, HookEvent, ResolvedHook};
|
||||||
use crate::mcp::{
|
use crate::mcp::{
|
||||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||||
MCP_PROMPT_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX,
|
MCP_PROMPT_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX,
|
||||||
@@ -53,6 +54,75 @@ pub fn is_agent_task(supervisor: Option<&Arc<RwLock<Supervisor>>>, id: &str) ->
|
|||||||
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
|
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct JobHookSnapshot {
|
||||||
|
completed: Vec<ResolvedHook>,
|
||||||
|
failed: Vec<ResolvedHook>,
|
||||||
|
session_name: Option<String>,
|
||||||
|
agent_name: Option<String>,
|
||||||
|
job_id: String,
|
||||||
|
tool: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobHookSnapshot {
|
||||||
|
fn resolve(ctx: &RequestContext, job_id: &str, tool: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
completed: ctx.resolved_hooks(HookEvent::JobCompleted),
|
||||||
|
failed: ctx.resolved_hooks(HookEvent::JobFailed),
|
||||||
|
session_name: ctx
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.map(|session| session.name().to_string()),
|
||||||
|
agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()),
|
||||||
|
job_id: job_id.to_string(),
|
||||||
|
tool: tool.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires `job.completed` or `job.failed`. `fire_resolved` callers supply
|
||||||
|
/// their own base envs; the event name and timestamp are computed here so
|
||||||
|
/// they reflect the actual outcome rather than the moment the snapshot
|
||||||
|
/// was taken.
|
||||||
|
fn fire_terminal(self, success: bool, error: Option<String>) {
|
||||||
|
let (event, resolved) = if success {
|
||||||
|
(HookEvent::JobCompleted, self.completed)
|
||||||
|
} else {
|
||||||
|
(HookEvent::JobFailed, self.failed)
|
||||||
|
};
|
||||||
|
|
||||||
|
if resolved.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_envs = hooks::base_envs_parts(
|
||||||
|
event,
|
||||||
|
self.session_name.as_deref(),
|
||||||
|
self.agent_name.as_deref(),
|
||||||
|
);
|
||||||
|
let mut extras = vec![
|
||||||
|
("COYOTE_JOB_ID", self.job_id),
|
||||||
|
("COYOTE_TOOL_NAME", self.tool),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(error) = error {
|
||||||
|
extras.push(("COYOTE_JOB_ERROR", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks::fire_resolved(event, resolved, base_envs, &extras, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_job_started(ctx: &RequestContext, job_id: &str, tool: &str) {
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::JobStarted,
|
||||||
|
ctx,
|
||||||
|
&[
|
||||||
|
("COYOTE_JOB_ID", job_id.to_string()),
|
||||||
|
("COYOTE_TOOL_NAME", tool.to_string()),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RingBuf {
|
pub struct RingBuf {
|
||||||
buf: Vec<u8>,
|
buf: Vec<u8>,
|
||||||
capacity: usize,
|
capacity: usize,
|
||||||
@@ -470,15 +540,19 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
|||||||
let task_notifications = Arc::clone(&ctx.notification_queue);
|
let task_notifications = Arc::clone(&ctx.notification_queue);
|
||||||
let notify_id = job_id.clone();
|
let notify_id = job_id.clone();
|
||||||
let notify_tool = tool.clone();
|
let notify_tool = tool.clone();
|
||||||
|
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
|
||||||
|
fire_job_started(ctx, &job_id, &tool);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
|
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
|
||||||
let success = result.is_ok();
|
let success = result.is_ok();
|
||||||
|
let error = result.as_ref().err().map(|err| err.to_string());
|
||||||
task_state.lock().status = if success {
|
task_state.lock().status = if success {
|
||||||
JobStatus::Completed
|
JobStatus::Completed
|
||||||
} else {
|
} else {
|
||||||
JobStatus::Failed
|
JobStatus::Failed
|
||||||
};
|
};
|
||||||
task_notifications.push(job_notification(¬ify_id, ¬ify_tool, success));
|
task_notifications.push(job_notification(¬ify_id, ¬ify_tool, success));
|
||||||
|
hook_snapshot.fire_terminal(success, error);
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -488,9 +562,18 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
|||||||
let task_notifications = Arc::clone(&ctx.notification_queue);
|
let task_notifications = Arc::clone(&ctx.notification_queue);
|
||||||
let notify_id = job_id.clone();
|
let notify_id = job_id.clone();
|
||||||
let notify_tool = tool.clone();
|
let notify_tool = tool.clone();
|
||||||
|
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
|
||||||
|
fire_job_started(ctx, &job_id, &tool);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await;
|
let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await;
|
||||||
let success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0));
|
let success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0));
|
||||||
|
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();
|
let mut job_state = task_state.lock();
|
||||||
job_state.pgid = None;
|
job_state.pgid = None;
|
||||||
job_state.status = if success {
|
job_state.status = if success {
|
||||||
@@ -502,6 +585,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
|||||||
drop(job_state);
|
drop(job_state);
|
||||||
|
|
||||||
task_notifications.push(job_notification(¬ify_id, ¬ify_tool, success));
|
task_notifications.push(job_notification(¬ify_id, ¬ify_tool, success));
|
||||||
|
hook_snapshot.fire_terminal(success, error);
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -3119,4 +3203,190 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(ctx.supervisor.is_none(), "no job may be spawned");
|
assert!(ctx.supervisor.is_none(), "no job may be spawned");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn job_hooks_map(marker: &str) -> hooks::HooksMap {
|
||||||
|
["job.started", "job.completed", "job.failed"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| {
|
||||||
|
(
|
||||||
|
event.to_string(),
|
||||||
|
vec![hooks::HookDef {
|
||||||
|
name: marker.to_string(),
|
||||||
|
command: "true".to_string(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_hooked_ctx(marker: &str) -> RequestContext {
|
||||||
|
let app = app_state_with_config(|config| {
|
||||||
|
config.hooks = job_hooks_map(marker);
|
||||||
|
});
|
||||||
|
RequestContext::new(app, WorkingMode::Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
|
||||||
|
hooks::test_sink::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|capture| capture.hook_name == marker)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn job_hooks_fire_started_and_completed() {
|
||||||
|
run_async(async {
|
||||||
|
let marker = "job-hooks-ok-x7x";
|
||||||
|
let mut ctx = job_hooked_ctx(marker);
|
||||||
|
ctx.declared_function_names.insert("echo".into());
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(started["status"], "ok");
|
||||||
|
let job_id = started["job_id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// `job.started` is dispatched before `handle_start` returns.
|
||||||
|
let captures = job_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 1, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].event, HookEvent::JobStarted);
|
||||||
|
assert_eq!(
|
||||||
|
captures[0].envs.get("COYOTE_JOB_ID").map(String::as_str),
|
||||||
|
Some(job_id.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
|
||||||
|
Some("echo")
|
||||||
|
);
|
||||||
|
|
||||||
|
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
|
||||||
|
assert_eq!(collected["status"], "completed");
|
||||||
|
|
||||||
|
let captures = job_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
let completed = &captures[1];
|
||||||
|
assert_eq!(completed.event, HookEvent::JobCompleted);
|
||||||
|
assert_eq!(
|
||||||
|
completed.envs.get("COYOTE_EVENT").map(String::as_str),
|
||||||
|
Some("job.completed")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
completed.envs.get("COYOTE_JOB_ID").map(String::as_str),
|
||||||
|
Some(job_id.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
completed.envs.get("COYOTE_TOOL_NAME").map(String::as_str),
|
||||||
|
Some("echo")
|
||||||
|
);
|
||||||
|
assert!(completed.envs.contains_key("COYOTE_CONFIG_DIR"));
|
||||||
|
assert!(completed.envs.contains_key("COYOTE_EVENT_TIMESTAMP"));
|
||||||
|
assert!(!completed.envs.contains_key("COYOTE_SESSION_ID"));
|
||||||
|
assert!(!completed.envs.contains_key("COYOTE_JOB_ERROR"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn job_hooks_fire_failed_with_job_error() {
|
||||||
|
run_async(async {
|
||||||
|
let marker = "job-hooks-fail-x7x";
|
||||||
|
let mut ctx = job_hooked_ctx(marker);
|
||||||
|
ctx.declared_function_names.insert("false".into());
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let job_id = started["job_id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
|
||||||
|
assert_eq!(collected["status"], "failed");
|
||||||
|
|
||||||
|
let captures = job_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].event, HookEvent::JobStarted);
|
||||||
|
let failed = &captures[1];
|
||||||
|
assert_eq!(failed.event, HookEvent::JobFailed);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_EVENT").map(String::as_str),
|
||||||
|
Some("job.failed")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_JOB_ID").map(String::as_str),
|
||||||
|
Some(job_id.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failed.envs.get("COYOTE_JOB_ERROR").map(String::as_str),
|
||||||
|
Some("exited with code 1")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn job_hooks_fire_for_mcp_jobs() {
|
||||||
|
run_async(async {
|
||||||
|
let marker = "job-hooks-mcp-x7x";
|
||||||
|
let mut ctx = job_hooked_ctx(marker);
|
||||||
|
ctx.declared_function_names
|
||||||
|
.insert("mcp_invoke_fixture".into());
|
||||||
|
let fixture = FixtureServer::default();
|
||||||
|
let (mut runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
let mut filter = ToolFilter::default();
|
||||||
|
filter.push_layer(LayerSource::Global, &[]);
|
||||||
|
runtime.tool_filters.insert("fixture".to_string(), filter);
|
||||||
|
ctx.tool_scope.mcp_runtime = runtime;
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
let started = handle_start(
|
||||||
|
&mut ctx,
|
||||||
|
&json!({"tool": "mcp_invoke_fixture", "arguments": {"tool": "dup"}}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let job_id = started["job_id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
|
||||||
|
assert_eq!(collected["status"], "failed");
|
||||||
|
|
||||||
|
let captures = job_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].event, HookEvent::JobStarted);
|
||||||
|
assert_eq!(
|
||||||
|
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
|
||||||
|
Some("mcp_invoke_fixture")
|
||||||
|
);
|
||||||
|
let failed = &captures[1];
|
||||||
|
assert_eq!(failed.event, HookEvent::JobFailed);
|
||||||
|
assert!(
|
||||||
|
failed
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_JOB_ERROR")
|
||||||
|
.unwrap()
|
||||||
|
.contains("dup not found"),
|
||||||
|
"{failed:?}"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn rejected_job_start_fires_no_hooks() {
|
||||||
|
let marker = "job-hooks-rejected-x7x";
|
||||||
|
let mut ctx = job_hooked_ctx(marker);
|
||||||
|
let _guard = hooks::test_sink::install();
|
||||||
|
|
||||||
|
let result = run_async(handle_start(
|
||||||
|
&mut ctx,
|
||||||
|
&json!({"tool": "undeclared_tool", "arguments": {}}),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["status"], "error");
|
||||||
|
assert!(job_captures(marker).is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+395
-19
@@ -17,6 +17,7 @@ use crate::{
|
|||||||
|
|
||||||
use crate::config::ensure_parent_exists;
|
use crate::config::ensure_parent_exists;
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
|
use crate::hooks::{self, HookEvent};
|
||||||
use crate::mcp::{
|
use crate::mcp::{
|
||||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||||
MCP_META_FUNCTION_PREFIXES, MCP_PROMPT_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> {
|
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 agent = ctx.agent.clone();
|
||||||
let functions = ctx.tool_scope.functions.clone();
|
let functions = ctx.tool_scope.functions.clone();
|
||||||
let current_depth = ctx.current_depth;
|
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)
|
if (*IS_STDOUT_TERMINAL || *SHOW_TOOL_CALLS)
|
||||||
&& current_depth == 0
|
&& current_depth == 0
|
||||||
@@ -1575,6 +1593,14 @@ impl ToolCall {
|
|||||||
println!("{}", format_call_log(&cmd_name, &cmd_args, &json_data));
|
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() {
|
let output = match cmd_name.as_str() {
|
||||||
_ if cmd_name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX) => {
|
_ if cmd_name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX) => {
|
||||||
Self::search_mcp_tools(ctx, &cmd_name, &json_data)
|
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)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2518,13 +2576,16 @@ fn polyfill_cmd_name<T: AsRef<Path>>(cmd_name: &str, bin_dir: &[T]) -> String {
|
|||||||
cmd_name
|
cmd_name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Polling tools are expected to repeat; recording them would also let them
|
// Polling tools are expected to repeat with identical arguments (status probes,
|
||||||
// break up detection of a real loop in the calls they interleave with.
|
// list views, inbox checks); recording them would also let them break up
|
||||||
const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 4] = [
|
// detection of a real loop in the calls they interleave with.
|
||||||
|
const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 6] = [
|
||||||
"job__check",
|
"job__check",
|
||||||
"job__list",
|
"job__list",
|
||||||
"agent__check",
|
"agent__check",
|
||||||
"agent__list_running",
|
"agent__list_running",
|
||||||
|
"agent__task_list",
|
||||||
|
"agent__check_inbox",
|
||||||
];
|
];
|
||||||
|
|
||||||
fn is_loop_tracker_exempt(name: &str) -> bool {
|
fn is_loop_tracker_exempt(name: &str) -> bool {
|
||||||
@@ -2633,7 +2694,7 @@ impl ToolCallTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::Value) -> String {
|
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &Value) -> String {
|
||||||
if *NO_COLOR {
|
if *NO_COLOR {
|
||||||
return format!("Call {cmd_name} {}", cmd_args.join(" "));
|
return format!("Call {cmd_name} {}", cmd_args.join(" "));
|
||||||
}
|
}
|
||||||
@@ -2652,8 +2713,8 @@ fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_json_colored_keys(value: &serde_json::Value) -> String {
|
fn format_json_colored_keys(value: &Value) -> String {
|
||||||
let serde_json::Value::Object(map) = value else {
|
let Value::Object(map) = value else {
|
||||||
return dimmed_text(&value.to_string());
|
return dimmed_text(&value.to_string());
|
||||||
};
|
};
|
||||||
if map.is_empty() {
|
if map.is_empty() {
|
||||||
@@ -3328,10 +3389,12 @@ mod tests {
|
|||||||
"job__list",
|
"job__list",
|
||||||
"agent__check",
|
"agent__check",
|
||||||
"agent__list_running",
|
"agent__list_running",
|
||||||
|
"agent__task_list",
|
||||||
|
"agent__check_inbox",
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 4);
|
assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 6);
|
||||||
assert_eq!(actual, expected);
|
assert_eq!(actual, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3368,6 +3431,29 @@ mod tests {
|
|||||||
assert!(tracker.check_loop(&x).is_some());
|
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]
|
#[test]
|
||||||
fn tracker_non_exempt_behavior_unchanged() {
|
fn tracker_non_exempt_behavior_unchanged() {
|
||||||
let mut tracker = ToolCallTracker::default();
|
let mut tracker = ToolCallTracker::default();
|
||||||
@@ -3400,16 +3486,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bundled_bash_tools_generate_declarations() {
|
fn bundled_bash_tools_generate_declarations() {
|
||||||
let tools_dir =
|
let tools_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
|
||||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
|
|
||||||
let mut checked = Vec::new();
|
let mut checked = Vec::new();
|
||||||
for entry in std::fs::read_dir(&tools_dir).unwrap() {
|
for entry in fs::read_dir(&tools_dir).unwrap() {
|
||||||
let path = entry.unwrap().path();
|
let path = entry.unwrap().path();
|
||||||
if path.extension().and_then(OsStr::to_str) != Some("sh") {
|
if path.extension().and_then(OsStr::to_str) != Some("sh") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = path.file_stem().unwrap().to_string_lossy().to_string();
|
let name = path.file_stem().unwrap().to_string_lossy().to_string();
|
||||||
let src = std::fs::read_to_string(&path).unwrap();
|
let src = fs::read_to_string(&path).unwrap();
|
||||||
let (_, declarations) = bash::build_bash_tool(&src, &name)
|
let (_, declarations) = bash::build_bash_tool(&src, &name)
|
||||||
.unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}"));
|
.unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4587,7 +4672,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("nonzero exit must return an error payload");
|
.expect("nonzero exit must return an error payload");
|
||||||
|
|
||||||
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
|
let json: Value = serde_json::from_str(&result).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
json["tool_call_error"]
|
json["tool_call_error"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -4616,7 +4701,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("nonzero exit must return an error payload");
|
.expect("nonzero exit must return an error payload");
|
||||||
|
|
||||||
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
|
let json: Value = serde_json::from_str(&result).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
json["tool_call_error"]
|
json["tool_call_error"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -4655,7 +4740,7 @@ mod tests {
|
|||||||
let elapsed = started.elapsed();
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
assert!(elapsed < Duration::from_secs(1), "took {elapsed:?}");
|
assert!(elapsed < Duration::from_secs(1), "took {elapsed:?}");
|
||||||
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
|
let json: Value = serde_json::from_str(&result).unwrap();
|
||||||
assert_eq!(json["tool_call_error"], "Tool call 'bash' aborted");
|
assert_eq!(json["tool_call_error"], "Tool call 'bash' aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4737,6 +4822,24 @@ mod tests {
|
|||||||
fn built_shims_resolve_paths_at_runtime_after_relocation() {
|
fn built_shims_resolve_paths_at_runtime_after_relocation() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
// A shim written moments ago can still be open for writing somewhere
|
||||||
|
// in the process (schedule-dependent), making exec fail with
|
||||||
|
// ETXTBSY; retry briefly instead of flaking.
|
||||||
|
fn output_with_etxtbsy_retry(command: &mut Command) -> io::Result<process::Output> {
|
||||||
|
let mut attempts = 0;
|
||||||
|
loop {
|
||||||
|
match command.output() {
|
||||||
|
Err(err)
|
||||||
|
if err.kind() == io::ErrorKind::ExecutableFileBusy && attempts < 10 =>
|
||||||
|
{
|
||||||
|
attempts += 1;
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
ret => return ret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let root = temp_file("-shim-relocate-", "");
|
let root = temp_file("-shim-relocate-", "");
|
||||||
let home_a = root.join("home-a");
|
let home_a = root.join("home-a");
|
||||||
let config_a = home_a.join("coyote");
|
let config_a = home_a.join("coyote");
|
||||||
@@ -4800,14 +4903,14 @@ mod tests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = process::Command::new(&moved_shim)
|
let mut command = Command::new(&moved_shim);
|
||||||
|
command
|
||||||
.arg("{}")
|
.arg("{}")
|
||||||
.env_remove(&config_env)
|
.env_remove(&config_env)
|
||||||
.env_remove(&functions_env)
|
.env_remove(&functions_env)
|
||||||
.env_remove("LLM_OUTPUT")
|
.env_remove("LLM_OUTPUT")
|
||||||
.env_remove("LLM_TOOL_DATA_FILE")
|
.env_remove("LLM_TOOL_DATA_FILE");
|
||||||
.output()
|
let output = output_with_etxtbsy_retry(&mut command).unwrap();
|
||||||
.unwrap();
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
assert!(
|
assert!(
|
||||||
output.status.success(),
|
output.status.success(),
|
||||||
@@ -5243,4 +5346,277 @@ mod tests {
|
|||||||
assert!(err.starts_with(expected), "{name}: {err}");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,16 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
|
|||||||
if !ctx.app.config.function_calling_support {
|
if !ctx.app.config.function_calling_support {
|
||||||
bail!("Cannot use todo tools: function calling is disabled.");
|
bail!("Cannot use todo tools: function calling is disabled.");
|
||||||
}
|
}
|
||||||
|
if ctx.in_graph_llm_node {
|
||||||
|
return Ok(json!({
|
||||||
|
"error": "todo tools are unavailable inside graph llm nodes: they drive chat turn \
|
||||||
|
loops (auto_continue), and llm nodes have no turn loop. Node \
|
||||||
|
continuation is owned by the node loop, max_iterations, and graph \
|
||||||
|
edges. For driven multi-step work, use an agent node whose agent sets \
|
||||||
|
auto_continue: true."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let auto_config = ctx.auto_continue_config();
|
let auto_config = ctx.auto_continue_config();
|
||||||
if !auto_config.enabled {
|
if !auto_config.enabled {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -245,4 +255,28 @@ mod tests {
|
|||||||
assert!(error.contains("requires an active session"));
|
assert!(error.contains("requires an active session"));
|
||||||
assert!(ctx.auto_continue_paused.is_none());
|
assert!(ctx.auto_continue_paused.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn graph_llm_node_returns_teaching_error() {
|
||||||
|
let mut app_state = AppState::test_default();
|
||||||
|
app_state.config = Arc::new(AppConfig {
|
||||||
|
auto_continue: true,
|
||||||
|
..AppConfig::default()
|
||||||
|
});
|
||||||
|
let mut ctx = RequestContext::new(Arc::new(app_state), WorkingMode::Cmd);
|
||||||
|
ctx.in_graph_llm_node = true;
|
||||||
|
|
||||||
|
let result = handle_todo_tool(&mut ctx, "todo__init", &json!({"goal": "g"})).unwrap();
|
||||||
|
|
||||||
|
let error = result["error"].as_str().unwrap();
|
||||||
|
assert!(error.contains("graph llm nodes"));
|
||||||
|
assert!(
|
||||||
|
ctx.todo_list.is_default(),
|
||||||
|
"the guard must fire before any todo state mutation (goal included)"
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.in_graph_llm_node = false;
|
||||||
|
let result = handle_todo_tool(&mut ctx, "todo__init", &json!({"goal": "g"})).unwrap();
|
||||||
|
assert_eq!(result["status"], "ok");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::{FunctionDeclaration, JsonSchema};
|
use super::{FunctionDeclaration, JsonSchema};
|
||||||
use crate::config::RequestContext;
|
use crate::config::RequestContext;
|
||||||
|
use crate::hooks::{self, HookEvent};
|
||||||
use crate::supervisor::escalation::{EscalationRequest, new_escalation_id};
|
use crate::supervisor::escalation::{EscalationRequest, new_escalation_id};
|
||||||
use crate::utils::{ACP_SERVER, HEADLESS, queue_acp_permission};
|
use crate::utils::{ACP_SERVER, HEADLESS, queue_acp_permission};
|
||||||
|
|
||||||
@@ -278,22 +279,37 @@ async fn handle_escalated(ctx: &RequestContext, action: &str, args: &Value) -> R
|
|||||||
let escalation_id = new_escalation_id();
|
let escalation_id = new_escalation_id();
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
|
let question = format!("[{action}] {question}");
|
||||||
let request = EscalationRequest {
|
let request = EscalationRequest {
|
||||||
id: escalation_id.clone(),
|
id: escalation_id.clone(),
|
||||||
from_agent_id,
|
from_agent_id: from_agent_id.clone(),
|
||||||
from_agent_name: from_agent_name.clone(),
|
from_agent_name: from_agent_name.clone(),
|
||||||
question: format!("[{action}] {question}"),
|
question: question.clone(),
|
||||||
options,
|
options,
|
||||||
reply_tx: tx,
|
reply_tx: tx,
|
||||||
};
|
};
|
||||||
|
|
||||||
root_queue.submit(request);
|
root_queue.submit(request);
|
||||||
|
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::EscalationRaised,
|
||||||
|
ctx,
|
||||||
|
&[
|
||||||
|
("COYOTE_ESCALATION_ID", escalation_id),
|
||||||
|
("COYOTE_ESCALATION_FROM_AGENT_ID", from_agent_id),
|
||||||
|
("COYOTE_ESCALATION_FROM_AGENT_NAME", from_agent_name),
|
||||||
|
("COYOTE_ESCALATION_QUESTION", question),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
await_escalation_reply(rx, timeout_secs).await
|
await_escalation_reply(rx, timeout_secs).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waits for the parent's reply. `timeout_secs == 0` waits indefinitely; a
|
/// Waits for the parent's reply. `timeout_secs == 0` waits indefinitely; a
|
||||||
/// dropped sender (the parent cancelled the request) resolves either way.
|
/// dropped sender (the parent cancelled the request) resolves either way.
|
||||||
|
/// Both fallback arms resolve without touching the escalation queue, so
|
||||||
|
/// neither fires an escalation event: the request was never answered.
|
||||||
async fn await_escalation_reply(rx: oneshot::Receiver<String>, timeout_secs: u64) -> Result<Value> {
|
async fn await_escalation_reply(rx: oneshot::Receiver<String>, timeout_secs: u64) -> Result<Value> {
|
||||||
let reply = if timeout_secs == 0 {
|
let reply = if timeout_secs == 0 {
|
||||||
rx.await
|
rx.await
|
||||||
@@ -346,8 +362,14 @@ fn parse_options(args: &Value) -> Result<Vec<String>> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::{AppConfig, AppState, WorkingMode};
|
||||||
|
use crate::function::agents::handle_agent_tool;
|
||||||
|
use crate::hooks::{HookDef, HooksMap, test_sink};
|
||||||
|
use crate::supervisor::escalation::EscalationQueue;
|
||||||
|
use serial_test::serial;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn headless_select_returns_structured_json() {
|
fn headless_select_returns_structured_json() {
|
||||||
@@ -374,6 +396,160 @@ mod tests {
|
|||||||
assert_eq!(DEFAULT_ESCALATION_TIMEOUT_SECS, 0);
|
assert_eq!(DEFAULT_ESCALATION_TIMEOUT_SECS, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ctx_with_escalation_hooks(marker: &str) -> RequestContext {
|
||||||
|
let mut hooks = HooksMap::default();
|
||||||
|
for event in ["escalation.raised", "escalation.answered"] {
|
||||||
|
hooks.insert(
|
||||||
|
event.to_string(),
|
||||||
|
vec![HookDef {
|
||||||
|
name: format!("{marker}-{event}"),
|
||||||
|
command: "true".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut app = AppState::test_default();
|
||||||
|
app.config = Arc::new(AppConfig {
|
||||||
|
hooks,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marker_captures(marker: &str, event: &str) -> Vec<test_sink::Capture> {
|
||||||
|
test_sink::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|capture| capture.hook_name == format!("{marker}-{event}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `escalation.raised` (child seam) and `escalation.answered` (parent
|
||||||
|
/// seam) must describe the same origin agent for one escalation id: the
|
||||||
|
/// answered envs come from the taken queue request, never from the
|
||||||
|
/// replying context.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn escalation_raised_and_answered_agree_on_origin_envs() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "esc-parity-k4w";
|
||||||
|
|
||||||
|
let queue = Arc::new(EscalationQueue::new());
|
||||||
|
let mut child_ctx = ctx_with_escalation_hooks(marker);
|
||||||
|
child_ctx.escalation_queue = Some(Arc::clone(&queue));
|
||||||
|
child_ctx.self_agent_id = Some("esc-child-1".to_string());
|
||||||
|
let mut parent_ctx = ctx_with_escalation_hooks(marker);
|
||||||
|
parent_ctx.escalation_queue = Some(Arc::clone(&queue));
|
||||||
|
|
||||||
|
let raise_args = json!({ "question": "which db?" });
|
||||||
|
let raise = handle_escalated(&child_ctx, "input", &raise_args);
|
||||||
|
let reply = async {
|
||||||
|
// Bounded poll: on the current-thread runtime the raise future
|
||||||
|
// submits before its first await, so this resolves immediately.
|
||||||
|
let mut escalation_id = None;
|
||||||
|
for _ in 0..1000 {
|
||||||
|
if let Some(entry) = queue.pending_summary().first() {
|
||||||
|
escalation_id = entry["escalation_id"].as_str().map(str::to_string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
let escalation_id = escalation_id.expect("escalation must reach the queue");
|
||||||
|
handle_agent_tool(
|
||||||
|
&mut parent_ctx,
|
||||||
|
"agent__reply_escalation",
|
||||||
|
&json!({ "escalation_id": escalation_id, "reply": "postgres" }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let (raised_result, replied) = tokio::join!(raise, reply);
|
||||||
|
assert_eq!(raised_result.unwrap()["answer"], "postgres");
|
||||||
|
assert_eq!(replied["status"], "ok");
|
||||||
|
|
||||||
|
let raised = marker_captures(marker, "escalation.raised");
|
||||||
|
let answered = marker_captures(marker, "escalation.answered");
|
||||||
|
assert_eq!(raised.len(), 1, "{raised:?}");
|
||||||
|
assert_eq!(answered.len(), 1, "{answered:?}");
|
||||||
|
for key in [
|
||||||
|
"COYOTE_ESCALATION_ID",
|
||||||
|
"COYOTE_ESCALATION_FROM_AGENT_ID",
|
||||||
|
"COYOTE_ESCALATION_FROM_AGENT_NAME",
|
||||||
|
"COYOTE_ESCALATION_QUESTION",
|
||||||
|
] {
|
||||||
|
assert!(raised[0].envs.contains_key(key), "{key} missing on raised");
|
||||||
|
assert_eq!(raised[0].envs.get(key), answered[0].envs.get(key), "{key}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
raised[0]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_ESCALATION_FROM_AGENT_ID")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("esc-child-1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
raised[0]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_ESCALATION_QUESTION")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("[input] which db?")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
answered[0]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_ESCALATION_REPLY")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("postgres")
|
||||||
|
);
|
||||||
|
assert!(!raised[0].envs.contains_key("COYOTE_ESCALATION_REPLY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `COYOTE_ESCALATION_QUESTION` rides through the standard env
|
||||||
|
/// truncation: a huge question is trimmed to 2048 bytes on the raised
|
||||||
|
/// seam, never passed through whole.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn escalation_question_env_is_truncated_to_2048_bytes() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "esc-trunc-p9j";
|
||||||
|
|
||||||
|
let queue = Arc::new(EscalationQueue::new());
|
||||||
|
let mut child_ctx = ctx_with_escalation_hooks(marker);
|
||||||
|
child_ctx.escalation_queue = Some(Arc::clone(&queue));
|
||||||
|
child_ctx.self_agent_id = Some("esc-child-trunc".to_string());
|
||||||
|
|
||||||
|
let raise_args = json!({ "question": "q".repeat(3000) });
|
||||||
|
let raise = handle_escalated(&child_ctx, "input", &raise_args);
|
||||||
|
let reply = async {
|
||||||
|
// Bounded poll: on the current-thread runtime the raise future
|
||||||
|
// submits before its first await, so this resolves immediately.
|
||||||
|
let mut request = None;
|
||||||
|
for _ in 0..1000 {
|
||||||
|
if let Some(entry) = queue.pending_summary().first() {
|
||||||
|
let id = entry["escalation_id"].as_str().unwrap().to_string();
|
||||||
|
request = queue.take(&id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
let request = request.expect("escalation must reach the queue");
|
||||||
|
let _ = request.reply_tx.send("ok".to_string());
|
||||||
|
};
|
||||||
|
let (raised_result, ()) = tokio::join!(raise, reply);
|
||||||
|
assert_eq!(raised_result.unwrap()["answer"], "ok");
|
||||||
|
|
||||||
|
let raised = marker_captures(marker, "escalation.raised");
|
||||||
|
assert_eq!(raised.len(), 1, "{raised:?}");
|
||||||
|
let question = raised[0]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_ESCALATION_QUESTION")
|
||||||
|
.expect("QUESTION env must be present on escalation.raised");
|
||||||
|
assert_eq!(
|
||||||
|
question.len(),
|
||||||
|
2048,
|
||||||
|
"QUESTION env must be 2048-byte truncated"
|
||||||
|
);
|
||||||
|
assert!(question.starts_with("[input] qqq"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
#[tokio::test(start_paused = true)]
|
||||||
async fn zero_timeout_waits_indefinitely_for_the_reply() {
|
async fn zero_timeout_waits_indefinitely_for_the_reply() {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|||||||
+133
-14
@@ -6,6 +6,7 @@ use super::types::AgentNode;
|
|||||||
use super::wall_clock;
|
use super::wall_clock;
|
||||||
use crate::config::RequestContext;
|
use crate::config::RequestContext;
|
||||||
use crate::function::agents::run_agent_for_graph;
|
use crate::function::agents::run_agent_for_graph;
|
||||||
|
use crate::hooks::{self, HookEvent};
|
||||||
use crate::supervisor::mailbox::{Inbox, PeerRegistry};
|
use crate::supervisor::mailbox::{Inbox, PeerRegistry};
|
||||||
use crate::utils::create_abort_signal;
|
use crate::utils::create_abort_signal;
|
||||||
use anyhow::{Context, Error, Result, anyhow};
|
use anyhow::{Context, Error, Result, anyhow};
|
||||||
@@ -35,6 +36,11 @@ impl AgentNodeExecutor {
|
|||||||
parent_ctx: &mut RequestContext,
|
parent_ctx: &mut RequestContext,
|
||||||
retire_peer_on_return: bool,
|
retire_peer_on_return: bool,
|
||||||
) -> Result<AgentExecutionOutcome> {
|
) -> 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(
|
let result = run(
|
||||||
node_id,
|
node_id,
|
||||||
node,
|
node,
|
||||||
@@ -43,6 +49,34 @@ impl AgentNodeExecutor {
|
|||||||
retire_peer_on_return,
|
retire_peer_on_return,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
if let Err(e) = &result {
|
||||||
|
if parent_ctx
|
||||||
|
.session_abort
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|signal| signal.aborted_ctrlc())
|
||||||
|
{
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::AgentInterrupted,
|
||||||
|
parent_ctx,
|
||||||
|
&[
|
||||||
|
("COYOTE_AGENT_ID", agent_hook_id),
|
||||||
|
("COYOTE_AGENT_NAME", node.agent.clone()),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
hooks::fire(
|
||||||
|
HookEvent::AgentFailed,
|
||||||
|
parent_ctx,
|
||||||
|
&[
|
||||||
|
("COYOTE_AGENT_ID", agent_hook_id),
|
||||||
|
("COYOTE_AGENT_NAME", node.agent.clone()),
|
||||||
|
("COYOTE_AGENT_ERROR", format!("{e:#}")),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
outcome_from(node_id, node, state_manager, result)
|
outcome_from(node_id, node, state_manager, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,10 +354,12 @@ fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, outpu
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::super::types::AgentNode;
|
use super::super::types::AgentNode;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{AppState, WorkingMode, default_max_agent_depth};
|
use crate::config::{AppConfig, AppState, WorkingMode, default_max_agent_depth};
|
||||||
|
use crate::hooks::{HookDef, HooksMap, test_sink};
|
||||||
use crate::supervisor::mailbox::{Inbox, PeerRegistry, graph_agent_id};
|
use crate::supervisor::mailbox::{Inbox, PeerRegistry, graph_agent_id};
|
||||||
use crate::testing::{install_warn_collector, warn_messages};
|
use crate::testing::{install_log_collector, warn_snapshot};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use serial_test::serial;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -398,6 +434,95 @@ mod tests {
|
|||||||
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ctx_with_agent_hooks(marker: &str) -> RequestContext {
|
||||||
|
let mut hooks = HooksMap::default();
|
||||||
|
for event in ["agent.interrupted", "agent.failed"] {
|
||||||
|
hooks.insert(
|
||||||
|
event.to_string(),
|
||||||
|
vec![HookDef {
|
||||||
|
name: format!("{marker}-{event}"),
|
||||||
|
command: "true".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut app = AppState::test_default();
|
||||||
|
app.config = Arc::new(AppConfig {
|
||||||
|
hooks,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fired session signal (the user's interrupt) reclassifies a failed
|
||||||
|
/// agent node: `agent.interrupted` fires instead of `agent.failed`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn execute_failure_with_fired_session_abort_fires_agent_interrupted() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "graph-agent-int-z2f";
|
||||||
|
let mut ctx = ctx_with_agent_hooks(marker);
|
||||||
|
// Past max depth the node fails before touching agent config on disk.
|
||||||
|
ctx.current_depth = default_max_agent_depth();
|
||||||
|
let session = create_abort_signal();
|
||||||
|
session.set_ctrlc();
|
||||||
|
ctx.session_abort = Some(session);
|
||||||
|
let node = node_with("hi", None);
|
||||||
|
let mut state = manager_with(&[]);
|
||||||
|
|
||||||
|
AgentNodeExecutor::execute("test_node", &node, &mut state, &mut ctx, false)
|
||||||
|
.await
|
||||||
|
.expect_err("agent past max depth should fail");
|
||||||
|
|
||||||
|
let captures: Vec<_> = test_sink::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|capture| capture.hook_name.starts_with(marker))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(captures.len(), 1, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].hook_name, format!("{marker}-agent.interrupted"));
|
||||||
|
assert_eq!(
|
||||||
|
captures[0]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_AGENT_NAME")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("test_agent")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!captures[0].envs.contains_key("COYOTE_AGENT_ERROR"),
|
||||||
|
"an interruption is not a failure and carries no error env"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse gate: a failure whose session signal never fired stays
|
||||||
|
/// `agent.failed` with its error env — the presence of a signal alone
|
||||||
|
/// must not reclassify a real failure as an interruption.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn execute_failure_without_fired_session_abort_fires_agent_failed() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "graph-agent-fail-inv-x7q";
|
||||||
|
let mut ctx = ctx_with_agent_hooks(marker);
|
||||||
|
// Past max depth the node fails before touching agent config on disk.
|
||||||
|
ctx.current_depth = default_max_agent_depth();
|
||||||
|
ctx.session_abort = Some(create_abort_signal());
|
||||||
|
let node = node_with("hi", None);
|
||||||
|
let mut state = manager_with(&[]);
|
||||||
|
|
||||||
|
AgentNodeExecutor::execute("test_node", &node, &mut state, &mut ctx, false)
|
||||||
|
.await
|
||||||
|
.expect_err("agent past max depth should fail");
|
||||||
|
|
||||||
|
let captures: Vec<_> = test_sink::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|capture| capture.hook_name.starts_with(marker))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(captures.len(), 1, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].hook_name, format!("{marker}-agent.failed"));
|
||||||
|
assert!(
|
||||||
|
captures[0].envs.contains_key("COYOTE_AGENT_ERROR"),
|
||||||
|
"a real failure keeps its error env"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn retryable_node(max_attempts: u32) -> AgentNode {
|
fn retryable_node(max_attempts: u32) -> AgentNode {
|
||||||
let mut node = node_with("hi", None);
|
let mut node = node_with("hi", None);
|
||||||
node.max_attempts = max_attempts;
|
node.max_attempts = max_attempts;
|
||||||
@@ -658,7 +783,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn retry_transient_warns_once_per_retried_attempt_with_the_context_chain() {
|
async fn retry_transient_warns_once_per_retried_attempt_with_the_context_chain() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let mut ctx = plain_ctx();
|
let mut ctx = plain_ctx();
|
||||||
let node = retryable_node(3);
|
let node = retryable_node(3);
|
||||||
let mut attempts = 0u32;
|
let mut attempts = 0u32;
|
||||||
@@ -682,12 +807,9 @@ mod tests {
|
|||||||
.expect_err("all attempts fail");
|
.expect_err("all attempts fail");
|
||||||
|
|
||||||
assert_eq!(attempts, 3);
|
assert_eq!(attempts, 3);
|
||||||
let warns: Vec<String> = warn_messages()
|
let warns: Vec<String> = warn_snapshot()
|
||||||
.lock()
|
.into_iter()
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter(|m| m.contains("'warn_capture'"))
|
.filter(|m| m.contains("'warn_capture'"))
|
||||||
.cloned()
|
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
warns.len(),
|
warns.len(),
|
||||||
@@ -843,7 +965,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn outcome_from_fallback_warn_names_the_node_and_the_fallback_target() {
|
fn outcome_from_fallback_warn_names_the_node_and_the_fallback_target() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let mut node = node_with("hi", None);
|
let mut node = node_with("hi", None);
|
||||||
node.fallback = Some("recover".into());
|
node.fallback = Some("recover".into());
|
||||||
let mut state = manager_with(&[]);
|
let mut state = manager_with(&[]);
|
||||||
@@ -857,12 +979,9 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(outcome, AgentExecutionOutcome::FellBack("recover".into()));
|
assert_eq!(outcome, AgentExecutionOutcome::FellBack("recover".into()));
|
||||||
let warns: Vec<String> = warn_messages()
|
let warns: Vec<String> = warn_snapshot()
|
||||||
.lock()
|
.into_iter()
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter(|m| m.contains("'fallback_warn_capture'"))
|
.filter(|m| m.contains("'fallback_warn_capture'"))
|
||||||
.cloned()
|
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(warns.len(), 1, "exactly one fallback warn: {warns:?}");
|
assert_eq!(warns.len(), 1, "exactly one fallback warn: {warns:?}");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+468
-49
@@ -1,6 +1,6 @@
|
|||||||
use super::agent::{AgentExecutionOutcome, AgentNodeExecutor};
|
use super::agent::{AgentExecutionOutcome, AgentNodeExecutor};
|
||||||
use super::llm::{LlmExecutionOutcome, LlmNodeExecutor};
|
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::map::MapNodeExecutor;
|
||||||
use super::rag::RagNodeExecutor;
|
use super::rag::RagNodeExecutor;
|
||||||
use super::script::ScriptExecutor;
|
use super::script::ScriptExecutor;
|
||||||
@@ -11,6 +11,7 @@ use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor};
|
|||||||
use super::validator::{AgentValidationContext, GraphValidator};
|
use super::validator::{AgentValidationContext, GraphValidator};
|
||||||
use super::wall_clock;
|
use super::wall_clock;
|
||||||
use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext};
|
use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext};
|
||||||
|
use crate::hooks::{self, HookEvent, ResolvedHook};
|
||||||
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
|
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
|
||||||
use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt};
|
use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt};
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
@@ -234,6 +235,16 @@ impl GraphExecutor {
|
|||||||
})?
|
})?
|
||||||
.clone();
|
.clone();
|
||||||
logger.node_start(&node, in_super_step);
|
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 branch_state = state.fork_for_branch_state();
|
||||||
let mut branch_ctx = ctx.fork_for_branch();
|
let mut branch_ctx = ctx.fork_for_branch();
|
||||||
let mut peer_id: Option<String> = None;
|
let mut peer_id: Option<String> = None;
|
||||||
@@ -281,6 +292,7 @@ impl GraphExecutor {
|
|||||||
"aborted",
|
"aborted",
|
||||||
in_super_step,
|
in_super_step,
|
||||||
);
|
);
|
||||||
|
node_hooks.failed(Duration::default(), "aborted");
|
||||||
return (
|
return (
|
||||||
current.clone(),
|
current.clone(),
|
||||||
branch_state,
|
branch_state,
|
||||||
@@ -315,6 +327,7 @@ impl GraphExecutor {
|
|||||||
route.as_deref(),
|
route.as_deref(),
|
||||||
in_super_step,
|
in_super_step,
|
||||||
);
|
);
|
||||||
|
node_hooks.completed(elapsed, route.as_deref());
|
||||||
}
|
}
|
||||||
Ok(StepResult::End(_)) => {
|
Ok(StepResult::End(_)) => {
|
||||||
narrate_node_complete(
|
narrate_node_complete(
|
||||||
@@ -324,6 +337,7 @@ impl GraphExecutor {
|
|||||||
Some("END"),
|
Some("END"),
|
||||||
in_super_step,
|
in_super_step,
|
||||||
);
|
);
|
||||||
|
node_hooks.completed(elapsed, Some("END"));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
narrate_node_failed(
|
narrate_node_failed(
|
||||||
@@ -333,6 +347,7 @@ impl GraphExecutor {
|
|||||||
&e.to_string(),
|
&e.to_string(),
|
||||||
in_super_step,
|
in_super_step,
|
||||||
);
|
);
|
||||||
|
node_hooks.failed(elapsed, &e.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(current, state, result, elapsed)
|
(current, state, result, elapsed)
|
||||||
@@ -498,6 +513,67 @@ fn provision_frontier_peers(
|
|||||||
(Some(registry), assignments)
|
(Some(registry), assignments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct NodeHookEmitter {
|
||||||
|
completed: Vec<ResolvedHook>,
|
||||||
|
failed: Vec<ResolvedHook>,
|
||||||
|
session_name: Option<String>,
|
||||||
|
agent_name: Option<String>,
|
||||||
|
node_id: String,
|
||||||
|
node_type: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NodeHookEmitter {
|
||||||
|
fn resolve(ctx: &RequestContext, node_id: &str, node_type: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
completed: ctx.resolved_hooks(HookEvent::GraphNodeCompleted),
|
||||||
|
failed: ctx.resolved_hooks(HookEvent::GraphNodeFailed),
|
||||||
|
session_name: ctx
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.map(|session| session.name().to_string()),
|
||||||
|
agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()),
|
||||||
|
node_id: node_id.to_string(),
|
||||||
|
node_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extras(&self, elapsed: Duration) -> Vec<(&'static str, String)> {
|
||||||
|
vec![
|
||||||
|
("COYOTE_NODE_ID", self.node_id.clone()),
|
||||||
|
("COYOTE_NODE_TYPE", self.node_type.to_string()),
|
||||||
|
("COYOTE_NODE_DURATION_MS", elapsed.as_millis().to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(&self, event: HookEvent, resolved: &[ResolvedHook], extras: &[(&str, String)]) {
|
||||||
|
hooks::fire_resolved(
|
||||||
|
event,
|
||||||
|
resolved.to_vec(),
|
||||||
|
hooks::base_envs_parts(
|
||||||
|
event,
|
||||||
|
self.session_name.as_deref(),
|
||||||
|
self.agent_name.as_deref(),
|
||||||
|
),
|
||||||
|
extras,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn completed(&self, elapsed: Duration, route: Option<&str>) {
|
||||||
|
let mut extras = self.extras(elapsed);
|
||||||
|
if let Some(route) = route {
|
||||||
|
extras.push(("COYOTE_NODE_ROUTE", route.to_string()));
|
||||||
|
}
|
||||||
|
self.dispatch(HookEvent::GraphNodeCompleted, &self.completed, &extras);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(&self, elapsed: Duration, error: &str) {
|
||||||
|
let mut extras = self.extras(elapsed);
|
||||||
|
extras.push(("COYOTE_NODE_ERROR", error.to_string()));
|
||||||
|
self.dispatch(HookEvent::GraphNodeFailed, &self.failed, &extras);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct TaskCancelGuard(Vec<AbortHandle>);
|
pub(super) struct TaskCancelGuard(Vec<AbortHandle>);
|
||||||
|
|
||||||
impl TaskCancelGuard {
|
impl TaskCancelGuard {
|
||||||
@@ -928,6 +1004,8 @@ mod tests {
|
|||||||
max_concurrent_agents: None,
|
max_concurrent_agents: None,
|
||||||
max_agent_depth: None,
|
max_agent_depth: None,
|
||||||
global_tools: Vec::new(),
|
global_tools: Vec::new(),
|
||||||
|
hooks: Default::default(),
|
||||||
|
global_hooks: Vec::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
mcp_tools: None,
|
mcp_tools: None,
|
||||||
skills_enabled: None,
|
skills_enabled: None,
|
||||||
@@ -1048,24 +1126,50 @@ mod tests {
|
|||||||
mod integration_tests {
|
mod integration_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
use crate::config::{AppState, WorkingMode};
|
use crate::config::{Agent, AgentConfig, AppConfig, AppState, WorkingMode};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::function::jobs::RingBuf;
|
use crate::function::jobs::RingBuf;
|
||||||
|
use crate::hooks::HookDef;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor, notification};
|
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor, notification};
|
||||||
use crate::utils::{create_abort_signal, get_env_name, temp_file};
|
use crate::utils::{create_abort_signal, temp_file};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::mem;
|
use std::mem;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn cmd_available(name: &str) -> bool {
|
fn cmd_available(name: &str) -> bool {
|
||||||
which::which(name).is_ok()
|
which::which(name).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
struct TestWorkspace {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -1161,7 +1265,7 @@ nodes:
|
|||||||
eprintln!("skipping: bash or python3 not available");
|
eprintln!("skipping: bash or python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-executor-tests");
|
||||||
materialize_probe_agent(0.0);
|
materialize_probe_agent(0.0);
|
||||||
let ws = TestWorkspace::new();
|
let ws = TestWorkspace::new();
|
||||||
ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n");
|
ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n");
|
||||||
@@ -1208,6 +1312,361 @@ nodes:
|
|||||||
assert_eq!(result, "held|held");
|
assert_eq!(result, "held|held");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An agent node that fails but declares a fallback must surface the
|
||||||
|
/// failure to observers as `agent.failed`, fired at the point of
|
||||||
|
/// failure, even though the fallback then converts it into a normal
|
||||||
|
/// node completion that routes to the fallback target — so the same
|
||||||
|
/// node also yields `graph.node.completed`, never `graph.node.failed`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn agent_node_failure_with_fallback_fires_agent_failed_and_node_completed() {
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let mut ctx = ctx_with_global_hooks(hooks_config(&[
|
||||||
|
("agent.failed", "t037_fb_agent_failed"),
|
||||||
|
("graph.node.completed", "t037_fb_node_completed"),
|
||||||
|
("graph.node.failed", "t037_fb_node_failed"),
|
||||||
|
]));
|
||||||
|
// Forces the agent node to fail before it ever loads the (absent)
|
||||||
|
// agent from disk, keeping the test hermetic.
|
||||||
|
ctx.current_depth = crate::config::default_max_agent_depth();
|
||||||
|
|
||||||
|
let yaml = r#"
|
||||||
|
name: t
|
||||||
|
settings:
|
||||||
|
validate_before_run: false
|
||||||
|
start: worker
|
||||||
|
nodes:
|
||||||
|
worker:
|
||||||
|
type: agent
|
||||||
|
agent: missing_agent
|
||||||
|
prompt: p
|
||||||
|
fallback: recover
|
||||||
|
next: done
|
||||||
|
recover:
|
||||||
|
type: end
|
||||||
|
output: recovered
|
||||||
|
done:
|
||||||
|
type: end
|
||||||
|
output: done
|
||||||
|
"#;
|
||||||
|
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
let result = GraphExecutor::new(graph, ".")
|
||||||
|
.execute(&mut ctx, abort)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||||
|
assert_eq!(result, "recovered");
|
||||||
|
|
||||||
|
let agent_failed = hook_captures_named("t037_fb_agent_failed");
|
||||||
|
assert_eq!(agent_failed.len(), 1, "{agent_failed:?}");
|
||||||
|
assert_eq!(agent_failed[0].envs["COYOTE_EVENT"], "agent.failed");
|
||||||
|
assert_eq!(agent_failed[0].envs["COYOTE_AGENT_ID"], "worker");
|
||||||
|
assert_eq!(agent_failed[0].envs["COYOTE_AGENT_NAME"], "missing_agent");
|
||||||
|
assert!(
|
||||||
|
agent_failed[0].envs["COYOTE_AGENT_ERROR"].contains("Max agent depth exceeded"),
|
||||||
|
"{:?}",
|
||||||
|
agent_failed[0].envs
|
||||||
|
);
|
||||||
|
|
||||||
|
let node_completed = hook_captures_named("t037_fb_node_completed");
|
||||||
|
let worker_completed: Vec<_> = node_completed
|
||||||
|
.iter()
|
||||||
|
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == "worker")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(worker_completed.len(), 1, "{node_completed:?}");
|
||||||
|
assert_eq!(worker_completed[0].envs["COYOTE_NODE_TYPE"], "agent");
|
||||||
|
assert_eq!(worker_completed[0].envs["COYOTE_NODE_ROUTE"], "recover");
|
||||||
|
|
||||||
|
let node_failed = hook_captures_named("t037_fb_node_failed");
|
||||||
|
assert!(
|
||||||
|
node_failed
|
||||||
|
.iter()
|
||||||
|
.all(|capture| capture.envs["COYOTE_NODE_ID"] != "worker"),
|
||||||
|
"a node that continues via fallback must not fire graph.node.failed: {node_failed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two branches running in the same super-step each carry their own
|
||||||
|
/// node metadata, captured task-locally before the branch was spawned;
|
||||||
|
/// the distinct routes prove one branch's metadata never leaks into a
|
||||||
|
/// sibling's events.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parallel_super_step_branches_fire_per_node_metadata() {
|
||||||
|
if !cmd_available("bash") {
|
||||||
|
eprintln!("skipping: bash not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let ws = TestWorkspace::new();
|
||||||
|
for script in [
|
||||||
|
"dispatcher.sh",
|
||||||
|
"worker_a.sh",
|
||||||
|
"worker_b.sh",
|
||||||
|
"sink_a.sh",
|
||||||
|
"sink_b.sh",
|
||||||
|
] {
|
||||||
|
ws.write_script(script, "#!/bin/bash\necho '{}'\n");
|
||||||
|
}
|
||||||
|
let mut ctx = ctx_with_global_hooks(hooks_config(&[
|
||||||
|
("graph.node.started", "t037_par_started"),
|
||||||
|
("graph.node.completed", "t037_par_completed"),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let yaml = r#"
|
||||||
|
name: t
|
||||||
|
start: dispatcher
|
||||||
|
nodes:
|
||||||
|
dispatcher:
|
||||||
|
type: script
|
||||||
|
script: dispatcher.sh
|
||||||
|
state_updates: {}
|
||||||
|
next: [worker_a, worker_b]
|
||||||
|
worker_a:
|
||||||
|
type: script
|
||||||
|
script: worker_a.sh
|
||||||
|
state_updates: {}
|
||||||
|
next: sink_a
|
||||||
|
worker_b:
|
||||||
|
type: script
|
||||||
|
script: worker_b.sh
|
||||||
|
state_updates: {}
|
||||||
|
next: sink_b
|
||||||
|
sink_a:
|
||||||
|
type: script
|
||||||
|
script: sink_a.sh
|
||||||
|
state_updates: {}
|
||||||
|
next: join
|
||||||
|
sink_b:
|
||||||
|
type: script
|
||||||
|
script: sink_b.sh
|
||||||
|
state_updates: {}
|
||||||
|
next: join
|
||||||
|
join:
|
||||||
|
type: end
|
||||||
|
output: done
|
||||||
|
"#;
|
||||||
|
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
let result = GraphExecutor::new(graph, &ws.dir)
|
||||||
|
.execute(&mut ctx, abort)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||||
|
assert_eq!(result, "done");
|
||||||
|
|
||||||
|
let started = hook_captures_named("t037_par_started");
|
||||||
|
for node_id in [
|
||||||
|
"dispatcher",
|
||||||
|
"worker_a",
|
||||||
|
"worker_b",
|
||||||
|
"sink_a",
|
||||||
|
"sink_b",
|
||||||
|
"join",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
started
|
||||||
|
.iter()
|
||||||
|
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == node_id)
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"exactly one graph.node.started for '{node_id}': {started:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed = hook_captures_named("t037_par_completed");
|
||||||
|
let completed_route = |node_id: &str| -> String {
|
||||||
|
let matches: Vec<_> = completed
|
||||||
|
.iter()
|
||||||
|
.filter(|capture| capture.envs["COYOTE_NODE_ID"] == node_id)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
matches.len(),
|
||||||
|
1,
|
||||||
|
"exactly one graph.node.completed for '{node_id}': {completed:?}"
|
||||||
|
);
|
||||||
|
let expected_type = if node_id == "join" { "end" } else { "script" };
|
||||||
|
assert_eq!(matches[0].envs["COYOTE_NODE_TYPE"], expected_type);
|
||||||
|
let duration = &matches[0].envs["COYOTE_NODE_DURATION_MS"];
|
||||||
|
let _: u128 = duration
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| panic!("non-numeric duration for '{node_id}': {duration}"));
|
||||||
|
matches[0].envs["COYOTE_NODE_ROUTE"].clone()
|
||||||
|
};
|
||||||
|
assert_eq!(completed_route("dispatcher"), "worker_a, worker_b");
|
||||||
|
assert_eq!(completed_route("worker_a"), "sink_a");
|
||||||
|
assert_eq!(completed_route("worker_b"), "sink_b");
|
||||||
|
assert_eq!(completed_route("sink_a"), "join");
|
||||||
|
assert_eq!(completed_route("sink_b"), "join");
|
||||||
|
assert_eq!(completed_route("join"), "END");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A graph agent's hooks come from graph.yaml's top level: they resolve
|
||||||
|
/// through the agent scope end-to-end, while a global hook on the same
|
||||||
|
/// event stays gated off because the agent whitelisted nothing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn graph_agent_hooks_resolve_from_graph_yaml_top_level() {
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let mut ctx =
|
||||||
|
ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")]));
|
||||||
|
|
||||||
|
let yaml = r#"
|
||||||
|
name: hooked_graph
|
||||||
|
settings:
|
||||||
|
validate_before_run: false
|
||||||
|
start: done
|
||||||
|
hooks:
|
||||||
|
graph.node.started:
|
||||||
|
- name: t037_gy_started
|
||||||
|
command: "true"
|
||||||
|
graph.node.completed:
|
||||||
|
- name: t037_gy_completed
|
||||||
|
command: "true"
|
||||||
|
nodes:
|
||||||
|
done:
|
||||||
|
type: end
|
||||||
|
output: ok
|
||||||
|
"#;
|
||||||
|
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
ctx.agent = Some(Agent::test_new(AgentConfig::from_graph(
|
||||||
|
"hooked_graph",
|
||||||
|
&graph,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
let result = GraphExecutor::new(graph, ".")
|
||||||
|
.execute(&mut ctx, abort)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||||
|
assert_eq!(result, "ok");
|
||||||
|
|
||||||
|
let started = hook_captures_named("t037_gy_started");
|
||||||
|
assert_eq!(started.len(), 1, "{started:?}");
|
||||||
|
assert_eq!(started[0].envs["COYOTE_EVENT"], "graph.node.started");
|
||||||
|
assert_eq!(started[0].envs["COYOTE_NODE_ID"], "done");
|
||||||
|
assert_eq!(started[0].envs["COYOTE_NODE_TYPE"], "end");
|
||||||
|
assert_eq!(started[0].envs["COYOTE_AGENT_NAME"], "hooked_graph");
|
||||||
|
|
||||||
|
let completed = hook_captures_named("t037_gy_completed");
|
||||||
|
assert_eq!(completed.len(), 1, "{completed:?}");
|
||||||
|
assert_eq!(completed[0].envs["COYOTE_NODE_ID"], "done");
|
||||||
|
assert_eq!(completed[0].envs["COYOTE_NODE_ROUTE"], "END");
|
||||||
|
assert_eq!(completed[0].envs["COYOTE_AGENT_NAME"], "hooked_graph");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
hook_captures_named("t037_gy_global").is_empty(),
|
||||||
|
"an agent context must not run global hooks it did not whitelist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node that genuinely fails — no fallback to absorb the error — fires
|
||||||
|
/// `graph.node.failed` with the full payload observers rely on: the node's
|
||||||
|
/// id and type, a numeric duration, and the error text, even though the
|
||||||
|
/// failure then propagates out of the executor.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn genuine_node_failure_fires_graph_node_failed_with_payload() {
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let mut ctx = ctx_with_global_hooks(hooks_config(&[(
|
||||||
|
"graph.node.failed",
|
||||||
|
"t037_gf_node_failed",
|
||||||
|
)]));
|
||||||
|
// Forces the agent node to fail before it ever loads the (absent)
|
||||||
|
// agent from disk, keeping the test hermetic.
|
||||||
|
ctx.current_depth = crate::config::default_max_agent_depth();
|
||||||
|
|
||||||
|
let yaml = r#"
|
||||||
|
name: t
|
||||||
|
settings:
|
||||||
|
validate_before_run: false
|
||||||
|
start: worker
|
||||||
|
nodes:
|
||||||
|
worker:
|
||||||
|
type: agent
|
||||||
|
agent: missing_agent
|
||||||
|
prompt: p
|
||||||
|
next: done
|
||||||
|
done:
|
||||||
|
type: end
|
||||||
|
output: done
|
||||||
|
"#;
|
||||||
|
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
let result = GraphExecutor::new(graph, ".")
|
||||||
|
.execute(&mut ctx, abort)
|
||||||
|
.await;
|
||||||
|
let error = result.expect_err("a node failure without a fallback must propagate");
|
||||||
|
|
||||||
|
let failed = hook_captures_named("t037_gf_node_failed");
|
||||||
|
assert_eq!(failed.len(), 1, "{failed:?}");
|
||||||
|
let envs = &failed[0].envs;
|
||||||
|
assert_eq!(envs["COYOTE_EVENT"], "graph.node.failed");
|
||||||
|
assert_eq!(envs["COYOTE_NODE_ID"], "worker");
|
||||||
|
assert_eq!(envs["COYOTE_NODE_TYPE"], "agent");
|
||||||
|
let duration = &envs["COYOTE_NODE_DURATION_MS"];
|
||||||
|
let _: u128 = duration
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| panic!("non-numeric duration: {duration}"));
|
||||||
|
assert!(!envs["COYOTE_NODE_ERROR"].is_empty(), "{envs:?}");
|
||||||
|
assert!(
|
||||||
|
format!("{error:#}").contains("Max agent depth exceeded"),
|
||||||
|
"unexpected executor error: {error:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A malformed hook definition reaching the executor seam through
|
||||||
|
/// graph.yaml's top level — an entry whose command is blank — degrades
|
||||||
|
/// safely: the node still runs, the malformed hook is skipped with a
|
||||||
|
/// debug log, and a well-formed sibling on the same event still fires.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
|
||||||
|
crate::testing::install_log_collector();
|
||||||
|
let _sink = hooks::test_sink::install();
|
||||||
|
let mut ctx = make_ctx();
|
||||||
|
|
||||||
|
let yaml = r#"
|
||||||
|
name: hooked_graph
|
||||||
|
settings:
|
||||||
|
validate_before_run: false
|
||||||
|
start: done
|
||||||
|
hooks:
|
||||||
|
graph.node.completed:
|
||||||
|
- name: t037_mal_empty
|
||||||
|
command: " "
|
||||||
|
- name: t037_mal_valid
|
||||||
|
command: "true"
|
||||||
|
nodes:
|
||||||
|
done:
|
||||||
|
type: end
|
||||||
|
output: ok
|
||||||
|
"#;
|
||||||
|
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
ctx.agent = Some(Agent::test_new(AgentConfig::from_graph(
|
||||||
|
"hooked_graph",
|
||||||
|
&graph,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
let result = GraphExecutor::new(graph, ".")
|
||||||
|
.execute(&mut ctx, abort)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||||
|
assert_eq!(result, "ok");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
hook_captures_named("t037_mal_empty").is_empty(),
|
||||||
|
"a blank-command hook must be skipped, not dispatched"
|
||||||
|
);
|
||||||
|
let valid = hook_captures_named("t037_mal_valid");
|
||||||
|
assert_eq!(valid.len(), 1, "{valid:?}");
|
||||||
|
assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done");
|
||||||
|
|
||||||
|
let debugs = crate::testing::debug_snapshot();
|
||||||
|
assert!(
|
||||||
|
debugs
|
||||||
|
.iter()
|
||||||
|
.any(|message| message.contains("t037_mal_empty")
|
||||||
|
&& message.contains("empty command")),
|
||||||
|
"expected a debug log for the skipped hook: {debugs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Declared variables are seeded into state at run start, so the agent
|
/// Declared variables are seeded into state at run start, so the agent
|
||||||
/// node's strict prompt interpolation of `{{foo}}` succeeds instead of
|
/// node's strict prompt interpolation of `{{foo}}` succeeds instead of
|
||||||
/// bailing with "not found in state". `ctx.agent` is None here, so each
|
/// bailing with "not found in state". `ctx.agent` is None here, so each
|
||||||
@@ -1220,7 +1679,7 @@ nodes:
|
|||||||
eprintln!("skipping: python3 not available");
|
eprintln!("skipping: python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-executor-tests");
|
||||||
materialize_probe_agent(0.0);
|
materialize_probe_agent(0.0);
|
||||||
let ws = TestWorkspace::new();
|
let ws = TestWorkspace::new();
|
||||||
|
|
||||||
@@ -1903,47 +2362,7 @@ nodes:
|
|||||||
assert_eq!(events[0].event, "job_completed");
|
assert_eq!(events[0].event, "job_completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TestConfigDirGuard {
|
use crate::testing::TestConfigDirGuard;
|
||||||
key: String,
|
|
||||||
previous: Option<std::ffi::OsString>,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestConfigDirGuard {
|
|
||||||
fn new() -> Self {
|
|
||||||
let key = get_env_name("config_dir");
|
|
||||||
let previous = env::var_os(&key);
|
|
||||||
let unique = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let path = env::temp_dir().join(format!("coyote-graph-executor-tests-{unique}"));
|
|
||||||
fs::create_dir_all(&path).unwrap();
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&key, &path);
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
key,
|
|
||||||
previous,
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestConfigDirGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Some(previous) = &self.previous {
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&self.key, previous);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unsafe {
|
|
||||||
env::remove_var(&self.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROBE_AGENT: &str = "timeout-probe";
|
const PROBE_AGENT: &str = "timeout-probe";
|
||||||
|
|
||||||
@@ -2193,7 +2612,7 @@ nodes:
|
|||||||
eprintln!("skipping: bash or python3 not available");
|
eprintln!("skipping: bash or python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-executor-tests");
|
||||||
materialize_probe_agent(5.0);
|
materialize_probe_agent(5.0);
|
||||||
let ws = TestWorkspace::new();
|
let ws = TestWorkspace::new();
|
||||||
// The abort must land after the worker tasks exist (their retirement
|
// The abort must land after the worker tasks exist (their retirement
|
||||||
|
|||||||
+101
-9
@@ -12,6 +12,7 @@ use crate::function::ToolResult;
|
|||||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||||
use crate::function::jobs::reap_jobs;
|
use crate::function::jobs::reap_jobs;
|
||||||
use crate::function::skill::skill_function_declarations;
|
use crate::function::skill::skill_function_declarations;
|
||||||
|
use crate::function::todo::TODO_FUNCTION_PREFIX;
|
||||||
use crate::utils::AbortSignal;
|
use crate::utils::AbortSignal;
|
||||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||||
use log::warn;
|
use log::warn;
|
||||||
@@ -38,7 +39,15 @@ impl LlmNodeExecutor {
|
|||||||
parent_ctx: &mut RequestContext,
|
parent_ctx: &mut RequestContext,
|
||||||
abort: &AbortSignal,
|
abort: &AbortSignal,
|
||||||
) -> Result<LlmExecutionOutcome> {
|
) -> Result<LlmExecutionOutcome> {
|
||||||
let result = run(node_id, node, state_manager, parent_ctx, abort).await;
|
let result = run(
|
||||||
|
node_id,
|
||||||
|
node,
|
||||||
|
state_manager,
|
||||||
|
parent_ctx,
|
||||||
|
abort,
|
||||||
|
&mut default_completion_runner(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
finish(node, state_manager, parent_ctx, result, abort).await
|
finish(node, state_manager, parent_ctx, result, abort).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,12 +103,15 @@ fn outcome_from(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The runner is a parameter for the same reason `finish` is split out:
|
||||||
|
/// tests drive the node scope without a model.
|
||||||
async fn run(
|
async fn run(
|
||||||
node_id: &str,
|
node_id: &str,
|
||||||
node: &LlmNode,
|
node: &LlmNode,
|
||||||
state_manager: &mut StateManager,
|
state_manager: &mut StateManager,
|
||||||
parent_ctx: &mut RequestContext,
|
parent_ctx: &mut RequestContext,
|
||||||
abort: &AbortSignal,
|
abort: &AbortSignal,
|
||||||
|
runner: &mut CompletionRunner<'_>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let mut instructions: Option<String> = match &node.instructions {
|
let mut instructions: Option<String> = match &node.instructions {
|
||||||
Some(s) => Some(
|
Some(s) => Some(
|
||||||
@@ -202,14 +214,11 @@ async fn run(
|
|||||||
node.mcp_tools.clone().map(|map| (node_id.to_string(), map)),
|
node.mcp_tools.clone().map(|map| (node_id.to_string(), map)),
|
||||||
);
|
);
|
||||||
parent_ctx.refresh_mcp_tool_filters();
|
parent_ctx.refresh_mcp_tool_filters();
|
||||||
let result = bounded_run(
|
// Inside the node, user__/todo__ builtins require explicit opt-in via the
|
||||||
node,
|
// node's `tools:` list; mem::replace so nested execution restores cleanly.
|
||||||
&prompt,
|
let saved_in_node = std::mem::replace(&mut parent_ctx.in_graph_llm_node, true);
|
||||||
parent_ctx,
|
let result = bounded_run(node, &prompt, parent_ctx, abort, runner).await;
|
||||||
abort,
|
parent_ctx.in_graph_llm_node = saved_in_node;
|
||||||
&mut default_completion_runner(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
parent_ctx.role = saved_role;
|
parent_ctx.role = saved_role;
|
||||||
let node_jobs =
|
let node_jobs =
|
||||||
std::mem::replace(&mut parent_ctx.node_job_scope, saved_job_scope).unwrap_or_default();
|
std::mem::replace(&mut parent_ctx.node_job_scope, saved_job_scope).unwrap_or_default();
|
||||||
@@ -477,6 +486,14 @@ fn validate_tools_subset(
|
|||||||
.map(|d| d.name.as_str())
|
.map(|d| d.name.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
for name in regular {
|
for name in regular {
|
||||||
|
if name.starts_with(TODO_FUNCTION_PREFIX) {
|
||||||
|
bail!(
|
||||||
|
"llm node cannot enable '{name}': todo tools drive chat turn loops \
|
||||||
|
(auto_continue), which llm nodes don't have. Node continuation is \
|
||||||
|
owned by the node loop, max_iterations, and graph edges. For driven \
|
||||||
|
multi-step work, use an agent node whose agent sets auto_continue: true."
|
||||||
|
);
|
||||||
|
}
|
||||||
if !known.contains(name.as_str()) {
|
if !known.contains(name.as_str()) {
|
||||||
let mut avail: Vec<&str> = known.iter().copied().collect();
|
let mut avail: Vec<&str> = known.iter().copied().collect();
|
||||||
avail.sort();
|
avail.sort();
|
||||||
@@ -786,6 +803,24 @@ mod tests {
|
|||||||
assert!(mcp.is_empty());
|
assert!(mcp.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_tools_subset_rejects_todo_tools() {
|
||||||
|
let mut ctx = plain_ctx();
|
||||||
|
let mut agent = Agent::test_new(AgentConfig {
|
||||||
|
auto_continue: true,
|
||||||
|
..AgentConfig::default()
|
||||||
|
});
|
||||||
|
agent.functions_mut().append_todo_functions();
|
||||||
|
ctx.agent = Some(agent);
|
||||||
|
|
||||||
|
let err = validate_tools_subset(&["todo__init".to_string()], &[], &ctx).unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("todo tools drive chat turn loops"),
|
||||||
|
"expected the todo teaching error, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zero_timeout_resolves_to_no_bound() {
|
fn zero_timeout_resolves_to_no_bound() {
|
||||||
assert!(Some(0u64).and_then(wall_clock).is_none());
|
assert!(Some(0u64).and_then(wall_clock).is_none());
|
||||||
@@ -1205,4 +1240,61 @@ mod tests {
|
|||||||
"{captured}"
|
"{captured}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn node_scope_flag_is_set_during_the_run_and_restored_on_ok() {
|
||||||
|
let node = node_with(None);
|
||||||
|
let mut state = manager_with(&[]);
|
||||||
|
let mut ctx = plain_ctx();
|
||||||
|
ctx.agent = Some(Agent::test_new(AgentConfig::default()));
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
|
||||||
|
let out = run(
|
||||||
|
"n",
|
||||||
|
&node,
|
||||||
|
&mut state,
|
||||||
|
&mut ctx,
|
||||||
|
&abort,
|
||||||
|
&mut completion_runner(|_input, ctx, _abort| {
|
||||||
|
assert!(
|
||||||
|
ctx.in_graph_llm_node,
|
||||||
|
"flag must be set while the node runs"
|
||||||
|
);
|
||||||
|
boxed_completion(async { Ok(("done".to_string(), vec![])) })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out, "done");
|
||||||
|
assert!(!ctx.in_graph_llm_node, "flag must not outlive the node");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn node_scope_flag_restores_prior_value_when_the_run_fails() {
|
||||||
|
let node = node_with(None);
|
||||||
|
let mut state = manager_with(&[]);
|
||||||
|
let mut ctx = plain_ctx();
|
||||||
|
ctx.agent = Some(Agent::test_new(AgentConfig::default()));
|
||||||
|
ctx.in_graph_llm_node = true;
|
||||||
|
let abort = create_abort_signal();
|
||||||
|
|
||||||
|
run(
|
||||||
|
"n",
|
||||||
|
&node,
|
||||||
|
&mut state,
|
||||||
|
&mut ctx,
|
||||||
|
&abort,
|
||||||
|
&mut completion_runner(|_input, _ctx, _abort| {
|
||||||
|
boxed_completion(async { Err(anyhow!("boom")) })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("the runner failure must surface");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ctx.in_graph_llm_node,
|
||||||
|
"a nested run must restore the outer scope's flag"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+11
-51
@@ -497,6 +497,8 @@ mod tests {
|
|||||||
max_concurrent_agents: None,
|
max_concurrent_agents: None,
|
||||||
max_agent_depth: None,
|
max_agent_depth: None,
|
||||||
global_tools: Vec::new(),
|
global_tools: Vec::new(),
|
||||||
|
hooks: Default::default(),
|
||||||
|
global_hooks: Vec::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
mcp_tools: None,
|
mcp_tools: None,
|
||||||
skills_enabled: None,
|
skills_enabled: None,
|
||||||
@@ -744,14 +746,12 @@ mod chain_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
use crate::config::{AppState, Role, WorkingMode};
|
use crate::config::{AppState, Role, WorkingMode};
|
||||||
use crate::utils::{AbortSignal, create_abort_signal, get_env_name, temp_file};
|
use crate::utils::{AbortSignal, create_abort_signal, temp_file};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn cmd_available(name: &str) -> bool {
|
fn cmd_available(name: &str) -> bool {
|
||||||
which::which(name).is_ok()
|
which::which(name).is_ok()
|
||||||
@@ -1737,47 +1737,7 @@ nodes:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TestConfigDirGuard {
|
use crate::testing::TestConfigDirGuard;
|
||||||
key: String,
|
|
||||||
previous: Option<std::ffi::OsString>,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestConfigDirGuard {
|
|
||||||
fn new() -> Self {
|
|
||||||
let key = get_env_name("config_dir");
|
|
||||||
let previous = env::var_os(&key);
|
|
||||||
let unique = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let path = env::temp_dir().join(format!("coyote-graph-map-tests-{unique}"));
|
|
||||||
fs::create_dir_all(&path).unwrap();
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&key, &path);
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
key,
|
|
||||||
previous,
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestConfigDirGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Some(previous) = &self.previous {
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&self.key, previous);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unsafe {
|
|
||||||
env::remove_var(&self.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROBE_AGENT: &str = "timeout-probe";
|
const PROBE_AGENT: &str = "timeout-probe";
|
||||||
|
|
||||||
@@ -2029,7 +1989,7 @@ nodes:
|
|||||||
eprintln!("skipping: python3 not available");
|
eprintln!("skipping: python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-map-tests");
|
||||||
materialize_probe_agent(0.0);
|
materialize_probe_agent(0.0);
|
||||||
let h = Harness::new(&flagged_probe_graph(None));
|
let h = Harness::new(&flagged_probe_graph(None));
|
||||||
let peers = manual_peer("worker");
|
let peers = manual_peer("worker");
|
||||||
@@ -2055,7 +2015,7 @@ nodes:
|
|||||||
eprintln!("skipping: python3 not available");
|
eprintln!("skipping: python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-map-tests");
|
||||||
materialize_probe_agent(0.0);
|
materialize_probe_agent(0.0);
|
||||||
let ws = TestWorkspace::new();
|
let ws = TestWorkspace::new();
|
||||||
let yaml = format!(
|
let yaml = format!(
|
||||||
@@ -2105,7 +2065,7 @@ nodes:
|
|||||||
eprintln!("skipping: python3 not available");
|
eprintln!("skipping: python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-map-tests");
|
||||||
materialize_probe_agent(5.0);
|
materialize_probe_agent(5.0);
|
||||||
let h = Harness::new(&flagged_probe_graph(Some(1)));
|
let h = Harness::new(&flagged_probe_graph(Some(1)));
|
||||||
let (registry, assignments) = h.provision("worker", 2);
|
let (registry, assignments) = h.provision("worker", 2);
|
||||||
@@ -2142,7 +2102,7 @@ nodes:
|
|||||||
eprintln!("skipping: python3 not available");
|
eprintln!("skipping: python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-map-tests");
|
||||||
materialize_probe_agent(1.5);
|
materialize_probe_agent(1.5);
|
||||||
let h = Harness::new(&flagged_probe_graph(Some(0)));
|
let h = Harness::new(&flagged_probe_graph(Some(0)));
|
||||||
let peers = manual_peer("worker");
|
let peers = manual_peer("worker");
|
||||||
@@ -2165,7 +2125,7 @@ nodes:
|
|||||||
eprintln!("skipping: python3 not available");
|
eprintln!("skipping: python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-map-tests");
|
||||||
materialize_probe_agent(0.0);
|
materialize_probe_agent(0.0);
|
||||||
let h = Harness::new(&format!(
|
let h = Harness::new(&format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -2270,7 +2230,7 @@ nodes:
|
|||||||
eprintln!("skipping: python3 not available");
|
eprintln!("skipping: python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-map-tests");
|
||||||
materialize_echo_inputs_agent();
|
materialize_echo_inputs_agent();
|
||||||
let parent = echo_inputs_parent_graph(" inputs:\n width: \"{{n}}\"\n");
|
let parent = echo_inputs_parent_graph(" inputs:\n width: \"{{n}}\"\n");
|
||||||
|
|
||||||
@@ -2289,7 +2249,7 @@ nodes:
|
|||||||
eprintln!("skipping: python3 not available");
|
eprintln!("skipping: python3 not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new("graph-map-tests");
|
||||||
materialize_echo_inputs_agent();
|
materialize_echo_inputs_agent();
|
||||||
let parent = echo_inputs_parent_graph("");
|
let parent = echo_inputs_parent_graph("");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::config::AgentVariable;
|
use crate::config::AgentVariable;
|
||||||
|
use crate::hooks::HooksMap;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -43,6 +44,12 @@ pub struct Graph {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_tools: Vec<String>,
|
pub global_tools: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub hooks: HooksMap,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_hooks: Vec<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mcp_servers: Vec<String>,
|
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]
|
#[test]
|
||||||
fn agent_node_teammates_parses_and_defaults_false() {
|
fn agent_node_teammates_parses_and_defaults_false() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use super::types::{ConcurrencyCap, Graph, NextTargets, Node, NodeType};
|
|||||||
use crate::client::{Model, ModelType};
|
use crate::client::{Model, ModelType};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::config::{Agent, AppConfig, paths};
|
use crate::config::{Agent, AppConfig, paths};
|
||||||
|
use crate::function::todo::TODO_FUNCTION_PREFIX;
|
||||||
|
use crate::function::user_interaction::USER_FUNCTION_PREFIX;
|
||||||
use crate::rag::{GraphRagConfig, RagData};
|
use crate::rag::{GraphRagConfig, RagData};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||||
@@ -179,6 +181,8 @@ impl GraphValidator {
|
|||||||
self.validate_max_attempts(graph, &mut result);
|
self.validate_max_attempts(graph, &mut result);
|
||||||
self.validate_fallback_capture(graph, &mut result);
|
self.validate_fallback_capture(graph, &mut result);
|
||||||
self.validate_output_schema_properties(graph, &mut result);
|
self.validate_output_schema_properties(graph, &mut result);
|
||||||
|
self.validate_structured_output_user_tools(graph, &mut result);
|
||||||
|
self.validate_llm_node_todo_tools(graph, &mut result);
|
||||||
self.validate_max_concurrency(graph, &mut result);
|
self.validate_max_concurrency(graph, &mut result);
|
||||||
self.validate_max_concurrency_template(graph, &mut result);
|
self.validate_max_concurrency_template(graph, &mut result);
|
||||||
self.validate_orchestration_limits(graph, &mut result);
|
self.validate_orchestration_limits(graph, &mut result);
|
||||||
@@ -458,6 +462,51 @@ impl GraphValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_structured_output_user_tools(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||||
|
for (node_id, node) in &graph.nodes {
|
||||||
|
let NodeType::Llm(llm) = &node.node_type else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if llm.output_schema.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let exposes_user_tools = llm.tools.as_ref().is_some_and(|tools| {
|
||||||
|
tools
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.trim().starts_with(USER_FUNCTION_PREFIX))
|
||||||
|
});
|
||||||
|
if exposes_user_tools {
|
||||||
|
result.warning(ValidationError::with_node(
|
||||||
|
node_id,
|
||||||
|
"structured-output llm node exposes interactive user tools (`user__*`); \
|
||||||
|
in headless runs a user__ call blocks the node as an escalation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_llm_node_todo_tools(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||||
|
for (node_id, node) in &graph.nodes {
|
||||||
|
let NodeType::Llm(llm) = &node.node_type else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let exposes_todo_tools = llm.tools.as_ref().is_some_and(|tools| {
|
||||||
|
tools
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.trim().starts_with(TODO_FUNCTION_PREFIX))
|
||||||
|
});
|
||||||
|
if exposes_todo_tools {
|
||||||
|
result.error(ValidationError::with_node(
|
||||||
|
node_id,
|
||||||
|
"llm node exposes todo tools (`todo__*`): todo tools drive chat turn \
|
||||||
|
loops (auto_continue), which llm nodes don't have. Node continuation \
|
||||||
|
is owned by the node loop, max_iterations, and graph edges. For driven \
|
||||||
|
multi-step work, use an agent node whose agent sets `auto_continue: true`",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_node_references(&self, graph: &Graph, result: &mut ValidationResult) {
|
fn validate_node_references(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||||
for (node_id, node) in &graph.nodes {
|
for (node_id, node) in &graph.nodes {
|
||||||
for (target, label) in declared_targets(node) {
|
for (target, label) in declared_targets(node) {
|
||||||
@@ -1410,6 +1459,8 @@ mod tests {
|
|||||||
max_concurrent_agents: None,
|
max_concurrent_agents: None,
|
||||||
max_agent_depth: None,
|
max_agent_depth: None,
|
||||||
global_tools: Vec::new(),
|
global_tools: Vec::new(),
|
||||||
|
hooks: Default::default(),
|
||||||
|
global_hooks: Vec::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
mcp_tools: None,
|
mcp_tools: None,
|
||||||
skills_enabled: None,
|
skills_enabled: None,
|
||||||
@@ -4685,6 +4736,151 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_output_node_with_user_tools_warns() {
|
||||||
|
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
|
||||||
|
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||||
|
n.tools = Some(vec!["user__select".into()]);
|
||||||
|
}
|
||||||
|
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||||
|
|
||||||
|
let result = validator().validate(&graph);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.node_id.as_deref() == Some("l")
|
||||||
|
&& e.message.contains("interactive user tools")),
|
||||||
|
"expected user-tool warning: {:?}",
|
||||||
|
result.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_output_node_with_all_tools_does_not_warn() {
|
||||||
|
// `all` is not a valid llm-node tools entry (validate_tools_subset
|
||||||
|
// rejects it at run()), so this advisory stays silent about it.
|
||||||
|
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
|
||||||
|
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||||
|
n.tools = Some(vec!["all".into()]);
|
||||||
|
}
|
||||||
|
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||||
|
|
||||||
|
let result = validator().validate(&graph);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!result
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.message.contains("interactive user tools")),
|
||||||
|
"unexpected user-tool warning: {:?}",
|
||||||
|
result.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_output_node_without_user_tools_does_not_warn() {
|
||||||
|
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
|
||||||
|
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||||
|
n.tools = Some(vec![]);
|
||||||
|
}
|
||||||
|
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||||
|
|
||||||
|
let result = validator().validate(&graph);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!result
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.message.contains("interactive user tools")),
|
||||||
|
"unexpected user-tool warning: {:?}",
|
||||||
|
result.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_structured_node_with_user_tools_does_not_warn() {
|
||||||
|
let mut node = llm_node("l", None, Some("end"));
|
||||||
|
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||||
|
n.tools = Some(vec!["user__select".into()]);
|
||||||
|
}
|
||||||
|
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||||
|
|
||||||
|
let result = validator().validate(&graph);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!result
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.message.contains("interactive user tools")),
|
||||||
|
"unexpected user-tool warning: {:?}",
|
||||||
|
result.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_output_node_with_todo_tools_errors() {
|
||||||
|
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
|
||||||
|
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||||
|
n.tools = Some(vec!["todo__init".into()]);
|
||||||
|
}
|
||||||
|
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||||
|
|
||||||
|
let result = validator().validate(&graph);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.node_id.as_deref() == Some("l")
|
||||||
|
&& e.message.contains("todo tools drive chat turn loops")),
|
||||||
|
"expected todo-tool error: {:?}",
|
||||||
|
result.errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_structured_node_with_todo_tools_errors() {
|
||||||
|
let mut node = llm_node("l", None, Some("end"));
|
||||||
|
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||||
|
n.tools = Some(vec!["todo__pause".into()]);
|
||||||
|
}
|
||||||
|
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||||
|
|
||||||
|
let result = validator().validate(&graph);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.node_id.as_deref() == Some("l")
|
||||||
|
&& e.message.contains("todo tools drive chat turn loops")),
|
||||||
|
"expected todo-tool error: {:?}",
|
||||||
|
result.errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn llm_node_without_todo_tools_does_not_error() {
|
||||||
|
let mut node = llm_node("l", None, Some("end"));
|
||||||
|
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||||
|
n.tools = Some(vec!["user__select".into()]);
|
||||||
|
}
|
||||||
|
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||||
|
|
||||||
|
let result = validator().validate(&graph);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!result
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.message.contains("todo tools drive chat turn loops")),
|
||||||
|
"unexpected todo-tool error: {:?}",
|
||||||
|
result.errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fallback_with_output_schema_and_empty_state_updates_warns() {
|
fn fallback_with_output_schema_and_empty_state_updates_warns() {
|
||||||
let mut node = llm_with_output_schema("l", &["summary"], Some("end"));
|
let mut node = llm_with_output_schema("l", &["summary"], Some("end"));
|
||||||
|
|||||||
+3103
File diff suppressed because it is too large.
Load diff
+159
-37
@@ -4,6 +4,7 @@ mod client;
|
|||||||
mod config;
|
mod config;
|
||||||
mod function;
|
mod function;
|
||||||
mod graph;
|
mod graph;
|
||||||
|
mod hooks;
|
||||||
mod rag;
|
mod rag;
|
||||||
mod render;
|
mod render;
|
||||||
mod repl;
|
mod repl;
|
||||||
@@ -22,7 +23,8 @@ extern crate log;
|
|||||||
|
|
||||||
use crate::cli::Cli;
|
use crate::cli::Cli;
|
||||||
use crate::client::{
|
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::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
@@ -35,7 +37,7 @@ use crate::config::{memory, paths};
|
|||||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||||
use crate::mcp::McpServersConfig;
|
use crate::mcp::McpServersConfig;
|
||||||
use crate::render::{prompt_theme, render_error};
|
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::utils::*;
|
||||||
use crate::vault::{Vault, interpolate_secrets};
|
use crate::vault::{Vault, interpolate_secrets};
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
@@ -56,8 +58,37 @@ use std::path::PathBuf;
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::{env, fs, process, sync::Arc};
|
use std::{env, fs, process, sync::Arc};
|
||||||
|
|
||||||
#[tokio::main]
|
const MAIN_STACK_SIZE: usize = 16 * 1024 * 1024;
|
||||||
async fn main() -> Result<()> {
|
|
||||||
|
/// Bootstraps the async entry point on a dedicated thread with an explicit
|
||||||
|
/// large stack instead of `#[tokio::main]`, which would poll the root
|
||||||
|
/// future on the (small, non-configurable) OS main thread. Everything else
|
||||||
|
/// matches the macro expansion: same multi-thread runtime flavor, same
|
||||||
|
/// `enable_all()` driver set (so `tokio::signal::ctrl_c()` keeps working),
|
||||||
|
/// same `block_on` of the async body, and the `Result` propagates
|
||||||
|
/// unchanged. Worker threads get the same headroom because spawned tasks
|
||||||
|
/// (graph nodes, subagents) embed the same class of giant state machine.
|
||||||
|
/// The thread is named "main" and panics are re-raised on the real main
|
||||||
|
/// thread without re-invoking the panic hook, keeping panic output and the
|
||||||
|
/// exit code identical to the direct arrangement.
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("main".into())
|
||||||
|
.stack_size(MAIN_STACK_SIZE)
|
||||||
|
.spawn(|| {
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.thread_stack_size(MAIN_STACK_SIZE)
|
||||||
|
.build()
|
||||||
|
.expect("failed to build the tokio runtime")
|
||||||
|
.block_on(async_main())
|
||||||
|
})
|
||||||
|
.expect("failed to spawn the main bootstrap thread")
|
||||||
|
.join()
|
||||||
|
.unwrap_or_else(|panic| std::panic::resume_unwind(panic))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn async_main() -> Result<()> {
|
||||||
load_env_file()?;
|
load_env_file()?;
|
||||||
CompleteEnv::with_factory(Cli::command).complete();
|
CompleteEnv::with_factory(Cli::command).complete();
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@@ -111,6 +142,15 @@ async fn main() -> Result<()> {
|
|||||||
|| cli.list_macros
|
|| cli.list_macros
|
||||||
|| cli.list_skills
|
|| cli.list_skills
|
||||||
|| cli.list_sessions;
|
|| cli.list_sessions;
|
||||||
|
let readout_flag = (cli.info
|
||||||
|
|| cli.list_models
|
||||||
|
|| cli.list_roles
|
||||||
|
|| cli.list_agents
|
||||||
|
|| cli.list_rags
|
||||||
|
|| cli.list_macros
|
||||||
|
|| cli.list_skills
|
||||||
|
|| cli.list_sessions)
|
||||||
|
&& !cli.sync_models;
|
||||||
let vault_flags = cli.add_secret.is_some()
|
let vault_flags = cli.add_secret.is_some()
|
||||||
|| cli.get_secret.is_some()
|
|| cli.get_secret.is_some()
|
||||||
|| cli.update_secret.is_some()
|
|| cli.update_secret.is_some()
|
||||||
@@ -138,7 +178,18 @@ async fn main() -> Result<()> {
|
|||||||
return config::list_installed_bundles();
|
return config::list_installed_bundles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mcp_inspect =
|
||||||
|
cli.mcp_list && cli.mcp_get.is_none() && cli.mcp_remove.is_none() && cli.mcp_add.is_none();
|
||||||
|
let vault_inspect = cli.list_secrets
|
||||||
|
&& cli.add_secret.is_none()
|
||||||
|
&& cli.get_secret.is_none()
|
||||||
|
&& cli.update_secret.is_none()
|
||||||
|
&& cli.delete_secret.is_none();
|
||||||
|
let inspection_only =
|
||||||
|
(readout_flag || vault_inspect || mcp_inspect) && cli.agent.is_none() && cli.role.is_none();
|
||||||
|
if !inspection_only {
|
||||||
install_builtins()?;
|
install_builtins()?;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(category) = cli.install_builtins {
|
if let Some(category) = cli.install_builtins {
|
||||||
return config::install_assets(category);
|
return config::install_assets(category);
|
||||||
@@ -162,7 +213,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(client_arg) = &cli.authenticate {
|
if let Some(client_arg) = &cli.authenticate {
|
||||||
let cfg = Config::load_with_interpolation(true).await?;
|
let cfg = Config::load_with_interpolation(true, false).await?;
|
||||||
let app_config = AppConfig::from_config(cfg)?;
|
let app_config = AppConfig::from_config(cfg)?;
|
||||||
let (client_name, provider) =
|
let (client_name, provider) =
|
||||||
resolve_oauth_client(client_arg.as_deref(), &app_config.clients)?;
|
resolve_oauth_client(client_arg.as_deref(), &app_config.clients)?;
|
||||||
@@ -171,7 +222,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(server_name) = &cli.auth_mcp {
|
if let Some(server_name) = &cli.auth_mcp {
|
||||||
let cfg = Config::load_with_interpolation(true).await?;
|
let cfg = Config::load_with_interpolation(true, false).await?;
|
||||||
let app_config = AppConfig::from_config(cfg)?;
|
let app_config = AppConfig::from_config(cfg)?;
|
||||||
let vault = Vault::init(&app_config)?;
|
let vault = Vault::init(&app_config)?;
|
||||||
let mcp_path = paths::mcp_config_file();
|
let mcp_path = paths::mcp_config_file();
|
||||||
@@ -223,9 +274,17 @@ async fn main() -> Result<()> {
|
|||||||
let mcp_action =
|
let mcp_action =
|
||||||
cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some();
|
cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some();
|
||||||
if mcp_action {
|
if mcp_action {
|
||||||
let cfg = Config::load_with_interpolation(true).await?;
|
let cfg = Config::load_with_interpolation(true, mcp_inspect).await?;
|
||||||
let app_config = AppConfig::from_config(cfg)?;
|
let app_config = if mcp_inspect {
|
||||||
let vault = Vault::init(&app_config)?;
|
AppConfig::from_config_lenient(cfg)?
|
||||||
|
} else {
|
||||||
|
AppConfig::from_config(cfg)?
|
||||||
|
};
|
||||||
|
let vault = if mcp_inspect {
|
||||||
|
Vault::init_lenient(&app_config)
|
||||||
|
} else {
|
||||||
|
Vault::init(&app_config)?
|
||||||
|
};
|
||||||
|
|
||||||
mcp::manage::handle(&cli, &vault)?;
|
mcp::manage::handle(&cli, &vault)?;
|
||||||
|
|
||||||
@@ -233,16 +292,24 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if vault_flags {
|
if vault_flags {
|
||||||
let cfg = Config::load_with_interpolation(true).await?;
|
let cfg = Config::load_with_interpolation(true, vault_inspect).await?;
|
||||||
let app_config = AppConfig::from_config(cfg)?;
|
let app_config = if vault_inspect {
|
||||||
|
AppConfig::from_config_lenient(cfg)?
|
||||||
|
} else {
|
||||||
|
AppConfig::from_config(cfg)?
|
||||||
|
};
|
||||||
let vault = Vault::init(&app_config)?;
|
let vault = Vault::init(&app_config)?;
|
||||||
return Vault::handle_vault_flags(cli, &vault);
|
return Vault::handle_vault_flags(cli, &vault);
|
||||||
}
|
}
|
||||||
|
|
||||||
let abort_signal = create_abort_signal();
|
let abort_signal = create_abort_signal();
|
||||||
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
|
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
|
||||||
let cfg = Config::load_with_interpolation(info_flag).await?;
|
let cfg = Config::load_with_interpolation(info_flag, readout_flag).await?;
|
||||||
let mut app_config = AppConfig::from_config(cfg)?;
|
let mut app_config = if readout_flag {
|
||||||
|
AppConfig::from_config_lenient(cfg)?
|
||||||
|
} else {
|
||||||
|
AppConfig::from_config(cfg)?
|
||||||
|
};
|
||||||
if cli.no_workspace_mcp {
|
if cli.no_workspace_mcp {
|
||||||
app_config.no_workspace_mcp = true;
|
app_config.no_workspace_mcp = true;
|
||||||
}
|
}
|
||||||
@@ -256,14 +323,17 @@ async fn main() -> Result<()> {
|
|||||||
log_path,
|
log_path,
|
||||||
start_mcp_servers,
|
start_mcp_servers,
|
||||||
info_flag,
|
info_flag,
|
||||||
|
inspection_only,
|
||||||
abort_signal.clone(),
|
abort_signal.clone(),
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
|
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
|
||||||
let app_config = Arc::clone(&ctx.app.config);
|
let app_config = Arc::clone(&ctx.app.config);
|
||||||
|
if !inspection_only {
|
||||||
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
|
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let app = &*ctx.app.config;
|
let app = &*ctx.app.config;
|
||||||
@@ -304,9 +374,12 @@ async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cli.list_models {
|
if cli.list_models {
|
||||||
for model in list_models(ctx.app.config.as_ref(), ModelType::Chat) {
|
let models = list_models(ctx.app.config.as_ref(), ModelType::Chat)
|
||||||
println!("{}", model.id());
|
.iter()
|
||||||
}
|
.map(|model| model.id())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
println!("{models}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if cli.list_roles {
|
if cli.list_roles {
|
||||||
@@ -386,6 +459,13 @@ async fn run(
|
|||||||
let app = Arc::clone(&ctx.app.config);
|
let app = Arc::clone(&ctx.app.config);
|
||||||
ctx.use_agent(app.as_ref(), agent, session, abort_signal.clone())
|
ctx.use_agent(app.as_ref(), agent, session, abort_signal.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
// The top-level agent bracket does NOT open here: the inspection
|
||||||
|
// flags below (--list-sessions, --info, --rebuild-rag, --init-*)
|
||||||
|
// return early without running the agent, and an open bracket on
|
||||||
|
// those paths would leak `agent.started` with no terminal event.
|
||||||
|
// It opens at the dispatch sites further down, where a run is
|
||||||
|
// actually committed, including the --macro arm, which executes
|
||||||
|
// rather than inspects.
|
||||||
} else {
|
} else {
|
||||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||||
if let Some(prompt) = &cli.temp_role {
|
if let Some(prompt) = &cli.temp_role {
|
||||||
@@ -518,13 +598,24 @@ async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(name) = &cli.macro_name {
|
if let Some(name) = &cli.macro_name {
|
||||||
macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await?;
|
ctx.top_level_agent_started();
|
||||||
return Ok(());
|
let result = macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await;
|
||||||
|
ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
|
||||||
|
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
if cli.execute && !is_repl {
|
if cli.execute && !is_repl {
|
||||||
|
// Dispatch committed: open the top-level agent bracket only now,
|
||||||
|
// 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?;
|
let input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
|
||||||
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await?;
|
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await
|
||||||
return Ok(());
|
}
|
||||||
|
.await;
|
||||||
|
ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
|
||||||
|
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -542,11 +633,23 @@ async fn run(
|
|||||||
return acp::run_acp_server(ctx, abort_signal).await;
|
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 {
|
match is_repl {
|
||||||
false => {
|
false => {
|
||||||
|
let result = async {
|
||||||
let mut input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
|
let mut input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
|
||||||
input.use_embeddings(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(), Some(&abort_signal));
|
||||||
|
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||||
|
result
|
||||||
}
|
}
|
||||||
true => {
|
true => {
|
||||||
if !*IS_STDOUT_TERMINAL {
|
if !*IS_STDOUT_TERMINAL {
|
||||||
@@ -563,6 +666,17 @@ async fn start_directive(
|
|||||||
input: Input,
|
input: Input,
|
||||||
code_mode: bool,
|
code_mode: bool,
|
||||||
abort_signal: AbortSignal,
|
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<()> {
|
) -> Result<()> {
|
||||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||||
ctx.session_abort = Some(abort_signal.clone());
|
ctx.session_abort = Some(abort_signal.clone());
|
||||||
@@ -638,22 +752,10 @@ async fn shell_execute(
|
|||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||||
let client = input.create_client()?;
|
// Each revision round is its own turn: the recursive 'r' call re-enters.
|
||||||
ctx.before_chat_completion(&input)?;
|
let bracket = TurnBracket::enter(ctx);
|
||||||
let (eval_str, _) = call_chat_completions(
|
let result = shell_execute_turn(ctx, &input, abort_signal.clone()).await;
|
||||||
&input,
|
let (client, eval_str) = bracket.finish(ctx, &abort_signal, result)?;
|
||||||
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");
|
|
||||||
}
|
|
||||||
if app.dry_run {
|
if app.dry_run {
|
||||||
app.print_markdown(&eval_str)?;
|
app.print_markdown(&eval_str)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -679,6 +781,8 @@ async fn shell_execute(
|
|||||||
if code == 0 && app.save_shell_history {
|
if code == 0 && app.save_shell_history {
|
||||||
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
||||||
}
|
}
|
||||||
|
ctx.top_level_agent_finished(None, None);
|
||||||
|
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||||
process::exit(code);
|
process::exit(code);
|
||||||
}
|
}
|
||||||
'r' => {
|
'r' => {
|
||||||
@@ -726,6 +830,24 @@ async fn shell_execute(
|
|||||||
Ok(())
|
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(
|
async fn create_input(
|
||||||
ctx: &RequestContext,
|
ctx: &RequestContext,
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
|
|||||||
+388
-12
@@ -13,6 +13,7 @@ mod splitter;
|
|||||||
use self::graph::{KnowledgeGraph, extract_entities};
|
use self::graph::{KnowledgeGraph, extract_entities};
|
||||||
use self::provider::RagProvider;
|
use self::provider::RagProvider;
|
||||||
use self::providers::{DuckDbProvider, QdrantProvider, YamlProvider};
|
use self::providers::{DuckDbProvider, QdrantProvider, YamlProvider};
|
||||||
|
use crate::hooks::RagSyncHooks;
|
||||||
use crate::sandbox::mcp_credentials;
|
use crate::sandbox::mcp_credentials;
|
||||||
use crate::vault::{Vault, interpolate_secrets};
|
use crate::vault::{Vault, interpolate_secrets};
|
||||||
|
|
||||||
@@ -144,6 +145,7 @@ impl Rag {
|
|||||||
doc_paths: &[String],
|
doc_paths: &[String],
|
||||||
config: &RagInitConfig,
|
config: &RagInitConfig,
|
||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
|
sync_hooks: RagSyncHooks,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
if doc_paths.is_empty() {
|
if doc_paths.is_empty() {
|
||||||
bail!("Cannot build RAG knowledge base '{name}' with no documents");
|
bail!("Cannot build RAG knowledge base '{name}' with no documents");
|
||||||
@@ -154,15 +156,27 @@ impl Rag {
|
|||||||
let mut rag = Self::create(app, name, save_path, data).await?;
|
let mut rag = Self::create(app, name, save_path, data).await?;
|
||||||
let loaders = app.document_loaders.clone();
|
let loaders = app.document_loaders.clone();
|
||||||
let (spinner, spinner_rx) = Spinner::create("");
|
let (spinner, spinner_rx) = Spinner::create("");
|
||||||
abortable_run_with_spinner_rx(
|
sync_hooks.fire_started(&rag.name, &rag.path);
|
||||||
|
let synced = abortable_run_with_spinner_rx(
|
||||||
rag.sync_documents(doc_paths, true, false, loaders, Some(spinner)),
|
rag.sync_documents(doc_paths, true, false, loaders, Some(spinner)),
|
||||||
spinner_rx,
|
spinner_rx,
|
||||||
abort_signal,
|
abort_signal,
|
||||||
)
|
)
|
||||||
.await?;
|
.await;
|
||||||
if rag.save()? {
|
if let Err(err) = synced {
|
||||||
println!("✓ Saved RAG to '{}'.", save_path.display());
|
sync_hooks.fire_failed(&rag.name, &rag.path, &err);
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match rag.save() {
|
||||||
|
Ok(true) => println!("✓ Saved RAG to '{}'.", save_path.display()),
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
sync_hooks.fire_failed(&rag.name, &rag.path, &err);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count());
|
||||||
Ok(rag)
|
Ok(rag)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +275,7 @@ impl Rag {
|
|||||||
doc_paths: &[String],
|
doc_paths: &[String],
|
||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
prompt_for_driver: bool,
|
prompt_for_driver: bool,
|
||||||
|
sync_hooks: RagSyncHooks,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
if !*IS_STDOUT_TERMINAL {
|
if !*IS_STDOUT_TERMINAL {
|
||||||
bail!("Failed to init rag in non-interactive mode");
|
bail!("Failed to init rag in non-interactive mode");
|
||||||
@@ -305,15 +320,27 @@ impl Rag {
|
|||||||
};
|
};
|
||||||
let loaders = app.document_loaders.clone();
|
let loaders = app.document_loaders.clone();
|
||||||
let (spinner, spinner_rx) = Spinner::create("");
|
let (spinner, spinner_rx) = Spinner::create("");
|
||||||
abortable_run_with_spinner_rx(
|
sync_hooks.fire_started(&rag.name, &rag.path);
|
||||||
|
let synced = abortable_run_with_spinner_rx(
|
||||||
rag.sync_documents(&paths, true, false, loaders, Some(spinner)),
|
rag.sync_documents(&paths, true, false, loaders, Some(spinner)),
|
||||||
spinner_rx,
|
spinner_rx,
|
||||||
abort_signal,
|
abort_signal,
|
||||||
)
|
)
|
||||||
.await?;
|
.await;
|
||||||
if rag.save()? {
|
if let Err(err) = synced {
|
||||||
println!("✓ Saved RAG to '{}'.", save_path.display());
|
sync_hooks.fire_failed(&rag.name, &rag.path, &err);
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match rag.save() {
|
||||||
|
Ok(true) => println!("✓ Saved RAG to '{}'.", save_path.display()),
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
sync_hooks.fire_failed(&rag.name, &rag.path, &err);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count());
|
||||||
Ok(rag)
|
Ok(rag)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,10 +663,12 @@ impl Rag {
|
|||||||
force_reingest: bool,
|
force_reingest: bool,
|
||||||
app: &AppConfig,
|
app: &AppConfig,
|
||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
|
sync_hooks: RagSyncHooks,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let loaders = app.document_loaders.clone();
|
let loaders = app.document_loaders.clone();
|
||||||
let (spinner, spinner_rx) = Spinner::create("");
|
let (spinner, spinner_rx) = Spinner::create("");
|
||||||
abortable_run_with_spinner_rx(
|
sync_hooks.fire_started(&self.name, &self.path);
|
||||||
|
let synced = abortable_run_with_spinner_rx(
|
||||||
self.sync_documents(
|
self.sync_documents(
|
||||||
document_paths,
|
document_paths,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -650,10 +679,21 @@ impl Rag {
|
|||||||
spinner_rx,
|
spinner_rx,
|
||||||
abort_signal,
|
abort_signal,
|
||||||
)
|
)
|
||||||
.await?;
|
.await;
|
||||||
if self.save()? {
|
if let Err(err) = synced {
|
||||||
println!("✓ Saved rag to '{}'.", self.path);
|
sync_hooks.fire_failed(&self.name, &self.path, &err);
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match self.save() {
|
||||||
|
Ok(true) => println!("✓ Saved rag to '{}'.", self.path),
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
sync_hooks.fire_failed(&self.name, &self.path, &err);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sync_hooks.fire_completed(&self.name, &self.path, self.file_count());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2280,6 +2320,8 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::hooks::{HookDef, HooksMap, test_sink};
|
||||||
|
use serial_test::serial;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -2313,6 +2355,340 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rag_hooks_ctx(marker: &str) -> RequestContext {
|
||||||
|
let mut hooks_map = HooksMap::default();
|
||||||
|
for event in ["rag.sync.started", "rag.sync.completed", "rag.sync.failed"] {
|
||||||
|
hooks_map.insert(
|
||||||
|
event.to_string(),
|
||||||
|
vec![HookDef {
|
||||||
|
name: format!("{marker}-{event}"),
|
||||||
|
command: "true".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut app = AppState::test_default();
|
||||||
|
app.config = Arc::new(AppConfig {
|
||||||
|
hooks: hooks_map,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rag_captures(marker: &str) -> Vec<test_sink::Capture> {
|
||||||
|
test_sink::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|capture| capture.hook_name.starts_with(marker))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buildable_data() -> RagData {
|
||||||
|
RagData {
|
||||||
|
driver: "yaml".to_string(),
|
||||||
|
// The only embedding model resolvable in tests: the model
|
||||||
|
// registry is a process-global OnceLock pre-seeded by
|
||||||
|
// `seed_model_registries` in the request_context tests.
|
||||||
|
embedding_model: "test-seeded:test-embedder".to_string(),
|
||||||
|
chunk_size: 1000,
|
||||||
|
chunk_overlap: 100,
|
||||||
|
top_k: 5,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An AppConfig whose client list can construct the "test-seeded"
|
||||||
|
/// embeddings client: init_with_config instantiates the client even
|
||||||
|
/// when a sync has nothing to embed.
|
||||||
|
fn seeded_client_app() -> AppConfig {
|
||||||
|
let mut client = ClientConfig::default();
|
||||||
|
if let ClientConfig::OpenAIConfig(config) = &mut client {
|
||||||
|
config.name = Some("test-seeded".to_string());
|
||||||
|
let mut embedder = ModelData::new("test-embedder");
|
||||||
|
embedder.model_type = "embedding".to_string();
|
||||||
|
config.models = vec![embedder];
|
||||||
|
}
|
||||||
|
AppConfig {
|
||||||
|
clients: vec![client],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_config() -> RagInitConfig {
|
||||||
|
RagInitConfig {
|
||||||
|
embedding_model: Some("test-seeded:test-embedder".to_string()),
|
||||||
|
chunk_size: Some(1000),
|
||||||
|
chunk_overlap: Some(100),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yaml_rag(name: &str, path: &Path, data: RagData) -> Rag {
|
||||||
|
Rag {
|
||||||
|
app_config: Arc::new(AppConfig::default()),
|
||||||
|
name: name.to_string(),
|
||||||
|
path: path.display().to_string(),
|
||||||
|
embedding_model: Model::new("openai", "text-embedding-3-small"),
|
||||||
|
bm25: data.build_bm25(),
|
||||||
|
provider: Box::new(YamlProvider::from_data(&data)),
|
||||||
|
node_to_docs: IndexMap::new(),
|
||||||
|
data,
|
||||||
|
last_sources: RwLock::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn refresh_document_paths_fires_started_then_completed_with_doc_count() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "rag-sync-ok-v5k";
|
||||||
|
let dir = TempDir::new("sync-hooks-ok");
|
||||||
|
let doc = dir.path.join("doc.txt");
|
||||||
|
fs::write(&doc, "hello rag hooks").unwrap();
|
||||||
|
let doc_path = doc.display().to_string();
|
||||||
|
|
||||||
|
// The prepopulated file entry must use the exact string
|
||||||
|
// sync_documents resolves, so the sync sees an unchanged corpus and
|
||||||
|
// never needs an embeddings client.
|
||||||
|
let loaders = HashMap::new();
|
||||||
|
let (_, _, _, _, local_paths) = resolve_paths(&loaders, std::slice::from_ref(&doc_path))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let resolved_path = local_paths.iter().next().unwrap().clone();
|
||||||
|
|
||||||
|
let mut data = buildable_data();
|
||||||
|
data.next_file_id = 1;
|
||||||
|
data.files.insert(
|
||||||
|
0,
|
||||||
|
RagFile {
|
||||||
|
hash: sha256("hello rag hooks"),
|
||||||
|
path: resolved_path,
|
||||||
|
documents: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let rag_path = dir.path.join("kb.yaml");
|
||||||
|
let rag_path_display = rag_path.display().to_string();
|
||||||
|
let mut rag = yaml_rag("kb", &rag_path, data);
|
||||||
|
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
|
||||||
|
|
||||||
|
rag.refresh_document_paths(
|
||||||
|
&[doc_path],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
&AppConfig::default(),
|
||||||
|
create_abort_signal(),
|
||||||
|
sync_hooks,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let captures = rag_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
|
||||||
|
assert_eq!(
|
||||||
|
captures[0].envs.get("COYOTE_RAG_NAME").map(String::as_str),
|
||||||
|
Some("kb")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
captures[0].envs.get("COYOTE_RAG_PATH").map(String::as_str),
|
||||||
|
Some(rag_path_display.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
captures[1].hook_name,
|
||||||
|
format!("{marker}-rag.sync.completed")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
captures[1]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_RAG_DOC_COUNT")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn refresh_document_paths_fires_started_then_failed_on_sync_error() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "rag-sync-fail-b8m";
|
||||||
|
let dir = TempDir::new("sync-hooks-fail");
|
||||||
|
let missing = dir.path.join("missing.txt").display().to_string();
|
||||||
|
let rag_path = dir.path.join("kb.yaml");
|
||||||
|
let mut rag = yaml_rag("kb", &rag_path, buildable_data());
|
||||||
|
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
|
||||||
|
|
||||||
|
let result = rag
|
||||||
|
.refresh_document_paths(
|
||||||
|
&[missing],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
&AppConfig::default(),
|
||||||
|
create_abort_signal(),
|
||||||
|
sync_hooks,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err(), "syncing a nonexistent document must fail");
|
||||||
|
let captures = rag_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
|
||||||
|
assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed"));
|
||||||
|
assert_eq!(
|
||||||
|
captures[1].envs.get("COYOTE_RAG_NAME").map(String::as_str),
|
||||||
|
Some("kb")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
captures[1]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_ERROR")
|
||||||
|
.is_some_and(|error| !error.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn init_with_config_fires_started_then_completed_after_save() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "rag-init-ok-q7t";
|
||||||
|
let dir = TempDir::new("init-hooks-ok");
|
||||||
|
// An empty document syncs without embedding anything, so the build
|
||||||
|
// needs no live embeddings endpoint.
|
||||||
|
let doc = dir.path.join("doc.txt");
|
||||||
|
fs::write(&doc, "").unwrap();
|
||||||
|
let save_path = dir.path.join("kb.yaml");
|
||||||
|
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
|
||||||
|
|
||||||
|
let rag = Rag::init_with_config(
|
||||||
|
&seeded_client_app(),
|
||||||
|
"kb-init",
|
||||||
|
&save_path,
|
||||||
|
&[doc.display().to_string()],
|
||||||
|
&init_config(),
|
||||||
|
create_abort_signal(),
|
||||||
|
sync_hooks,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rag.file_count(), 1);
|
||||||
|
assert!(
|
||||||
|
save_path.exists(),
|
||||||
|
"completed must only fire after the knowledge base was saved"
|
||||||
|
);
|
||||||
|
let captures = rag_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
|
||||||
|
assert_eq!(
|
||||||
|
captures[1].hook_name,
|
||||||
|
format!("{marker}-rag.sync.completed")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
captures[1]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_RAG_DOC_COUNT")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn init_with_config_fires_failed_when_save_fails() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "rag-init-save-fail-w2j";
|
||||||
|
let dir = TempDir::new("init-hooks-save-fail");
|
||||||
|
let doc = dir.path.join("doc.txt");
|
||||||
|
fs::write(&doc, "").unwrap();
|
||||||
|
// A regular file where save() expects a parent directory makes the
|
||||||
|
// save fail after a successful sync.
|
||||||
|
let blocker = dir.path.join("blocker");
|
||||||
|
fs::write(&blocker, "not a directory").unwrap();
|
||||||
|
let save_path = blocker.join("kb.yaml");
|
||||||
|
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
|
||||||
|
|
||||||
|
let result = Rag::init_with_config(
|
||||||
|
&seeded_client_app(),
|
||||||
|
"kb-init",
|
||||||
|
&save_path,
|
||||||
|
&[doc.display().to_string()],
|
||||||
|
&init_config(),
|
||||||
|
create_abort_signal(),
|
||||||
|
sync_hooks,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"an unwritable save path must fail the init"
|
||||||
|
);
|
||||||
|
let captures = rag_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
|
||||||
|
assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed"));
|
||||||
|
assert!(
|
||||||
|
captures[1]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_ERROR")
|
||||||
|
.is_some_and(|error| !error.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user-abort arm in abortable_run_with_spinner_rx only exists when
|
||||||
|
// stdout is a terminal, so a unit test cannot reach it; its
|
||||||
|
// bail!("Aborted.") surfaces through the same `synced` Err arm this
|
||||||
|
// sync error drives.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn init_with_config_fires_failed_on_sync_error() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "rag-init-fail-d4n";
|
||||||
|
let dir = TempDir::new("init-hooks-fail");
|
||||||
|
let missing = dir.path.join("missing.txt").display().to_string();
|
||||||
|
let save_path = dir.path.join("kb.yaml");
|
||||||
|
let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker));
|
||||||
|
|
||||||
|
let result = Rag::init_with_config(
|
||||||
|
&seeded_client_app(),
|
||||||
|
"kb-init",
|
||||||
|
&save_path,
|
||||||
|
&[missing],
|
||||||
|
&init_config(),
|
||||||
|
create_abort_signal(),
|
||||||
|
sync_hooks,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err(), "syncing a nonexistent document must fail");
|
||||||
|
assert!(!save_path.exists());
|
||||||
|
let captures = rag_captures(marker);
|
||||||
|
assert_eq!(captures.len(), 2, "{captures:?}");
|
||||||
|
assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started"));
|
||||||
|
assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed"));
|
||||||
|
assert!(
|
||||||
|
captures[1]
|
||||||
|
.envs
|
||||||
|
.get("COYOTE_ERROR")
|
||||||
|
.is_some_and(|error| !error.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn pure_load_fires_no_rag_sync_events() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
test_sink::drain();
|
||||||
|
let dir = TempDir::new("sync-hooks-load");
|
||||||
|
let path = dir.path.join("kb.yaml");
|
||||||
|
fs::write(&path, serde_yaml::to_string(&buildable_data()).unwrap()).unwrap();
|
||||||
|
|
||||||
|
let rag = Rag::load(&AppConfig::default(), "kb", &path).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rag.file_count(), 0);
|
||||||
|
let captures = test_sink::drain();
|
||||||
|
assert!(
|
||||||
|
captures.is_empty(),
|
||||||
|
"a pure load must dispatch no hooks: {captures:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attached_ragdata_serializes_driver_and_attached() {
|
fn attached_ragdata_serializes_driver_and_attached() {
|
||||||
let mut data = RagData {
|
let mut data = RagData {
|
||||||
|
|||||||
+1370
-35
File diff suppressed because it is too large.
Load diff
@@ -197,9 +197,23 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
|||||||
) {
|
) {
|
||||||
out.push(read_mixin(path)?);
|
out.push(read_mixin(path)?);
|
||||||
}
|
}
|
||||||
|
for agent_dir in subdirs_of(&paths::agents_data_dir()) {
|
||||||
|
for path in collect_mixins(&agent_dir.join("hooks"), &[ScanMode::Flat]) {
|
||||||
|
out.push(read_mixin(path)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
|
for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
|
||||||
out.push(read_mixin(path)?);
|
out.push(read_mixin(path)?);
|
||||||
}
|
}
|
||||||
|
for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) {
|
||||||
|
out.push(read_mixin(path)?);
|
||||||
|
}
|
||||||
|
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
|
||||||
|
out.push(read_mixin(path)?);
|
||||||
|
}
|
||||||
|
for path in collect_mixins(&paths::hooks_dir(), &[ScanMode::Flat]) {
|
||||||
|
out.push(read_mixin(path)?);
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(cwd) = env::current_dir()
|
if let Ok(cwd) = env::current_dir()
|
||||||
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
|
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
|
||||||
@@ -940,6 +954,110 @@ network:
|
|||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn discover_scans_roles_and_role_hooks_mixins() {
|
||||||
|
use crate::utils::get_env_name;
|
||||||
|
|
||||||
|
let root = unique_root("discover-roles");
|
||||||
|
let roles = root.join("roles");
|
||||||
|
fs::create_dir_all(roles.join("hooks")).unwrap();
|
||||||
|
let role_mixin = roles.join("review.sbx-mixin.yaml");
|
||||||
|
let hook_mixin = roles.join("hooks").join("notify.sbx-mixin.yaml");
|
||||||
|
fs::write(&role_mixin, "kind: mixin\n").unwrap();
|
||||||
|
fs::write(&hook_mixin, "kind: mixin\n").unwrap();
|
||||||
|
// The builtin installer's hidden manifest lives in the same dir but
|
||||||
|
// is not a mixin and must never be picked up by the flat scan.
|
||||||
|
let manifest = roles
|
||||||
|
.join("hooks")
|
||||||
|
.join(crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE);
|
||||||
|
fs::write(&manifest, "notify.sh\n").unwrap();
|
||||||
|
|
||||||
|
let config_env = get_env_name("config_dir");
|
||||||
|
let roles_env = get_env_name("roles_dir");
|
||||||
|
let prev_config = env::var_os(&config_env);
|
||||||
|
let prev_roles = env::var_os(&roles_env);
|
||||||
|
unsafe {
|
||||||
|
env::set_var(&config_env, &root);
|
||||||
|
env::set_var(&roles_env, &roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
let discovered = discover();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match prev_config {
|
||||||
|
Some(v) => env::set_var(&config_env, v),
|
||||||
|
None => env::remove_var(&config_env),
|
||||||
|
}
|
||||||
|
match prev_roles {
|
||||||
|
Some(v) => env::set_var(&roles_env, v),
|
||||||
|
None => env::remove_var(&roles_env),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let found: Vec<PathBuf> = discovered
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|mixin| mixin.path)
|
||||||
|
.collect();
|
||||||
|
assert!(found.contains(&role_mixin), "missing role mixin: {found:?}");
|
||||||
|
assert!(
|
||||||
|
found.contains(&hook_mixin),
|
||||||
|
"missing role hook mixin: {found:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!found.contains(&manifest),
|
||||||
|
"builtin manifest must not be scanned as a mixin: {found:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn discover_scans_global_and_agent_hooks_mixins_but_never_the_manifest() {
|
||||||
|
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
|
||||||
|
use crate::utils::get_env_name;
|
||||||
|
|
||||||
|
let root = unique_root("discover-hooks-dirs");
|
||||||
|
let hooks = root.join("hooks");
|
||||||
|
let agent_hooks = root.join("agents").join("researcher").join("hooks");
|
||||||
|
fs::create_dir_all(&hooks).unwrap();
|
||||||
|
fs::create_dir_all(&agent_hooks).unwrap();
|
||||||
|
let global_mixin = hooks.join("notify.sbx-mixin.yaml");
|
||||||
|
let agent_mixin = agent_hooks.join("guard.sbx-mixin.yaml");
|
||||||
|
fs::write(&global_mixin, "kind: mixin\n").unwrap();
|
||||||
|
fs::write(&agent_mixin, "kind: mixin\n").unwrap();
|
||||||
|
// The builtin installer's hidden manifest lives in both hooks dirs
|
||||||
|
// but is not a mixin and must never be picked up.
|
||||||
|
fs::write(hooks.join(BUILTIN_MANIFEST_FILE), "notify.sh\n").unwrap();
|
||||||
|
fs::write(agent_hooks.join(BUILTIN_MANIFEST_FILE), "guard.sh\n").unwrap();
|
||||||
|
|
||||||
|
let _config = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &root);
|
||||||
|
let found: Vec<PathBuf> = discover()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|mixin| mixin.path)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
found.contains(&global_mixin),
|
||||||
|
"missing global hooks mixin: {found:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
found.contains(&agent_mixin),
|
||||||
|
"missing agent hooks mixin: {found:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
found.iter().all(|path| path
|
||||||
|
.file_name()
|
||||||
|
.is_none_or(|name| name != BUILTIN_MANIFEST_FILE)),
|
||||||
|
"builtin manifest must not be scanned as a mixin: {found:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
/// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the
|
/// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the
|
||||||
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
|
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
|
||||||
/// existed, nothing scanned that shape and attaching a Qdrant RAG from
|
/// existed, nothing scanned that shape and attaching a Qdrant RAG from
|
||||||
|
|||||||
@@ -190,6 +190,17 @@ impl Supervisor {
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_active_tasks(&self) -> bool {
|
||||||
|
self.effective_active_count() > 0
|
||||||
|
|| self.active_job_count() > 0
|
||||||
|
|| self.agents().any(|agent| {
|
||||||
|
agent
|
||||||
|
.child_supervisor
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|child_sup| child_sup.read().has_active_tasks())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn max_concurrent(&self) -> usize {
|
pub fn max_concurrent(&self) -> usize {
|
||||||
self.max_concurrent
|
self.max_concurrent
|
||||||
}
|
}
|
||||||
@@ -449,6 +460,85 @@ mod tests {
|
|||||||
assert!(result.unwrap_err().to_string().contains("at capacity"));
|
assert!(result.unwrap_err().to_string().contains("at capacity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_active_tasks_false_when_empty_or_all_finished() {
|
||||||
|
let mut sup = Supervisor::new(4, 3);
|
||||||
|
assert!(!sup.has_active_tasks());
|
||||||
|
// make_handle drops its runtime, aborting the task, so the
|
||||||
|
// registered agent is already finished.
|
||||||
|
sup.register(make_handle("a1", "explore", 1)).unwrap();
|
||||||
|
assert!(!sup.has_active_tasks());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_active_tasks_true_with_running_agent() {
|
||||||
|
// Keep the runtime alive so the task is never polled and the agent
|
||||||
|
// counts as running.
|
||||||
|
let rt = Builder::new_current_thread().enable_all().build().unwrap();
|
||||||
|
let join_handle = rt.spawn(async {
|
||||||
|
Ok::<AgentResult, Error>(AgentResult {
|
||||||
|
id: "done".into(),
|
||||||
|
agent_name: "test".into(),
|
||||||
|
output: "result".into(),
|
||||||
|
exit_status: AgentExitStatus::Completed,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let running_handle = AgentHandle {
|
||||||
|
id: "a1".to_string(),
|
||||||
|
agent_name: "explore".to_string(),
|
||||||
|
depth: 1,
|
||||||
|
inbox: Arc::new(Inbox::new()),
|
||||||
|
abort_signal: create_abort_signal(),
|
||||||
|
join_handle,
|
||||||
|
child_supervisor: None,
|
||||||
|
};
|
||||||
|
let mut sup = Supervisor::new(4, 3);
|
||||||
|
sup.register(running_handle).unwrap();
|
||||||
|
assert!(sup.has_active_tasks());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_active_tasks_true_with_running_job() {
|
||||||
|
let mut sup = Supervisor::new(4, 3).with_max_concurrent_jobs(1);
|
||||||
|
sup.register(make_job("j1", create_abort_signal())).unwrap();
|
||||||
|
assert!(sup.has_active_tasks());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_active_tasks_true_with_only_grandchild_activity() {
|
||||||
|
// Keep the runtime alive so the grandchild task is never polled and
|
||||||
|
// counts as running.
|
||||||
|
let rt = Builder::new_current_thread().enable_all().build().unwrap();
|
||||||
|
let join_handle = rt.spawn(async {
|
||||||
|
Ok::<AgentResult, Error>(AgentResult {
|
||||||
|
id: "done".into(),
|
||||||
|
agent_name: "test".into(),
|
||||||
|
output: "result".into(),
|
||||||
|
exit_status: AgentExitStatus::Completed,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let grandchild = AgentHandle {
|
||||||
|
id: "g1".to_string(),
|
||||||
|
agent_name: "worker".to_string(),
|
||||||
|
depth: 2,
|
||||||
|
inbox: Arc::new(Inbox::new()),
|
||||||
|
abort_signal: create_abort_signal(),
|
||||||
|
join_handle,
|
||||||
|
child_supervisor: None,
|
||||||
|
};
|
||||||
|
let mut child_sup = Supervisor::new(4, 3);
|
||||||
|
child_sup.register(grandchild).unwrap();
|
||||||
|
|
||||||
|
// make_handle's direct task is already finished, so the only live
|
||||||
|
// work sits one supervisor level down.
|
||||||
|
let mut parent_handle = make_handle("a1", "explore", 1);
|
||||||
|
parent_handle.child_supervisor = Some(Arc::new(RwLock::new(child_sup)));
|
||||||
|
let mut sup = Supervisor::new(4, 3);
|
||||||
|
sup.register(parent_handle).unwrap();
|
||||||
|
|
||||||
|
assert!(sup.has_active_tasks());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supervisor_register_rejects_exceeding_depth() {
|
fn supervisor_register_rejects_exceeding_depth() {
|
||||||
let mut sup = Supervisor::new(4, 2);
|
let mut sup = Supervisor::new(4, 2);
|
||||||
|
|||||||
+171
-14
@@ -1,29 +1,94 @@
|
|||||||
//! Test-only helpers shared across modules' unit tests.
|
//! Test-only helpers shared across modules' unit tests.
|
||||||
|
|
||||||
|
use crate::config::WORKSPACE_COYOTE_DIR_NAME;
|
||||||
|
use crate::utils;
|
||||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, Once, OnceLock};
|
use std::sync::{Mutex, Once, OnceLock};
|
||||||
|
|
||||||
struct WarnCollector;
|
struct TestLogCollector;
|
||||||
|
|
||||||
static WARN_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
static WARN_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
||||||
|
static DEBUG_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
||||||
|
|
||||||
/// Every warn-level message captured since the collector was installed. The
|
/// Debug capture is restricted to the modules whose tests assert on debug
|
||||||
/// buffer is process-global and shared by all tests, so assert by filtering
|
/// output so the buffer is not flooded by the rest of the crate. Matching is
|
||||||
/// for a marker unique to the test rather than on the whole buffer.
|
/// module-boundary-aware: only these exact modules or their `::` submodules
|
||||||
|
/// qualify, so a sibling like `hooks_registry` would not.
|
||||||
|
const DEBUG_TARGET_PREFIXES: [&str; 2] = [
|
||||||
|
concat!(env!("CARGO_CRATE_NAME"), "::hooks"),
|
||||||
|
concat!(env!("CARGO_CRATE_NAME"), "::config::agent"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn captures_warn(metadata: &Metadata) -> bool {
|
||||||
|
metadata.level() <= Level::Warn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn captures_module_debug(metadata: &Metadata) -> bool {
|
||||||
|
if metadata.level() != Level::Debug {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let target = metadata.target();
|
||||||
|
DEBUG_TARGET_PREFIXES.iter().any(|prefix| {
|
||||||
|
target == *prefix
|
||||||
|
|| target
|
||||||
|
.strip_prefix(prefix)
|
||||||
|
.is_some_and(|rest| rest.starts_with("::"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every warn- or error-level message captured since the collector was
|
||||||
|
/// installed. The buffer is process-global and shared by all tests, so assert
|
||||||
|
/// by filtering for a marker unique to the test rather than on the whole
|
||||||
|
/// buffer.
|
||||||
pub(crate) fn warn_messages() -> &'static Mutex<Vec<String>> {
|
pub(crate) fn warn_messages() -> &'static Mutex<Vec<String>> {
|
||||||
WARN_MESSAGES.get_or_init(Mutex::default)
|
WARN_MESSAGES.get_or_init(Mutex::default)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Log for WarnCollector {
|
/// Every debug-level message from the [`DEBUG_TARGET_PREFIXES`] modules
|
||||||
|
/// captured since the collector was installed. Same marker-filtering
|
||||||
|
/// discipline as [`warn_messages`].
|
||||||
|
pub(crate) fn debug_messages() -> &'static Mutex<Vec<String>> {
|
||||||
|
DEBUG_MESSAGES.get_or_init(Mutex::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of everything in [`warn_messages`], recovering from poisoning the
|
||||||
|
/// same way the collector itself does.
|
||||||
|
pub(crate) fn warn_snapshot() -> Vec<String> {
|
||||||
|
snapshot_of(warn_messages())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of everything in [`debug_messages`], recovering from poisoning the
|
||||||
|
/// same way the collector itself does.
|
||||||
|
pub(crate) fn debug_snapshot() -> Vec<String> {
|
||||||
|
snapshot_of(debug_messages())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_of(buffer: &Mutex<Vec<String>>) -> Vec<String> {
|
||||||
|
buffer
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Log for TestLogCollector {
|
||||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
metadata.level() <= Level::Warn
|
captures_warn(metadata) || captures_module_debug(metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A test that panics while holding a buffer poisons its mutex; the logger
|
||||||
|
// recovers via `into_inner` so every later test can still log.
|
||||||
fn log(&self, record: &Record) {
|
fn log(&self, record: &Record) {
|
||||||
if self.enabled(record.metadata()) {
|
if captures_warn(record.metadata()) {
|
||||||
warn_messages()
|
warn_messages()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.push(record.args().to_string());
|
||||||
|
} else if captures_module_debug(record.metadata()) {
|
||||||
|
debug_messages()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
.push(record.args().to_string());
|
.push(record.args().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,13 +96,105 @@ impl Log for WarnCollector {
|
|||||||
fn flush(&self) {}
|
fn flush(&self) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs the process-wide warn collector. `log::set_logger` accepts one
|
/// Installs the process-wide log collector capturing warn-level messages and
|
||||||
/// logger per process, so every test that captures warns must install through
|
/// debug messages from the [`DEBUG_TARGET_PREFIXES`] modules.
|
||||||
/// this shared entry point.
|
/// `log::set_logger` accepts one logger per process, so every test that
|
||||||
pub(crate) fn install_warn_collector() {
|
/// captures either stream must install through this shared entry point. The
|
||||||
|
/// max level is Debug so the debug capture sees its records; warn capture is
|
||||||
|
/// unaffected.
|
||||||
|
pub(crate) fn install_log_collector() {
|
||||||
static INSTALL: Once = Once::new();
|
static INSTALL: Once = Once::new();
|
||||||
INSTALL.call_once(|| {
|
INSTALL.call_once(|| {
|
||||||
log::set_logger(&WarnCollector).expect("no other logger should be installed");
|
log::set_logger(&TestLogCollector).expect("no other logger should be installed");
|
||||||
log::set_max_level(LevelFilter::Warn);
|
log::set_max_level(LevelFilter::Debug);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets (or removes) an environment variable for the guard's lifetime and
|
||||||
|
/// restores the previous value on drop, including on panic, so a failing
|
||||||
|
/// assertion cannot leak the override into subsequent tests. Tests using it
|
||||||
|
/// must serialize (`#[serial]`) — the process environment is global.
|
||||||
|
pub(crate) struct EnvVarGuard {
|
||||||
|
key: String,
|
||||||
|
previous: Option<OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvVarGuard {
|
||||||
|
pub(crate) fn set(key: impl Into<String>, value: impl AsRef<OsStr>) -> Self {
|
||||||
|
let key = key.into();
|
||||||
|
let previous = std::env::var_os(&key);
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(&key, value);
|
||||||
|
}
|
||||||
|
Self { key, previous }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unset(key: impl Into<String>) -> Self {
|
||||||
|
let key = key.into();
|
||||||
|
let previous = std::env::var_os(&key);
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var(&key);
|
||||||
|
}
|
||||||
|
Self { key, previous }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnvVarGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
match &self.previous {
|
||||||
|
Some(previous) => std::env::set_var(&self.key, previous),
|
||||||
|
None => std::env::remove_var(&self.key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points the config dir and the workspace config dir at fresh temp
|
||||||
|
/// directories for the guard's lifetime and removes them on drop, including
|
||||||
|
/// on panic, so the developer's real `./.coyote/` never leaks into a test.
|
||||||
|
/// The sessions-dir override is cleared too, so Global-scope session writes
|
||||||
|
/// land under the temp config dir rather than the developer's real one.
|
||||||
|
/// Tests using it must serialize (`#[serial]`) — the env vars are
|
||||||
|
/// process-global.
|
||||||
|
pub(crate) struct TestConfigDirGuard {
|
||||||
|
_env: EnvVarGuard,
|
||||||
|
_workspace_env: EnvVarGuard,
|
||||||
|
_sessions_env: EnvVarGuard,
|
||||||
|
pub(crate) path: PathBuf,
|
||||||
|
workspace_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestConfigDirGuard {
|
||||||
|
pub(crate) fn new(label: &str) -> Self {
|
||||||
|
let unique = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
||||||
|
std::fs::create_dir_all(&path).unwrap();
|
||||||
|
let workspace_path = std::env::temp_dir()
|
||||||
|
.join(format!("coyote-{label}-ws-{unique}"))
|
||||||
|
.join(WORKSPACE_COYOTE_DIR_NAME);
|
||||||
|
std::fs::create_dir_all(&workspace_path).unwrap();
|
||||||
|
Self {
|
||||||
|
_env: EnvVarGuard::set(utils::get_env_name("config_dir"), &path),
|
||||||
|
_workspace_env: EnvVarGuard::set(
|
||||||
|
utils::get_env_name("workspace_config_dir"),
|
||||||
|
&workspace_path,
|
||||||
|
),
|
||||||
|
_sessions_env: EnvVarGuard::unset(utils::get_env_name("sessions_dir")),
|
||||||
|
path,
|
||||||
|
workspace_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestConfigDirGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.path);
|
||||||
|
if let Some(root) = self.workspace_path.parent() {
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user