Compare commits

...
40 Commits
Author SHA1 Message Date
Dark-Alex-17 4c3c3e69b2 docs: Updated the AI assistance attribution requirements to read as less discouraging of AI assistance and more "allowed only when disclosed"
CI / All (ubuntu-latest) (push) Failing after 27s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-09-30 00:07:45 -06:00
github-actions[bot] 3a017d7fda chore: refresh generated model catalog [skip ci] 2026-09-30 00:55:37 +00:00
github-actions[bot] 53e0f3b54b chore: refresh generated model catalog [skip ci] 2026-09-29 12:37:32 +00:00
github-actions[bot] 22809e2a1b chore: refresh generated model catalog [skip ci] 2026-09-29 00:54:47 +00:00
Dark-Alex-17 9ae26f4194 tests: fixed failing Windows test for odd session names that doesn't allow escape characters in file names 2026-09-28 16:03:48 -06:00
Dark-Alex-17 a3c4b6a364 feat: applied the whetstone's adversary and security-review change suggestions 2026-09-28 15:43:50 -06:00
Dark-Alex-17 72eb5a100c feat: apply whetstone retry handler fixes 2026-09-28 15:10:11 -06:00
Dark-Alex-17 afab1b6da5 feat: increment the max_fix_attempts in the coder from 3 to 5 2026-09-28 13:30:10 -06:00
Dark-Alex-17 c9fcea2849 feat: support workspace-level sessions as well as global sessions 2026-09-28 13:29:25 -06:00
github-actions[bot] e7a7cd9704 chore: refresh generated model catalog [skip ci] 2026-09-28 12:37:40 +00:00
github-actions[bot] 2b3839881c chore: refresh generated model catalog [skip ci] 2026-09-28 00:56:58 +00:00
github-actions[bot] c32ba9157e chore: refresh generated model catalog [skip ci] 2026-09-27 12:31:15 +00:00
github-actions[bot] 0442d77059 chore: refresh generated model catalog [skip ci] 2026-09-27 00:58:07 +00:00
Dark-Alex-17 4e33979a92 fix: sbx bug for reaping zombie processes 2026-09-26 16:19:04 -06:00
github-actions[bot] fac1569646 chore: refresh generated model catalog [skip ci] 2026-09-26 12:32:24 +00:00
github-actions[bot] 6490128f07 chore: refresh generated model catalog [skip ci] 2026-09-26 00:50:35 +00:00
github-actions[bot] 3e496ec2fd chore: refresh generated model catalog [skip ci] 2026-09-25 12:35:35 +00:00
github-actions[bot] 57809ddf29 chore: refresh generated model catalog [skip ci] 2026-09-25 00:52:18 +00:00
github-actions[bot] 184712621e chore: refresh generated model catalog [skip ci] 2026-09-24 12:34:05 +00:00
github-actions[bot] c45d73c94a chore: refresh generated model catalog [skip ci] 2026-09-24 00:50:14 +00:00
github-actions[bot] 4b2ab2f867 chore: refresh generated model catalog [skip ci] 2026-09-23 12:34:07 +00:00
github-actions[bot] 4cec912f8e chore: refresh generated model catalog [skip ci] 2026-09-23 00:48:22 +00:00
github-actions[bot] e0bcf340fe chore: refresh generated model catalog [skip ci] 2026-09-22 12:33:41 +00:00
github-actions[bot] 9609e333f3 chore: refresh generated model catalog [skip ci] 2026-09-22 00:51:55 +00:00
Dark-Alex-17 a8c0ec64fa feat: Deep research upgrade so sisyphus and the architect now have 3 possible research types: librarian standard, librarian deep, and true deep research with reflexion 2026-09-21 12:29:07 -06:00
Dark-Alex-17 37ba25adc5 Merge branch 'main' of github.com:Dark-Alex-17/coyote 2026-09-21 12:20:33 -06:00
Dark-Alex-17 a65280754e feat: Support the merging of custom user-defined models on clients configured in the global configuration file with the built in models 2026-09-21 12:08:53 -06:00
github-actions[bot] b6022654eb chore: refresh generated model catalog [skip ci] 2026-09-21 12:33:59 +00:00
github-actions[bot] 0c17076bdf chore: refresh generated model catalog [skip ci] 2026-09-21 00:54:08 +00:00
github-actions[bot] a85d933d33 chore: refresh generated model catalog [skip ci] 2026-09-20 12:32:28 +00:00
github-actions[bot] 13640981c7 chore: refresh generated model catalog [skip ci] 2026-09-20 00:56:01 +00:00
github-actions[bot] f106b101ab chore: refresh generated model catalog [skip ci] 2026-09-19 12:31:07 +00:00
github-actions[bot] 77322698e4 chore: refresh generated model catalog [skip ci] 2026-09-19 00:50:24 +00:00
Dark-Alex-17 e237845827 feat: Improved comment etiquette for the sisyphus suite 2026-09-18 12:31:48 -06:00
Dark-Alex-17 4571ed647e feat: Improved PR hygiene for commenting and posting review summaries on PRs created by the architect and Sisyphus 2026-09-18 12:27:37 -06:00
Dark-Alex-17 0b55f7c735 test: update agent suite tests to work with new code review suite changes 2026-09-18 11:42:54 -06:00
Dark-Alex-17 461245a57c fix: force code review lanes to be additive only, not opt-in, and bump the verifier run cap 2026-09-18 10:44:04 -06:00
github-actions[bot] dcf2d99fa9 chore: refresh generated model catalog [skip ci] 2026-09-18 12:32:28 +00:00
github-actions[bot] d524328d86 chore: refresh generated model catalog [skip ci] 2026-09-18 00:51:35 +00:00
Dark-Alex-17 e002bd6727 feat: Suppor mid-turn auto compressions so sufficiently large histories don't create deadlocks for max input tokens 2026-09-17 17:12:18 -06:00
50 changed files with 4904 additions and 787 deletions

No files matched your search

