Compare commits
41
Commits
56ed4664cd
..
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 |
No files matched your search
@@ -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
|
||||||
|
|
||||||
+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.
|
||||||
|
|
||||||
|
|||||||
+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"]
|
||||||
@@ -144,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
|
||||||
|
|||||||
@@ -205,9 +205,26 @@ nodes:
|
|||||||
confirm the change makes the criterion true, confirm the test targets
|
confirm the change makes the criterion true, confirm the test targets
|
||||||
BEHAVIOR (not implementation echo), use ast_grep for structural claims.
|
BEHAVIOR (not implementation echo), use ast_grep for structural claims.
|
||||||
Watch for gamed evidence: a test weakened/deleted so it passes, hardcoded
|
Watch for gamed evidence: a test weakened/deleted so it passes, hardcoded
|
||||||
values shaped to the criterion's example, TODO/stub presented as done —
|
values shaped to the criterion's example, TODO/stub presented as done, a
|
||||||
|
mock expectation whose count is relaxed (`AnyTimes`, `MinTimes`, ignored
|
||||||
|
request body) AND whose matcher is permissive (`gomock.Any()`,
|
||||||
|
`mock.Anything`) — it proves a call happened, never that it carried the
|
||||||
|
criterion's values, so it is not a proving test —
|
||||||
those are PARTIAL or DIVERGED with the gaming named in the complaint.
|
those are PARTIAL or DIVERGED with the gaming named in the complaint.
|
||||||
|
|
||||||
|
Retry / redelivery / idempotency demands: before you mark a criterion PARTIAL
|
||||||
|
or UNMET for lacking a retry, re-run, or "second attempt emits nothing" spec,
|
||||||
|
classify the handler. STATEFUL — it carries something across attempts (emits
|
||||||
|
events, calls an external system, performs an unconditional write, keeps an
|
||||||
|
in-memory or ledger record) — a retry spec is a legitimate demand. STATELESS —
|
||||||
|
each attempt is a pure function of the request plus a conditional store
|
||||||
|
predicate (`WHERE status = 'pending'`, `ended_at IS NULL`, `INSERT … ON
|
||||||
|
CONFLICT DO NOTHING`) and nothing is emitted or ledgered outside that
|
||||||
|
predicate — then "retry produces no event" is a tautology of the mechanism,
|
||||||
|
not a missing behavior: demand at most one test of the predicate itself,
|
||||||
|
never a retry spec. Name the classification and its file:line evidence in
|
||||||
|
the complaint either way.
|
||||||
|
|
||||||
Execution criteria ("tests pass", "lint clean", "build succeeds") are
|
Execution criteria ("tests pass", "lint clean", "build succeeds") are
|
||||||
judged ONLY against the recorded verification runs in your prompt — you
|
judged ONLY against the recorded verification runs in your prompt — you
|
||||||
have no execution tools BY DESIGN and must never claim to have run
|
have no execution tools BY DESIGN and must never claim to have run
|
||||||
@@ -297,7 +314,10 @@ nodes:
|
|||||||
The checklist (each hit becomes one complaint with kind, file:line
|
The checklist (each hit becomes one complaint with kind, file:line
|
||||||
location, the violation, and the fix):
|
location, the violation, and the fix):
|
||||||
- **Absence** — something the PLAN requires that maps to no criterion and
|
- **Absence** — something the PLAN requires that maps to no criterion and
|
||||||
never showed up in the diff (the dog that didn't bark).
|
never showed up in the diff (the dog that didn't bark). A missing
|
||||||
|
retry/redelivery spec is an absence ONLY for a handler that carries state
|
||||||
|
or side effects across attempts; a handler that is a pure function of the
|
||||||
|
request plus a conditional store predicate owes none.
|
||||||
- **Scope drift / out-of-scope** — the diff touches what the plan's scope
|
- **Scope drift / out-of-scope** — the diff touches what the plan's scope
|
||||||
notes say not to touch, or quietly does extra unplanned work.
|
notes say not to touch, or quietly does extra unplanned work.
|
||||||
- **Interface/approach substitution** — the plan named an interface,
|
- **Interface/approach substitution** — the plan named an interface,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: |
|
|||||||
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and
|
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and
|
||||||
finishes with ONE draft PR (CI checks watched to green) plus tracked follow-up tasks. Task state
|
finishes with ONE draft PR (CI checks watched to green) plus tracked follow-up tasks. Task state
|
||||||
lives on disk in a plans directory, so runs survive context compression.
|
lives on disk in a plans directory, so runs survive context compression.
|
||||||
version: 2.5.0
|
version: 2.8.0
|
||||||
agent_session: temp
|
agent_session: temp
|
||||||
auto_continue: true
|
auto_continue: true
|
||||||
max_auto_continues: 100
|
max_auto_continues: 100
|
||||||
@@ -18,6 +18,7 @@ spawnable_agents:
|
|||||||
- oracle
|
- oracle
|
||||||
- explore
|
- explore
|
||||||
- librarian
|
- librarian
|
||||||
|
- deep-research
|
||||||
- adversary
|
- adversary
|
||||||
- probe
|
- probe
|
||||||
- review-gauntlet
|
- review-gauntlet
|
||||||
@@ -180,7 +181,9 @@ instructions: |
|
|||||||
<label>; authoritative sources preferred; return a distilled, deduplicated checklist"), put the
|
<label>; authoritative sources preferred; return a distilled, deduplicated checklist"), put the
|
||||||
returned checklist through the same accept/drop round with the user, and write the ACCEPTED
|
returned checklist through the same accept/drop round with the user, and write the ACCEPTED
|
||||||
items into the plan — as measurable acceptance criteria on the relevant tasks where possible,
|
items into the plan — as measurable acceptance criteria on the relevant tasks where possible,
|
||||||
otherwise as a checklist under `## Quality bar → Long-tail criteria`.
|
otherwise as a checklist under `## Quality bar → Long-tail criteria`. Spawn checklist
|
||||||
|
librarians with `--variables {"research_depth": "deep"}` — checklist distillation is
|
||||||
|
broad-topic research and the default standard depth (3 searches / 8 fetches) starves it.
|
||||||
|
|
||||||
Ground the breakdown in real code: fan out `explore` agents (load `parallel-research`) across
|
Ground the breakdown in real code: fan out `explore` agents (load `parallel-research`) across
|
||||||
{{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
|
{{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
|
||||||
@@ -373,6 +376,12 @@ instructions: |
|
|||||||
`## Review round N @ <full head SHA>` (lane table, verdicts, what changed since the
|
`## Review round N @ <full head SHA>` (lane table, verdicts, what changed since the
|
||||||
prior round) — never by rewriting the PR description, which carries only the stable
|
prior round) — never by rewriting the PR description, which carries only the stable
|
||||||
summary and the required digest sections; comments are the append-only review log.
|
summary and the required digest sections; comments are the append-only review log.
|
||||||
|
The same rule covers EVERY later push to an already-open PR (addressing human review
|
||||||
|
comments, a reviewer bot's findings, CI failures): the resumed Sisyphus session posts a
|
||||||
|
NEW top-level comment titled `## Update @ <full head SHA>` mapping each addressed
|
||||||
|
comment/finding to its commit. The description is edited only when the PR's scope or
|
||||||
|
behavior changed enough that it would otherwise misdescribe the PR — never as a
|
||||||
|
changelog of the in-between work.
|
||||||
- **Spawn `adversary`** with the diff base and the criteria pasted in:
|
- **Spawn `adversary`** with the diff base and the criteria pasted in:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -587,7 +596,8 @@ instructions: |
|
|||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `sisyphus` | Implement ONE task's code in {{project_dir}} (its own explore/coder/verify/review loop). One per task. |
|
| `sisyphus` | Implement ONE task's code in {{project_dir}} (its own explore/coder/verify/review loop). One per task. |
|
||||||
| `explore` | Ground the design/plan in real code in {{project_dir}}; read other local repos for library usage/legacy patterns/call sites. Fan out in parallel. |
|
| `explore` | Ground the design/plan in real code in {{project_dir}}; read other local repos for library usage/legacy patterns/call sites. Fan out in parallel. |
|
||||||
| `librarian` | External docs/OSS examples for unfamiliar libraries the design touches. |
|
| `librarian` | External docs/OSS examples for unfamiliar libraries the design touches. Design-time/broad topics: pass `--variables {"research_depth": "deep"}` (~3× budgets); default depth is for narrow lookups. One librarian per question beats one broad librarian — deep is for inherently broad questions. |
|
||||||
|
| `deep-research` | Open-ended/exhaustive research that should churn until its critique loop passes (plans sub-questions, deep librarian per question, credibility vetting). Spawn with `--variables {"autonomy": "autonomous"}` — MANDATORY, the supervised default blocks on a human approval gate; raise `reflexion_budget` (default 2) for longer churn. |
|
||||||
| `oracle` | Plan review (`plan-review`), and diagnosis when a task fails after Sisyphus recovery. |
|
| `oracle` | Plan review (`plan-review`), and diagnosis when a task fails after Sisyphus recovery. |
|
||||||
| `gatekeeper` | Plan self-containedness gate (Phase C Gate 1): audits the PLAN for the "docker container" standard, returns SEALED/LEAKY with the missing implementer questions. |
|
| `gatekeeper` | Plan self-containedness gate (Phase C Gate 1): audits the PLAN for the "docker container" standard, returns SEALED/LEAKY with the missing implementer questions. |
|
||||||
| `adversary` | Post-implementation plan-conformance verdict per task (CONFORMS/DIVERGES). |
|
| `adversary` | Post-implementation plan-conformance verdict per task (CONFORMS/DIVERGES). |
|
||||||
@@ -622,6 +632,10 @@ instructions: |
|
|||||||
an unprobeable consumer-facing change is a plan gap to fix, not a checkbox to skip.
|
an unprobeable consumer-facing change is a plan gap to fix, not a checkbox to skip.
|
||||||
- Code comments referencing the plan/design doc/phases/steps/TASK numbers → docs drift, comments
|
- Code comments referencing the plan/design doc/phases/steps/TASK numbers → docs drift, comments
|
||||||
rot; plan references live in commit messages only.
|
rot; plan references live in commit messages only.
|
||||||
|
- Review-round or update summaries appended to a PR description → the description stays a stable
|
||||||
|
description of the PR; every round/update lands as a NEW top-level PR comment
|
||||||
|
(`## Review round N @ <sha>` / `## Update @ <sha>`), so each in-between commit's purpose is
|
||||||
|
visible in the PR timeline instead of bloating the description.
|
||||||
- A `.env` file landing in any repo → only `.env.example` with placeholder keys is committable;
|
- A `.env` file landing in any repo → only `.env.example` with placeholder keys is committable;
|
||||||
`.env` stays gitignored and real values are a follow-up.
|
`.env` stays gitignored and real values are a follow-up.
|
||||||
- Dropping a Sisyphus-reported follow-up (not logged in the task's log.md, not in the PR's
|
- Dropping a Sisyphus-reported follow-up (not logged in the task's log.md, not in the PR's
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -408,4 +410,4 @@ nodes:
|
|||||||
{{build_output}}
|
{{build_output}}
|
||||||
|
|
||||||
Last tests output:
|
Last tests output:
|
||||||
{{tests_output}}
|
{{tests_output}}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ nodes:
|
|||||||
- fs_read
|
- fs_read
|
||||||
- fs_cat
|
- fs_cat
|
||||||
- fs_grep
|
- fs_grep
|
||||||
max_iterations: 14
|
max_iterations: 28
|
||||||
max_attempts: 2
|
max_attempts: 2
|
||||||
state_updates:
|
state_updates:
|
||||||
verdict: '{{output}}'
|
verdict: '{{output}}'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -120,7 +120,15 @@ instructions: |
|
|||||||
update — never delete or silence it).
|
update — never delete or silence it).
|
||||||
4. **Clean, local, isolated.** Fresh ephemeral state, mocked externals, no dependence on
|
4. **Clean, local, isolated.** Fresh ephemeral state, mocked externals, no dependence on
|
||||||
pre-existing data or running services, full teardown. Bounded retries for startup only —
|
pre-existing data or running services, full teardown. Bounded retries for startup only —
|
||||||
never to mask a flaky assertion.
|
never to mask a flaky assertion. Teardown MUST NOT depend on a shell trap firing (a
|
||||||
|
killed or timed-out lane leaks containers — it has happened): pick one unique run
|
||||||
|
prefix up front (e.g. `probe-$$`), give EVERY container you create `--name <prefix>-…`
|
||||||
|
and `--label coyote-probe=<prefix>` (compose: `-p <prefix>`), prefer `docker run --rm`,
|
||||||
|
and make the LAST action before emitting your verdict an explicit sweep:
|
||||||
|
`docker ps -aq --filter label=coyote-probe=<prefix> | xargs -r docker rm -f` (compose:
|
||||||
|
`docker compose -p <prefix> down -v`). The label filter keeps the sweep scoped to YOUR
|
||||||
|
resources — rule 0 still forbids touching anyone else's. A leftover you cannot remove
|
||||||
|
is a finding to REPORT in the verdict, never to silence.
|
||||||
5. **Classify every failure** as BUG / EXPECTED-CHANGE / ENV per the skill table. The verdict
|
5. **Classify every failure** as BUG / EXPECTED-CHANGE / ENV per the skill table. The verdict
|
||||||
depends on the classification being honest.
|
depends on the classification being honest.
|
||||||
6. **Committed tests are the deliverable** alongside the verdict: write them where the repo's
|
6. **Committed tests are the deliverable** alongside the verdict: write them where the repo's
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -107,10 +107,14 @@ nodes:
|
|||||||
You extract the facts of a review request into structured fields.
|
You extract the facts of a review request into structured fields.
|
||||||
EXTRACTION ONLY — do not decide which review lanes should run.
|
EXTRACTION ONLY — do not decide which review lanes should run.
|
||||||
|
|
||||||
- `diff_spec`: the git diff specification the caller named — a ref
|
- `diff_spec`: the git diff specification the caller named, as a BARE
|
||||||
range ("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
|
revision token usable verbatim in `git diff <spec>` — a ref range
|
||||||
"worktree" for uncommitted changes against HEAD. When the caller
|
("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
|
||||||
did not name one, use "worktree".
|
"worktree" for uncommitted changes against HEAD. NEVER prose, a
|
||||||
|
command, or a path: from "run get_diff --base origin/main" extract
|
||||||
|
"origin/main...HEAD"; from "diff origin/main...HEAD in ~/code/x"
|
||||||
|
extract "origin/main...HEAD". When the caller did not name one, use
|
||||||
|
"worktree".
|
||||||
- `project_dir_in`: the repository directory the caller named as holding
|
- `project_dir_in`: the repository directory the caller named as holding
|
||||||
the change (absolute path preferred); empty string when the caller
|
the change (absolute path preferred); empty string when the caller
|
||||||
named none.
|
named none.
|
||||||
@@ -127,7 +131,8 @@ nodes:
|
|||||||
pointers for black-box probing (how to boot the service, where the
|
pointers for black-box probing (how to boot the service, where the
|
||||||
suites live). Empty string when the caller supplied none.
|
suites live). Empty string when the caller supplied none.
|
||||||
- `forced_lanes`: lanes the caller explicitly demanded (verbatim
|
- `forced_lanes`: lanes the caller explicitly demanded (verbatim
|
||||||
names). Empty array = automatic selection.
|
names). Empty array = automatic selection. Forcing is ADDITIVE
|
||||||
|
downstream: it widens the computed selection, never replaces it.
|
||||||
- `extra_context`: any remaining caller context reviewers should see,
|
- `extra_context`: any remaining caller context reviewers should see,
|
||||||
condensed.
|
condensed.
|
||||||
prompt: |
|
prompt: |
|
||||||
@@ -261,7 +266,7 @@ nodes:
|
|||||||
build_items:
|
build_items:
|
||||||
id: build_items
|
id: build_items
|
||||||
type: script
|
type: script
|
||||||
description: Deterministic lane builder — hard rules are the floor, LLM flags may only add, forced lanes override exactly. Emits one 0-or-1-item list per lane; an empty list skips that lane's map. On a re-run pass (retry_lanes non-empty) it skips selection and emits items only for those lanes, each carrying its attempt number.
|
description: Deterministic lane builder — hard rules are the floor, LLM flags may only add, forced lanes are ADDITIVE (selection only ever widens; unavailable signals widen it too). Emits one 0-or-1-item list per lane; an empty list skips that lane's map. On a re-run pass (retry_lanes non-empty) it skips selection and emits items only for those lanes, each carrying its attempt number.
|
||||||
script: scripts/build_items.py
|
script: scripts/build_items.py
|
||||||
timeout: 30
|
timeout: 30
|
||||||
next: [map_code_review, map_adversary, map_security, map_probe]
|
next: [map_code_review, map_adversary, map_security, map_probe]
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Deterministic lane builder for the review gauntlet.
|
"""Deterministic lane builder for the review gauntlet.
|
||||||
|
|
||||||
Selection = hard rules (the floor) ∪ LLM additions; forced lanes override
|
Selection = hard rules (the floor) ∪ LLM additions ∪ caller-forced lanes.
|
||||||
everything exactly. Emits one 0-or-1-item list per lane: an empty list makes
|
Forcing is ADDITIVE: it can only widen the computed selection — honoring a
|
||||||
that lane's map node run zero branches (map-over-nothing), which IS the
|
forced list as the exact set once dropped the always-on code-review lane in
|
||||||
skip mechanism — no dynamic routing anywhere.
|
a live run. Emits one 0-or-1-item list per lane: an empty list makes that
|
||||||
|
lane's map node run zero branches (map-over-nothing), which IS the skip
|
||||||
|
mechanism — no dynamic routing anywhere.
|
||||||
|
|
||||||
The LLM's add_* flags can only ADD lanes. A flag that is false never
|
The LLM's add_* flags can only ADD lanes. A flag that is false never
|
||||||
removes a lane the hard rules selected.
|
removes a lane the hard rules selected.
|
||||||
|
|
||||||
|
Unavailable diff signals (malformed spec, git failure) mean the surface is
|
||||||
|
UNKNOWN — selection degrades WIDER (security on; probe when a recipe
|
||||||
|
exists), never narrower.
|
||||||
|
|
||||||
A re-run pass (retry_gate emitted `retry_lanes`) skips selection: only the
|
A re-run pass (retry_gate emitted `retry_lanes`) skips selection: only the
|
||||||
named lanes get an item, stamped with their attempt number, and every other
|
named lanes get an item, stamped with their attempt number, and every other
|
||||||
lane maps over nothing — retry_gate restores their earlier reports.
|
lane maps over nothing — retry_gate restores their earlier reports.
|
||||||
@@ -101,56 +107,90 @@ 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 = {"code-review"}
|
||||||
lanes = set(forced)
|
reasons.append(
|
||||||
reasons.append(f"forced lanes honored exactly: {sorted(lanes)}")
|
"code-review: always on — the gauntlet is only spawned for non-trivial work"
|
||||||
else:
|
)
|
||||||
lanes = {"code-review"}
|
if text("plan_context"):
|
||||||
|
lanes.add("adversary")
|
||||||
|
reasons.append("adversary: a spec/plan/acceptance-criteria context is present")
|
||||||
|
if (
|
||||||
|
state.get("touches_auth")
|
||||||
|
or state.get("touches_deps")
|
||||||
|
or state.get("touches_exec")
|
||||||
|
or state.get("security_posture") == "hardened"
|
||||||
|
):
|
||||||
|
lanes.add("security")
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"code-review: always on — the gauntlet is only spawned for non-trivial work"
|
"security: attack-surface signals (auth/deps/exec) or hardened posture"
|
||||||
)
|
)
|
||||||
if text("plan_context"):
|
if state.get("consumer_surface") and text("probe_context"):
|
||||||
lanes.add("adversary")
|
lanes.add("probe")
|
||||||
reasons.append("adversary: a spec/plan/acceptance-criteria context is present")
|
reasons.append(
|
||||||
if (
|
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists"
|
||||||
state.get("touches_auth")
|
)
|
||||||
or state.get("touches_deps")
|
|
||||||
or state.get("touches_exec")
|
# Unavailable signals (malformed diff spec, git failure) = surface UNKNOWN.
|
||||||
or state.get("security_posture") == "hardened"
|
# 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")
|
lanes.add("security")
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"security: attack-surface signals (auth/deps/exec) or hardened posture"
|
"security: diff signals unavailable — surface unknown, degrading wider"
|
||||||
)
|
)
|
||||||
if state.get("consumer_surface") and text("probe_context"):
|
if "probe" not in lanes and text("probe_context"):
|
||||||
lanes.add("probe")
|
lanes.add("probe")
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists"
|
"probe: diff signals unavailable and a local-run recipe/suite pointer "
|
||||||
|
"exists — degrading wider"
|
||||||
)
|
)
|
||||||
|
|
||||||
# LLM judgment is additive-only.
|
# LLM judgment is additive-only.
|
||||||
llm_why = text("lane_reasons")[:300]
|
llm_why = text("lane_reasons")[:300]
|
||||||
for flag, lane in (
|
for flag, lane in (
|
||||||
("add_code_review", "code-review"),
|
("add_code_review", "code-review"),
|
||||||
("add_adversary", "adversary"),
|
("add_adversary", "adversary"),
|
||||||
("add_security", "security"),
|
("add_security", "security"),
|
||||||
("add_probe", "probe"),
|
("add_probe", "probe"),
|
||||||
):
|
):
|
||||||
if state.get(flag) and lane not in lanes:
|
if state.get(flag) and lane not in lanes:
|
||||||
if lane == "probe" and not text("probe_context"):
|
if lane == "probe" and not text("probe_context"):
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"probe: requested by lane judgment but skipped — no local-run "
|
"probe: requested by lane judgment but skipped — no local-run "
|
||||||
"recipe/suite pointer (a probe without one is guaranteed INCONCLUSIVE)"
|
"recipe/suite pointer (a probe without one is guaranteed INCONCLUSIVE)"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if lane == "adversary" and not text("plan_context"):
|
if lane == "adversary" and not text("plan_context"):
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"adversary: requested by lane judgment but skipped — no spec/plan "
|
"adversary: requested by lane judgment but skipped — no spec/plan "
|
||||||
"to check conformance against"
|
"to check conformance against"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
lanes.add(lane)
|
lanes.add(lane)
|
||||||
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}")
|
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}")
|
||||||
|
|
||||||
|
# Caller-forced lanes are ADDITIVE: they widen the computed selection,
|
||||||
|
# never replace it. (Honoring them as the exact set dropped code-review —
|
||||||
|
# documented as always-on — and adversary in a live run.) Forcing also
|
||||||
|
# bypasses the context guards above: the caller demanded the lane, so it
|
||||||
|
# runs, with the expectation named in the summary.
|
||||||
|
if forced:
|
||||||
|
newly = sorted(set(forced) - lanes)
|
||||||
|
lanes |= set(forced)
|
||||||
|
reasons.append(
|
||||||
|
f"forced lanes (additive): {sorted(set(forced))}"
|
||||||
|
+ (f" — newly added: {newly}" if newly else " — all already selected")
|
||||||
|
)
|
||||||
|
if "probe" in forced and not text("probe_context"):
|
||||||
|
reasons.append(
|
||||||
|
"probe: forced without a local-run recipe/suite pointer — expect INCONCLUSIVE"
|
||||||
|
)
|
||||||
|
if "adversary" in forced and not text("plan_context"):
|
||||||
|
reasons.append(
|
||||||
|
"adversary: forced without spec/plan context — it fails closed "
|
||||||
|
"(DIVERGES) without extractable criteria"
|
||||||
|
)
|
||||||
|
|
||||||
if unknown:
|
if unknown:
|
||||||
reasons.append(f"ignored unknown forced lane name(s): {unknown}")
|
reasons.append(f"ignored unknown forced lane name(s): {unknown}")
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ AND a local-run recipe exists (build_items gates probe the same way — a
|
|||||||
probe without a recipe is INCONCLUSIVE by construction),
|
probe without a recipe is INCONCLUSIVE by construction),
|
||||||
plus security when the attack-surface signals (auth/deps/exec) or a hardened
|
plus security when the attack-surface signals (auth/deps/exec) or a hardened
|
||||||
posture demand it, unioned with any lanes the caller forced (canonicalized
|
posture demand it, unioned with any lanes the caller forced (canonicalized
|
||||||
with the same aliases build_items uses) — which build_items honors exactly,
|
with the same aliases build_items uses) — which build_items honors
|
||||||
so selection degrades WIDER, never narrower. The degradation note goes into
|
ADDITIVELY, so selection degrades WIDER, never narrower. Unavailable diff
|
||||||
|
signals also widen: unknown surface adds security (and probe when a
|
||||||
|
local-run recipe exists). The degradation note goes into
|
||||||
`lanes_degraded`; build_items folds it into lanes_summary so the final
|
`lanes_degraded`; build_items folds it into lanes_summary so the final
|
||||||
report names the degradation.
|
report names the degradation.
|
||||||
|
|
||||||
@@ -72,6 +74,11 @@ def main():
|
|||||||
or state.get("security_posture") == "hardened"
|
or state.get("security_posture") == "hardened"
|
||||||
):
|
):
|
||||||
lanes.add("security")
|
lanes.add("security")
|
||||||
|
# Unavailable signals = surface UNKNOWN → widen (mirrors build_items).
|
||||||
|
if (state.get("signals_summary") or "") == "signals unavailable":
|
||||||
|
lanes.add("security")
|
||||||
|
if isinstance(probe_context, str) and probe_context.strip():
|
||||||
|
lanes.add("probe")
|
||||||
# Caller-forced lanes survive degradation — union, never overwrite.
|
# Caller-forced lanes survive degradation — union, never overwrite.
|
||||||
for lane in state.get("forced_lanes") or []:
|
for lane in state.get("forced_lanes") or []:
|
||||||
if canon := ALIASES.get(str(lane).strip().lower()):
|
if canon := ALIASES.get(str(lane).strip().lower()):
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
Pure git — no LLM judgment. Every signal is computed from the changed-file
|
Pure git — no LLM judgment. Every signal is computed from the changed-file
|
||||||
list and the added lines of the diff. Failures never sink the gauntlet:
|
list and the added lines of the diff. Failures never sink the gauntlet:
|
||||||
on any error the script reports `signals_error` and leaves the defaults
|
on any error the script reports `signals_error` and marks the signals
|
||||||
(false/empty) in place, which biases lane selection toward the caller's
|
unavailable — downstream (build_items / default_lanes) treats that as
|
||||||
context and forced lanes.
|
UNKNOWN surface and degrades WIDER (security on; probe when a recipe
|
||||||
|
exists), on top of caller context and forced lanes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -26,6 +27,19 @@ proj = os.path.expanduser(
|
|||||||
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
|
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
|
||||||
)
|
)
|
||||||
spec = (state.get("diff_spec") or "worktree").strip()
|
spec = (state.get("diff_spec") or "worktree").strip()
|
||||||
|
spec_raw = spec
|
||||||
|
# The parse stage should emit a bare revision token, but callers write prose
|
||||||
|
# and extraction drifts ("run get_diff --base origin/main" once reached
|
||||||
|
# `git diff` verbatim, killing the signals — and with them the security and
|
||||||
|
# probe lanes). Deterministically recover a usable spec before giving up.
|
||||||
|
_TOKEN = r"[\w./~^@{}-]+"
|
||||||
|
if spec not in ("", "worktree") and not re.fullmatch(
|
||||||
|
rf"{_TOKEN}(\.\.\.?{_TOKEN})?", spec
|
||||||
|
):
|
||||||
|
if m := re.search(rf"({_TOKEN}\.\.\.?{_TOKEN})", spec):
|
||||||
|
spec = m.group(1) # a revision range buried in prose
|
||||||
|
elif m := re.search(rf"--base[= ]({_TOKEN})", spec):
|
||||||
|
spec = f"{m.group(1)}...HEAD" # the get_diff idiom
|
||||||
|
|
||||||
|
|
||||||
def git(*args):
|
def git(*args):
|
||||||
@@ -112,13 +126,18 @@ try:
|
|||||||
),
|
),
|
||||||
"docs_only": bool(files)
|
"docs_only": bool(files)
|
||||||
and all(re.search(r"\.(md|rst|txt|adoc)$", f, re.I) for f in files),
|
and all(re.search(r"\.(md|rst|txt|adoc)$", f, re.I) for f in files),
|
||||||
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})",
|
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})"
|
||||||
|
+ (f" — spec normalized from {spec_raw!r}" if spec != spec_raw else ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as e: # noqa: BLE001 — degraded signals must not sink the gauntlet
|
except Exception as e: # noqa: BLE001 — degraded signals must not sink the gauntlet
|
||||||
|
what = f"diff spec {spec!r}" + (
|
||||||
|
f", normalized from {spec_raw!r}" if spec != spec_raw else ""
|
||||||
|
)
|
||||||
out["signals_error"] = (
|
out["signals_error"] = (
|
||||||
f"NOTE: diff signals could not be computed ({e}); "
|
f"NOTE: diff signals could not be computed ({what}: {e}); surface UNKNOWN — "
|
||||||
"lane selection falls back to caller context and forced lanes."
|
"lane selection degrades WIDER (security lane added; probe eligible when a "
|
||||||
|
"local-run recipe exists), plus caller context and forced lanes."
|
||||||
)
|
)
|
||||||
out["signals_summary"] = "signals unavailable"
|
out["signals_summary"] = "signals unavailable"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: sisyphus
|
name: sisyphus
|
||||||
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline
|
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline
|
||||||
version: 3.13.0
|
version: 3.17.0
|
||||||
|
|
||||||
agent_session: temp
|
agent_session: temp
|
||||||
auto_continue: true
|
auto_continue: true
|
||||||
@@ -11,6 +11,7 @@ can_spawn_agents: true
|
|||||||
spawnable_agents:
|
spawnable_agents:
|
||||||
- explore
|
- explore
|
||||||
- librarian
|
- librarian
|
||||||
|
- deep-research
|
||||||
- coder
|
- coder
|
||||||
- oracle
|
- oracle
|
||||||
- code-reviewer
|
- code-reviewer
|
||||||
@@ -157,7 +158,8 @@ instructions: |
|
|||||||
| Agent | Use For | Characteristics |
|
| Agent | Use For | Characteristics |
|
||||||
|-------|---------|-----------------|
|
|-------|---------|-----------------|
|
||||||
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 5–15+. |
|
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 5–15+. |
|
||||||
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. |
|
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. Default depth = quick-lookup budgets; for a BROAD topic (design research, best-practice surveys, architecture comparisons) pass `--variables {"research_depth": "deep"}` (~3× search/fetch budgets). Still prefer several narrow librarians over one giant one — deep is for questions that are inherently broad, not a substitute for fan-out. |
|
||||||
|
| `deep-research` | Open-ended investigation that must CHURN until solid — plans sub-questions, fans out a deep librarian per question, vets source credibility, runs a critique→re-research loop | Spawn with `--variables {"autonomy": "autonomous"}` — MANDATORY when spawned as a subagent (the supervised default blocks on a human approval gate). Raise `reflexion_budget` (e.g. "6", default 2) to let it churn longer. Expect hours, not minutes; one per investigation — its internal map is the fan-out. |
|
||||||
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
|
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
|
||||||
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
|
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
|
||||||
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
|
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
|
||||||
@@ -575,7 +577,7 @@ instructions: |
|
|||||||
|
|
||||||
When you write or modify files yourself (rather than delegating to coder):
|
When you write or modify files yourself (rather than delegating to coder):
|
||||||
|
|
||||||
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment.
|
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment. In every register, a comment must EARN ITS KEEP — if the code already tells the reader, don't add it.
|
||||||
- **Calibrate logging before writing.** When the change touches boundaries, error paths, jobs, or state transitions, load `logging-discipline` and note the repo's logging register (logger, message style, payload vs IDs, level semantics) from the same sibling reads; match it. No discernible convention → its best-judgment defaults. Never leave a new error path silently swallowed, and never delete existing log lines as drive-by cleanup.
|
- **Calibrate logging before writing.** When the change touches boundaries, error paths, jobs, or state transitions, load `logging-discipline` and note the repo's logging register (logger, message style, payload vs IDs, level semantics) from the same sibling reads; match it. No discernible convention → its best-judgment defaults. Never leave a new error path silently swallowed, and never delete existing log lines as drive-by cleanup.
|
||||||
- **For editing an existing file**, prefer `fs_patch`. It's a surgical edit that preserves unchanged content. Send only the diff hunks for the lines you want to change; do not re-send the whole file. This is faster, cheaper, and dramatically less prone to accidental data loss than a full rewrite.
|
- **For editing an existing file**, prefer `fs_patch`. It's a surgical edit that preserves unchanged content. Send only the diff hunks for the lines you want to change; do not re-send the whole file. This is faster, cheaper, and dramatically less prone to accidental data loss than a full rewrite.
|
||||||
- **For writing a NEW file or doing a COMPLETE rewrite**, use `fs_write`. Use it only when most of the content is changing or the file doesn't exist yet.
|
- **For writing a NEW file or doing a COMPLETE rewrite**, use `fs_write`. Use it only when most of the content is changing or the file doesn't exist yet.
|
||||||
@@ -709,6 +711,13 @@ instructions: |
|
|||||||
description: in-place edits destroy the review history and hide which commits each round
|
description: in-place edits destroy the review history and hide which commits each round
|
||||||
actually reviewed. Comments are the append-only review log; the SHA in the title names
|
actually reviewed. Comments are the append-only review log; the SHA in the title names
|
||||||
exactly what was reviewed.
|
exactly what was reviewed.
|
||||||
|
The same discipline covers EVERY later push to a PR you (or your run) opened — addressing
|
||||||
|
human review comments, a reviewer bot's findings, CI failures, or any other PR event: post
|
||||||
|
a NEW top-level PR comment titled `## Update @ <full head SHA>` mapping each addressed
|
||||||
|
comment/finding to the commit that addresses it (one line each), plus anything
|
||||||
|
deliberately NOT addressed and why. NEVER append update summaries to the description.
|
||||||
|
Edit the description ONLY when the PR's actual scope or behavior changed enough that it
|
||||||
|
would otherwise misdescribe the PR — it stays a description, never a changelog.
|
||||||
|
|
||||||
**Close the loop.** When a decision DID reach the user (step 4), suggest in your final report
|
**Close the loop.** When a decision DID reach the user (step 4), suggest in your final report
|
||||||
that they record the resolution with `coyote --macro record-ruling` so that class of decision
|
that they record the resolution with `coyote --macro record-ruling` so that class of decision
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ save_session: null # Controls the persistence of the session. If t
|
|||||||
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
|
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
|
||||||
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
|
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
|
||||||
compression_model: null # Model to use for session compression (e.g. 'openai:gpt-4o-mini'); defaults to the session's current model
|
compression_model: null # Model to use for session compression (e.g. 'openai:gpt-4o-mini'); defaults to the session's current model
|
||||||
|
compression_safety_valve: 0.8 # Force compression when the last real request's prompt size (or the local estimate) reaches this fraction of the model's max_input_tokens, even if compression_threshold would not fire (0 = disabled)
|
||||||
summarization_prompt: null # The text prompt used for creating a concise summary of session messages. If null, uses built-in default
|
summarization_prompt: null # The text prompt used for creating a concise summary of session messages. If null, uses built-in default
|
||||||
summary_context_prompt: null # The text prompt used for including the summary of the entire session as context to the model. If null, uses built-in default
|
summary_context_prompt: null # The text prompt used for including the summary of the entire session as context to the model. If null, uses built-in default
|
||||||
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
|
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
|
||||||
@@ -276,6 +277,7 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons
|
|||||||
# All clients have the following configuration:
|
# All clients have the following configuration:
|
||||||
# - type: xxxx
|
# - type: xxxx
|
||||||
# name: xxxx # Only use it to distinguish clients with the same client type. Optional
|
# name: xxxx # Only use it to distinguish clients with the same client type. Optional
|
||||||
|
# extend_models: true # Merge the models below on top of the catalog models for this client instead of replacing them. Optional, default false
|
||||||
# models:
|
# models:
|
||||||
# - name: xxxx # Chat model
|
# - name: xxxx # Chat model
|
||||||
# max_input_tokens: 100000
|
# max_input_tokens: 100000
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+1102
-376
File diff suppressed because it is too large.
Load diff
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 "$@"
|
||||||
+131
-14
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
+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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ use serde_json::{Value, json};
|
|||||||
const API_BASE: &str = "https://api.anthropic.com/v1";
|
const API_BASE: &str = "https://api.anthropic.com/v1";
|
||||||
const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
|
const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct ClaudeConfig {
|
pub struct ClaudeConfig {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
@@ -24,6 +24,8 @@ pub struct ClaudeConfig {
|
|||||||
pub oauth: Option<Box<OAuthConfig>>,
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub prompt_cache: Option<bool>,
|
pub prompt_cache: Option<bool>,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
@@ -1122,16 +1124,12 @@ mod tests {
|
|||||||
fn claude_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> ClaudeConfig {
|
fn claude_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> ClaudeConfig {
|
||||||
ClaudeConfig {
|
ClaudeConfig {
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
api_key: None,
|
|
||||||
api_base: oauth
|
api_base: oauth
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|_| "https://gateway.example/v1".to_string()),
|
.map(|_| "https://gateway.example/v1".to_string()),
|
||||||
auth: auth.map(str::to_string),
|
auth: auth.map(str::to_string),
|
||||||
oauth: oauth.map(Box::new),
|
oauth: oauth.map(Box::new),
|
||||||
models: vec![],
|
..Default::default()
|
||||||
prompt_cache: None,
|
|
||||||
patch: None,
|
|
||||||
extra: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,6 +420,15 @@ impl TokenUsage {
|
|||||||
&& self.cache_read_input_tokens.is_none()
|
&& self.cache_read_input_tokens.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prompt-side tokens the API reported for one request: fresh input plus
|
||||||
|
/// cache writes and cache reads. The buckets are disjoint (see
|
||||||
|
/// [`Self::cost_usd`]), so the terms sum without adjustment.
|
||||||
|
pub fn total_prompt_tokens(&self) -> u64 {
|
||||||
|
self.input_tokens.unwrap_or(0)
|
||||||
|
+ self.cache_creation_input_tokens.unwrap_or(0)
|
||||||
|
+ self.cache_read_input_tokens.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn accumulate(&mut self, other: &TokenUsage) {
|
pub fn accumulate(&mut self, other: &TokenUsage) {
|
||||||
fn add(acc: &mut Option<u64>, value: Option<u64>) {
|
fn add(acc: &mut Option<u64>, value: Option<u64>) {
|
||||||
if let Some(value) = value {
|
if let Some(value) = value {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub struct GeminiConfig {
|
|||||||
pub oauth: Option<Box<OAuthConfig>>,
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -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);
|
||||||
|
let find_catalog = || $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
|
||||||
|
v.provider == $name ||
|
||||||
|
($name == OpenAICompatibleClient::NAME
|
||||||
|
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
|
||||||
|
});
|
||||||
if local_config.models.is_empty() {
|
if local_config.models.is_empty() {
|
||||||
if let Some(v) = $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
|
match find_catalog() {
|
||||||
v.provider == $name ||
|
Some(v) => Model::from_config(client_name, &v.models),
|
||||||
($name == OpenAICompatibleClient::NAME
|
None => vec![],
|
||||||
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
|
}
|
||||||
}) {
|
} else if local_config.extend_models {
|
||||||
return Model::from_config(client_name, &v.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -557,4 +557,96 @@ reasoning_levels: [none, low, medium, high]
|
|||||||
"oauth blocks in the shipped models.yaml are limited to providers with no built-in stock oauth path (currently only xai's bundled defaults); adding one for claude/openai/gemini would silently swap stock oauth users onto a non-stock provider"
|
"oauth blocks in the shipped models.yaml are limited to providers with no built-in stock oauth path (currently only xai's bundled defaults); adding one for claude/openai/gemini would silently swap stock oauth users onto a non-stock provider"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_models_replace_catalog_by_default() {
|
||||||
|
let ClientConfig::ClaudeConfig(config) = serde_yaml::from_str::<ClientConfig>(
|
||||||
|
"type: claude\nmodels:\n - name: test-replace-model\n",
|
||||||
|
)
|
||||||
|
.unwrap() else {
|
||||||
|
panic!("expected a claude config");
|
||||||
|
};
|
||||||
|
assert!(!config.extend_models);
|
||||||
|
|
||||||
|
let models = crate::client::ClaudeClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), 1);
|
||||||
|
assert_eq!(models[0].name(), "test-replace-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_models_extend_catalog_when_flag_set() {
|
||||||
|
let ClientConfig::ClaudeConfig(config) = serde_yaml::from_str::<ClientConfig>(
|
||||||
|
"type: claude\nextend_models: true\nmodels:\n - name: test-extra-model\n",
|
||||||
|
)
|
||||||
|
.unwrap() else {
|
||||||
|
panic!("expected a claude config");
|
||||||
|
};
|
||||||
|
|
||||||
|
let catalog_len = crate::client::ALL_PROVIDER_MODELS
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.provider == "claude")
|
||||||
|
.map(|v| v.models.len())
|
||||||
|
.unwrap();
|
||||||
|
let models = crate::client::ClaudeClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), catalog_len + 1);
|
||||||
|
assert_eq!(models.last().unwrap().name(), "test-extra-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_models_with_empty_models_returns_catalog_exactly() {
|
||||||
|
let ClientConfig::ClaudeConfig(config) =
|
||||||
|
serde_yaml::from_str::<ClientConfig>("type: claude\nextend_models: true\n").unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected a claude config");
|
||||||
|
};
|
||||||
|
|
||||||
|
let catalog = crate::client::ALL_PROVIDER_MODELS
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.provider == "claude")
|
||||||
|
.unwrap();
|
||||||
|
let models = crate::client::ClaudeClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), catalog.models.len());
|
||||||
|
for (model, catalog_model) in models.iter().zip(catalog.models.iter()) {
|
||||||
|
assert_eq!(model.name(), catalog_model.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_models_without_catalog_match_returns_config_models_only() {
|
||||||
|
let ClientConfig::OpenAICompatibleConfig(config) = serde_yaml::from_str::<ClientConfig>(
|
||||||
|
"type: openai-compatible\nname: no-such-provider-xyz\nextend_models: true\nmodels:\n - name: test-only-model\n",
|
||||||
|
)
|
||||||
|
.unwrap() else {
|
||||||
|
panic!("expected an openai-compatible config");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!crate::client::ALL_PROVIDER_MODELS
|
||||||
|
.iter()
|
||||||
|
.any(|v| "no-such-provider-xyz".starts_with(v.provider.as_str())),
|
||||||
|
"test premise: the client name must not prefix-match any catalog provider"
|
||||||
|
);
|
||||||
|
let models = crate::client::OpenAICompatibleClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), 1);
|
||||||
|
assert_eq!(models[0].name(), "test-only-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_models_extends_catalog_via_provider_name_prefix_match() {
|
||||||
|
let ClientConfig::OpenAICompatibleConfig(config) = serde_yaml::from_str::<ClientConfig>(
|
||||||
|
"type: openai-compatible\nname: openrouter-custom\nextend_models: true\nmodels:\n - name: test-net-new-model\n",
|
||||||
|
)
|
||||||
|
.unwrap() else {
|
||||||
|
panic!("expected an openai-compatible config");
|
||||||
|
};
|
||||||
|
|
||||||
|
let catalog_len = crate::client::ALL_PROVIDER_MODELS
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.provider == "openrouter")
|
||||||
|
.map(|v| v.models.len())
|
||||||
|
.unwrap();
|
||||||
|
let models = crate::client::OpenAICompatibleClient::list_models(&config);
|
||||||
|
assert_eq!(models.len(), catalog_len + 1);
|
||||||
|
assert_eq!(models.last().unwrap().name(), "test-net-new-model");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
-23
@@ -1457,13 +1457,9 @@ echo_pkce_in_token_exchange: true
|
|||||||
ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig {
|
ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig {
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
api_base: Some("https://api.example/v1".into()),
|
api_base: Some("https://api.example/v1".into()),
|
||||||
api_key: None,
|
|
||||||
auth: auth.map(str::to_string),
|
auth: auth.map(str::to_string),
|
||||||
oauth: oauth.map(Box::new),
|
oauth: oauth.map(Box::new),
|
||||||
wire_api: None,
|
..Default::default()
|
||||||
models: vec![],
|
|
||||||
patch: None,
|
|
||||||
extra: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1474,14 +1470,9 @@ echo_pkce_in_token_exchange: true
|
|||||||
) -> ClientConfig {
|
) -> ClientConfig {
|
||||||
ClientConfig::ClaudeConfig(ClaudeConfig {
|
ClientConfig::ClaudeConfig(ClaudeConfig {
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
api_key: None,
|
|
||||||
api_base: None,
|
|
||||||
auth: auth.map(str::to_string),
|
auth: auth.map(str::to_string),
|
||||||
oauth: oauth.map(Box::new),
|
oauth: oauth.map(Box::new),
|
||||||
models: vec![],
|
..Default::default()
|
||||||
prompt_cache: None,
|
|
||||||
patch: None,
|
|
||||||
extra: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,15 +1483,9 @@ echo_pkce_in_token_exchange: true
|
|||||||
) -> ClientConfig {
|
) -> ClientConfig {
|
||||||
ClientConfig::OpenAIConfig(OpenAIConfig {
|
ClientConfig::OpenAIConfig(OpenAIConfig {
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
api_key: None,
|
|
||||||
api_base: None,
|
|
||||||
organization_id: None,
|
|
||||||
auth: auth.map(str::to_string),
|
auth: auth.map(str::to_string),
|
||||||
oauth: oauth.map(Box::new),
|
oauth: oauth.map(Box::new),
|
||||||
wire_api: None,
|
..Default::default()
|
||||||
models: vec![],
|
|
||||||
patch: None,
|
|
||||||
extra: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,13 +1496,9 @@ echo_pkce_in_token_exchange: true
|
|||||||
) -> ClientConfig {
|
) -> ClientConfig {
|
||||||
ClientConfig::GeminiConfig(GeminiConfig {
|
ClientConfig::GeminiConfig(GeminiConfig {
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
api_key: None,
|
|
||||||
api_base: None,
|
|
||||||
auth: auth.map(str::to_string),
|
auth: auth.map(str::to_string),
|
||||||
oauth: oauth.map(Box::new),
|
oauth: oauth.map(Box::new),
|
||||||
models: vec![],
|
..Default::default()
|
||||||
patch: None,
|
|
||||||
extra: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-6
@@ -24,6 +24,8 @@ pub struct OpenAIConfig {
|
|||||||
pub wire_api: Option<WireApi>,
|
pub wire_api: Option<WireApi>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
}
|
}
|
||||||
@@ -869,17 +871,22 @@ fn reasoning_thinking_block(item: &Value) -> Option<ThinkingBlock> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// OpenAI reports cached input under `input_tokens_details` and has no
|
/// OpenAI reports cached input under `input_tokens_details` and has no
|
||||||
/// cache-creation concept, so that field stays `None`.
|
/// cache-creation concept, so that field stays `None`. The reported
|
||||||
|
/// `input_tokens` total already includes cached reads, so cached tokens are
|
||||||
|
/// subtracted here to keep the buckets disjoint like other providers.
|
||||||
fn openai_parse_responses_usage(usage: &Value) -> Option<TokenUsage> {
|
fn openai_parse_responses_usage(usage: &Value) -> Option<TokenUsage> {
|
||||||
if !usage.is_object() {
|
if !usage.is_object() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cached_tokens = usage["input_tokens_details"]["cached_tokens"].as_u64();
|
||||||
Some(TokenUsage {
|
Some(TokenUsage {
|
||||||
input_tokens: usage["input_tokens"].as_u64(),
|
input_tokens: usage["input_tokens"]
|
||||||
|
.as_u64()
|
||||||
|
.map(|v| v.saturating_sub(cached_tokens.unwrap_or(0))),
|
||||||
output_tokens: usage["output_tokens"].as_u64(),
|
output_tokens: usage["output_tokens"].as_u64(),
|
||||||
cache_creation_input_tokens: None,
|
cache_creation_input_tokens: None,
|
||||||
cache_read_input_tokens: usage["input_tokens_details"]["cached_tokens"].as_u64(),
|
cache_read_input_tokens: cached_tokens,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1267,12 +1274,31 @@ mod tests {
|
|||||||
let output = openai_extract_responses(&data).unwrap();
|
let output = openai_extract_responses(&data).unwrap();
|
||||||
|
|
||||||
let usage = output.usage.unwrap();
|
let usage = output.usage.unwrap();
|
||||||
assert_eq!(usage.input_tokens, Some(100));
|
assert_eq!(usage.input_tokens, Some(40));
|
||||||
assert_eq!(usage.output_tokens, Some(20));
|
assert_eq!(usage.output_tokens, Some(20));
|
||||||
assert_eq!(usage.cache_creation_input_tokens, None);
|
assert_eq!(usage.cache_creation_input_tokens, None);
|
||||||
assert_eq!(usage.cache_read_input_tokens, Some(60));
|
assert_eq!(usage.cache_read_input_tokens, Some(60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_responses_usage_without_cached_details_keeps_input_intact() {
|
||||||
|
let data = json!({
|
||||||
|
"output": [
|
||||||
|
{ "type": "message", "content": [{ "type": "output_text", "text": "answer" }] },
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 20,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = openai_extract_responses(&data).unwrap();
|
||||||
|
|
||||||
|
let usage = output.usage.unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, Some(100));
|
||||||
|
assert_eq!(usage.cache_read_input_tokens, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_responses_without_usage_leaves_it_none() {
|
fn extract_responses_without_usage_leaves_it_none() {
|
||||||
let data = json!({
|
let data = json!({
|
||||||
@@ -1357,7 +1383,7 @@ mod tests {
|
|||||||
assert!(done);
|
assert!(done);
|
||||||
let (_, _, _, usage) = handler.take();
|
let (_, _, _, usage) = handler.take();
|
||||||
let usage = usage.unwrap();
|
let usage = usage.unwrap();
|
||||||
assert_eq!(usage.input_tokens, Some(100));
|
assert_eq!(usage.input_tokens, Some(40));
|
||||||
assert_eq!(usage.output_tokens, Some(20));
|
assert_eq!(usage.output_tokens, Some(20));
|
||||||
assert_eq!(usage.cache_creation_input_tokens, None);
|
assert_eq!(usage.cache_creation_input_tokens, None);
|
||||||
assert_eq!(usage.cache_read_input_tokens, Some(60));
|
assert_eq!(usage.cache_read_input_tokens, Some(60));
|
||||||
@@ -1467,7 +1493,7 @@ mod tests {
|
|||||||
let (text, _, _, usage) = handler.take();
|
let (text, _, _, usage) = handler.take();
|
||||||
assert_eq!(text, "partial answer");
|
assert_eq!(text, "partial answer");
|
||||||
let usage = usage.expect("usage should be recorded for truncated turns");
|
let usage = usage.expect("usage should be recorded for truncated turns");
|
||||||
assert_eq!(usage.input_tokens, Some(50));
|
assert_eq!(usage.input_tokens, Some(25));
|
||||||
assert_eq!(usage.output_tokens, Some(10));
|
assert_eq!(usage.output_tokens, Some(10));
|
||||||
assert_eq!(usage.cache_read_input_tokens, Some(25));
|
assert_eq!(usage.cache_read_input_tokens, Some(25));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use serde::Deserialize;
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use oauth::OAuthConfig;
|
use oauth::OAuthConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct OpenAICompatibleConfig {
|
pub struct OpenAICompatibleConfig {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
@@ -19,6 +19,8 @@ pub struct OpenAICompatibleConfig {
|
|||||||
pub wire_api: Option<WireApi>,
|
pub wire_api: Option<WireApi>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extend_models: bool,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
pub extra: Option<ExtraConfig>,
|
pub extra: Option<ExtraConfig>,
|
||||||
}
|
}
|
||||||
@@ -274,12 +276,8 @@ mod tests {
|
|||||||
name: Some("compat-test".into()),
|
name: Some("compat-test".into()),
|
||||||
api_base: Some("https://compat.example/v1".into()),
|
api_base: Some("https://compat.example/v1".into()),
|
||||||
api_key: Some("sk-test".into()),
|
api_key: Some("sk-test".into()),
|
||||||
auth: None,
|
|
||||||
oauth: None,
|
|
||||||
wire_api,
|
wire_api,
|
||||||
models: vec![],
|
..Default::default()
|
||||||
patch: None,
|
|
||||||
extra: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ pub struct AppConfig {
|
|||||||
pub compression_threshold: usize,
|
pub compression_threshold: usize,
|
||||||
pub compression_keep_last: usize,
|
pub compression_keep_last: usize,
|
||||||
pub compression_model: Option<String>,
|
pub compression_model: Option<String>,
|
||||||
|
pub compression_safety_valve: f32,
|
||||||
pub summarization_prompt: Option<String>,
|
pub summarization_prompt: Option<String>,
|
||||||
pub summary_context_prompt: Option<String>,
|
pub summary_context_prompt: Option<String>,
|
||||||
pub max_tool_result_chars: Option<usize>,
|
pub max_tool_result_chars: Option<usize>,
|
||||||
@@ -164,6 +165,7 @@ impl Default for AppConfig {
|
|||||||
compression_threshold: 4000,
|
compression_threshold: 4000,
|
||||||
compression_keep_last: 0,
|
compression_keep_last: 0,
|
||||||
compression_model: None,
|
compression_model: None,
|
||||||
|
compression_safety_valve: 0.8,
|
||||||
summarization_prompt: None,
|
summarization_prompt: None,
|
||||||
summary_context_prompt: None,
|
summary_context_prompt: None,
|
||||||
max_tool_result_chars: None,
|
max_tool_result_chars: None,
|
||||||
@@ -270,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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+6
-6
@@ -58,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)]
|
||||||
@@ -183,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";
|
||||||
@@ -286,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>,
|
||||||
@@ -376,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,
|
||||||
@@ -581,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.
|
||||||
@@ -1379,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");
|
||||||
@@ -1419,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);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -5,7 +5,7 @@ use super::{
|
|||||||
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_NAME, INSTALLED_BUNDLES_FILE_NAME,
|
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_NAME, INSTALLED_BUNDLES_FILE_NAME,
|
||||||
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
|
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
|
||||||
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
|
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
|
||||||
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
SBX_MIXIN_KITS_DIR_NAME, SESSIONS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
||||||
};
|
};
|
||||||
use crate::client::ProviderModels;
|
use crate::client::ProviderModels;
|
||||||
use crate::config::REPL_HISTORY_DIR_NAME;
|
use crate::config::REPL_HISTORY_DIR_NAME;
|
||||||
@@ -229,6 +229,17 @@ pub fn workspace_macros_dir() -> PathBuf {
|
|||||||
workspace_config_dir().join(MACROS_DIR_NAME)
|
workspace_config_dir().join(MACROS_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn workspace_sessions_dir() -> PathBuf {
|
||||||
|
workspace_config_dir().join(SESSIONS_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_agent_sessions_dir(name: &str) -> PathBuf {
|
||||||
|
workspace_config_dir()
|
||||||
|
.join(AGENTS_DIR_NAME)
|
||||||
|
.join(name)
|
||||||
|
.join(SESSIONS_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
|
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
|
||||||
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
|
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
|
||||||
}
|
}
|
||||||
@@ -332,6 +343,10 @@ pub fn agent_data_dir(name: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn agent_sessions_dir(name: &str) -> PathBuf {
|
||||||
|
agent_data_dir(name).join(SESSIONS_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
|
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
|
||||||
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
|
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
|
||||||
}
|
}
|
||||||
@@ -854,6 +869,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn workspace_session_dirs_live_under_workspace_config_dir() {
|
||||||
|
with_workspace_dir(|_, ws_dir| {
|
||||||
|
assert_eq!(workspace_sessions_dir(), ws_dir.join(SESSIONS_DIR_NAME));
|
||||||
|
assert_eq!(
|
||||||
|
workspace_agent_sessions_dir("helper"),
|
||||||
|
ws_dir
|
||||||
|
.join(AGENTS_DIR_NAME)
|
||||||
|
.join("helper")
|
||||||
|
.join(SESSIONS_DIR_NAME)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod user_mcp_resolution {
|
mod user_mcp_resolution {
|
||||||
|
|||||||
+1640
-111
File diff suppressed because it is too large.
Load diff
+328
-12
@@ -12,8 +12,10 @@ use inquire::{Confirm, Text, validator::Validation};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::{read_to_string, write};
|
use std::fmt;
|
||||||
use std::path::Path;
|
use std::fs::{OpenOptions, read_to_string, remove_file, rename};
|
||||||
|
use std::io::{self, Write as _};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
|
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
|
||||||
@@ -29,6 +31,62 @@ fn cost_is_zero(v: &f64) -> bool {
|
|||||||
*v == 0.0
|
*v == 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum SessionScope {
|
||||||
|
#[default]
|
||||||
|
Global,
|
||||||
|
Workspace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SessionScope {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
SessionScope::Global => write!(f, "global"),
|
||||||
|
SessionScope::Workspace => write!(f, "workspace"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_scope_dirs(agent: Option<&str>) -> (PathBuf, PathBuf) {
|
||||||
|
match agent {
|
||||||
|
Some(agent) => (
|
||||||
|
paths::workspace_agent_sessions_dir(agent),
|
||||||
|
paths::agent_sessions_dir(agent),
|
||||||
|
),
|
||||||
|
None => (paths::workspace_sessions_dir(), default_sessions_dir()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session names across both scope dirs, workspace first, deduped by name so
|
||||||
|
/// a workspace session shadows a global one with the same name.
|
||||||
|
pub fn labeled_session_names(
|
||||||
|
workspace_dir: &Path,
|
||||||
|
global_dir: &Path,
|
||||||
|
) -> Vec<(String, SessionScope)> {
|
||||||
|
let mut values = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for (dir, scope) in [
|
||||||
|
(workspace_dir, SessionScope::Workspace),
|
||||||
|
(global_dir, SessionScope::Global),
|
||||||
|
] {
|
||||||
|
for name in list_file_names(dir, ".yaml") {
|
||||||
|
if seen.insert(name.clone()) {
|
||||||
|
values.push((name, scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merged_session_names(workspace_dir: &Path, global_dir: &Path) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = labeled_session_names(workspace_dir, global_dir)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
#[serde(rename(serialize = "model", deserialize = "model"))]
|
#[serde(rename(serialize = "model", deserialize = "model"))]
|
||||||
@@ -120,6 +178,8 @@ pub struct Session {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
scope: SessionScope,
|
||||||
|
#[serde(skip)]
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
save_session_this_time: bool,
|
save_session_this_time: bool,
|
||||||
@@ -173,6 +233,7 @@ impl Session {
|
|||||||
app: &AppConfig,
|
app: &AppConfig,
|
||||||
name: &str,
|
name: &str,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
scope: SessionScope,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let content = read_to_string(path)
|
let content = read_to_string(path)
|
||||||
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
|
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
|
||||||
@@ -180,6 +241,7 @@ impl Session {
|
|||||||
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
|
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
|
||||||
|
|
||||||
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
|
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
|
||||||
|
session.scope = scope;
|
||||||
|
|
||||||
if let Some(autoname) = name.strip_prefix("_/") {
|
if let Some(autoname) = name.strip_prefix("_/") {
|
||||||
session.name = TEMP_SESSION_NAME.to_string();
|
session.name = TEMP_SESSION_NAME.to_string();
|
||||||
@@ -250,6 +312,14 @@ impl Session {
|
|||||||
self.name = name;
|
self.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn scope(&self) -> SessionScope {
|
||||||
|
self.scope
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_scope(&mut self, scope: SessionScope) {
|
||||||
|
self.scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_autoname(&mut self) {
|
pub fn clear_autoname(&mut self) {
|
||||||
self.autoname = None;
|
self.autoname = None;
|
||||||
}
|
}
|
||||||
@@ -313,6 +383,7 @@ impl Session {
|
|||||||
pub fn export(&self) -> Result<String> {
|
pub fn export(&self) -> Result<String> {
|
||||||
let mut data = json!({
|
let mut data = json!({
|
||||||
"path": self.path,
|
"path": self.path,
|
||||||
|
"scope": self.scope.to_string(),
|
||||||
"model": self.model().id(),
|
"model": self.model().id(),
|
||||||
});
|
});
|
||||||
if let Some(temperature) = self.temperature() {
|
if let Some(temperature) = self.temperature() {
|
||||||
@@ -403,6 +474,7 @@ impl Session {
|
|||||||
if let Some(path) = &self.path {
|
if let Some(path) = &self.path {
|
||||||
items.push(("path", path.to_string()));
|
items.push(("path", path.to_string()));
|
||||||
}
|
}
|
||||||
|
items.push(("scope", self.scope.to_string()));
|
||||||
|
|
||||||
if let Some(autoname) = self.autoname() {
|
if let Some(autoname) = self.autoname() {
|
||||||
items.push(("autoname", autoname.to_string()));
|
items.push(("autoname", autoname.to_string()));
|
||||||
@@ -719,17 +791,50 @@ impl Session {
|
|||||||
self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1)
|
self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimated tokens of the messages that compression would actually fold
|
/// Whether the prompt is close enough to the model's hard input limit
|
||||||
/// away: everything except the leading system message (whose content is
|
/// that compression must run regardless of how `compression_threshold`
|
||||||
/// carried into the summary message) and the `keep_last` tail (kept verbatim).
|
/// is configured. Sizes the prompt as the larger of the API-reported
|
||||||
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
|
/// measurement from the last request that reported usage and the local
|
||||||
|
/// estimate, because the estimate can run well below the provider's
|
||||||
|
/// real count.
|
||||||
|
pub fn safety_valve_triggered(
|
||||||
|
&self,
|
||||||
|
valve: f32,
|
||||||
|
keep_last: usize,
|
||||||
|
last_prompt_tokens: Option<usize>,
|
||||||
|
) -> bool {
|
||||||
|
if self.compressing || !valve.is_finite() || valve <= 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(max_input_tokens) = self.model().max_input_tokens() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// With nothing to fold away, compressing is a no-op; firing anyway
|
||||||
|
// would spin compress attempts while a single in-flight turn holds
|
||||||
|
// all the tokens.
|
||||||
|
if self.reclaimable_tokens(keep_last) == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
last_prompt_tokens.unwrap_or(0).max(self.tokens())
|
||||||
|
>= (valve * max_input_tokens as f32) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The messages that compression would actually fold away: everything
|
||||||
|
/// except the leading system message (whose content is carried into the
|
||||||
|
/// summary message) and the `keep_last` tail (kept verbatim).
|
||||||
|
pub fn foldable_messages(&self, keep_last: usize) -> &[Message] {
|
||||||
let start = usize::from(
|
let start = usize::from(
|
||||||
self.messages
|
self.messages
|
||||||
.first()
|
.first()
|
||||||
.is_some_and(|v| v.role == MessageRole::System),
|
.is_some_and(|v| v.role == MessageRole::System),
|
||||||
);
|
);
|
||||||
let end = self.messages.len().saturating_sub(keep_last).max(start);
|
let end = self.messages.len().saturating_sub(keep_last).max(start);
|
||||||
self.model().total_tokens(&self.messages[start..end])
|
&self.messages[start..end]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimated tokens of the messages compression would fold away.
|
||||||
|
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
|
||||||
|
self.model().total_tokens(self.foldable_messages(keep_last))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compressing(&self) -> bool {
|
pub fn compressing(&self) -> bool {
|
||||||
@@ -853,17 +958,52 @@ impl Session {
|
|||||||
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
|
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
|
||||||
ensure_parent_exists(session_path)?;
|
ensure_parent_exists(session_path)?;
|
||||||
|
|
||||||
self.path = Some(session_path.display().to_string());
|
|
||||||
|
|
||||||
let content = serde_yaml::to_string(&self)
|
let content = serde_yaml::to_string(&self)
|
||||||
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
|
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
|
||||||
write(session_path, content).with_context(|| {
|
// Write through a sibling temp file and rename it over the target so a
|
||||||
|
// symlink planted at the session path is replaced, never followed. The
|
||||||
|
// temp file is opened with create_new so a symlink planted at the temp
|
||||||
|
// path is not followed either; a stale entry there is removed and the
|
||||||
|
// open retried once.
|
||||||
|
let mut tmp_name = session_path.as_os_str().to_os_string();
|
||||||
|
tmp_name.push(format!(".{}.tmp", process::id()));
|
||||||
|
let tmp_path = PathBuf::from(tmp_name);
|
||||||
|
let write_context = || {
|
||||||
format!(
|
format!(
|
||||||
"Failed to write session '{}' to '{}'",
|
"Failed to write session '{}' to '{}'",
|
||||||
self.name,
|
self.name,
|
||||||
session_path.display()
|
session_path.display()
|
||||||
)
|
)
|
||||||
})?;
|
};
|
||||||
|
let open_tmp = || {
|
||||||
|
OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&tmp_path)
|
||||||
|
};
|
||||||
|
let mut tmp_file = match open_tmp() {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||||
|
remove_file(&tmp_path).with_context(write_context)?;
|
||||||
|
open_tmp().with_context(write_context)?
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error).with_context(write_context),
|
||||||
|
};
|
||||||
|
if let Err(error) = tmp_file
|
||||||
|
.write_all(content.as_bytes())
|
||||||
|
.and_then(|_| tmp_file.flush())
|
||||||
|
{
|
||||||
|
drop(tmp_file);
|
||||||
|
let _ = remove_file(&tmp_path);
|
||||||
|
return Err(error).with_context(write_context);
|
||||||
|
}
|
||||||
|
drop(tmp_file);
|
||||||
|
if let Err(error) = rename(&tmp_path, session_path) {
|
||||||
|
let _ = remove_file(&tmp_path);
|
||||||
|
return Err(error).with_context(write_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.path = Some(session_path.display().to_string());
|
||||||
|
|
||||||
if is_repl {
|
if is_repl {
|
||||||
println!("✓ Saved the session to '{}'.", session_path.display());
|
println!("✓ Saved the session to '{}'.", session_path.display());
|
||||||
@@ -1109,7 +1249,7 @@ impl AutoName {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, TokenUsage,
|
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, ModelData, TokenUsage,
|
||||||
};
|
};
|
||||||
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
|
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
|
||||||
use crate::function::{Functions, ToolCall, ToolResult};
|
use crate::function::{Functions, ToolCall, ToolResult};
|
||||||
@@ -1284,6 +1424,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_scope_is_not_serialized() {
|
||||||
|
let mut session = Session::default();
|
||||||
|
session.set_scope(SessionScope::Workspace);
|
||||||
|
|
||||||
|
let yaml = serde_yaml::to_string(&session).unwrap();
|
||||||
|
|
||||||
|
assert!(!yaml.contains("scope"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_deserialization_ignores_injected_role_hooks() {
|
fn session_deserialization_ignores_injected_role_hooks() {
|
||||||
// A session file is attacker-writable data: a `role_hooks:` key
|
// A session file is attacker-writable data: a `role_hooks:` key
|
||||||
@@ -1323,8 +1473,10 @@ mod tests {
|
|||||||
fn session_export_includes_real_usage_only_when_reported() {
|
fn session_export_includes_real_usage_only_when_reported() {
|
||||||
let empty = Session::default().export().unwrap();
|
let empty = Session::default().export().unwrap();
|
||||||
assert!(!empty.contains("real_input_tokens"));
|
assert!(!empty.contains("real_input_tokens"));
|
||||||
|
assert!(empty.contains("scope: global"));
|
||||||
|
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
|
session.set_scope(SessionScope::Workspace);
|
||||||
session.accumulate_token_usage(&TokenUsage {
|
session.accumulate_token_usage(&TokenUsage {
|
||||||
input_tokens: Some(5),
|
input_tokens: Some(5),
|
||||||
output_tokens: Some(7),
|
output_tokens: Some(7),
|
||||||
@@ -1332,12 +1484,97 @@ mod tests {
|
|||||||
cache_read_input_tokens: Some(3),
|
cache_read_input_tokens: Some(3),
|
||||||
});
|
});
|
||||||
let exported = session.export().unwrap();
|
let exported = session.export().unwrap();
|
||||||
|
assert!(exported.contains("scope: workspace"));
|
||||||
assert!(exported.contains("real_input_tokens: 5"));
|
assert!(exported.contains("real_input_tokens: 5"));
|
||||||
assert!(exported.contains("real_output_tokens: 7"));
|
assert!(exported.contains("real_output_tokens: 7"));
|
||||||
assert!(exported.contains("cache_read_tokens: 3"));
|
assert!(exported.contains("cache_read_tokens: 3"));
|
||||||
assert!(!exported.contains("cache_creation_tokens"));
|
assert!(!exported.contains("cache_creation_tokens"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rendered_lines(session: &Session) -> Vec<String> {
|
||||||
|
let options = crate::render::RenderOptions {
|
||||||
|
raw_markdown: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut render = MarkdownRender::init(options).unwrap();
|
||||||
|
session
|
||||||
|
.render(&mut render, &None)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_render_reports_scope_line() {
|
||||||
|
let mut session = Session::default();
|
||||||
|
assert!(
|
||||||
|
rendered_lines(&session)
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.starts_with("scope") && line.ends_with("global"))
|
||||||
|
);
|
||||||
|
|
||||||
|
session.set_scope(SessionScope::Workspace);
|
||||||
|
assert!(
|
||||||
|
rendered_lines(&session)
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.starts_with("scope") && line.ends_with("workspace"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn session_save_replaces_a_symlink_instead_of_following_it() {
|
||||||
|
let unique = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!("coyote-session-save-symlink-{unique}"));
|
||||||
|
let sessions_dir = dir.join("sessions");
|
||||||
|
std::fs::create_dir_all(&sessions_dir).unwrap();
|
||||||
|
let target = dir.join("target.txt");
|
||||||
|
std::fs::write(&target, "untouched").unwrap();
|
||||||
|
let link = sessions_dir.join("link.yaml");
|
||||||
|
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let mut session = Session::default();
|
||||||
|
session.save("link", &link, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_to_string(&target).unwrap(), "untouched");
|
||||||
|
assert!(link.symlink_metadata().unwrap().is_file());
|
||||||
|
assert!(read_to_string(&link).unwrap().contains("model:"));
|
||||||
|
assert_eq!(session.name(), "link");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn session_save_does_not_follow_a_symlink_planted_at_the_temp_path() {
|
||||||
|
let unique = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!("coyote-session-save-tmp-symlink-{unique}"));
|
||||||
|
let sessions_dir = dir.join("sessions");
|
||||||
|
std::fs::create_dir_all(&sessions_dir).unwrap();
|
||||||
|
let target = dir.join("target.txt");
|
||||||
|
std::fs::write(&target, "untouched").unwrap();
|
||||||
|
let destination = sessions_dir.join("notes.yaml");
|
||||||
|
let mut tmp_name = destination.as_os_str().to_os_string();
|
||||||
|
tmp_name.push(format!(".{}.tmp", process::id()));
|
||||||
|
let tmp_path = PathBuf::from(tmp_name);
|
||||||
|
std::os::unix::fs::symlink(&target, &tmp_path).unwrap();
|
||||||
|
|
||||||
|
let mut session = Session::default();
|
||||||
|
session.save("notes", &destination, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_to_string(&target).unwrap(), "untouched");
|
||||||
|
assert!(destination.symlink_metadata().unwrap().is_file());
|
||||||
|
assert!(read_to_string(&destination).unwrap().contains("model:"));
|
||||||
|
assert!(!tmp_path.exists(), "temp file must not be left behind");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_todo_state_survives_yaml_round_trip() {
|
fn session_todo_state_survives_yaml_round_trip() {
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
@@ -1578,6 +1815,85 @@ mod tests {
|
|||||||
assert!(session.needs_compression(100, 1));
|
assert!(session.needs_compression(100, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session with a system message, a large user message, and a small
|
||||||
|
/// assistant reply, on a model with the given input limit. With
|
||||||
|
/// `keep_last = 1` the user message is reclaimable; with `keep_last = 2`
|
||||||
|
/// nothing is.
|
||||||
|
fn valve_session(max_input_tokens: Option<usize>) -> Session {
|
||||||
|
let mut session = Session::default();
|
||||||
|
let mut data = ModelData::new("valve-model");
|
||||||
|
data.max_input_tokens = max_input_tokens;
|
||||||
|
session.set_model(Model::from_config("provider", &[data]).remove(0));
|
||||||
|
session.messages.push(Message::new(
|
||||||
|
MessageRole::System,
|
||||||
|
MessageContent::Text("x".repeat(400)),
|
||||||
|
));
|
||||||
|
session.messages.push(Message::new(
|
||||||
|
MessageRole::User,
|
||||||
|
MessageContent::Text("y".repeat(4000)),
|
||||||
|
));
|
||||||
|
session.messages.push(Message::new(
|
||||||
|
MessageRole::Assistant,
|
||||||
|
MessageContent::Text("ok".to_string()),
|
||||||
|
));
|
||||||
|
session.update_tokens();
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_fires_from_estimate() {
|
||||||
|
let session = valve_session(Some(1000));
|
||||||
|
|
||||||
|
assert!(session.tokens() >= 800);
|
||||||
|
assert!(session.safety_valve_triggered(0.8, 1, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_fires_from_real_usage_over_estimate() {
|
||||||
|
let session = valve_session(Some(1_000_000));
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 1, None));
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 1, Some(700_000)));
|
||||||
|
assert!(session.safety_valve_triggered(0.8, 1, Some(800_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_disabled_when_zero() {
|
||||||
|
let session = valve_session(Some(1000));
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.0, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_disabled_when_nan() {
|
||||||
|
let session = valve_session(Some(1000));
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(f32::NAN, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_respects_compressing() {
|
||||||
|
let mut session = valve_session(Some(1000));
|
||||||
|
session.set_compressing(true);
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_requires_reclaimable_tokens() {
|
||||||
|
let session = valve_session(Some(1000));
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 2, Some(1_000_000)));
|
||||||
|
assert!(session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_safety_valve_requires_model_max_input_tokens() {
|
||||||
|
let session = valve_session(None);
|
||||||
|
|
||||||
|
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_set_compressing_flag() {
|
fn session_set_compressing_flag() {
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
|
|||||||
+782
-70
@@ -13,7 +13,8 @@ use crate::client::{
|
|||||||
};
|
};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
||||||
StateFlags, flatten_prompt_messages, macro_execute, resolve_prompt_args, sanitize_display_text,
|
SessionScope, StateFlags, flatten_prompt_messages, macro_execute, resolve_prompt_args,
|
||||||
|
sanitize_display_text,
|
||||||
};
|
};
|
||||||
use crate::config::{AssetCategory, paths};
|
use crate::config::{AssetCategory, paths};
|
||||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||||
@@ -1088,11 +1089,24 @@ pub async fn run_repl_command(
|
|||||||
Some(("role", name)) => {
|
Some(("role", name)) => {
|
||||||
ctx.save_role(name)?;
|
ctx.save_role(name)?;
|
||||||
}
|
}
|
||||||
Some(("session", name)) => {
|
Some(("session", rest)) => match parse_repl_save_session(rest) {
|
||||||
ctx.save_session(name)?;
|
ReplSaveSessionDispatch::Run { name, scope } => {
|
||||||
}
|
ctx.save_session(name.as_deref(), scope)?;
|
||||||
|
}
|
||||||
|
ReplSaveSessionDispatch::Help => println!("{}", repl_save_session_help()),
|
||||||
|
ReplSaveSessionDispatch::Conflict => {
|
||||||
|
println!("--workspace and --global are mutually exclusive")
|
||||||
|
}
|
||||||
|
ReplSaveSessionDispatch::Usage => {
|
||||||
|
println!(
|
||||||
|
"Usage: .save session [name] [--workspace|--global] (see `.save session --help`)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
_ => {
|
_ => {
|
||||||
println!(r#"Usage: .save <role|session> [name]"#)
|
println!(
|
||||||
|
r#"Usage: .save role [name] | .save session [name] [--workspace|--global]"#
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
".edit" => {
|
".edit" => {
|
||||||
@@ -1515,6 +1529,8 @@ async fn ask_inner(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut input = input;
|
let mut input = input;
|
||||||
let mut with_embeddings = with_embeddings;
|
let mut with_embeddings = with_embeddings;
|
||||||
|
// One compress-and-retry per fresh input; tool-loop iterations share it.
|
||||||
|
let mut retried_after_compress = false;
|
||||||
loop {
|
loop {
|
||||||
if input.is_empty() {
|
if input.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1530,6 +1546,10 @@ async fn ask_inner(
|
|||||||
|
|
||||||
let app = Arc::clone(&ctx.app.config);
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
|
||||||
|
if maybe_auto_compress(ctx, app.as_ref()).await.succeeded {
|
||||||
|
input.refresh_session(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if graph::active_agent_graph_name(ctx).is_some() {
|
if graph::active_agent_graph_name(ctx).is_some() {
|
||||||
ctx.before_chat_completion(&input)?;
|
ctx.before_chat_completion(&input)?;
|
||||||
let output =
|
let output =
|
||||||
@@ -1560,6 +1580,18 @@ async fn ask_inner(
|
|||||||
match result {
|
match result {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
if compress_for_error_retry(
|
||||||
|
ctx,
|
||||||
|
app.as_ref(),
|
||||||
|
retried_after_compress,
|
||||||
|
&abort_signal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
input.refresh_session(ctx);
|
||||||
|
retried_after_compress = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ctx.on_chat_completion_error(app.as_ref(), &input);
|
ctx.on_chat_completion_error(app.as_ref(), &input);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
@@ -1574,6 +1606,7 @@ async fn ask_inner(
|
|||||||
match check_pending_tasks_guardrail(ctx) {
|
match check_pending_tasks_guardrail(ctx) {
|
||||||
GuardrailAction::Inject(prompt) => {
|
GuardrailAction::Inject(prompt) => {
|
||||||
input = Input::from_str(ctx, &prompt, None)?;
|
input = Input::from_str(ctx, &prompt, None)?;
|
||||||
|
retried_after_compress = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
GuardrailAction::ForceTerminate(ids) => {
|
GuardrailAction::ForceTerminate(ids) => {
|
||||||
@@ -1617,6 +1650,7 @@ async fn ask_inner(
|
|||||||
format!("{prompt}\n\n{todo_state}")
|
format!("{prompt}\n\n{todo_state}")
|
||||||
};
|
};
|
||||||
input = Input::from_str(ctx, &full_prompt, None)?;
|
input = Input::from_str(ctx, &full_prompt, None)?;
|
||||||
|
retried_after_compress = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1637,63 +1671,45 @@ async fn ask_inner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let keep_last = ctx.compression_keep_last();
|
let agent_can_continue_after_compress =
|
||||||
let needs_compression = ctx
|
should_continue(ctx) && !abort_signal.aborted_ctrlc();
|
||||||
.session
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|s| s.needs_compression(app.compression_threshold, keep_last));
|
|
||||||
|
|
||||||
if needs_compression {
|
// Auto-continue keys off the compression CONDITION, not the outcome:
|
||||||
let agent_can_continue_after_compress =
|
// a transiently failed compression retries at the next turn end, and
|
||||||
should_continue(ctx) && !abort_signal.aborted_ctrlc();
|
// gating on success would stall an unattended agent at the pause
|
||||||
|
// banner with incomplete todos.
|
||||||
|
let compression = maybe_auto_compress(ctx, app.as_ref()).await;
|
||||||
|
if compression.fired && agent_can_continue_after_compress {
|
||||||
|
let full_prompt = {
|
||||||
|
let config = ctx.auto_continue_config();
|
||||||
|
let todo_state = ctx.todo_list.render_for_model();
|
||||||
|
let remaining = ctx.todo_list.incomplete_count();
|
||||||
|
ctx.increment_auto_continue_count();
|
||||||
|
let count = ctx.auto_continue_count;
|
||||||
|
let max = config.max_continues;
|
||||||
|
|
||||||
if let Some(session) = ctx.session.as_mut() {
|
let prompt = config
|
||||||
session.set_compressing(true);
|
.continuation_prompt
|
||||||
}
|
.as_deref()
|
||||||
|
.unwrap_or(DEFAULT_CONTINUATION_PROMPT);
|
||||||
|
|
||||||
let color = if app.light_theme() {
|
let color = if app.light_theme() {
|
||||||
nu_ansi_term::Color::LightGray
|
nu_ansi_term::Color::LightGray
|
||||||
} else {
|
} else {
|
||||||
nu_ansi_term::Color::DarkGray
|
nu_ansi_term::Color::DarkGray
|
||||||
};
|
|
||||||
eprintln!("\n📢 {}", color.italic().paint("Compressing the session."),);
|
|
||||||
|
|
||||||
auto_compress_session(ctx).await;
|
|
||||||
if let Some(session) = ctx.session.as_mut() {
|
|
||||||
session.set_compressing(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if agent_can_continue_after_compress {
|
|
||||||
let full_prompt = {
|
|
||||||
let config = ctx.auto_continue_config();
|
|
||||||
let todo_state = ctx.todo_list.render_for_model();
|
|
||||||
let remaining = ctx.todo_list.incomplete_count();
|
|
||||||
ctx.increment_auto_continue_count();
|
|
||||||
let count = ctx.auto_continue_count;
|
|
||||||
let max = config.max_continues;
|
|
||||||
|
|
||||||
let prompt = config
|
|
||||||
.continuation_prompt
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(DEFAULT_CONTINUATION_PROMPT);
|
|
||||||
|
|
||||||
let color = if app.light_theme() {
|
|
||||||
nu_ansi_term::Color::LightGray
|
|
||||||
} else {
|
|
||||||
nu_ansi_term::Color::DarkGray
|
|
||||||
};
|
|
||||||
eprintln!(
|
|
||||||
"\n📋 {}",
|
|
||||||
color.italic().paint(format!(
|
|
||||||
"Auto-continuing after compression ({count}/{max}): {remaining} incomplete todo(s) remain"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
format!("{prompt}\n\n{todo_state}")
|
|
||||||
};
|
};
|
||||||
input = Input::from_str(ctx, &full_prompt, None)?;
|
eprintln!(
|
||||||
continue;
|
"\n📋 {}",
|
||||||
}
|
color.italic().paint(format!(
|
||||||
|
"Auto-continuing after compression ({count}/{max}): {remaining} incomplete todo(s) remain"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
format!("{prompt}\n\n{todo_state}")
|
||||||
|
};
|
||||||
|
input = Input::from_str(ctx, &full_prompt, None)?;
|
||||||
|
retried_after_compress = false;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
print_turn_divider();
|
print_turn_divider();
|
||||||
@@ -1709,15 +1725,87 @@ fn print_turn_divider() {
|
|||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn auto_compress_session(ctx: &mut RequestContext) {
|
struct AutoCompressOutcome {
|
||||||
|
fired: bool,
|
||||||
|
succeeded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn maybe_auto_compress(ctx: &mut RequestContext, app: &AppConfig) -> AutoCompressOutcome {
|
||||||
|
let keep_last = ctx.compression_keep_last();
|
||||||
|
let should_compress = ctx.session.as_ref().is_some_and(|session| {
|
||||||
|
session.needs_compression(app.compression_threshold, keep_last)
|
||||||
|
|| session.safety_valve_triggered(
|
||||||
|
app.compression_safety_valve,
|
||||||
|
keep_last,
|
||||||
|
ctx.last_prompt_token_usage,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if !should_compress {
|
||||||
|
return AutoCompressOutcome {
|
||||||
|
fired: false,
|
||||||
|
succeeded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(session) = ctx.session.as_mut() {
|
||||||
|
session.set_compressing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let color = if app.light_theme() {
|
||||||
|
nu_ansi_term::Color::LightGray
|
||||||
|
} else {
|
||||||
|
nu_ansi_term::Color::DarkGray
|
||||||
|
};
|
||||||
|
eprintln!("\n📢 {}", color.italic().paint("Compressing the session."),);
|
||||||
|
|
||||||
|
let compressed = auto_compress_session(ctx).await;
|
||||||
|
if let Some(session) = ctx.session.as_mut() {
|
||||||
|
session.set_compressing(false);
|
||||||
|
}
|
||||||
|
if compressed {
|
||||||
|
ctx.last_prompt_token_usage = None;
|
||||||
|
}
|
||||||
|
AutoCompressOutcome {
|
||||||
|
fired: true,
|
||||||
|
succeeded: compressed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compress_for_error_retry(
|
||||||
|
ctx: &mut RequestContext,
|
||||||
|
app: &AppConfig,
|
||||||
|
already_retried: bool,
|
||||||
|
abort_signal: &AbortSignal,
|
||||||
|
) -> bool {
|
||||||
|
if already_retried || abort_signal.aborted() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let keep_last = ctx.compression_keep_last();
|
||||||
|
let valve_triggered = ctx.session.as_ref().is_some_and(|session| {
|
||||||
|
session.safety_valve_triggered(
|
||||||
|
app.compression_safety_valve,
|
||||||
|
keep_last,
|
||||||
|
ctx.last_prompt_token_usage,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
valve_triggered && maybe_auto_compress(ctx, app).await.succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auto_compress_session(ctx: &mut RequestContext) -> bool {
|
||||||
match ctx.compress_session().await {
|
match ctx.compress_session().await {
|
||||||
Ok(()) => hooks::fire(
|
Ok(()) => {
|
||||||
HookEvent::SessionCompressed,
|
hooks::fire(
|
||||||
ctx,
|
HookEvent::SessionCompressed,
|
||||||
&hooks::role_extras(ctx),
|
ctx,
|
||||||
None,
|
&hooks::role_extras(ctx),
|
||||||
),
|
None,
|
||||||
Err(err) => warn!("Failed to compress the session: {err}"),
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to compress the session: {err}");
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1860,6 +1948,61 @@ fn parse_repl_uninstall(args: Option<&str>) -> ReplUninstallDispatch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repl_save_session_help() -> String {
|
||||||
|
r#"Save the current session to a file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
.save session [name] [--workspace|--global]
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--workspace Save under .coyote/ in the current workspace
|
||||||
|
--global Save under the global config dir
|
||||||
|
|
||||||
|
Without a flag the session is re-saved wherever it currently lives; a brand-new
|
||||||
|
session lives in the global scope. Re-homing a session never deletes the old file."#
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
enum ReplSaveSessionDispatch {
|
||||||
|
Run {
|
||||||
|
name: Option<String>,
|
||||||
|
scope: Option<SessionScope>,
|
||||||
|
},
|
||||||
|
Help,
|
||||||
|
Conflict,
|
||||||
|
Usage,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_repl_save_session(args: Option<&str>) -> ReplSaveSessionDispatch {
|
||||||
|
let tokens: Vec<&str> = args.unwrap_or("").split_whitespace().collect();
|
||||||
|
if tokens
|
||||||
|
.iter()
|
||||||
|
.any(|token| *token == "--help" || *token == "-h")
|
||||||
|
{
|
||||||
|
return ReplSaveSessionDispatch::Help;
|
||||||
|
}
|
||||||
|
let mut scope = None;
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for token in tokens {
|
||||||
|
let requested = match token {
|
||||||
|
"--workspace" => SessionScope::Workspace,
|
||||||
|
"--global" => SessionScope::Global,
|
||||||
|
other if other.starts_with('-') => return ReplSaveSessionDispatch::Usage,
|
||||||
|
other => {
|
||||||
|
names.push(other);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if scope.is_some_and(|current| current != requested) {
|
||||||
|
return ReplSaveSessionDispatch::Conflict;
|
||||||
|
}
|
||||||
|
scope = Some(requested);
|
||||||
|
}
|
||||||
|
let name = (!names.is_empty()).then(|| names.join(" "));
|
||||||
|
ReplSaveSessionDispatch::Run { name, scope }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn builtin_command_names() -> Vec<&'static str> {
|
pub fn builtin_command_names() -> Vec<&'static str> {
|
||||||
let mut names: Vec<&'static str> = REPL_COMMANDS
|
let mut names: Vec<&'static str> = REPL_COMMANDS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2036,10 +2179,12 @@ pub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::client::{ClientConfig, Model};
|
use crate::client::{ClientConfig, MessageContent, Model, ModelData, ToolCall};
|
||||||
use crate::config::{AppState, Role, RoleLike, Session, TEMP_ROLE_NAME, WorkingMode};
|
use crate::config::{AppState, Role, RoleLike, Session, TEMP_ROLE_NAME, WorkingMode};
|
||||||
|
use crate::function::ToolResult;
|
||||||
use crate::hooks::{HookDef, HooksMap, test_sink};
|
use crate::hooks::{HookDef, HooksMap, test_sink};
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
|
use serde_json::json;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
@@ -2357,6 +2502,13 @@ mod tests {
|
|||||||
/// resolvable, and the session already holds a user exchange so
|
/// resolvable, and the session already holds a user exchange so
|
||||||
/// `compress_session` has something to summarize.
|
/// `compress_session` has something to summarize.
|
||||||
fn compressible_ctx(marker: &str) -> RequestContext {
|
fn compressible_ctx(marker: &str) -> RequestContext {
|
||||||
|
compressible_ctx_with(marker, AppConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `compressible_ctx` with caller-tuned compression settings, so tests
|
||||||
|
/// can drive `needs_compression` and the safety valve; `dry_run` and the
|
||||||
|
/// default client are enforced here to keep compression offline.
|
||||||
|
fn compressible_ctx_with(marker: &str, config: AppConfig) -> RequestContext {
|
||||||
let mut hooks_map = HooksMap::default();
|
let mut hooks_map = HooksMap::default();
|
||||||
hooks_map.insert(
|
hooks_map.insert(
|
||||||
"session.compressed".to_string(),
|
"session.compressed".to_string(),
|
||||||
@@ -2370,7 +2522,7 @@ mod tests {
|
|||||||
hooks: hooks_map,
|
hooks: hooks_map,
|
||||||
dry_run: true,
|
dry_run: true,
|
||||||
clients: vec![ClientConfig::default()],
|
clients: vec![ClientConfig::default()],
|
||||||
..Default::default()
|
..config
|
||||||
});
|
});
|
||||||
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Repl);
|
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Repl);
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
@@ -2382,6 +2534,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.add_message(&input, "hi")
|
.add_message(&input, "hi")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
ctx.session.as_mut().unwrap().update_tokens();
|
||||||
ctx
|
ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2432,7 +2585,7 @@ mod tests {
|
|||||||
let marker = "auto-compress-ok-f8r";
|
let marker = "auto-compress-ok-f8r";
|
||||||
run_async(async {
|
run_async(async {
|
||||||
let mut ctx = compressible_ctx(marker);
|
let mut ctx = compressible_ctx(marker);
|
||||||
auto_compress_session(&mut ctx).await;
|
assert!(auto_compress_session(&mut ctx).await);
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2494,7 +2647,7 @@ mod tests {
|
|||||||
// An empty session makes compress_session fail; the seam must
|
// An empty session makes compress_session fail; the seam must
|
||||||
// swallow the error (no panic, no propagation) and fire nothing.
|
// swallow the error (no panic, no propagation) and fire nothing.
|
||||||
ctx.session = Some(Session::default());
|
ctx.session = Some(Session::default());
|
||||||
auto_compress_session(&mut ctx).await;
|
assert!(!auto_compress_session(&mut ctx).await);
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2504,6 +2657,427 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn maybe_auto_compress_skips_when_neither_threshold_nor_valve_fires() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "maybe-compress-skip-q7m";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = compressible_ctx_with(
|
||||||
|
marker,
|
||||||
|
AppConfig {
|
||||||
|
compression_keep_last: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
|
||||||
|
assert!(!maybe_auto_compress(&mut ctx, app.as_ref()).await.fired);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn maybe_auto_compress_fires_on_threshold_each_loop_iteration() {
|
||||||
|
// Exercises the shared seam the ask loop calls at the top of every
|
||||||
|
// iteration (tool-loop, guardrail-inject, and auto-continue turns
|
||||||
|
// included) as well as at turn end.
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "maybe-compress-threshold-n2x";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = compressible_ctx_with(
|
||||||
|
marker,
|
||||||
|
AppConfig {
|
||||||
|
compression_threshold: 1,
|
||||||
|
compression_keep_last: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
|
||||||
|
assert!(maybe_auto_compress(&mut ctx, app.as_ref()).await.succeeded);
|
||||||
|
assert!(
|
||||||
|
!ctx.is_compressing_session(),
|
||||||
|
"the compressing flag must be released after compression"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn maybe_auto_compress_reports_no_success_when_compression_fails() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "maybe-compress-fail-g4z";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = compressible_ctx_with(
|
||||||
|
marker,
|
||||||
|
AppConfig {
|
||||||
|
compression_threshold: 1,
|
||||||
|
compression_keep_last: 0,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// A session whose only reclaimable message is non-user: the
|
||||||
|
// threshold fires, but `compress_session` bails on a session
|
||||||
|
// without user messages.
|
||||||
|
{
|
||||||
|
let input = Input::from_str(&ctx, "more context", None).unwrap();
|
||||||
|
let session = ctx.session.as_mut().unwrap();
|
||||||
|
session.add_message(&input, &"y".repeat(400)).unwrap();
|
||||||
|
session.compress("recap".into(), 1);
|
||||||
|
}
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
|
||||||
|
let outcome = maybe_auto_compress(&mut ctx, app.as_ref()).await;
|
||||||
|
assert!(outcome.fired, "the compression condition must fire");
|
||||||
|
assert!(
|
||||||
|
!outcome.succeeded,
|
||||||
|
"a failed compression must not report success"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ctx.is_compressing_session(),
|
||||||
|
"the compressing flag must be released after a failed compression"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn failed_compression_still_reports_fired_with_kept_tail() {
|
||||||
|
// Pins the turn-end auto-continue gate: with keep_last > 0, a
|
||||||
|
// compression whose condition fired but whose summarization FAILED
|
||||||
|
// must still report `fired`, so an unattended agent auto-continues
|
||||||
|
// and retries the compression at the next turn end instead of
|
||||||
|
// stalling at the pause banner.
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "maybe-compress-fired-fail-t8r";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = compressible_ctx_with(
|
||||||
|
marker,
|
||||||
|
AppConfig {
|
||||||
|
compression_threshold: 1,
|
||||||
|
compression_keep_last: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// A session whose foldable middle is a tool transcript and whose
|
||||||
|
// kept tail is an assistant reply: the threshold fires, but
|
||||||
|
// `compress_session` bails on a session without user messages.
|
||||||
|
{
|
||||||
|
let input = Input::from_str(&ctx, "more context", None)
|
||||||
|
.unwrap()
|
||||||
|
.merge_tool_results(
|
||||||
|
"calling a tool to gather context".into(),
|
||||||
|
vec![ToolResult::new(ToolCall::default(), json!("ok"))],
|
||||||
|
);
|
||||||
|
let session = ctx.session.as_mut().unwrap();
|
||||||
|
session.add_message(&input, &"y".repeat(400)).unwrap();
|
||||||
|
session.compress("recap".into(), 2);
|
||||||
|
}
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
|
||||||
|
let outcome = maybe_auto_compress(&mut ctx, app.as_ref()).await;
|
||||||
|
assert!(outcome.fired, "the compression condition must fire");
|
||||||
|
assert!(
|
||||||
|
!outcome.succeeded,
|
||||||
|
"a failed compression must not report success"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn loop_top_compression_refresh_rebuilds_from_compressed_session() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "loop-top-refresh-h5w";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = compressible_ctx_with(
|
||||||
|
marker,
|
||||||
|
AppConfig {
|
||||||
|
compression_threshold: 1,
|
||||||
|
compression_keep_last: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
let mut input = Input::from_str(&ctx, "next turn", None).unwrap();
|
||||||
|
let stale = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert!(maybe_auto_compress(&mut ctx, app.as_ref()).await.succeeded);
|
||||||
|
input.refresh_session(&ctx);
|
||||||
|
|
||||||
|
let refreshed = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
refreshed, stale,
|
||||||
|
"the request built after a loop-top compression must reflect the compressed session"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn maybe_auto_compress_fires_on_safety_valve_despite_huge_threshold() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "maybe-compress-valve-v5c";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = compressible_ctx_with(
|
||||||
|
marker,
|
||||||
|
AppConfig {
|
||||||
|
compression_threshold: 1_000_000,
|
||||||
|
compression_keep_last: 1,
|
||||||
|
compression_safety_valve: 0.8,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// A distinct name: `set_model` is a no-op for an unchanged model
|
||||||
|
// id, and the ctx's default session model declares no limit.
|
||||||
|
let mut data = ModelData::new("test-compress-model-limited");
|
||||||
|
data.max_input_tokens = Some(100_000);
|
||||||
|
let model = Model::from_config("openai", &[data]).remove(0);
|
||||||
|
ctx.session.as_mut().unwrap().set_model(model);
|
||||||
|
// The last successful request measured a prompt near the limit
|
||||||
|
// even though the local estimate is tiny.
|
||||||
|
ctx.last_prompt_token_usage = Some(90_000);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
|
||||||
|
assert!(maybe_auto_compress(&mut ctx, app.as_ref()).await.succeeded);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ctx where only the safety valve can trigger compression: the last
|
||||||
|
/// successful request measured a prompt near the session model's limit
|
||||||
|
/// while the threshold is out of reach.
|
||||||
|
fn valve_tripped_ctx(marker: &str) -> RequestContext {
|
||||||
|
let mut ctx = compressible_ctx_with(
|
||||||
|
marker,
|
||||||
|
AppConfig {
|
||||||
|
compression_threshold: 1_000_000,
|
||||||
|
compression_keep_last: 1,
|
||||||
|
compression_safety_valve: 0.8,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut data = ModelData::new("test-compress-model-limited");
|
||||||
|
data.max_input_tokens = Some(100_000);
|
||||||
|
let model = Model::from_config("openai", &[data]).remove(0);
|
||||||
|
ctx.session.as_mut().unwrap().set_model(model);
|
||||||
|
ctx.last_prompt_token_usage = Some(90_000);
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn successful_compression_clears_stale_prompt_measurement() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "maybe-compress-stale-p6b";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = valve_tripped_ctx(marker);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
|
||||||
|
assert!(maybe_auto_compress(&mut ctx, app.as_ref()).await.succeeded);
|
||||||
|
assert_eq!(
|
||||||
|
ctx.last_prompt_token_usage, None,
|
||||||
|
"a successful compression must drop the pre-compression prompt measurement"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A subsequent usage-less turn makes tokens reclaimable again;
|
||||||
|
// the valve must not re-fire from the vanished prompt's
|
||||||
|
// measurement.
|
||||||
|
{
|
||||||
|
let input = Input::from_str(&ctx, "next turn", None).unwrap();
|
||||||
|
let session = ctx.session.as_mut().unwrap();
|
||||||
|
session.add_message(&input, "reply").unwrap();
|
||||||
|
}
|
||||||
|
assert!(!maybe_auto_compress(&mut ctx, app.as_ref()).await.fired);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts `on_chat_completion_error` checkpoints in the session; the
|
||||||
|
/// literal must match `INTERRUPTED_RESPONSE_TEXT`, whose presence is
|
||||||
|
/// pinned by the give-up tests below.
|
||||||
|
fn interrupted_checkpoint_count(ctx: &RequestContext) -> usize {
|
||||||
|
ctx.session.as_ref().map_or(0, |session| {
|
||||||
|
session
|
||||||
|
.messages()
|
||||||
|
.iter()
|
||||||
|
.filter(|message| {
|
||||||
|
matches!(&message.content, MessageContent::Text(text)
|
||||||
|
if text.ends_with("[Response interrupted due to error]"))
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn error_retry_compresses_once_without_checkpointing() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "error-retry-valve-k1s";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = valve_tripped_ctx(marker);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
let abort_signal = create_abort_signal();
|
||||||
|
|
||||||
|
assert!(compress_for_error_retry(&mut ctx, app.as_ref(), false, &abort_signal).await);
|
||||||
|
assert!(
|
||||||
|
!ctx.is_compressing_session(),
|
||||||
|
"the compressing flag must be released after compression"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
interrupted_checkpoint_count(&ctx),
|
||||||
|
0,
|
||||||
|
"the retry path must not checkpoint the in-flight turn"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn error_retry_gives_up_on_second_failure_with_single_checkpoint() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "error-retry-giveup-p4t";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = valve_tripped_ctx(marker);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
let abort_signal = create_abort_signal();
|
||||||
|
let input = Input::from_str(&ctx, "still too big", None).unwrap();
|
||||||
|
|
||||||
|
assert!(compress_for_error_retry(&mut ctx, app.as_ref(), false, &abort_signal).await);
|
||||||
|
// The retried request failed too: no second compression, and the
|
||||||
|
// give-up path checkpoints the interrupted turn exactly once.
|
||||||
|
assert!(!compress_for_error_retry(&mut ctx, app.as_ref(), true, &abort_signal).await);
|
||||||
|
ctx.on_chat_completion_error(app.as_ref(), &input);
|
||||||
|
assert_eq!(interrupted_checkpoint_count(&ctx), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn error_retry_declines_when_valve_is_quiet() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "error-retry-quiet-m8d";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = compressible_ctx_with(
|
||||||
|
marker,
|
||||||
|
AppConfig {
|
||||||
|
compression_keep_last: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
let abort_signal = create_abort_signal();
|
||||||
|
let input = Input::from_str(&ctx, "transient failure", None).unwrap();
|
||||||
|
|
||||||
|
assert!(!compress_for_error_retry(&mut ctx, app.as_ref(), false, &abort_signal).await);
|
||||||
|
// Valve-false errors keep today's behavior: checkpoint and
|
||||||
|
// surface the error immediately, no compression, no retry.
|
||||||
|
ctx.on_chat_completion_error(app.as_ref(), &input);
|
||||||
|
assert_eq!(interrupted_checkpoint_count(&ctx), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn error_retry_flag_blocks_repeat_and_rearms_on_fresh_input() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "error-retry-flag-c6y";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = valve_tripped_ctx(marker);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
let abort_signal = create_abort_signal();
|
||||||
|
|
||||||
|
// Tool-loop iterations carry the flag forward: a set flag wins
|
||||||
|
// over a still-firing valve.
|
||||||
|
assert!(!compress_for_error_retry(&mut ctx, app.as_ref(), true, &abort_signal).await);
|
||||||
|
assert_eq!(session_compressed_count(marker), 0);
|
||||||
|
// A fresh input rebuild clears the flag and re-arms the retry.
|
||||||
|
assert!(compress_for_error_retry(&mut ctx, app.as_ref(), false, &abort_signal).await);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn error_retry_refreshed_input_rebuilds_from_compressed_session() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "error-retry-refresh-j7q";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = valve_tripped_ctx(marker);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
let abort_signal = create_abort_signal();
|
||||||
|
let mut input = Input::from_str(&ctx, "still too big", None).unwrap();
|
||||||
|
let stale = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert!(compress_for_error_retry(&mut ctx, app.as_ref(), false, &abort_signal).await);
|
||||||
|
// The snapshot captured at construction is untouched by the
|
||||||
|
// compression; retrying without a refresh would re-send the
|
||||||
|
// failed prompt byte for byte.
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&input.build_messages().unwrap()).unwrap(),
|
||||||
|
stale
|
||||||
|
);
|
||||||
|
|
||||||
|
input.refresh_session(&ctx);
|
||||||
|
let refreshed = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
|
||||||
|
assert_ne!(refreshed, stale);
|
||||||
|
let rebuilt = Input::from_str(&ctx, "still too big", None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
refreshed,
|
||||||
|
serde_json::to_string(&rebuilt.build_messages().unwrap()).unwrap(),
|
||||||
|
"a refreshed input must build the same request as one created after compression"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn error_retry_declines_when_user_aborted() {
|
||||||
|
let _sink = test_sink::install();
|
||||||
|
let marker = "error-retry-abort-b2n";
|
||||||
|
run_async(async {
|
||||||
|
let mut ctx = valve_tripped_ctx(marker);
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
let input = Input::from_str(&ctx, "killed mid-request", None).unwrap();
|
||||||
|
|
||||||
|
let ctrlc = create_abort_signal();
|
||||||
|
ctrlc.set_ctrlc();
|
||||||
|
assert!(!compress_for_error_retry(&mut ctx, app.as_ref(), false, &ctrlc).await);
|
||||||
|
let ctrld = create_abort_signal();
|
||||||
|
ctrld.set_ctrld();
|
||||||
|
assert!(!compress_for_error_retry(&mut ctx, app.as_ref(), false, &ctrld).await);
|
||||||
|
|
||||||
|
// A user-killed request keeps today's behavior: checkpoint and
|
||||||
|
// surface the error, no compression, no re-send.
|
||||||
|
ctx.on_chat_completion_error(app.as_ref(), &input);
|
||||||
|
assert_eq!(interrupted_checkpoint_count(&ctx), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(session_compressed_count(marker), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_process_command_line() {
|
fn test_process_command_line() {
|
||||||
assert_eq!(parse_command(" ."), Some((".", None)));
|
assert_eq!(parse_command(" ."), Some((".", None)));
|
||||||
@@ -2702,6 +3276,144 @@ mod tests {
|
|||||||
assert_eq!(parse_repl_uninstall(None), ReplUninstallDispatch::Usage);
|
assert_eq!(parse_repl_uninstall(None), ReplUninstallDispatch::Usage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_repl_save_session_routes_name_and_scope_in_either_order() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(None),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: None,
|
||||||
|
scope: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("notes")),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: Some("notes".to_string()),
|
||||||
|
scope: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("notes --workspace")),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: Some("notes".to_string()),
|
||||||
|
scope: Some(SessionScope::Workspace)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("--global notes")),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: Some("notes".to_string()),
|
||||||
|
scope: Some(SessionScope::Global)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("--workspace")),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: None,
|
||||||
|
scope: Some(SessionScope::Workspace)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_repl_save_session_rejects_conflicts_and_unknown_flags_but_joins_spaced_names() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("--workspace --global")),
|
||||||
|
ReplSaveSessionDispatch::Conflict
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("notes --force")),
|
||||||
|
ReplSaveSessionDispatch::Usage
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("one two")),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: Some("one two".to_string()),
|
||||||
|
scope: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("--help")),
|
||||||
|
ReplSaveSessionDispatch::Help
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("notes -h")),
|
||||||
|
ReplSaveSessionDispatch::Help
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("--workspace --global -h")),
|
||||||
|
ReplSaveSessionDispatch::Help
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("--global notes --global")),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: Some("notes".to_string()),
|
||||||
|
scope: Some(SessionScope::Global)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repl_save_session_help_text_covers_the_full_surface() {
|
||||||
|
let help = repl_save_session_help();
|
||||||
|
for needle in [
|
||||||
|
"--workspace",
|
||||||
|
"--global",
|
||||||
|
"[name]",
|
||||||
|
"never deletes the old file",
|
||||||
|
] {
|
||||||
|
assert!(help.contains(needle), "save session help missing {needle}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage probe (spec-first, criterion 5): `-h/--help` wins from ANY
|
||||||
|
// position — including when it is preceded by a token that would
|
||||||
|
// otherwise route to Usage (unknown `-` flag) or Conflict (both scope
|
||||||
|
// flags) — while, absent help, an unknown `-` token still routes to
|
||||||
|
// Usage even when a valid scope flag is present, and a repeated
|
||||||
|
// identical flag stays idempotent for `--workspace` too.
|
||||||
|
#[test]
|
||||||
|
fn usage_probe_save_session_help_prescan_beats_usage_and_conflict() {
|
||||||
|
for input in [
|
||||||
|
"--bogus -h",
|
||||||
|
"-h --bogus",
|
||||||
|
"notes --bogus --help",
|
||||||
|
"--workspace --global notes -h",
|
||||||
|
"--help --workspace --global",
|
||||||
|
"one -h two",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some(input)),
|
||||||
|
ReplSaveSessionDispatch::Help,
|
||||||
|
"help must win for {input:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("--workspace --bogus notes")),
|
||||||
|
ReplSaveSessionDispatch::Usage
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("-x")),
|
||||||
|
ReplSaveSessionDispatch::Usage
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("--workspace notes --workspace")),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: Some("notes".to_string()),
|
||||||
|
scope: Some(SessionScope::Workspace)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Non-flag tokens join into ONE name even when a flag sits between them.
|
||||||
|
assert_eq!(
|
||||||
|
parse_repl_save_session(Some("one --global two")),
|
||||||
|
ReplSaveSessionDispatch::Run {
|
||||||
|
name: Some("one two".to_string()),
|
||||||
|
scope: Some(SessionScope::Global)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_install_and_uninstall_help_text_cover_the_full_surface() {
|
fn repl_install_and_uninstall_help_text_cover_the_full_surface() {
|
||||||
let install = repl_install_help();
|
let install = repl_install_help();
|
||||||
|
|||||||
+28
-5
@@ -1,7 +1,10 @@
|
|||||||
//! Test-only helpers shared across modules' unit tests.
|
//! Test-only helpers shared across modules' unit tests.
|
||||||
|
|
||||||
|
use crate::config::WORKSPACE_COYOTE_DIR_NAME;
|
||||||
|
use crate::utils;
|
||||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, Once, OnceLock};
|
use std::sync::{Mutex, Once, OnceLock};
|
||||||
|
|
||||||
struct TestLogCollector;
|
struct TestLogCollector;
|
||||||
@@ -147,12 +150,19 @@ impl Drop for EnvVarGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Points the config dir at a fresh temp directory for the guard's lifetime
|
/// Points the config dir and the workspace config dir at fresh temp
|
||||||
/// and removes it on drop, including on panic. Tests using it must serialize
|
/// directories for the guard's lifetime and removes them on drop, including
|
||||||
/// (`#[serial]`) — the config-dir env var is process-global.
|
/// 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 {
|
pub(crate) struct TestConfigDirGuard {
|
||||||
_env: EnvVarGuard,
|
_env: EnvVarGuard,
|
||||||
pub(crate) path: std::path::PathBuf,
|
_workspace_env: EnvVarGuard,
|
||||||
|
_sessions_env: EnvVarGuard,
|
||||||
|
pub(crate) path: PathBuf,
|
||||||
|
workspace_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestConfigDirGuard {
|
impl TestConfigDirGuard {
|
||||||
@@ -163,9 +173,19 @@ impl TestConfigDirGuard {
|
|||||||
.as_nanos();
|
.as_nanos();
|
||||||
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
||||||
std::fs::create_dir_all(&path).unwrap();
|
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 {
|
Self {
|
||||||
_env: EnvVarGuard::set(crate::utils::get_env_name("config_dir"), &path),
|
_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,
|
path,
|
||||||
|
workspace_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,5 +193,8 @@ impl TestConfigDirGuard {
|
|||||||
impl Drop for TestConfigDirGuard {
|
impl Drop for TestConfigDirGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = std::fs::remove_dir_all(&self.path);
|
let _ = std::fs::remove_dir_all(&self.path);
|
||||||
|
if let Some(root) = self.workspace_path.parent() {
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+108
-2
@@ -15,11 +15,11 @@
|
|||||||
//! a real ~/.coyote_password or a sandbox shell — cannot mask
|
//! a real ~/.coyote_password or a sandbox shell — cannot mask
|
||||||
//! virgin-environment regressions.
|
//! virgin-environment regressions.
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use std::{env, process};
|
||||||
|
|
||||||
fn fresh_config_dir(label: &str) -> PathBuf {
|
fn fresh_config_dir(label: &str) -> PathBuf {
|
||||||
let unique = SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
@@ -438,3 +438,109 @@ fn sync_models_stays_on_bootstrap_path() {
|
|||||||
"--sync-models: expected the builtins bootstrap to populate the config dir"
|
"--sync-models: expected the builtins bootstrap to populate the config dir"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_list_sessions(
|
||||||
|
cwd: &Path,
|
||||||
|
config_dir: &Path,
|
||||||
|
global_sessions: &Path,
|
||||||
|
home_dir: &Path,
|
||||||
|
) -> process::Output {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_coyote"))
|
||||||
|
.arg("--list-sessions")
|
||||||
|
.env("COYOTE_CONFIG_DIR", config_dir)
|
||||||
|
.env("COYOTE_SESSIONS_DIR", global_sessions)
|
||||||
|
.env("HOME", home_dir)
|
||||||
|
.env("USERPROFILE", home_dir)
|
||||||
|
.env_remove("IS_SANDBOX")
|
||||||
|
.env_remove("COYOTE_PROVIDER")
|
||||||
|
.env_remove("COYOTE_PLATFORM")
|
||||||
|
.env_remove("COYOTE_WORKSPACE_CONFIG_DIR")
|
||||||
|
.current_dir(cwd)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_session(dir: &Path, name: &str) {
|
||||||
|
fs::create_dir_all(dir).unwrap();
|
||||||
|
fs::write(dir.join(format!("{name}.yaml")), "messages: []\n").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_probe_list_sessions_merges_cwd_workspace_scope_with_global() {
|
||||||
|
let config_dir = fresh_config_dir("list-sessions-ws");
|
||||||
|
let home_dir = fresh_config_dir("list-sessions-ws-home");
|
||||||
|
let global_sessions = fresh_config_dir("list-sessions-ws-global");
|
||||||
|
let workspace = fresh_config_dir("list-sessions-ws-cwd");
|
||||||
|
let bare_cwd = fresh_config_dir("list-sessions-ws-bare");
|
||||||
|
let workspace_sessions = workspace.join(".coyote").join("sessions");
|
||||||
|
|
||||||
|
seed_session(&global_sessions, "both");
|
||||||
|
seed_session(&global_sessions, "glob-only");
|
||||||
|
seed_session(&workspace_sessions, "both");
|
||||||
|
seed_session(&workspace_sessions, "ws-only");
|
||||||
|
|
||||||
|
let in_workspace = run_list_sessions(&workspace, &config_dir, &global_sessions, &home_dir);
|
||||||
|
let in_bare = run_list_sessions(&bare_cwd, &config_dir, &global_sessions, &home_dir);
|
||||||
|
|
||||||
|
let config_leftover: Vec<_> = fs::read_dir(&config_dir)
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().file_name())
|
||||||
|
.collect();
|
||||||
|
let bare_created_workspace = bare_cwd.join(".coyote").exists();
|
||||||
|
let workspace_entries: Vec<_> = fs::read_dir(&workspace_sessions)
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
for dir in [
|
||||||
|
&config_dir,
|
||||||
|
&home_dir,
|
||||||
|
&global_sessions,
|
||||||
|
&workspace,
|
||||||
|
&bare_cwd,
|
||||||
|
] {
|
||||||
|
let _ = fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (label, output) in [("workspace", &in_workspace), ("bare", &in_bare)] {
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"--list-sessions ({label}): expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = |output: &process::Output| -> Vec<String> {
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
lines(&in_workspace),
|
||||||
|
vec!["both", "glob-only", "ws-only"],
|
||||||
|
"workspace CWD: merged, deduplicated, sorted"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lines(&in_bare),
|
||||||
|
vec!["both", "glob-only"],
|
||||||
|
"CWD without .coyote/: behaviour unchanged (global listing only)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!bare_created_workspace,
|
||||||
|
"--list-sessions must never create .coyote/ in the CWD"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config_leftover.is_empty(),
|
||||||
|
"--list-sessions must write nothing into the config dir: {config_leftover:?}"
|
||||||
|
);
|
||||||
|
let mut workspace_entries = workspace_entries;
|
||||||
|
workspace_entries.sort_unstable();
|
||||||
|
assert_eq!(
|
||||||
|
workspace_entries,
|
||||||
|
vec!["both.yaml", "ws-only.yaml"],
|
||||||
|
"listing is read-only for the workspace sessions dir"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user