@@ -1,11 +1,18 @@
### AI assistance (if any):
- List tools here and files touched by them
### 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
- [ ] 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 below
- [ ] If AI assistance was used, it is documented above
+3 -2
View File
@@ -78,8 +78,9 @@ act -W .github/workflows/release.yml --input_type bump=minor
## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is not permitted unless explicitly disclosed and approved.
All code in this repository is written and reviewed by humans, or AI-generated where explicitly disclosed.
AI-generated code (e.g., Copilot, ChatGPT, Claude, etc.) is permitted only when explicitly disclosed and
approved. For example: disclosing the use of AI-generated code in a PR description.
Submissions must certify that the contributor understands and can maintain the code they submit.
+6 -1
View File
@@ -82,6 +82,8 @@ RUN set -euo pipefail; \
chown 1000:1000 /home/agent/.cargo/bin/coyote; \
rm -rf "$TMPDIR"
COPY --chmod=0755 scripts/docker-entrypoint.sh /usr/local/bin/coyote-entrypoint
FROM scratch
ARG COYOTE_VERSION
@@ -107,4 +109,7 @@ WORKDIR /home/agent/workspace
USER 1000
ENTRYPOINT ["coyote"]
# tini as PID 1 (as in the sandbox-templates base) reaps orphans and forwards
# signals; coyote-entrypoint runs coyote by default, or the given command
# (e.g. the Docker Sandboxes keep-alive) when the first arg is sh/bash/a path.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/coyote-entrypoint"]
+7
View File
@@ -144,6 +144,13 @@ docker run --rm -it \
darkalex17/coyote
```
Arguments are passed to `coyote`. If the first argument is `sh`, `bash`, or an absolute path, that command runs instead,
e.g. to get a shell inside the image:
```bash
docker run --rm -it darkalex17/coyote bash
```
### Scripts
#### Linux/MacOS (`bash`)
You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
+22 -2
View File
@@ -205,9 +205,26 @@ nodes:
confirm the change makes the criterion true, confirm the test targets
BEHAVIOR (not implementation echo), use ast_grep for structural claims.
Watch for gamed evidence: a test weakened/deleted so it passes, hardcoded
values shaped to the criterion's example, TODO/stub presented as done —
values shaped to the criterion's example, TODO/stub presented as done, a
mock expectation whose count is relaxed (`AnyTimes`, `MinTimes`, ignored
request body) AND whose matcher is permissive (`gomock.Any()`,
`mock.Anything`) — it proves a call happened, never that it carried the
criterion's values, so it is not a proving test —
those are PARTIAL or DIVERGED with the gaming named in the complaint.
Retry / redelivery / idempotency demands: before you mark a criterion PARTIAL
or UNMET for lacking a retry, re-run, or "second attempt emits nothing" spec,
classify the handler. STATEFUL — it carries something across attempts (emits
events, calls an external system, performs an unconditional write, keeps an
in-memory or ledger record) — a retry spec is a legitimate demand. STATELESS —
each attempt is a pure function of the request plus a conditional store
predicate (`WHERE status = 'pending'`, `ended_at IS NULL`, `INSERT … ON
CONFLICT DO NOTHING`) and nothing is emitted or ledgered outside that
predicate — then "retry produces no event" is a tautology of the mechanism,
not a missing behavior: demand at most one test of the predicate itself,
never a retry spec. Name the classification and its file:line evidence in
the complaint either way.
Execution criteria ("tests pass", "lint clean", "build succeeds") are
judged ONLY against the recorded verification runs in your prompt — you
have no execution tools BY DESIGN and must never claim to have run
@@ -297,7 +314,10 @@ nodes:
The checklist (each hit becomes one complaint with kind, file:line
location, the violation, and the fix):
- **Absence** — something the PLAN requires that maps to no criterion and
never showed up in the diff (the dog that didn't bark).
never showed up in the diff (the dog that didn't bark). A missing
retry/redelivery spec is an absence ONLY for a handler that carries state
or side effects across attempts; a handler that is a pure function of the
request plus a conditional store predicate owes none.
- **Scope drift / out-of-scope** — the diff touches what the plan's scope
notes say not to touch, or quietly does extra unplanned work.
- **Interface/approach substitution** — the plan named an interface,
+17 -3
View File
@@ -6,7 +6,7 @@ description: |
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and
finishes with ONE draft PR (CI checks watched to green) plus tracked follow-up tasks. Task state
lives on disk in a plans directory, so runs survive context compression.
version: 2.5.0
version: 2.8.0
agent_session: temp
auto_continue: true
max_auto_continues: 100
@@ -18,6 +18,7 @@ spawnable_agents:
- oracle
- explore
- librarian
- deep-research
- adversary
- probe
- review-gauntlet
@@ -180,7 +181,9 @@ instructions: |
<label>; authoritative sources preferred; return a distilled, deduplicated checklist"), put the
returned checklist through the same accept/drop round with the user, and write the ACCEPTED
items into the plan — as measurable acceptance criteria on the relevant tasks where possible,
otherwise as a checklist under `## Quality bar → Long-tail criteria`.
otherwise as a checklist under `## Quality bar → Long-tail criteria`. Spawn checklist
librarians with `--variables {"research_depth": "deep"}` — checklist distillation is
broad-topic research and the default standard depth (3 searches / 8 fetches) starves it.
Ground the breakdown in real code: fan out `explore` agents (load `parallel-research`) across
{{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
@@ -373,6 +376,12 @@ instructions: |
`## Review round N @ <full head SHA>` (lane table, verdicts, what changed since the
prior round) — never by rewriting the PR description, which carries only the stable
summary and the required digest sections; comments are the append-only review log.
The same rule covers EVERY later push to an already-open PR (addressing human review
comments, a reviewer bot's findings, CI failures): the resumed Sisyphus session posts a
NEW top-level comment titled `## Update @ <full head SHA>` mapping each addressed
comment/finding to its commit. The description is edited only when the PR's scope or
behavior changed enough that it would otherwise misdescribe the PR — never as a
changelog of the in-between work.
- **Spawn `adversary`** with the diff base and the criteria pasted in:
```
@@ -587,7 +596,8 @@ instructions: |
|-------|---------|
| `sisyphus` | Implement ONE task's code in {{project_dir}} (its own explore/coder/verify/review loop). One per task. |
| `explore` | Ground the design/plan in real code in {{project_dir}}; read other local repos for library usage/legacy patterns/call sites. Fan out in parallel. |
| `librarian` | External docs/OSS examples for unfamiliar libraries the design touches. |
| `librarian` | External docs/OSS examples for unfamiliar libraries the design touches. Design-time/broad topics: pass `--variables {"research_depth": "deep"}` (~3× budgets); default depth is for narrow lookups. One librarian per question beats one broad librarian — deep is for inherently broad questions. |
| `deep-research` | Open-ended/exhaustive research that should churn until its critique loop passes (plans sub-questions, deep librarian per question, credibility vetting). Spawn with `--variables {"autonomy": "autonomous"}` — MANDATORY, the supervised default blocks on a human approval gate; raise `reflexion_budget` (default 2) for longer churn. |
| `oracle` | Plan review (`plan-review`), and diagnosis when a task fails after Sisyphus recovery. |
| `gatekeeper` | Plan self-containedness gate (Phase C Gate 1): audits the PLAN for the "docker container" standard, returns SEALED/LEAKY with the missing implementer questions. |
| `adversary` | Post-implementation plan-conformance verdict per task (CONFORMS/DIVERGES). |
@@ -622,6 +632,10 @@ instructions: |
an unprobeable consumer-facing change is a plan gap to fix, not a checkbox to skip.
- Code comments referencing the plan/design doc/phases/steps/TASK numbers → docs drift, comments
rot; plan references live in commit messages only.
- Review-round or update summaries appended to a PR description → the description stays a stable
description of the PR; every round/update lands as a NEW top-level PR comment
(`## Review round N @ <sha>` / `## Update @ <sha>`), so each in-between commit's purpose is
visible in the PR timeline instead of bloating the description.
- A `.env` file landing in any repo → only `.env.example` with placeholder keys is committable;
`.env` stays gitignored and real values are a follow-up.
- Dropping a Sisyphus-reported follow-up (not logged in the task's log.md, not in the PR's
+6 -4
View File
@@ -39,7 +39,7 @@ settings:
initial_state:
project_dir: ''
fix_attempts: 0
max_fix_attempts: 3
max_fix_attempts: 5
fix_instructions: ''
build_output: ''
tests_output: ''
@@ -222,7 +222,8 @@ nodes:
3. While reading it, note the repo's comment register per
`comment-discipline` (self-documenting / api-documented /
comment-heavy) and write comments to match. When the signal is
weak, write NO comment.
weak, write NO comment. Every comment must EARN ITS KEEP — if
the code already tells the reader, don't add it.
4. If the change touches boundaries, error paths, jobs, or state
transitions, also note the logging register per
`logging-discipline` (logger, message style, payload vs IDs,
@@ -323,7 +324,8 @@ nodes:
- Suppressed errors (as any, @ts-ignore, #[allow(...)] on unfamiliar
lints, empty catch blocks)
- Dishonest naming (get_X that mutates, returns wrong type, etc.)
- Useless comments that restate the code
- Useless comments that restate the code, or that otherwise don't earn
their keep (the code already tells the reader)
- AI slop (filler prose, multi-paragraph docstrings, defensive
handling of impossible cases)
@@ -408,4 +410,4 @@ nodes:
{{build_output}}
Last tests output:
{{tests_output}}
{{tests_output}}
+7
View File
@@ -13,6 +13,13 @@ this agent runs a fixed graph: every request goes through the same
`plan -> parallel research -> vet -> critique -> synthesize -> verify -> approve`
pipeline.
Two variables tune a run (pass via `--variables`): `reflexion_budget`
(default 2) caps the automated critique -> re-research passes — raise it
to let the run churn until the critique passes — and
`autonomy: autonomous` skips the final human approval gate so other
agents can spawn this one autonomously. Each sub-question is researched
by a spawned `librarian` running at deep budgets.
This agent is also the **canonical reference for the Coyote graph
system**: it exercises every node type (`script`, `llm`, `rag`, `map`,
`agent`, `input`, `approval`, `end`) and both static fan-out and
+40 -26
View File
@@ -7,7 +7,12 @@ description: |
delegates the final write-up to a focused sub-agent, checks that the
cited sources are reachable, and gates the result behind human
approval. A reviewer's free-form feedback at the approval step feeds
back into another research pass.
back into another research pass. Each sub-question is researched by a
spawned librarian running at deep budgets. Tunable via --variables:
reflexion_budget (default 2) caps the automated critique->re-research
passes - raise it to let the run churn until the critique passes;
autonomy=autonomous skips the human approval gate (REQUIRED when
another agent spawns this one).
This is the canonical Coyote graph-agent reference: it exercises every
node type (script, llm, rag, map, agent, input, approval, end) and
@@ -27,8 +32,16 @@ conversation_starters:
- 'How does HTTP/3 differ from HTTP/2?'
- 'Summarize recent advances in solid-state battery chemistry'
variables:
- name: reflexion_budget
description: "Max automated critique->re-research passes before synthesis proceeds anyway. Default 2; raise it (4-8) for exhaustive runs that should churn until the critique passes."
default: '2'
- name: autonomy
description: "supervised (default): the finished report stops at the human approval gate. autonomous: skip the gate and return the report directly - REQUIRED when another agent spawns this one (a human gate would block the run)."
default: 'supervised'
settings:
max_loop_iterations: 40
max_loop_iterations: 120
log_state_snapshots: false
validate_before_run: true
max_concurrency: 4
@@ -142,23 +155,11 @@ nodes:
research_one_question:
id: research_one_question
type: llm
instructions: |
You are a web research assistant. Investigate the SINGLE question
given to you using your tools: search the web, fetch and read
pages, and search arXiv for academic sources.
Rules:
- Every factual claim must be backed by a real source you
actually retrieved. Never fabricate URLs, page titles,
authors, or DOIs.
- Prefer primary and authoritative sources over aggregators.
- Where sources disagree, report the disagreement rather than
papering over it.
- Put the URL (or DOI) inline next to each claim it supports.
Return organized findings in plain text. Do not include
meta-commentary about the process.
type: agent
agent: librarian
# The leaf research primitive: triage -> doc+OSS search -> cited
# synthesis. `inputs:` overlays the librarian graph's initial state,
# pinning research_depth to deep for every sub-question lane.
prompt: |
Research question: {{question}}
@@ -166,16 +167,13 @@ nodes:
{{local_context}}
{{research_feedback}}
tools:
- web_search_coyote
- fetch_url_via_curl
- search_arxiv
- mcp:ddg-search
max_iterations: 10
inputs:
research_depth: deep
timeout: 1900
max_attempts: 2
state_updates:
finding: '{{output}}'
# On failure the engine writes "LLM node ...failed: <chain>" into `finding`
# On failure the engine writes "Agent node failed: <chain>" into `finding`
# via the state_updates above BEFORE routing to the fallback. The marker
# is branch-local: a map branch's fallback must stay inside the branch.
fallback: question_fault
@@ -312,6 +310,13 @@ nodes:
id: verify_sources
type: script
script: scripts/verify_sources.py
next: approval_gate
approval_gate:
id: approval_gate
type: script
description: Autonomy router - autonomous runs (spawned by another agent) skip the human approval gate and end accepted with the source check + pipeline notes attached; supervised (default) proceeds to the approval node. A crash fails forward via `next` to approve, the safe human-gated direction.
script: scripts/approval_gate.py
next: approve
approve:
@@ -348,6 +353,15 @@ nodes:
type: end
output: '{{report}}{{pipeline_notes}}'
end_autonomous:
id: end_autonomous
type: end
output: |
{{report}}
----
{{source_check}}{{pipeline_notes}}
end_rejected:
id: end_rejected
type: end
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Autonomy router for deep-research's approval gate.
Runs after verify_sources. When the `autonomy` variable is
'autonomous' (the run was spawned by another agent — architect or
sisyphus research tiers — with no human at the terminal), the report
skips the human approval node and ends accepted: the reflexion loop
already served as the quality gate, and end_autonomous carries the
source check + pipeline notes with the report so the caller sees them.
Any other value (default 'supervised') proceeds to the human approval
gate unchanged.
Routing (`_next`):
- autonomy == 'autonomous' -> end_autonomous
- anything else -> approve
Fail-safe: a crash here fails FORWARD via the node's `next` to
`approve` — the safe, human-gated direction.
"""
import json
import os
def load_state():
if path := os.environ.get("GRAPH_STATE_FILE"):
with open(path) as f:
return json.load(f)
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
def main():
state = load_state()
autonomy = str(state.get("autonomy") or "supervised").strip().lower()
target = "end_autonomous" if autonomy == "autonomous" else "approve"
print(json.dumps({"_next": target}))
main()
@@ -4,10 +4,11 @@
Reached ONLY via research_one_question's `fallback:` route inside the
`research_each_question` map branch (branch-local — a map branch's fallback
must stay inside the branch subgraph). Grounded in engine behavior: on
llm-node failure the engine writes "LLM node …failed: <chain>" into
`finding` via the node's state_updates BEFORE routing to the fallback (on
success the same key holds the researched findings, which never start with
the "LLM node" prefix). This node normalizes the failure text into a
branch failure the engine writes its failure text into `finding` via the
node's state_updates BEFORE routing to the fallback — "Agent node failed:
<chain>" now that the branch spawns a librarian agent, "LLM node …failed:
<chain>" for an llm branch (on success the same key holds the researched
findings, which never start with either prefix). This node normalizes the failure text into a
PIPELINE-FAULT finding and the chain ends here — the map collects the fault
as the lane's `finding`, so one dead research lane never sinks the whole
map. combine_findings later lifts PIPELINE-FAULT findings into
@@ -53,7 +54,7 @@ def main():
state = load_state()
raw = state.get("finding")
detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else ""
if not detail.startswith("LLM node"):
if not detail.startswith(("LLM node", "Agent node")):
detail = "the research lane died without recording a failure detail"
if len(detail) > MAX_DETAIL_CHARS:
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
@@ -2,7 +2,8 @@
"""Reflexion gate for deep-research.
Runs after `critique` has reviewed the current research findings. If the
critique's verdict is REVISE and the reflexion budget is not spent,
critique's verdict is REVISE and the reflexion budget (the graph's
`reflexion_budget` variable, default 2) is not spent,
loops back to `research` with the critique attached as
`research_feedback`, so the retry is informed rather than a blind
re-run. Otherwise it proceeds to `synthesize`.
@@ -26,9 +27,11 @@ import json
import os
import re
# Automated revision passes allowed. `research` runs at most
# MAX_REFLEXION_REVISIONS + 1 times per user pass. Bump to allow more.
MAX_REFLEXION_REVISIONS = 2
# Default automated revision passes when the `reflexion_budget` variable
# is absent or malformed. `research` runs at most budget + 1 times per
# user pass. Callers raise it via --variables {"reflexion_budget": "6"}
# to let the run churn until the critique passes.
DEFAULT_REFLEXION_REVISIONS = 2
def load_state():
@@ -69,8 +72,9 @@ def main():
critique = state.get("critique") or ""
verdict = parse_verdict(critique)
attempts = as_int(state.get("research_attempts"))
budget = as_int(state.get("reflexion_budget"), DEFAULT_REFLEXION_REVISIONS)
if verdict == "REVISE" and attempts < MAX_REFLEXION_REVISIONS:
if verdict == "REVISE" and attempts < budget:
feedback = (
"A reviewer judged the previous research pass incomplete. "
"Address every point in the critique below:\n\n" + critique
+1 -1
View File
@@ -174,7 +174,7 @@ nodes:
- fs_read
- fs_cat
- fs_grep
max_iterations: 14
max_iterations: 28
max_attempts: 2
state_updates:
verdict: '{{output}}'
+43 -17
View File
@@ -6,13 +6,16 @@ description: |
preamble. The "external grep" sibling of explore (which handles
internal/codebase grep). Designed to be fanned out 1-3 in parallel by
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
involved.
involved. Supports research_depth=deep (via --variables) for broad
design-time topics: ~3x the search/OSS/fetch budgets of the standard
quick-lookup mode.
version: '1.0'
global_tools:
- web_search_coyote.sh
- fetch_url_via_curl.sh
- search_arxiv.sh
mcp_servers:
- ddg-search
@@ -26,11 +29,14 @@ variables:
- name: project_dir
description: Project directory for context (unused in MVP but reserved for future iterations).
default: '.'
- name: research_depth
description: "standard (default) = quick targeted lookup budgets. deep = design-time research: ~3x search/OSS/fetch budgets for broad topics (best-practice checklists, architecture surveys)."
default: 'standard'
settings:
max_loop_iterations: 12
log_state_snapshots: true
timeout: 600
timeout: 1800
initial_state:
language_ecosystem: 'general'
@@ -117,7 +123,7 @@ nodes:
search:
id: search
type: llm
description: Identify 3-5 authoritative documentation sources via ddg-search.
description: Identify authoritative documentation sources via ddg-search (3-5 standard depth, 6-12 deep).
skills_enabled: true
enabled_skills:
- ai-slop-remover
@@ -129,7 +135,10 @@ nodes:
Priority order:
1. Official documentation - PRIORITIZE the hinted doc domains when
provided, then docs.X.org / readthedocs.io / MDN / vendor docs
2. Specifications (RFCs, W3C, ECMA, IEEE)
2. Specifications (RFCs, W3C, ECMA, IEEE) and academic sources -
the search_arxiv tool is available; use it when the topic is
academic/research-flavored (arXiv/DOI links count as
authoritative)
3. Credible secondary references (PEPs, official blog posts) - only
if 1-2 are sparse
@@ -142,14 +151,19 @@ nodes:
## Search budget and fail-fast rules
You have a HARD BUDGET of 3 search calls total. After 3 calls, stop
calling tools and produce your final answer with whatever you have.
Your budget is set by the `Research depth` line in the prompt:
- standard -> HARD BUDGET of 3 search calls total.
- deep -> HARD BUDGET of 10 search calls total; spend the extra
calls covering DISTINCT subtopics of the research topic, never
re-running near-duplicate queries.
After the budget is spent, stop calling tools and produce your final
answer with whatever you have.
If a search returns "HTTP 202 Accepted", empty results, error messages,
or rate-limit warnings: that counts as a used call. Do not retry the
same query - either rephrase OR give up.
If after 3 calls you have NO usable URLs, output exactly:
If after spending the budget you have NO usable URLs, output exactly:
NO_AUTHORITATIVE_SOURCES_FOUND
Reason: <one line>
@@ -168,9 +182,11 @@ nodes:
URL: <full url>
...
Output 3-5 source blocks. No prose intro, no closing summary.
Output 3-5 source blocks (standard depth) or 6-12 (deep). No prose
intro, no closing summary.
prompt: |
Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Triage hints:
- Language/ecosystem: {{language_ecosystem}}
@@ -183,8 +199,9 @@ nodes:
pathlib` style queries).
tools:
- mcp:ddg-search
- search_arxiv
- web_search_coyote
max_iterations: 15
max_iterations: 30
state_updates:
search_output: '{{output}}'
fallback: synthesize
@@ -193,7 +210,7 @@ nodes:
search_oss:
id: search_oss
type: llm
description: Find 2-3 production OSS examples relevant to the topic via the personal-github MCP.
description: Find production OSS examples relevant to the topic via the personal-github MCP (2-3 standard depth, 4-8 deep).
skills_enabled: true
enabled_skills:
- ai-slop-remover
@@ -219,8 +236,12 @@ nodes:
## Search budget and fail-fast rules
HARD BUDGET: 8 tool calls total. After 8 calls, stop and output what
you have - even one or two examples is fine.
Your budget is set by the `Research depth` line in the prompt:
- standard -> HARD BUDGET of 8 tool calls total; output 2-3 blocks.
- deep -> HARD BUDGET of 20 tool calls total; output 4-8 blocks
across DIFFERENT repos/approaches, not near-duplicates.
After the budget is spent, stop and output what you have - even one or
two examples is fine.
If you find no production examples, output exactly:
@@ -240,10 +261,12 @@ nodes:
REPO: ...
Output 2-3 blocks. The URL should point to a specific file that
Output 2-3 blocks (standard depth) or 4-8 (deep). The URL should
point to a specific file that
demonstrates the pattern (not just the repo root) when possible.
prompt: |
Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Triage hints:
- Language/ecosystem: {{language_ecosystem}}
@@ -254,7 +277,7 @@ nodes:
Filter to {{language_ecosystem}} repositories when the API allows.
tools:
- mcp:personal-github
max_iterations: 15
max_iterations: 30
state_updates:
oss_output: '{{output}}'
fallback: synthesize
@@ -308,8 +331,10 @@ nodes:
5. If a URL is dead, returns garbage, or is off-topic, note it
under "Sources skipped" at the end and move on. Do not retry.
Budget: max 8 fetches total (across both source lists). Skip
aggressively.
Fetch budget, set by the `Research depth` line in the prompt:
- standard -> max 8 fetches total (across both source lists).
- deep -> max 25 fetches total. Depth buys MORE SOURCES, never
re-fetching dead or off-topic URLs - skip aggressively either way.
## Output format
@@ -332,6 +357,7 @@ nodes:
No flattery, no preamble. Start with `## Findings`.
prompt: |
Research topic: {{initial_prompt}}
Research depth: {{research_depth}}
Documentation sources (from doc search branch):
{{search_output}}
@@ -340,7 +366,7 @@ nodes:
{{oss_output}}
tools:
- fetch_url_via_curl
max_iterations: 20
max_iterations: 45
state_updates:
findings: '{{output}}'
fallback: final_format
+9 -1
View File
@@ -120,7 +120,15 @@ instructions: |
update — never delete or silence it).
4. **Clean, local, isolated.** Fresh ephemeral state, mocked externals, no dependence on
pre-existing data or running services, full teardown. Bounded retries for startup only —
never to mask a flaky assertion.
never to mask a flaky assertion. Teardown MUST NOT depend on a shell trap firing (a
killed or timed-out lane leaks containers — it has happened): pick one unique run
prefix up front (e.g. `probe-$$`), give EVERY container you create `--name <prefix>-…`
and `--label coyote-probe=<prefix>` (compose: `-p <prefix>`), prefer `docker run --rm`,
and make the LAST action before emitting your verdict an explicit sweep:
`docker ps -aq --filter label=coyote-probe=<prefix> | xargs -r docker rm -f` (compose:
`docker compose -p <prefix> down -v`). The label filter keeps the sweep scoped to YOUR
resources — rule 0 still forbids touching anyone else's. A leftover you cannot remove
is a finding to REPORT in the verdict, never to silence.
5. **Classify every failure** as BUG / EXPECTED-CHANGE / ENV per the skill table. The verdict
depends on the classification being honest.
6. **Committed tests are the deliverable** alongside the verdict: write them where the repo's
+5 -5
View File
@@ -14,7 +14,7 @@ flowchart TD
parse -. "fallback" .-> pfault["parse_fault<br/>(script: PIPELINE-FAULT marker)"]
pfault --> gate
signals --> select["select_lanes<br/>(llm: ADDITIVE-only judgment)"]
select --> build["build_items<br/>(script: rules floor ∪ additions;<br/>forced lanes override exactly)"]
select --> build["build_items<br/>(script: rules floor ∪ additions;<br/>forced lanes add)"]
select -. "fallback" .-> dlanes["default_lanes<br/>(script: deterministic degraded<br/>selection — wider, never narrower)"]
dlanes --> build
build --> mcr["map: code-review"] & madv["map: adversary"] & msec["map: security"] & mpb["map: probe"]
@@ -155,7 +155,7 @@ Plan / acceptance criteria:
Local-run recipe / usage suites:
<how to boot the service locally + where existing suites live — enables the probe lane>
Forced lanes: (optional — e.g. 'adversary, probe' to run exactly those)" \
Forced lanes: (optional, ADDITIVE — e.g. 'adversary, probe' run on top of the computed selection; forcing never drops the always-on floor)" \
--variables {"verification_commands": "[\"cargo test --all\", \"cargo clippy -- -D warnings\"]"}
```
@@ -210,9 +210,9 @@ verbatim.
(`'[]'` ⇒ "none declared"); a declaration that is not a JSON array of
strings executes nothing and records a `PIPELINE-FAULT`, which the
adversary lane reports as DIVERGES and the gate as BLOCKED.
- Signals failures degrade gracefully: if git can't compute the diff, lane
selection falls back to caller context + forced lanes, and the report notes
it. The auth-path regex deliberately over-fires (`auth(?!or)` matches
- Signals failures degrade WIDER: if git can't compute the diff, the surface
is unknown — the security lane is added (and probe, when a local-run recipe
exists) on top of caller context + forced lanes, and the report notes it. The auth-path regex deliberately over-fires (`auth(?!or)` matches
auth/authn/authz/oauth but not "author") — the safe direction, since signals
only ever ADD the security lane.
- Requires the `code-reviewer`, `adversary`, `security-reviewer`, and `probe`
+11 -6
View File
@@ -107,10 +107,14 @@ nodes:
You extract the facts of a review request into structured fields.
EXTRACTION ONLY — do not decide which review lanes should run.
- `diff_spec`: the git diff specification the caller named — a ref
range ("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
"worktree" for uncommitted changes against HEAD. When the caller
did not name one, use "worktree".
- `diff_spec`: the git diff specification the caller named, as a BARE
revision token usable verbatim in `git diff <spec>` — a ref range
("main...HEAD", "HEAD~3..HEAD"), a single ref, or the literal
"worktree" for uncommitted changes against HEAD. NEVER prose, a
command, or a path: from "run get_diff --base origin/main" extract
"origin/main...HEAD"; from "diff origin/main...HEAD in ~/code/x"
extract "origin/main...HEAD". When the caller did not name one, use
"worktree".
- `project_dir_in`: the repository directory the caller named as holding
the change (absolute path preferred); empty string when the caller
named none.
@@ -127,7 +131,8 @@ nodes:
pointers for black-box probing (how to boot the service, where the
suites live). Empty string when the caller supplied none.
- `forced_lanes`: lanes the caller explicitly demanded (verbatim
names). Empty array = automatic selection.
names). Empty array = automatic selection. Forcing is ADDITIVE
downstream: it widens the computed selection, never replaces it.
- `extra_context`: any remaining caller context reviewers should see,
condensed.
prompt: |
@@ -261,7 +266,7 @@ nodes:
build_items:
id: build_items
type: script
description: Deterministic lane builder — hard rules are the floor, LLM flags may only add, forced lanes override exactly. Emits one 0-or-1-item list per lane; an empty list skips that lane's map. On a re-run pass (retry_lanes non-empty) it skips selection and emits items only for those lanes, each carrying its attempt number.
description: Deterministic lane builder — hard rules are the floor, LLM flags may only add, forced lanes are ADDITIVE (selection only ever widens; unavailable signals widen it too). Emits one 0-or-1-item list per lane; an empty list skips that lane's map. On a re-run pass (retry_lanes non-empty) it skips selection and emits items only for those lanes, each carrying its attempt number.
script: scripts/build_items.py
timeout: 30
next: [map_code_review, map_adversary, map_security, map_probe]
@@ -1,14 +1,20 @@
#!/usr/bin/env python3
"""Deterministic lane builder for the review gauntlet.
Selection = hard rules (the floor) ∪ LLM additions; forced lanes override
everything exactly. Emits one 0-or-1-item list per lane: an empty list makes
that lane's map node run zero branches (map-over-nothing), which IS the
skip mechanism — no dynamic routing anywhere.
Selection = hard rules (the floor) ∪ LLM additions ∪ caller-forced lanes.
Forcing is ADDITIVE: it can only widen the computed selection — honoring a
forced list as the exact set once dropped the always-on code-review lane in
a live run. Emits one 0-or-1-item list per lane: an empty list makes that
lane's map node run zero branches (map-over-nothing), which IS the skip
mechanism — no dynamic routing anywhere.
The LLM's add_* flags can only ADD lanes. A flag that is false never
removes a lane the hard rules selected.
Unavailable diff signals (malformed spec, git failure) mean the surface is
UNKNOWN — selection degrades WIDER (security on; probe when a recipe
exists), never narrower.
A re-run pass (retry_gate emitted `retry_lanes`) skips selection: only the
named lanes get an item, stamped with their attempt number, and every other
lane maps over nothing — retry_gate restores their earlier reports.
@@ -101,56 +107,90 @@ def main():
(forced.append(canon) if canon else unknown.append(str(lane)))
reasons = []
if forced:
lanes = set(forced)
reasons.append(f"forced lanes honored exactly: {sorted(lanes)}")
else:
lanes = {"code-review"}
lanes = {"code-review"}
reasons.append(
"code-review: always on — the gauntlet is only spawned for non-trivial work"
)
if text("plan_context"):
lanes.add("adversary")
reasons.append("adversary: a spec/plan/acceptance-criteria context is present")
if (
state.get("touches_auth")
or state.get("touches_deps")
or state.get("touches_exec")
or state.get("security_posture") == "hardened"
):
lanes.add("security")
reasons.append(
"code-review: always on — the gauntlet is only spawned for non-trivial work"
"security: attack-surface signals (auth/deps/exec) or hardened posture"
)
if text("plan_context"):
lanes.add("adversary")
reasons.append("adversary: a spec/plan/acceptance-criteria context is present")
if (
state.get("touches_auth")
or state.get("touches_deps")
or state.get("touches_exec")
or state.get("security_posture") == "hardened"
):
if state.get("consumer_surface") and text("probe_context"):
lanes.add("probe")
reasons.append(
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists"
)
# Unavailable signals (malformed diff spec, git failure) = surface UNKNOWN.
# Degrade WIDER, never narrower — a run without signals once silently
# dropped the security and probe lanes.
if text("signals_summary") == "signals unavailable":
if "security" not in lanes:
lanes.add("security")
reasons.append(
"security: attack-surface signals (auth/deps/exec) or hardened posture"
"security: diff signals unavailable — surface unknown, degrading wider"
)
if state.get("consumer_surface") and text("probe_context"):
if "probe" not in lanes and text("probe_context"):
lanes.add("probe")
reasons.append(
"probe: consumer-facing surface changed and a local-run recipe/suite pointer exists"
"probe: diff signals unavailable and a local-run recipe/suite pointer "
"exists — degrading wider"
)
# LLM judgment is additive-only.
llm_why = text("lane_reasons")[:300]
for flag, lane in (
("add_code_review", "code-review"),
("add_adversary", "adversary"),
("add_security", "security"),
("add_probe", "probe"),
):
if state.get(flag) and lane not in lanes:
if lane == "probe" and not text("probe_context"):
reasons.append(
"probe: requested by lane judgment but skipped — no local-run "
"recipe/suite pointer (a probe without one is guaranteed INCONCLUSIVE)"
)
continue
if lane == "adversary" and not text("plan_context"):
reasons.append(
"adversary: requested by lane judgment but skipped — no spec/plan "
"to check conformance against"
)
continue
lanes.add(lane)
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}")
# LLM judgment is additive-only.
llm_why = text("lane_reasons")[:300]
for flag, lane in (
("add_code_review", "code-review"),
("add_adversary", "adversary"),
("add_security", "security"),
("add_probe", "probe"),
):
if state.get(flag) and lane not in lanes:
if lane == "probe" and not text("probe_context"):
reasons.append(
"probe: requested by lane judgment but skipped — no local-run "
"recipe/suite pointer (a probe without one is guaranteed INCONCLUSIVE)"
)
continue
if lane == "adversary" and not text("plan_context"):
reasons.append(
"adversary: requested by lane judgment but skipped — no spec/plan "
"to check conformance against"
)
continue
lanes.add(lane)
reasons.append(f"{lane}: added by lane-selection judgment — {llm_why}")
# Caller-forced lanes are ADDITIVE: they widen the computed selection,
# never replace it. (Honoring them as the exact set dropped code-review —
# documented as always-on — and adversary in a live run.) Forcing also
# bypasses the context guards above: the caller demanded the lane, so it
# runs, with the expectation named in the summary.
if forced:
newly = sorted(set(forced) - lanes)
lanes |= set(forced)
reasons.append(
f"forced lanes (additive): {sorted(set(forced))}"
+ (f" — newly added: {newly}" if newly else " — all already selected")
)
if "probe" in forced and not text("probe_context"):
reasons.append(
"probe: forced without a local-run recipe/suite pointer — expect INCONCLUSIVE"
)
if "adversary" in forced and not text("plan_context"):
reasons.append(
"adversary: forced without spec/plan context — it fails closed "
"(DIVERGES) without extractable criteria"
)
if unknown:
reasons.append(f"ignored unknown forced lane name(s): {unknown}")
@@ -8,8 +8,10 @@ AND a local-run recipe exists (build_items gates probe the same way — a
probe without a recipe is INCONCLUSIVE by construction),
plus security when the attack-surface signals (auth/deps/exec) or a hardened
posture demand it, unioned with any lanes the caller forced (canonicalized
with the same aliases build_items uses) — which build_items honors exactly,
so selection degrades WIDER, never narrower. The degradation note goes into
with the same aliases build_items uses) — which build_items honors
ADDITIVELY, so selection degrades WIDER, never narrower. Unavailable diff
signals also widen: unknown surface adds security (and probe when a
local-run recipe exists). The degradation note goes into
`lanes_degraded`; build_items folds it into lanes_summary so the final
report names the degradation.
@@ -72,6 +74,11 @@ def main():
or state.get("security_posture") == "hardened"
):
lanes.add("security")
# Unavailable signals = surface UNKNOWN → widen (mirrors build_items).
if (state.get("signals_summary") or "") == "signals unavailable":
lanes.add("security")
if isinstance(probe_context, str) and probe_context.strip():
lanes.add("probe")
# Caller-forced lanes survive degradation — union, never overwrite.
for lane in state.get("forced_lanes") or []:
if canon := ALIASES.get(str(lane).strip().lower()):
@@ -3,9 +3,10 @@
Pure git — no LLM judgment. Every signal is computed from the changed-file
list and the added lines of the diff. Failures never sink the gauntlet:
on any error the script reports `signals_error` and leaves the defaults
(false/empty) in place, which biases lane selection toward the caller's
context and forced lanes.
on any error the script reports `signals_error` and marks the signals
unavailable — downstream (build_items / default_lanes) treats that as
UNKNOWN surface and degrades WIDER (security on; probe when a recipe
exists), on top of caller context and forced lanes.
"""
import json
@@ -26,6 +27,19 @@ proj = os.path.expanduser(
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
)
spec = (state.get("diff_spec") or "worktree").strip()
spec_raw = spec
# The parse stage should emit a bare revision token, but callers write prose
# and extraction drifts ("run get_diff --base origin/main" once reached
# `git diff` verbatim, killing the signals — and with them the security and
# probe lanes). Deterministically recover a usable spec before giving up.
_TOKEN = r"[\w./~^@{}-]+"
if spec not in ("", "worktree") and not re.fullmatch(
rf"{_TOKEN}(\.\.\.?{_TOKEN})?", spec
):
if m := re.search(rf"({_TOKEN}\.\.\.?{_TOKEN})", spec):
spec = m.group(1) # a revision range buried in prose
elif m := re.search(rf"--base[= ]({_TOKEN})", spec):
spec = f"{m.group(1)}...HEAD" # the get_diff idiom
def git(*args):
@@ -112,13 +126,18 @@ try:
),
"docs_only": bool(files)
and all(re.search(r"\.(md|rst|txt|adoc)$", f, re.I) for f in files),
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})",
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})"
+ (f" — spec normalized from {spec_raw!r}" if spec != spec_raw else ""),
}
)
except Exception as e: # noqa: BLE001 — degraded signals must not sink the gauntlet
what = f"diff spec {spec!r}" + (
f", normalized from {spec_raw!r}" if spec != spec_raw else ""
)
out["signals_error"] = (
f"NOTE: diff signals could not be computed ({e}); "
"lane selection falls back to caller context and forced lanes."
f"NOTE: diff signals could not be computed ({what}: {e}); surface UNKNOWN — "
"lane selection degrades WIDER (security lane added; probe eligible when a "
"local-run recipe exists), plus caller context and forced lanes."
)
out["signals_summary"] = "signals unavailable"
+12 -3
View File
@@ -1,6 +1,6 @@
name: sisyphus
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline
version: 3.13.0
version: 3.17.0
agent_session: temp
auto_continue: true
@@ -11,6 +11,7 @@ can_spawn_agents: true
spawnable_agents:
- explore
- librarian
- deep-research
- coder
- oracle
- code-reviewer
@@ -157,7 +158,8 @@ instructions: |
| Agent | Use For | Characteristics |
|-------|---------|-----------------|
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 5–15+. |
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. |
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. Default depth = quick-lookup budgets; for a BROAD topic (design research, best-practice surveys, architecture comparisons) pass `--variables {"research_depth": "deep"}` (~3× search/fetch budgets). Still prefer several narrow librarians over one giant one — deep is for questions that are inherently broad, not a substitute for fan-out. |
| `deep-research` | Open-ended investigation that must CHURN until solid — plans sub-questions, fans out a deep librarian per question, vets source credibility, runs a critique→re-research loop | Spawn with `--variables {"autonomy": "autonomous"}` — MANDATORY when spawned as a subagent (the supervised default blocks on a human approval gate). Raise `reflexion_budget` (e.g. "6", default 2) to let it churn longer. Expect hours, not minutes; one per investigation — its internal map is the fan-out. |
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
@@ -575,7 +577,7 @@ instructions: |
When you write or modify files yourself (rather than delegating to coder):
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment.
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment. In every register, a comment must EARN ITS KEEP — if the code already tells the reader, don't add it.
- **Calibrate logging before writing.** When the change touches boundaries, error paths, jobs, or state transitions, load `logging-discipline` and note the repo's logging register (logger, message style, payload vs IDs, level semantics) from the same sibling reads; match it. No discernible convention → its best-judgment defaults. Never leave a new error path silently swallowed, and never delete existing log lines as drive-by cleanup.
- **For editing an existing file**, prefer `fs_patch`. It's a surgical edit that preserves unchanged content. Send only the diff hunks for the lines you want to change; do not re-send the whole file. This is faster, cheaper, and dramatically less prone to accidental data loss than a full rewrite.
- **For writing a NEW file or doing a COMPLETE rewrite**, use `fs_write`. Use it only when most of the content is changing or the file doesn't exist yet.
@@ -709,6 +711,13 @@ instructions: |
description: in-place edits destroy the review history and hide which commits each round
actually reviewed. Comments are the append-only review log; the SHA in the title names
exactly what was reviewed.
The same discipline covers EVERY later push to a PR you (or your run) opened — addressing
human review comments, a reviewer bot's findings, CI failures, or any other PR event: post
a NEW top-level PR comment titled `## Update @ <full head SHA>` mapping each addressed
comment/finding to the commit that addresses it (one line each), plus anything
deliberately NOT addressed and why. NEVER append update summaries to the description.
Edit the description ONLY when the PR's actual scope or behavior changed enough that it
would otherwise misdescribe the PR — it stays a description, never a changelog.
**Close the loop.** When a decision DID reach the user (step 4), suggest in your final report
that they record the resolution with `coyote --macro record-ruling` so that class of decision
+2
View File
@@ -186,6 +186,7 @@ save_session: null # Controls the persistence of the session. If t
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
compression_model: null # Model to use for session compression (e.g. 'openai:gpt-4o-mini'); defaults to the session's current model
compression_safety_valve: 0.8 # Force compression when the last real request's prompt size (or the local estimate) reaches this fraction of the model's max_input_tokens, even if compression_threshold would not fire (0 = disabled)
summarization_prompt: null # The text prompt used for creating a concise summary of session messages. If null, uses built-in default
summary_context_prompt: null # The text prompt used for including the summary of the entire session as context to the model. If null, uses built-in default
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
@@ -276,6 +277,7 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons
# All clients have the following configuration:
# - type: xxxx
# name: xxxx # Only use it to distinguish clients with the same client type. Optional
# extend_models: true # Merge the models below on top of the catalog models for this client instead of replacing them. Optional, default false
# models:
# - name: xxxx # Chat model
# max_input_tokens: 100000
+24
View File
@@ -89,6 +89,15 @@ These are review findings that only surface in a diff context, not in a whole-fi
finding (dead weight or a forgotten wiring step). Used in SOME applicable in-diff sites but
not others = a finding naming the sites that didn't adopt it — half-adopted artifacts are how
two mechanisms for the same job end up coexisting forever.
- **"Ignored / redundant request field" claims need a wire-validation check first** — before
calling a request/message field ignored, redundant, or dead on some code path because the
handler never reads it there, `fs_grep` the boundary contract for that field: proto validation
rules (`(validate.rules)`, `buf.validate`), OpenAPI/JSON-schema `required`, struct-tag
`required`/`binding`/serde attributes, or a hand-written validator/interceptor. A field the
validator MANDATES is load-bearing at the boundary even when the handler ignores it — the
honest finding (if any) is "validator requires `<field>` on the `<path>` path that never reads
it (`path:line`); relax with `ignore_empty`/`oneof` or document why", never "`<field>` is
ignored". No validation found ⇒ the ignored-field finding stands, citing the grep you ran.
- **Version-literal consistency and freshness** — when the diff bumps a version (image tag,
tool version, dependency pin), `fs_grep` the repo for other occurrences of the OLD version
string (compose files, workflows, docs, sibling Dockerfiles) — stragglers are findings. For
@@ -143,6 +152,21 @@ Named adequacy anti-patterns — each is a finding even when coverage looks gree
- **Leaky fixture** — an integration test against a shared/persistent database inserts fixture
rows directly with no paired cleanup (deferred delete/teardown/transaction rollback). The
test passes today and leaves a primary-key landmine for the next run.
- **Struct-typed args against an inferred schema** — when a struct drives an inferred wire schema
(MCP go-sdk, OpenAPI/JSON-schema generators, serde-derived schemas), every field without
`omitempty`/pointer/`Option` becomes schema-REQUIRED. Cross-check the optionality the doc
strings promise ("X as an alternative to Y") against the tags — a documented-optional field
without the omission marker is a live validation bug, not a style nit. And tests that build the
request from the typed struct serialize EVERY field (zero values included), so they can never
exercise the required-ness check: demand raw-JSON / map-typed tests that OMIT each
documented-optional field.
- **Relaxed call count with a permissive matcher** — a mock expectation whose COUNT is relaxed
(`AnyTimes()`, `MinTimes(1)`, `Maybe()`, "called at least once") leaves the ARGUMENT matcher as
the only thing asserted; pair it with `gomock.Any()` / `mock.Anything` / `mock.ANY` /
`expect.anything()` / an ignored request body and the expectation proves nothing — a call with
the wrong ID, wrong quantity, or wrong principal satisfies it. Flag every relaxed-count
expectation whose matcher is permissive; the fix pins the load-bearing fields in the matcher
(or exact count + captured args).
## 3. Clarity
+1 -1
View File
@@ -1,7 +1,7 @@
---
description: Calibrate comment density and style to the repository's existing conventions before writing code. Detects the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you already read for pattern matching, or from a declared policy in workspace instructions, then dictates when a comment is warranted. Default when signal is weak - write NO comment. Complements ai-slop-remover (which bans comments that restate code in every register).
---
You are about to write or modify code. LLMs systematically over-comment — narrating every block, restating signatures, banner-ing sections — and that default is wrong in most repositories. Before writing, determine the repo's **comment register** and match it, exactly the way you already match imports, naming, and error handling.
You are about to write or modify code. LLMs systematically over-comment — narrating every block, restating signatures, banner-ing sections — and that default is wrong in most repositories. Before writing, determine the repo's **comment register** and match it, exactly the way you already match imports, naming, and error handling. Across every register, one test governs each comment you write: it must **earn its keep**. If the code already tells the reader, the comment doesn't get written — don't add comments you don't need; make the code tell them instead.
## Step 0: Check for a declared policy first
+10
View File
@@ -56,6 +56,16 @@ If no posture is given, assume `standard` and say so in the report.
8. **Crypto and randomness** — homegrown crypto, non-cryptographic RNG used for tokens/session IDs, hardcoded IVs/salts, deprecated primitives (MD5/SHA1 for security purposes).
9. **Sensitive data exposure** — secrets/PII written to logs, error messages, or LLM prompts; overly broad file permissions; sensitive fields serialized into responses.
10. **Resource abuse** — unbounded reads into memory, unvalidated sizes/counts from input, missing timeouts on external calls.
11. **Error-text passthrough on a NEW surface** — a new mount, gateway, adapter, or endpoint that
writes an upstream/middleware error's text (`err.Error()`, connect/gRPC `Message()`, a wrapped
library message) into its own response body. `fs_grep` the new surface's response writers for
interpolated error strings. Default expectation: fixed strings per error class; passthrough
needs POSITIVE justification (the upstream text is a documented, stable, caller-facing
contract). "The same principal already sees this text on another surface" is NOT a clearance
— upstream strings are not a stable contract, grow internal detail over time (verifier
internals, allow-list names, hostnames), and the new surface may acquire callers with
different trust. Medium by default; High when the passed-through source is an authorizer,
token verifier, or anything that names internal identifiers.
## Ground-truth verification (verify, don't pattern-match)
+1102 -376
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Entrypoint for the coyote Docker image. Runs under tini:
# ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/coyote-entrypoint"]
#
# tini (PID 1) reaps orphaned processes and forwards signals; coyote is not an
# init and does neither.
case "$1" in
sh | bash | /*) exec "$@" ;;
esac
exec coyote "$@"
+131 -14
View File
@@ -1,7 +1,9 @@
use crate::client::{ModelType, list_models};
use crate::config::paths;
use crate::config::{AppConfig, Config, list_agents_with_descriptions, list_sessions};
use crate::utils::list_file_names;
use crate::config::{
AppConfig, Config, labeled_session_names, list_agents_with_descriptions, sanitize_display_text,
session_scope_dirs,
};
use crate::vault::Vault;
use clap_complete::{CompletionCandidate, Shell, generate};
use clap_complete_nushell::Nushell;
@@ -115,7 +117,11 @@ pub(super) fn bundle_completer(current: &OsStr) -> Vec<CompletionCandidate> {
}
fn extract_agent_from_args() -> Option<String> {
let args: Vec<String> = env::args().collect();
extract_agent_from_args_in(env::args())
}
fn extract_agent_from_args_in<I: IntoIterator<Item = String>>(args: I) -> Option<String> {
let args: Vec<String> = args.into_iter().collect();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
@@ -134,19 +140,21 @@ fn extract_agent_from_args() -> Option<String> {
}
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
session_candidates(
&current.to_string_lossy(),
extract_agent_from_args().as_deref(),
)
}
let sessions = if let Some(agent_name) = extract_agent_from_args() {
let sessions_dir = paths::agent_data_dir(&agent_name).join("sessions");
list_file_names(sessions_dir, ".yaml")
} else {
list_sessions()
};
sessions
fn session_candidates(cur: &str, agent: Option<&str>) -> Vec<CompletionCandidate> {
let (workspace_dir, global_dir) = session_scope_dirs(agent);
labeled_session_names(&workspace_dir, &global_dir)
.into_iter()
.filter(|s| s.starts_with(&*cur))
.map(CompletionCandidate::new)
.filter(|(name, _)| name.starts_with(cur))
.map(|(name, scope)| {
CompletionCandidate::new(sanitize_display_text(&name))
.help(Some(scope.to_string().into()))
})
.collect()
}
@@ -194,3 +202,112 @@ pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
Err(_) => vec![],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::default_sessions_dir;
use crate::testing::{EnvVarGuard, TestConfigDirGuard};
use serial_test::serial;
use std::path::Path;
fn seed(dir: &Path, name: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(format!("{name}.yaml")), "model: x\n").unwrap();
}
fn labeled(candidates: Vec<CompletionCandidate>) -> Vec<(String, Option<String>)> {
candidates
.into_iter()
.map(|c| {
(
c.get_value().to_string_lossy().into_owned(),
c.get_help().map(|h| h.to_string()),
)
})
.collect()
}
#[test]
#[serial]
fn session_completer_labels_scope_and_prefers_workspace() {
let _guard = TestConfigDirGuard::new("cli-session-completer");
seed(&paths::workspace_sessions_dir(), "both");
seed(&default_sessions_dir(), "both");
seed(&default_sessions_dir(), "only-global");
seed(&paths::workspace_sessions_dir(), "only-ws");
assert_eq!(
labeled(session_completer(OsStr::new(""))),
vec![
("both".to_string(), Some("workspace".to_string())),
("only-ws".to_string(), Some("workspace".to_string())),
("only-global".to_string(), Some("global".to_string())),
]
);
assert_eq!(
labeled(session_completer(OsStr::new("on"))),
vec![
("only-ws".to_string(), Some("workspace".to_string())),
("only-global".to_string(), Some("global".to_string())),
]
);
}
#[test]
#[serial]
fn session_candidates_use_the_agent_scope_dirs() {
let _guard = TestConfigDirGuard::new("cli-agent-session-completer");
let _agent_data_dir = EnvVarGuard::unset("A_DATA_DIR");
seed(&paths::workspace_agent_sessions_dir("a"), "both");
seed(&paths::agent_sessions_dir("a"), "both");
seed(&paths::agent_sessions_dir("a"), "agent-global");
seed(&default_sessions_dir(), "plain-global");
assert_eq!(
labeled(session_candidates("", Some("a"))),
vec![
("both".to_string(), Some("workspace".to_string())),
("agent-global".to_string(), Some("global".to_string())),
]
);
assert_eq!(
labeled(session_candidates("", None)),
vec![("plain-global".to_string(), Some("global".to_string()))]
);
}
#[test]
fn extract_agent_from_args_in_reads_both_flag_forms() {
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(str::to_string).collect()
}
assert_eq!(
extract_agent_from_args_in(args("coyote --agent a --session x")),
Some("a".to_string())
);
assert_eq!(
extract_agent_from_args_in(args("coyote -a b")),
Some("b".to_string())
);
assert_eq!(
extract_agent_from_args_in(args("coyote --agent=c")),
Some("c".to_string())
);
assert_eq!(extract_agent_from_args_in(args("coyote --agent")), None);
assert_eq!(extract_agent_from_args_in(args("coyote --session x")), None);
}
#[test]
#[serial]
fn config_dir_guard_clears_and_restores_the_sessions_dir_override() {
let key = crate::utils::get_env_name("sessions_dir");
let junk = std::path::PathBuf::from("/nonexistent/junk-sessions");
let _outer = EnvVarGuard::set(&key, &junk);
{
let guard = TestConfigDirGuard::new("cli-sessions-dir-guard");
assert!(default_sessions_dir().starts_with(&guard.path));
}
assert_eq!(default_sessions_dir(), junk);
}
}
+3 -1
View File
@@ -4,13 +4,15 @@ use super::*;
use anyhow::Result;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AzureOpenAIConfig {
pub name: Option<String>,
pub api_base: Option<String>,
pub api_key: Option<String>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
+3 -1
View File
@@ -16,7 +16,7 @@ use reqwest::{Client as ReqwestClient, Method, RequestBuilder};
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct BedrockConfig {
pub name: Option<String>,
pub access_key_id: Option<String>,
@@ -25,6 +25,8 @@ pub struct BedrockConfig {
pub session_token: Option<String>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
+61 -1
View File
@@ -572,7 +572,7 @@ pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 {
kept as f64 / old.len() as f64
}
fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
pub(crate) fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
ModelData {
name: hand.name,
model_type: hand.model_type,
@@ -604,6 +604,23 @@ fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
}
}
pub(crate) fn extend_catalog_models(
catalog: &[ModelData],
overrides: &[ModelData],
) -> Vec<ModelData> {
let mut models = catalog.to_vec();
for override_model in overrides {
match models.iter_mut().find(|model| {
model.name == override_model.name && model.model_type == override_model.model_type
}) {
Some(model) => *model = overlay_hand_owned(override_model.clone(), model.clone()),
None => models.push(override_model.clone()),
}
}
models
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderQuirks {
@@ -1724,4 +1741,47 @@ rules:
);
}
}
fn typed_model(name: &str, model_type: &str) -> ModelData {
ModelData {
name: name.to_string(),
model_type: model_type.to_string(),
..Default::default()
}
}
#[test]
fn extend_catalog_models_merges_collisions_field_wise() {
let mut catalog_model = ModelData::new("m");
catalog_model.max_input_tokens = Some(100_000);
catalog_model.input_price = Some(1.0);
catalog_model.supports_vision = true;
let mut override_model = ModelData::new("m");
override_model.input_price = Some(9.0);
let merged = extend_catalog_models(&[catalog_model], &[override_model]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].input_price, Some(9.0));
assert_eq!(merged[0].max_input_tokens, Some(100_000));
assert!(merged[0].supports_vision);
}
#[test]
fn extend_catalog_models_appends_net_new_after_catalog() {
let catalog = [ModelData::new("a"), ModelData::new("b")];
let merged = extend_catalog_models(&catalog, &[ModelData::new("c")]);
let names: Vec<&str> = merged.iter().map(|model| model.name.as_str()).collect();
assert_eq!(names, ["a", "b", "c"]);
}
#[test]
fn extend_catalog_models_collides_on_name_and_type() {
let mut catalog_model = typed_model("m", "embedding");
catalog_model.default_chunk_size = Some(1500);
let merged = extend_catalog_models(&[catalog_model], &[typed_model("m", "reranker")]);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].model_type, "embedding");
assert_eq!(merged[0].default_chunk_size, Some(1500));
assert_eq!(merged[1].model_type, "reranker");
}
}
+4 -6
View File
@@ -15,7 +15,7 @@ use serde_json::{Value, json};
const API_BASE: &str = "https://api.anthropic.com/v1";
const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ClaudeConfig {
pub name: Option<String>,
pub api_key: Option<String>,
@@ -24,6 +24,8 @@ pub struct ClaudeConfig {
pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
@@ -1122,16 +1124,12 @@ mod tests {
fn claude_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> ClaudeConfig {
ClaudeConfig {
name: Some(name.into()),
api_key: None,
api_base: oauth
.as_ref()
.map(|_| "https://gateway.example/v1".to_string()),
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
models: vec![],
prompt_cache: None,
patch: None,
extra: None,
..Default::default()
}
}
+2
View File
@@ -16,6 +16,8 @@ pub struct CohereConfig {
pub api_base: Option<String>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
+9
View File
@@ -420,6 +420,15 @@ impl TokenUsage {
&& self.cache_read_input_tokens.is_none()
}
/// Prompt-side tokens the API reported for one request: fresh input plus
/// cache writes and cache reads. The buckets are disjoint (see
/// [`Self::cost_usd`]), so the terms sum without adjustment.
pub fn total_prompt_tokens(&self) -> u64 {
self.input_tokens.unwrap_or(0)
+ self.cache_creation_input_tokens.unwrap_or(0)
+ self.cache_read_input_tokens.unwrap_or(0)
}
pub fn accumulate(&mut self, other: &TokenUsage) {
fn add(acc: &mut Option<u64>, value: Option<u64>) {
if let Some(value) = value {
+2
View File
@@ -20,6 +20,8 @@ pub struct GeminiConfig {
pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
+16 -7
View File
@@ -51,15 +51,24 @@ macro_rules! register_client {
pub fn list_models(local_config: &$config) -> Vec<Model> {
let client_name = Self::name(local_config);
let find_catalog = || $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
v.provider == $name ||
($name == OpenAICompatibleClient::NAME
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
});
if local_config.models.is_empty() {
if let Some(v) = $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {
v.provider == $name ||
($name == OpenAICompatibleClient::NAME
&& local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())
}) {
return Model::from_config(client_name, &v.models);
match find_catalog() {
Some(v) => Model::from_config(client_name, &v.models),
None => vec![],
}
} else if local_config.extend_models {
match find_catalog() {
Some(v) => Model::from_config(
client_name,
&$crate::client::catalog::extend_catalog_models(&v.models, &local_config.models),
),
None => Model::from_config(client_name, &local_config.models),
}
vec![]
} else {
Model::from_config(client_name, &local_config.models)
}
+92
View File
@@ -557,4 +557,96 @@ reasoning_levels: [none, low, medium, high]
"oauth blocks in the shipped models.yaml are limited to providers with no built-in stock oauth path (currently only xai's bundled defaults); adding one for claude/openai/gemini would silently swap stock oauth users onto a non-stock provider"
);
}
#[test]
fn config_models_replace_catalog_by_default() {
let ClientConfig::ClaudeConfig(config) = serde_yaml::from_str::<ClientConfig>(
"type: claude\nmodels:\n - name: test-replace-model\n",
)
.unwrap() else {
panic!("expected a claude config");
};
assert!(!config.extend_models);
let models = crate::client::ClaudeClient::list_models(&config);
assert_eq!(models.len(), 1);
assert_eq!(models[0].name(), "test-replace-model");
}
#[test]
fn config_models_extend_catalog_when_flag_set() {
let ClientConfig::ClaudeConfig(config) = serde_yaml::from_str::<ClientConfig>(
"type: claude\nextend_models: true\nmodels:\n - name: test-extra-model\n",
)
.unwrap() else {
panic!("expected a claude config");
};
let catalog_len = crate::client::ALL_PROVIDER_MODELS
.iter()
.find(|v| v.provider == "claude")
.map(|v| v.models.len())
.unwrap();
let models = crate::client::ClaudeClient::list_models(&config);
assert_eq!(models.len(), catalog_len + 1);
assert_eq!(models.last().unwrap().name(), "test-extra-model");
}
#[test]
fn extend_models_with_empty_models_returns_catalog_exactly() {
let ClientConfig::ClaudeConfig(config) =
serde_yaml::from_str::<ClientConfig>("type: claude\nextend_models: true\n").unwrap()
else {
panic!("expected a claude config");
};
let catalog = crate::client::ALL_PROVIDER_MODELS
.iter()
.find(|v| v.provider == "claude")
.unwrap();
let models = crate::client::ClaudeClient::list_models(&config);
assert_eq!(models.len(), catalog.models.len());
for (model, catalog_model) in models.iter().zip(catalog.models.iter()) {
assert_eq!(model.name(), catalog_model.name);
}
}
#[test]
fn extend_models_without_catalog_match_returns_config_models_only() {
let ClientConfig::OpenAICompatibleConfig(config) = serde_yaml::from_str::<ClientConfig>(
"type: openai-compatible\nname: no-such-provider-xyz\nextend_models: true\nmodels:\n - name: test-only-model\n",
)
.unwrap() else {
panic!("expected an openai-compatible config");
};
assert!(
!crate::client::ALL_PROVIDER_MODELS
.iter()
.any(|v| "no-such-provider-xyz".starts_with(v.provider.as_str())),
"test premise: the client name must not prefix-match any catalog provider"
);
let models = crate::client::OpenAICompatibleClient::list_models(&config);
assert_eq!(models.len(), 1);
assert_eq!(models[0].name(), "test-only-model");
}
#[test]
fn extend_models_extends_catalog_via_provider_name_prefix_match() {
let ClientConfig::OpenAICompatibleConfig(config) = serde_yaml::from_str::<ClientConfig>(
"type: openai-compatible\nname: openrouter-custom\nextend_models: true\nmodels:\n - name: test-net-new-model\n",
)
.unwrap() else {
panic!("expected an openai-compatible config");
};
let catalog_len = crate::client::ALL_PROVIDER_MODELS
.iter()
.find(|v| v.provider == "openrouter")
.map(|v| v.models.len())
.unwrap();
let models = crate::client::OpenAICompatibleClient::list_models(&config);
assert_eq!(models.len(), catalog_len + 1);
assert_eq!(models.last().unwrap().name(), "test-net-new-model");
}
}
+4 -23
View File
@@ -1457,13 +1457,9 @@ echo_pkce_in_token_exchange: true
ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig {
name: Some(name.into()),
api_base: Some("https://api.example/v1".into()),
api_key: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
wire_api: None,
models: vec![],
patch: None,
extra: None,
..Default::default()
})
}
@@ -1474,14 +1470,9 @@ echo_pkce_in_token_exchange: true
) -> ClientConfig {
ClientConfig::ClaudeConfig(ClaudeConfig {
name: Some(name.into()),
api_key: None,
api_base: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
models: vec![],
prompt_cache: None,
patch: None,
extra: None,
..Default::default()
})
}
@@ -1492,15 +1483,9 @@ echo_pkce_in_token_exchange: true
) -> ClientConfig {
ClientConfig::OpenAIConfig(OpenAIConfig {
name: Some(name.into()),
api_key: None,
api_base: None,
organization_id: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
wire_api: None,
models: vec![],
patch: None,
extra: None,
..Default::default()
})
}
@@ -1511,13 +1496,9 @@ echo_pkce_in_token_exchange: true
) -> ClientConfig {
ClientConfig::GeminiConfig(GeminiConfig {
name: Some(name.into()),
api_key: None,
api_base: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
models: vec![],
patch: None,
extra: None,
..Default::default()
})
}
+32 -6
View File
@@ -24,6 +24,8 @@ pub struct OpenAIConfig {
pub wire_api: Option<WireApi>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
@@ -869,17 +871,22 @@ fn reasoning_thinking_block(item: &Value) -> Option<ThinkingBlock> {
}
/// 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> {
if !usage.is_object() {
return None;
}
let cached_tokens = usage["input_tokens_details"]["cached_tokens"].as_u64();
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(),
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 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.cache_creation_input_tokens, None);
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]
fn extract_responses_without_usage_leaves_it_none() {
let data = json!({
@@ -1357,7 +1383,7 @@ mod tests {
assert!(done);
let (_, _, _, usage) = handler.take();
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.cache_creation_input_tokens, None);
assert_eq!(usage.cache_read_input_tokens, Some(60));
@@ -1467,7 +1493,7 @@ mod tests {
let (text, _, _, usage) = handler.take();
assert_eq!(text, "partial answer");
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.cache_read_input_tokens, Some(25));
}
+4 -6
View File
@@ -9,7 +9,7 @@ use serde::Deserialize;
use serde_json::{Value, json};
use oauth::OAuthConfig;
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct OpenAICompatibleConfig {
pub name: Option<String>,
pub api_base: Option<String>,
@@ -19,6 +19,8 @@ pub struct OpenAICompatibleConfig {
pub wire_api: Option<WireApi>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
}
@@ -274,12 +276,8 @@ mod tests {
name: Some("compat-test".into()),
api_base: Some("https://compat.example/v1".into()),
api_key: Some("sk-test".into()),
auth: None,
oauth: None,
wire_api,
models: vec![],
patch: None,
extra: None,
..Default::default()
}
}
+2
View File
@@ -18,6 +18,8 @@ pub struct VertexAIConfig {
pub adc_file: Option<String>,
#[serde(default)]
pub models: Vec<ModelData>,
#[serde(default)]
pub extend_models: bool,
pub prompt_cache: Option<bool>,
pub patch: Option<RequestPatch>,
pub extra: Option<ExtraConfig>,
+3
View File
@@ -71,6 +71,7 @@ pub struct AppConfig {
pub compression_threshold: usize,
pub compression_keep_last: usize,
pub compression_model: Option<String>,
pub compression_safety_valve: f32,
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>,
@@ -164,6 +165,7 @@ impl Default for AppConfig {
compression_threshold: 4000,
compression_keep_last: 0,
compression_model: None,
compression_safety_valve: 0.8,
summarization_prompt: None,
summary_context_prompt: None,
max_tool_result_chars: None,
@@ -270,6 +272,7 @@ impl AppConfig {
compression_threshold: config.compression_threshold,
compression_keep_last: config.compression_keep_last,
compression_model: config.compression_model,
compression_safety_valve: config.compression_safety_valve,
summarization_prompt: config.summarization_prompt,
summary_context_prompt: config.summary_context_prompt,
max_tool_result_chars: config.max_tool_result_chars,
+90
View File
@@ -68,6 +68,34 @@ impl Input {
})
}
/// An input carrying only `text`: no session history, no RAG, and no
/// function declarations. For auxiliary LLM calls (such as session
/// summarization) whose requests must not grow with the session.
pub fn detached_text(ctx: &RequestContext, text: &str) -> Self {
let mut role = Role::new("", "");
role.set_model(ctx.current_model().clone());
Self {
app_config: Arc::clone(&ctx.app.config),
stream_enabled: ctx.app.config.stream,
session: None,
rag: None,
functions: None,
text: text.to_string(),
raw: (text.to_string(), vec![]),
patched_text: None,
last_reply: None,
continue_output: None,
regenerate: false,
medias: Default::default(),
data_urls: Default::default(),
tool_calls: None,
role,
rag_name: None,
with_session: false,
with_agent: false,
}
}
pub async fn from_files(
ctx: &RequestContext,
raw_text: &str,
@@ -329,6 +357,14 @@ impl Input {
}
}
/// Re-captures `ctx.session` in place of the snapshot taken at
/// construction. Session compression mutates the ctx's session, not the
/// snapshot, so an input that outlives a compression must refresh before
/// building its next request or it re-sends the pre-compression history.
pub fn refresh_session(&mut self, ctx: &RequestContext) {
self.session = ctx.session.clone();
}
pub fn session_mut<'a>(&self, session: &'a mut Option<Session>) -> Option<&'a mut Session> {
if self.with_session {
session.as_mut()
@@ -660,6 +696,60 @@ mod tests {
assert!(!with_session);
}
#[test]
fn detached_text_input_carries_no_session_history_or_functions() {
let mut ctx = create_test_ctx();
ctx.session = Some(Session::default());
let earlier = Input::from_str(&ctx, "earlier question", None).unwrap();
ctx.session
.as_mut()
.unwrap()
.add_message(&earlier, "earlier answer")
.unwrap();
let detached = Input::detached_text(&ctx, "summarize the above");
let messages = detached.build_messages().unwrap();
assert_eq!(messages.len(), 1, "must contain only the detached text");
assert!(messages[0].role.is_user());
assert_eq!(messages[0].content.to_text(), "summarize the above");
assert!(detached.declared_function_names().is_empty());
assert!(!detached.with_session());
assert!(!detached.with_agent());
}
#[test]
fn refresh_session_rebuilds_from_compressed_session() {
let mut ctx = create_test_ctx();
ctx.session = Some(Session::default());
for i in 0..4 {
let question =
Input::from_str(&ctx, &format!("question {i}: {}", "x".repeat(500)), None).unwrap();
ctx.session
.as_mut()
.unwrap()
.add_message(&question, &"y".repeat(500))
.unwrap();
}
let mut input = Input::from_str(&ctx, "retry me", None).unwrap();
let stale = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
ctx.session.as_mut().unwrap().compress("recap".into(), 1);
assert_eq!(
serde_json::to_string(&input.build_messages().unwrap()).unwrap(),
stale,
"the snapshot captured at construction must be untouched by compression"
);
input.refresh_session(&ctx);
let refreshed = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
assert!(
refreshed.len() < stale.len(),
"a refreshed input must build a strictly smaller request after compression"
);
}
#[test]
fn resolve_paths_detects_last_reply_syntax() {
let loaders = HashMap::new();
+6 -6
View File
@@ -58,7 +58,7 @@ pub use self::request_context::{
pub use self::role::{
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
};
pub use self::session::Session;
pub use self::session::{Session, SessionScope, labeled_session_names, session_scope_dirs};
#[allow(unused_imports)]
pub use self::skill::Skill;
#[allow(unused_imports)]
@@ -183,7 +183,7 @@ const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
pub(crate) const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
@@ -286,6 +286,7 @@ pub struct Config {
pub compression_threshold: usize,
pub compression_keep_last: usize,
pub compression_model: Option<String>,
pub compression_safety_valve: f32,
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>,
@@ -376,6 +377,7 @@ impl Default for Config {
compression_threshold: 4000,
compression_keep_last: 0,
compression_model: None,
compression_safety_valve: 0.8,
summarization_prompt: None,
summary_context_prompt: None,
max_tool_result_chars: None,
@@ -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
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
/// APIs merged with the embedded quirks.
@@ -1379,6 +1377,7 @@ clients:
assert!(cfg.mcp_server_support);
assert_eq!(cfg.compression_threshold, 4000);
assert!(cfg.compression_model.is_none());
assert_eq!(cfg.compression_safety_valve, 0.8);
assert_eq!(cfg.rag_top_k, 5);
assert!(cfg.save_shell_history);
assert_eq!(cfg.keybindings, "emacs");
@@ -1419,6 +1418,7 @@ clients:
assert!(cfg.mapping_tools.is_empty());
assert!(cfg.document_loaders.is_empty());
assert_eq!(cfg.compression_threshold, 4000);
assert_eq!(cfg.compression_safety_valve, 0.8);
assert!(cfg.theme.is_none());
assert_eq!(cfg.clients.len(), 1);
}
+31 -1
View File
@@ -5,7 +5,7 @@ use super::{
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_NAME, INSTALLED_BUNDLES_FILE_NAME,
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
SBX_MIXIN_KITS_DIR_NAME, SESSIONS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
};
use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME;
@@ -229,6 +229,17 @@ pub fn workspace_macros_dir() -> PathBuf {
workspace_config_dir().join(MACROS_DIR_NAME)
}
pub fn workspace_sessions_dir() -> PathBuf {
workspace_config_dir().join(SESSIONS_DIR_NAME)
}
pub fn workspace_agent_sessions_dir(name: &str) -> PathBuf {
workspace_config_dir()
.join(AGENTS_DIR_NAME)
.join(name)
.join(SESSIONS_DIR_NAME)
}
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
}
@@ -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 {
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 {
File diff suppressed because it is too large. Load diff
+328 -12
View File
@@ -12,8 +12,10 @@ use inquire::{Confirm, Text, validator::Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::fs::{read_to_string, write};
use std::path::Path;
use std::fmt;
use std::fs::{OpenOptions, read_to_string, remove_file, rename};
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
@@ -29,6 +31,62 @@ fn cost_is_zero(v: &f64) -> bool {
*v == 0.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SessionScope {
#[default]
Global,
Workspace,
}
impl fmt::Display for SessionScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SessionScope::Global => write!(f, "global"),
SessionScope::Workspace => write!(f, "workspace"),
}
}
}
pub fn session_scope_dirs(agent: Option<&str>) -> (PathBuf, PathBuf) {
match agent {
Some(agent) => (
paths::workspace_agent_sessions_dir(agent),
paths::agent_sessions_dir(agent),
),
None => (paths::workspace_sessions_dir(), default_sessions_dir()),
}
}
/// Session names across both scope dirs, workspace first, deduped by name so
/// a workspace session shadows a global one with the same name.
pub fn labeled_session_names(
workspace_dir: &Path,
global_dir: &Path,
) -> Vec<(String, SessionScope)> {
let mut values = Vec::new();
let mut seen = HashSet::new();
for (dir, scope) in [
(workspace_dir, SessionScope::Workspace),
(global_dir, SessionScope::Global),
] {
for name in list_file_names(dir, ".yaml") {
if seen.insert(name.clone()) {
values.push((name, scope));
}
}
}
values
}
pub fn merged_session_names(workspace_dir: &Path, global_dir: &Path) -> Vec<String> {
let mut names: Vec<String> = labeled_session_names(workspace_dir, global_dir)
.into_iter()
.map(|(name, _)| name)
.collect();
names.sort_unstable();
names
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Session {
#[serde(rename(serialize = "model", deserialize = "model"))]
@@ -120,6 +178,8 @@ pub struct Session {
#[serde(skip)]
path: Option<String>,
#[serde(skip)]
scope: SessionScope,
#[serde(skip)]
dirty: bool,
#[serde(skip)]
save_session_this_time: bool,
@@ -173,6 +233,7 @@ impl Session {
app: &AppConfig,
name: &str,
path: &Path,
scope: SessionScope,
) -> Result<Self> {
let content = read_to_string(path)
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
@@ -180,6 +241,7 @@ impl Session {
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
session.scope = scope;
if let Some(autoname) = name.strip_prefix("_/") {
session.name = TEMP_SESSION_NAME.to_string();
@@ -250,6 +312,14 @@ impl Session {
self.name = name;
}
pub fn scope(&self) -> SessionScope {
self.scope
}
pub fn set_scope(&mut self, scope: SessionScope) {
self.scope = scope;
}
pub fn clear_autoname(&mut self) {
self.autoname = None;
}
@@ -313,6 +383,7 @@ impl Session {
pub fn export(&self) -> Result<String> {
let mut data = json!({
"path": self.path,
"scope": self.scope.to_string(),
"model": self.model().id(),
});
if let Some(temperature) = self.temperature() {
@@ -403,6 +474,7 @@ impl Session {
if let Some(path) = &self.path {
items.push(("path", path.to_string()));
}
items.push(("scope", self.scope.to_string()));
if let Some(autoname) = self.autoname() {
items.push(("autoname", autoname.to_string()));
@@ -719,17 +791,50 @@ impl Session {
self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1)
}
/// Estimated tokens of the messages that compression would actually fold
/// away: everything except the leading system message (whose content is
/// carried into the summary message) and the `keep_last` tail (kept verbatim).
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
/// Whether the prompt is close enough to the model's hard input limit
/// that compression must run regardless of how `compression_threshold`
/// is configured. Sizes the prompt as the larger of the API-reported
/// measurement from the last request that reported usage and the local
/// estimate, because the estimate can run well below the provider's
/// real count.
pub fn safety_valve_triggered(
&self,
valve: f32,
keep_last: usize,
last_prompt_tokens: Option<usize>,
) -> bool {
if self.compressing || !valve.is_finite() || valve <= 0.0 {
return false;
}
let Some(max_input_tokens) = self.model().max_input_tokens() else {
return false;
};
// With nothing to fold away, compressing is a no-op; firing anyway
// would spin compress attempts while a single in-flight turn holds
// all the tokens.
if self.reclaimable_tokens(keep_last) == 0 {
return false;
}
last_prompt_tokens.unwrap_or(0).max(self.tokens())
>= (valve * max_input_tokens as f32) as usize
}
/// The messages that compression would actually fold away: everything
/// except the leading system message (whose content is carried into the
/// summary message) and the `keep_last` tail (kept verbatim).
pub fn foldable_messages(&self, keep_last: usize) -> &[Message] {
let start = usize::from(
self.messages
.first()
.is_some_and(|v| v.role == MessageRole::System),
);
let end = self.messages.len().saturating_sub(keep_last).max(start);
self.model().total_tokens(&self.messages[start..end])
&self.messages[start..end]
}
/// Estimated tokens of the messages compression would fold away.
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
self.model().total_tokens(self.foldable_messages(keep_last))
}
pub fn compressing(&self) -> bool {
@@ -853,17 +958,52 @@ impl Session {
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
ensure_parent_exists(session_path)?;
self.path = Some(session_path.display().to_string());
let content = serde_yaml::to_string(&self)
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
write(session_path, content).with_context(|| {
// Write through a sibling temp file and rename it over the target so a
// symlink planted at the session path is replaced, never followed. The
// temp file is opened with create_new so a symlink planted at the temp
// path is not followed either; a stale entry there is removed and the
// open retried once.
let mut tmp_name = session_path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", process::id()));
let tmp_path = PathBuf::from(tmp_name);
let write_context = || {
format!(
"Failed to write session '{}' to '{}'",
self.name,
session_path.display()
)
})?;
};
let open_tmp = || {
OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
};
let mut tmp_file = match open_tmp() {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
remove_file(&tmp_path).with_context(write_context)?;
open_tmp().with_context(write_context)?
}
Err(error) => return Err(error).with_context(write_context),
};
if let Err(error) = tmp_file
.write_all(content.as_bytes())
.and_then(|_| tmp_file.flush())
{
drop(tmp_file);
let _ = remove_file(&tmp_path);
return Err(error).with_context(write_context);
}
drop(tmp_file);
if let Err(error) = rename(&tmp_path, session_path) {
let _ = remove_file(&tmp_path);
return Err(error).with_context(write_context);
}
self.path = Some(session_path.display().to_string());
if is_repl {
println!("✓ Saved the session to '{}'.", session_path.display());
@@ -1109,7 +1249,7 @@ impl AutoName {
mod tests {
use super::*;
use crate::client::{
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, TokenUsage,
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, ModelData, TokenUsage,
};
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
use crate::function::{Functions, ToolCall, ToolResult};
@@ -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]
fn session_deserialization_ignores_injected_role_hooks() {
// 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() {
let empty = Session::default().export().unwrap();
assert!(!empty.contains("real_input_tokens"));
assert!(empty.contains("scope: global"));
let mut session = Session::default();
session.set_scope(SessionScope::Workspace);
session.accumulate_token_usage(&TokenUsage {
input_tokens: Some(5),
output_tokens: Some(7),
@@ -1332,12 +1484,97 @@ mod tests {
cache_read_input_tokens: Some(3),
});
let exported = session.export().unwrap();
assert!(exported.contains("scope: workspace"));
assert!(exported.contains("real_input_tokens: 5"));
assert!(exported.contains("real_output_tokens: 7"));
assert!(exported.contains("cache_read_tokens: 3"));
assert!(!exported.contains("cache_creation_tokens"));
}
fn rendered_lines(session: &Session) -> Vec<String> {
let options = crate::render::RenderOptions {
raw_markdown: true,
..Default::default()
};
let mut render = MarkdownRender::init(options).unwrap();
session
.render(&mut render, &None)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn session_render_reports_scope_line() {
let mut session = Session::default();
assert!(
rendered_lines(&session)
.iter()
.any(|line| line.starts_with("scope") && line.ends_with("global"))
);
session.set_scope(SessionScope::Workspace);
assert!(
rendered_lines(&session)
.iter()
.any(|line| line.starts_with("scope") && line.ends_with("workspace"))
);
}
#[cfg(unix)]
#[test]
fn session_save_replaces_a_symlink_instead_of_following_it() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("coyote-session-save-symlink-{unique}"));
let sessions_dir = dir.join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
let target = dir.join("target.txt");
std::fs::write(&target, "untouched").unwrap();
let link = sessions_dir.join("link.yaml");
std::os::unix::fs::symlink(&target, &link).unwrap();
let mut session = Session::default();
session.save("link", &link, false).unwrap();
assert_eq!(read_to_string(&target).unwrap(), "untouched");
assert!(link.symlink_metadata().unwrap().is_file());
assert!(read_to_string(&link).unwrap().contains("model:"));
assert_eq!(session.name(), "link");
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn session_save_does_not_follow_a_symlink_planted_at_the_temp_path() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("coyote-session-save-tmp-symlink-{unique}"));
let sessions_dir = dir.join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
let target = dir.join("target.txt");
std::fs::write(&target, "untouched").unwrap();
let destination = sessions_dir.join("notes.yaml");
let mut tmp_name = destination.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", process::id()));
let tmp_path = PathBuf::from(tmp_name);
std::os::unix::fs::symlink(&target, &tmp_path).unwrap();
let mut session = Session::default();
session.save("notes", &destination, false).unwrap();
assert_eq!(read_to_string(&target).unwrap(), "untouched");
assert!(destination.symlink_metadata().unwrap().is_file());
assert!(read_to_string(&destination).unwrap().contains("model:"));
assert!(!tmp_path.exists(), "temp file must not be left behind");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn session_todo_state_survives_yaml_round_trip() {
let mut session = Session::default();
@@ -1578,6 +1815,85 @@ mod tests {
assert!(session.needs_compression(100, 1));
}
/// A session with a system message, a large user message, and a small
/// assistant reply, on a model with the given input limit. With
/// `keep_last = 1` the user message is reclaimable; with `keep_last = 2`
/// nothing is.
fn valve_session(max_input_tokens: Option<usize>) -> Session {
let mut session = Session::default();
let mut data = ModelData::new("valve-model");
data.max_input_tokens = max_input_tokens;
session.set_model(Model::from_config("provider", &[data]).remove(0));
session.messages.push(Message::new(
MessageRole::System,
MessageContent::Text("x".repeat(400)),
));
session.messages.push(Message::new(
MessageRole::User,
MessageContent::Text("y".repeat(4000)),
));
session.messages.push(Message::new(
MessageRole::Assistant,
MessageContent::Text("ok".to_string()),
));
session.update_tokens();
session
}
#[test]
fn session_safety_valve_fires_from_estimate() {
let session = valve_session(Some(1000));
assert!(session.tokens() >= 800);
assert!(session.safety_valve_triggered(0.8, 1, None));
}
#[test]
fn session_safety_valve_fires_from_real_usage_over_estimate() {
let session = valve_session(Some(1_000_000));
assert!(!session.safety_valve_triggered(0.8, 1, None));
assert!(!session.safety_valve_triggered(0.8, 1, Some(700_000)));
assert!(session.safety_valve_triggered(0.8, 1, Some(800_000)));
}
#[test]
fn session_safety_valve_disabled_when_zero() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(0.0, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_disabled_when_nan() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(f32::NAN, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_respects_compressing() {
let mut session = valve_session(Some(1000));
session.set_compressing(true);
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_requires_reclaimable_tokens() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(0.8, 2, Some(1_000_000)));
assert!(session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_requires_model_max_input_tokens() {
let session = valve_session(None);
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_set_compressing_flag() {
let mut session = Session::default();
+782 -70
View File
@@ -13,7 +13,8 @@ use crate::client::{
};
use crate::config::{
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::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
@@ -1088,11 +1089,24 @@ pub async fn run_repl_command(
Some(("role", name)) => {
ctx.save_role(name)?;
}
Some(("session", name)) => {
ctx.save_session(name)?;
}
Some(("session", rest)) => match parse_repl_save_session(rest) {
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" => {
@@ -1515,6 +1529,8 @@ async fn ask_inner(
) -> Result<()> {
let mut input = input;
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 {
if input.is_empty() {
return Ok(());
@@ -1530,6 +1546,10 @@ async fn ask_inner(
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() {
ctx.before_chat_completion(&input)?;
let output =
@@ -1560,6 +1580,18 @@ async fn ask_inner(
match result {
Ok(v) => v,
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);
return Err(err);
}
@@ -1574,6 +1606,7 @@ async fn ask_inner(
match check_pending_tasks_guardrail(ctx) {
GuardrailAction::Inject(prompt) => {
input = Input::from_str(ctx, &prompt, None)?;
retried_after_compress = false;
continue;
}
GuardrailAction::ForceTerminate(ids) => {
@@ -1617,6 +1650,7 @@ async fn ask_inner(
format!("{prompt}\n\n{todo_state}")
};
input = Input::from_str(ctx, &full_prompt, None)?;
retried_after_compress = false;
continue;
}
@@ -1637,63 +1671,45 @@ async fn ask_inner(
}
}
let keep_last = ctx.compression_keep_last();
let needs_compression = ctx
.session
.as_ref()
.is_some_and(|s| s.needs_compression(app.compression_threshold, keep_last));
let agent_can_continue_after_compress =
should_continue(ctx) && !abort_signal.aborted_ctrlc();
if needs_compression {
let agent_can_continue_after_compress =
should_continue(ctx) && !abort_signal.aborted_ctrlc();
// Auto-continue keys off the compression CONDITION, not the outcome:
// a transiently failed compression retries at the next turn end, and
// 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() {
session.set_compressing(true);
}
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("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}")
let color = if app.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
input = Input::from_str(ctx, &full_prompt, None)?;
continue;
}
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)?;
retried_after_compress = false;
continue;
}
print_turn_divider();
@@ -1709,15 +1725,87 @@ fn print_turn_divider() {
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 {
Ok(()) => hooks::fire(
HookEvent::SessionCompressed,
ctx,
&hooks::role_extras(ctx),
None,
),
Err(err) => warn!("Failed to compress the session: {err}"),
Ok(()) => {
hooks::fire(
HookEvent::SessionCompressed,
ctx,
&hooks::role_extras(ctx),
None,
);
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> {
let mut names: Vec<&'static str> = REPL_COMMANDS
.iter()
@@ -2036,10 +2179,12 @@ pub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{ClientConfig, Model};
use crate::client::{ClientConfig, MessageContent, Model, ModelData, ToolCall};
use crate::config::{AppState, Role, RoleLike, Session, TEMP_ROLE_NAME, WorkingMode};
use crate::function::ToolResult;
use crate::hooks::{HookDef, HooksMap, test_sink};
use anyhow::anyhow;
use serde_json::json;
use serial_test::serial;
use std::future::Future;
@@ -2357,6 +2502,13 @@ mod tests {
/// resolvable, and the session already holds a user exchange so
/// `compress_session` has something to summarize.
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();
hooks_map.insert(
"session.compressed".to_string(),
@@ -2370,7 +2522,7 @@ mod tests {
hooks: hooks_map,
dry_run: true,
clients: vec![ClientConfig::default()],
..Default::default()
..config
});
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Repl);
let mut session = Session::default();
@@ -2382,6 +2534,7 @@ mod tests {
.unwrap()
.add_message(&input, "hi")
.unwrap();
ctx.session.as_mut().unwrap().update_tokens();
ctx
}
@@ -2432,7 +2585,7 @@ mod tests {
let marker = "auto-compress-ok-f8r";
run_async(async {
let mut ctx = compressible_ctx(marker);
auto_compress_session(&mut ctx).await;
assert!(auto_compress_session(&mut ctx).await);
});
assert_eq!(
@@ -2494,7 +2647,7 @@ mod tests {
// An empty session makes compress_session fail; the seam must
// swallow the error (no panic, no propagation) and fire nothing.
ctx.session = Some(Session::default());
auto_compress_session(&mut ctx).await;
assert!(!auto_compress_session(&mut ctx).await);
});
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]
fn test_process_command_line() {
assert_eq!(parse_command(" ."), Some((".", None)));
@@ -2702,6 +3276,144 @@ mod tests {
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]
fn repl_install_and_uninstall_help_text_cover_the_full_surface() {
let install = repl_install_help();
+28 -5
View File
@@ -1,7 +1,10 @@
//! Test-only helpers shared across modules' unit tests.
use crate::config::WORKSPACE_COYOTE_DIR_NAME;
use crate::utils;
use log::{Level, LevelFilter, Log, Metadata, Record};
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::sync::{Mutex, Once, OnceLock};
struct TestLogCollector;
@@ -147,12 +150,19 @@ impl Drop for EnvVarGuard {
}
}
/// Points the config dir at a fresh temp directory for the guard's lifetime
/// and removes it on drop, including on panic. Tests using it must serialize
/// (`#[serial]`) — the config-dir env var is process-global.
/// Points the config dir and the workspace config dir at fresh temp
/// directories for the guard's lifetime and removes them on drop, including
/// on panic, so the developer's real `./.coyote/` never leaks into a test.
/// The sessions-dir override is cleared too, so Global-scope session writes
/// land under the temp config dir rather than the developer's real one.
/// Tests using it must serialize (`#[serial]`) — the env vars are
/// process-global.
pub(crate) struct TestConfigDirGuard {
_env: EnvVarGuard,
pub(crate) path: std::path::PathBuf,
_workspace_env: EnvVarGuard,
_sessions_env: EnvVarGuard,
pub(crate) path: PathBuf,
workspace_path: PathBuf,
}
impl TestConfigDirGuard {
@@ -163,9 +173,19 @@ impl TestConfigDirGuard {
.as_nanos();
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
std::fs::create_dir_all(&path).unwrap();
let workspace_path = std::env::temp_dir()
.join(format!("coyote-{label}-ws-{unique}"))
.join(WORKSPACE_COYOTE_DIR_NAME);
std::fs::create_dir_all(&workspace_path).unwrap();
Self {
_env: EnvVarGuard::set(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,
workspace_path,
}
}
}
@@ -173,5 +193,8 @@ impl TestConfigDirGuard {
impl Drop for TestConfigDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
if let Some(root) = self.workspace_path.parent() {
let _ = std::fs::remove_dir_all(root);
}
}
}
+108 -2
View File
@@ -15,11 +15,11 @@
//! a real ~/.coyote_password or a sandbox shell — cannot mask
//! virgin-environment regressions.
use std::env;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{env, process};
fn fresh_config_dir(label: &str) -> PathBuf {
let unique = SystemTime::now()
@@ -438,3 +438,109 @@ fn sync_models_stays_on_bootstrap_path() {
"--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"
);
}