Compare commits

...
33 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Fable 5 39cb1ca1bc chore(release): bump workspace version to 0.1.13
Release / prepare draft release (push) Successful in 6s
Release / build (aarch64-apple-darwin) (push) Failing after 1s
Release / build (x86_64-apple-darwin) (push) Failing after 1s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish release (push) Canceled after 0s
Release / build (x86_64-unknown-linux-gnu) (push) Successful in 16m32s
Release / build (aarch64-unknown-linux-gnu) (push) Successful in 16m13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:43:58 -04:00
ZacharyZhang-NYandClaude Fable 5 e83610db6a chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.13 dependency set
cargo-about over the updated lockfile (h2 0.4.19, webbrowser 1.2.4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:09:34 -04:00
ZacharyZhang-NYandClaude Fable 5 fd5cff2de6 chore(gates): finish the Windows sweep — zero clippy findings workspace-wide
Second layer of platform gaps the mac/linux-only gate history left:
unix-only test imports/helpers now cfg-gated (mermaid, fast-worktree,
hunk-tracker, pager-render link test, three pty_e2e module decls),
clippy-suggested cleanups in Windows-cfg code (file-utils classifier,
tui console-mode restore, shared clipboard arboard-error flow, tools
terminal closure, workspace capability tail), and `cargo update` of h2
(RUSTSEC-2026-0258) and webbrowser (RUSTSEC-2026-0257) so `cargo deny
check advisories` passes again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:04:32 -04:00
ZacharyZhang-NYandClaude Fable 5 427e8b6fea docs(agents): record the upstream sync baseline and the subagent spawn bound
The 0.1.13 sync had to reconstruct the fork point by content
fingerprinting; the sync record (baseline snapshot, last-evaluated
upstream commit, and where upstream hides its per-change lists) now
lives in AGENTS.md so the next sync starts from facts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:55:19 -04:00
ZacharyZhang-NYandClaude Fable 5 50669bf302 chore(gates): zero clippy warnings and a buildable sandbox example on Windows
The full-workspace gates had never run on this platform: collapsible
ifs in the Windows shell probe, a same-type cast in the crash handler,
an unused AsRawHandle import, and the sandbox smoke-test example
building its unix-only body (libc, kernel enforcement) unconditionally.
The example now stubs main off-unix; formatting reflow from cargo fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:55:19 -04:00
ZacharyZhang-NYandClaude Fable 5 25d1a4f980 fix(sampler): retry Cloudflare 52x and other 5xx during outages
Port of upstream grok-build 1.0.0/1.0.1-window retry policy.

- is_retryable_api_status: 429 plus any 5xx except Cloudflare 525/526
  (origin TLS handshake / invalid certificate — never clears on its
  own), replacing the 500|502|503|504|520 allowlist so the 521-524,
  529, and 530 edge pages an outage serves actually retry.
- The generic retry path clamps a server Retry-After to
  MAX_RETRY_BACKOFF (30s) and jitters it: Cloudflare 52x pages ask for
  60-120s, which across the budget stalled a turn ~28 minutes. The 429
  path keeps the full (parse-capped) Retry-After under its low attempt
  cap, and jitter is shared via jittered().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:51:36 -04:00
ZacharyZhang-NYandClaude Fable 5 a441b23a6d fix(mcp): kigi mcp add <name> <url> infers an HTTP server
Port of upstream grok-build 1.0.9. A bare positional http(s):// URL
with no transport flag was silently added as a stdio command whose
"command" was the URL. It now infers the HTTP transport with a notice;
extra args, --env, legacy --command/--url, and every explicit flag keep
their prior meaning, and scheme-less URL-looking commands still stay
stdio with the existing warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:42:47 -04:00
ZacharyZhang-NYandClaude Fable 5 574122884a fix(windows): resolve the home directory via the standard lookup
Port of upstream grok-build 1.0.4/1.0.5. Two paths read $HOME only,
which is usually unset on Windows outside Git Bash:

- the global-gitignore fallback (prompt/ignore.rs) now uses
  std::env::home_dir(), the crate-wide idiom;
- fast-worktree's resolve_kigi_home falls back to std::env::home_dir()
  with the same dunce canonicalization, eliminating the documented
  divergence from kigi_config::default_kigi_home. On Unix home_dir()
  reads $HOME first, so behavior is unchanged where HOME is set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:34:45 -04:00
ZacharyZhang-NYandClaude Fable 5 97de661554 fix(startup): thread-exhaustion resilience — cap workers, never panic
Port of upstream grok-build 0.2.113/0.2.114 fixes, extended to Kigi's
startup order.

- kigi-tty-utils runtime module: worker-cap policy (8 max) — tokio's
  per-core default pins 100+ thread slots on many-core shared hosts
  against RLIMIT_NPROC / pids.max, and later spawns die with EAGAIN.
- kigi-bin builds the main runtime with capped workers and reports an
  out-of-threads host with a diagnostic + exit(1) instead of a panic.
- The pre-runtime optimization threads (HTTP warmup, early model
  prefetch) spawn recoverably and degrade — their panic would have
  preempted the runtime's own diagnostic.
- The per-session actor thread and its runtime report spawn/build
  failure through the existing acp::Error / AgentBuildError boundaries
  (new RuntimeBuild variant) instead of panicking the agent host.
- fsnotify's watcher thread spawn maps into FsNotifyError::WatcherStart
  so callers degrade instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:28:17 -04:00
ZacharyZhang-NYandClaude Fable 5 bf93091416 fix(agents): bound how many subagents one session runs at once
Port of upstream grok-build 1.0.1 (landed upstream 2026-08-06). The
coordinator fire-and-forgot every spawn, so a wide fan-out (graph
waves, swarm bursts, model-driven Task storms) could exhaust file
descriptors.

- kigi-tools task/admission: SubagentLimits — 32 concurrent per session
  by default (KIGI_MAX_CONCURRENT_SUBAGENTS), queue|fail behavior
  (KIGI_SUBAGENT_LIMIT_BEHAVIOR), zero clamped to 1, read once at the
  composition root; parse_positive_env helper.
- Each parent session gets one spawn semaphore (map entries live for
  the process so a resumed session shares its predecessor's limit);
  handle_subagent_request holds an owned permit for the whole child
  run — queue awaits FIFO with a unified-log notice at saturation,
  fail routes through send_pre_spawn_failure. After a queued wait, a
  closed parent command channel (teardown) fails the spawn instead of
  running against a dead parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:09:24 -04:00
ZacharyZhang-NYandClaude Fable 5 26d26e15c9 fix(exec): bounded post-kill reaps — D-state children can't wedge a turn
Port of upstream grok-build 1.0.1 (landed upstream 2026-08-06).

A child wedged in an uninterruptible kernel syscall (D-state) never
observes SIGKILL, and a background descendant can hold the output pipes
open past the kill; both previously stalled tool futures without bound.

- kigi-tty-utils: KILL_REAP_TIMEOUT (5s) + reap_killed_bounded — wait
  for an already-killed child at most the bound, then abandon the corpse
  to tokio's orphan reaper.
- local_terminal: pipe readers share their buffer with the spawner and
  join under the reap bound on both exit arms (aborted readers are
  awaited so no tail bytes race the take); the whole tree is enrolled in
  a ProcessGroup and group-killed on timeout; the timeout arm returns
  the synthetic result instead of waiting on the corpse. Grandchild
  kill + prompt-return pinned by a ported unix regression test.
- kigi-tools: reap_killed_search_child wrapper; grep's four kill sites
  reap bounded; glob's stderr goes to null (an unread pipe blocks rg on
  warning backpressure) and its truncation kill reaps bounded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 10:45:17 -04:00
ZacharyZhang-NYandClaude Fable 5 1ae803519e fix(compaction): configurable summarizer tool_choice; wider overflow detection
Port of upstream grok-build 0.2.105 + 0.2.118.

- CompactionToolChoice (auto default | none), resolved env
  KIGI_COMPACTION_TOOL_CHOICE > [features].compaction_tool_choice >
  remote settings. Some OpenAI-compatible servers reject
  tool_choice:"none" with tools attached, failing every long-session
  compaction; auto keeps them working (the summarization prompt already
  forbids tool use). Threaded from agent + subagent spawn through
  CompactionConfig into generate_session_compact; the Responses gate
  counts hosted tools too, so a hosted-only request still carries the
  configured choice.
- is_context_length_error recognizes the "current message ... exceeds
  budget" phrasing, so that overflow is Fatal instead of burning the
  retry budget on a deterministic failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 10:22:18 -04:00
ZacharyZhang-NYandClaude Fable 5 6a4bff0d45 fix(agent): a racing cancel can no longer wedge the session's turn slot
Port of upstream grok-build 0.2.103. session/prompt runs an async intake
preamble before enqueueing SessionCommand::Prompt on the actor mailbox; a
concurrent cancel could enqueue Cancel first, land on an idle actor as a
no-op, and the prompt then started a turn nothing would ever cancel.

The per-session prompt-intake lock becomes the dispatch lock: prompt()
holds it across intake as before, and cancel() now holds it around its
Cancel send, so a cancel orders behind the prompt it targets. Regression
test ported verbatim (unix-only harness, like its siblings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 09:53:09 -04:00
ZacharyZhang-NYandClaude Fable 5 6e0464fa74 fix(auth): unique tmp per auth.json write; tighten loose credential perms
Port of upstream grok-build 0.2.113-window fixes:
- write_auth_json_atomic tmp path gains a monotonic sequence next to the
  pid, so two concurrent in-process writers (background mint + proactive
  refresher) cannot share one tmp path and tear each other's bytes; a tmp
  write that fails partway is reclaimed instead of orphaned.
- New secure_file::ensure_owner_only_permissions (0o600 / Windows ACL)
  tightens pre-existing world-readable credential files on read, backup,
  write, replace, and restore. Best-effort with warns after durable
  content — a chmod-only failure must not masquerade as a failed write —
  and propagating in restore_prior_bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 09:31:21 -04:00
ZacharyZhang-NYandClaude Fable 5 257caed650 Add kigicli.dev frontend worker with built site assets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pFJn6ycA4hPRwM9sqasi6
2026-08-25 23:04:06 -04:00
ZacharyZhang-NYandClaude Opus 5 3e1652ca98 ci: drop the inert GitHub workflows
Gitea ignores `.github/workflows/` entirely once `.gitea/workflows/` is
non-empty, so these two files have had no effect since the release workflow
landed — and the GitHub repo they targeted is gone (404, suspended account),
so they cannot run there either.

They were not merely dead: warm-cache.yml's Monday cron kept firing on the
Gitea side before `.gitea/` existed, queueing jobs against GitHub-hosted
labels (`macos-14`, `windows-2022`, `ubuntu-24.04-arm`) that no self-hosted
runner advertises. Those sat queued forever, and its one dispatchable leg
failed on `rustup: command not found` — the same bare-image gap the Gitea
workflow now provisions around.

`.gitea/workflows/release.yml` is the only pipeline; docs/RELEASE.md already
documents it as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:11:28 -04:00
ZacharyZhang-NYandClaude Opus 5 479fb8cbbd chore(release): bump workspace version to 0.1.12
Release / prepare draft release (push) Successful in 5s
Release / build (aarch64-apple-darwin) (push) Failing after 1s
Release / build (x86_64-apple-darwin) (push) Failing after 2s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish release (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Successful in 18m14s
Release / build (x86_64-unknown-linux-gnu) (push) Successful in 17m20s
Every crate inherits `workspace.package.version`, so the single edit plus
the refreshed lockfile carries the release. The `v0.1.12` tag is what
triggers the build; release.yml gates the tag against this value, and
`cargo metadata --locked` passes so the build's `--locked` will too.

This is the first release cut on Gitea Actions. v0.1.6 through v0.1.9 were
all cancelled for want of a runner whose label matched anything, so nothing
has ever published from the new forge — `releases/latest` still 404s, which
is exactly why `kigi update` finds nothing today. Ships the link migration
in the previous commit, so the installers and self-updater published here
point at Gitea instead of a GitHub repo that no longer serves them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:13:19 -04:00
ZacharyZhang-NYandClaude Opus 5 9fb82291b2 chore(migration): repoint every published link off GitHub
The canonical repo is git.zacharyzhang.com now, so every URL a user can
actually reach had to move with it.

Self-updater: `PRODUCTION_ENDPOINTS.update_base_url` targets Gitea's
`/api/v1/repos/.../releases`, which serves the same wire format GitHub's
releases API did — `Release`/asset deserialisation is unchanged. The alpha
channel requests `?per_page=30&limit=30` because GitHub reads the page size
from `per_page` and Gitea from `limit`, and each ignores the other's, so one
URL stays correct on either.

Installers: same API base, and the documented one-liners become
`https://kigicli.dev/install.{sh,ps1}`. That domain's Worker proxies both
files from Gitea raw `main`, so the published install command survives any
future forge move without being reprinted everywhere. install.ps1 also drops
the GitHub-specific `Accept: application/vnd.github+json`.

Everything else user-facing: `/feedback` and the shell's feedback fallback
point at the Gitea issue tracker, `/docs` at the Gitea-rendered README, and
web_fetch's User-Agent at kigicli.dev. SECURITY.md now routes reports to the
issue tracker with a warning to withhold exploitable detail, because Gitea
has no equivalent of GitHub's private vulnerability reporting. Comments
naming `.github/workflows/release.yml` name `.gitea/` now.

Tests assert against these constants rather than literal URLs, so they
follow automatically; the one URL-shape assertion (production endpoints are
https) still holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:13:19 -04:00
ZacharyZhang-NYandClaude Opus 5 8bdfb551b6 chore(git): ignore prompt.txt and the kigicli.dev worker artifacts
`/prompt.txt` is the one-shot Mac Mini runner-setup prompt. It carries a
Gitea runner registration token once filled in, and a leaked registration
token lets anyone attach a runner to the account and receive our jobs —
including release jobs. Anchored with a leading slash so a legitimately
tracked prompt.txt deeper in the tree would still be versioned.

The .wrangler/ and node_modules/ lines were already sitting uncommitted in
the working tree for the untracked frontend/ worker; they belong in this
same file, so they ride along rather than being split out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:24:30 -04:00
ZacharyZhang-NYandClaude Opus 5 8864b9ecc5 ci(gitea): add the release workflow and point it at our real runners
Gitea has no hosted runners, so every `runs-on` has to name a machine we
registered ourselves — and the labels in the first draft of this file did
not match what our machines advertise. The only online Linux runner
(`dokploy-runner-01`) offers `ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04`,
never `linux-x86_64`, and Gitea's API cannot relabel a runner remotely, so
a `v*` tag would have queued forever against a label nothing served.

The three Linux jobs therefore run on `ubuntu-latest`. That runner is in
container mode against a bare runner-image, so those legs now provision
what they need per run: build-essential, pkg-config, unzip (protoc), tar
and jq, plus rustup when absent — installed with `--default-toolchain none`
so `rust-toolchain.toml` still decides the channel and `rustup show`
materialises it. `prepare` installs jq/curl the same way. macOS stays on
`macos-arm64` and Windows on `windows-x86_64`, both host mode with their
toolchains preinstalled, so the new provisioning step is gated on a
`linux:` matrix key rather than on `ext == 'tar.gz'`, which covers Darwin
too.

docs/RELEASE.md picks up the corrected label table, the fact that
`register --labels` is ignored once config.yaml supplies them, the
registration-token call being a POST (a GET parses as runner id 0 and
404s), and how each host-mode runner is supervised so it survives reboots
and crashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:21:22 -04:00
ZacharyZhang-NY 553ec1c620 chore(release): bump workspace version to 0.1.11
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 5s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
All 62 crates inherit `workspace.package.version`, so the single edit
plus the refreshed lockfile carries the release. The `v0.1.11` tag is
what triggers the build; release.yml gates the tag against this value.

Ships the sessionless-model-switch fix (f656627): `/model` from a
non-project launch directory now starts the session its deferred switch
drains into instead of dangling silently.
2026-07-27 16:04:51 -04:00
ZacharyZhang-NY f656627c10 fix(tui): start the session a sessionless model switch defers into
Launching kigi in a non-project directory (~/Downloads, ~, /tmp — anywhere
`is_project_dir` rejects) leaves the agent view session-less behind the
project-picker question, which only a PLAIN prompt can open. Slash
commands still execute there, so `/model <name> <effort>` stashed its
switch in `deferred_model_switch` — a stash that assumes a create is in
flight — and with none pending it dangled forever with zero feedback:
the picker rendered, the user chose model and effort, and nothing
changed. Not a 0.1.10 regression: 0.1.9 pty-reproduces identically; the
report correlated with the upgrade only because every earlier launch
happened to be from a project directory.

Apply the QueueCommand precedent ("queued slash work bypasses the
picker, so create the deferred session or it never drains") to both
deferral sites — Action::SwitchModel in the router and
set_default_model's no-session branch — via
skip_picker_and_create_session, whose in-flight guard already makes it a
no-op while a create is pending, so the racing-create case is unchanged.
SessionCreated then applies the stash through the existing
apply_deferred_model_switch path; pty-verified end-to-end from
~/Downloads (session created, model changed, effort applied).
2026-07-27 15:55:57 -04:00
ZacharyZhang-NY 11c3ca9803 chore(release): bump workspace version to 0.1.10
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
All 62 crates inherit `workspace.package.version`, so the single edit
plus the refreshed lockfile carries the release. The `v0.1.10` tag is
what triggers the build; release.yml gates the tag against this value.

Also records the shared-SSRF-policy invariant in AGENTS.md, so a future
port does not reintroduce a second copy. The zero-egress entry is
compressed by the same number of lines to keep the file at 500.
2026-07-27 13:26:25 -04:00
ZacharyZhang-NY bac8470c80 fix(hooks): share one SSRF policy instead of a second, stale copy
The hook runner carried its own `is_blocked_ip`, a line-for-line copy of
the pre-hardening web_fetch predicate: loopback allowed unconditionally,
and no TEST-NET, 198.18/15, 240/4, 0.0.0.0/8, multicast, IPv6 site-local
or embedded-v4 wrapper coverage. Hook URLs come from settings, and
project settings from an untrusted repo are loaded today, so the gap is
reachable.

`kigi-hooks` already depends on `kigi-tools`, so the fix is to delete the
copy and call the shared predicate — no new crate, and no third
implementation to drift.

`allow_local` is on for hooks: a loopback hook receiver is a legitimate
local setup, which the old copy also allowed. It is now allowed only
when the URL names the host literally, so a public name resolving to
loopback is refused.

Range coverage now lives with the policy; the runner's tests pin what it
adds on top. The URL-scrubbing regression test moves off TEST-NET-1,
which the policy now blocks, onto a closed loopback port — faster, and
no real egress from a test.
2026-07-27 13:01:50 -04:00
ZacharyZhang-NY 74402f3078 fix(pager): stop a sibling test flattening the diff-band assertion
`committed_edit_keeps_diff_line_backgrounds` passed alone and
single-threaded but failed under parallel execution, so the workspace
gate was red.

`terminal_native_lock_paints_only_native_colors` engages the
process-global terminal-native lock. While it is held `Theme::current()`
returns `terminal_default()`, whose `diff_*_bg` are all `Color::Reset`,
so no band is painted at all — and `current_kind()` reports a nominal
`KigiNight`, which is what made the failure read as a theme mismatch.

Two changes, both at the cause:
- hold the shared theme lock via `theme_cache::pin_theme()`, the helper
  written for this and until now unused, so the lock cannot be engaged
  mid-test
- assert the invariant (two distinct non-Reset bands) instead of exact
  RGB: the entry line cache is keyed on the GLOBAL theme kind, so an
  exact-color assertion races by construction. Span-level colors stay
  pinned by the `tool::edit` tests that own them

The failure message now reports the theme, height, and the bands it
actually found, which is how the cause was located.
2026-07-27 12:38:14 -04:00
ZacharyZhang-NY 649c5f5641 fix(models): drop the ultra reasoning effort, which no backend accepts
The hardcoded Codex catalog advertised `ultra` for gpt-5.6-sol and
gpt-5.6-terra, so selecting it sent `reasoning.effort = "ultra"` and the
Responses endpoint answered 400 with its real menu, which ends at `max`.

The tier was kigi's invention: the enum documented it as codex-only, the
catalog was its only producer, and the upstream model snapshot never
listed it. Removing the variant as well as the catalog entries means
`/effort ultra` is rejected at the TUI instead of parsing and failing on
the wire; a persisted `ultra` degrades to the model default through
`lenient_reasoning_effort_opt`, as designed for vocabulary changes.
2026-07-27 12:12:14 -04:00
ZacharyZhang-NY ad3840f9ec fix(web_fetch): block non-public targets by default and gate every hop
kigi allowed loopback unconditionally and missed several non-public
ranges, and the SSRF check ran only on the initial URL.

Policy (ssrf.rs):
- loopback is blocked unless `[toolset.web_fetch] allow_local` (or
  KIGI_WEB_FETCH_ALLOW_LOCAL) is on, AND the URL names it explicitly,
  so a public name resolving to loopback stays blocked (DNS rebinding)
- add 0.0.0.0/8, 100.64/10, 192.0.0.0/24, TEST-NET-1/2/3, 198.18/15,
  240/4, IPv6 site-local and documentation prefixes
- inherit the IPv4 verdict through mapped, compatible, NAT64 and 6to4
  wrappers; network-specific NAT64 prefixes remain uncovered (see doc)

Plumbing (client.rs), where the exploitable half lived:
- re-check every redirect hop, not just the first
- compare hosts exactly; a `www` sibling has its own A records, so it
  is a cross-host redirect rather than an auto-followed hop
- run the check before the fetch service, so a blocked URL is never
  posted to an endpoint that egresses elsewhere
- exempt explicit local hosts from the https upgrade and from the
  single-label filter, and re-upgrade each followed hop

Wiring: allow_local reaches WebFetchParams from both construction
paths; documented in the config guide and the README env table.
2026-07-27 11:44:20 -04:00
ZacharyZhang-NY ac23ebc9a1 feat(turn): halt a turn stuck repeating one identical tool call
Ports grok-build's action stationarity breaker. A polling loop looks like
progress from inside the turn — every step succeeds — so nothing stops it
and it burns the whole budget re-reading one file. Nudge at 8 identical
batches, halt at 16, halt a pure no-op run at 4.

Three defects were found and fixed before this landed, none of which the
gates caught:

- The check runs AFTER execute_tool_calls, not before. Before it, the
  assistant's tool_calls are recorded with no results, so pushing the
  nudge triggers repair_dangling_tool_calls: synthetic "cancelled by the
  user" results get spliced in, the real results land after a user
  message, and dedup misses them — the orphan tool_result shape that
  draws a provider 400. It is Ok-gated too, since one `?` inside can
  leave a call resultless.
- `TurnOutcome::StationarityHalted` is its own variant grouped with
  Completed, not a reuse of Cancelled. As a cancellation it would report
  StopReason::Cancelled, fire the abort lifecycle, kill the turn's
  subagents, grow the goal harness back-off streak until three halts
  paused a running goal, and let completion-requirement recovery re-run
  the very turn halted for looping.
- The no-op gate is size-based, not name-based. kigi registers the shell
  tool as `run_terminal_cmd` and renames it `run_terminal_command`, so
  matching on "bash" silently disabled the tighter ceiling while every
  test still passed.
2026-07-27 10:37:41 -04:00
ZacharyZhang-NY a07d889ad9 fix(swarm): label each member with its own item
Every member was launched with the swarm's description, so the pager drew
N identical subagent blocks: the user could not tell which member was
running, and a failure could not be attributed to an item.

This is also why K6 does not port upstream's progress grid. A member IS
an ordinary subagent, so the pager already renders one block per member
with live status, duration, tool-call and turn counts — richer than a
grid cell. The gap was the label, not the widget; upstream's grid (and
the estimator behind it, which infers progress from tool-call rate)
answers a different TUI's shortcomings. The parent tool call already
reads "<description> (N members)" and `is_task_variant` already knows
`AgentSwarm`, so the blocking-wait spinner registers.
2026-07-27 03:01:42 -04:00
ZacharyZhang-NY 16328e55f9 feat(swarm): /swarm mode — a standing instruction to fan work out
The `agent_swarm` tool works with the mode off; what the mode adds is the
doctrine — decompose finely, give every member a disjoint scope, do not
do the work yourself. `/swarm`, `/swarm on|off`, `/swarm <task>`, gated
on the tool actually being in the toolset.

Two triggers, not upstream's three. Upstream's `tool` trigger exists so
invoking the tool makes the doctrine appear; kigi's tool carries its own
description and works with the mode off, so that trigger would add a
state only the tool can reach. `Manual` persists until switched off,
`Task` expires at turn end — both are real user intents.

Load-bearing decisions, each from a defect found in review:

- Expiry is a DROP guard, not a post-loop call. A user interrupt aborts
  the turn future rather than cancelling it, so post-loop code never
  runs; three `?` paths skip it too. Either way a `/swarm <task>` mode
  would leak into the next, unrelated prompt.
- `enter` is a total no-op while armed, so the `/swarm <task>` shorthand
  cannot downgrade a standing `/swarm on` into a per-turn mode that then
  disarms itself. This matches upstream; the first cut diverged, and the
  test asserting the divergence was inverted with the fix.
- An explicit `/swarm off` retracts UNCONDITIONALLY. The doctrine rides
  the conversation and so survives a resume, a fork and a compaction that
  an in-memory flag does not; trusting the flag left a session reading
  "off" with the instruction still steering and no way to clear it. That
  also retires the `reminder_live` field, whose doc comment claimed to
  know something the code cannot.
- The mode is deliberately NOT persisted, unlike /goal and /graph: those
  strand real work when lost, this is a prompt hint whose recovery is
  retyping one command. Recorded in AGENTS.md so the omission reads as a
  decision, not a gap.
- The pre-session gate is `subagents_enabled`, not a hardcoded `true`:
  the builder strips `agent_swarm` wherever it strips `task`, and
  advertising it then offers a menu entry that resolves to literal text.
- The exit reminder is as emphatic as the doctrine it revokes; a single
  weak clause is the likelier of the two to be summarised away.

The doctrine rides the existing `push_system_reminder` channel rather
than a second injection path of its own.
2026-07-27 02:39:55 -04:00
ZacharyZhang-NY 9edb8729ef feat(swarm): agent_swarm — one prompt over many items, paced as a fleet
Ports kimi-code's AgentSwarm: a `prompt_template` containing `{{item}}`
expanded over an `items` list into up to 128 subagents, run to completion
and returned as one aggregate. Kigi already exceeds upstream on planning,
verification, isolation and merge via /graph; what it lacked was cheap
immediate fan-out. Entirely client-side — no new backend surface.

The engine is three pure pieces plus a runner: `plan` validates and
expands (every fault reported before a single member starts — a
half-launched swarm is expensive to unwind), `schedule` is the launch
ramp as testable arithmetic, `run` drives it against the existing
`SubagentBackend`. Reuses the single-spawn coordinator rather than
inventing a batch API.

Load-bearing decisions, each the result of a defect found in review:

- `backgrounded` is its own outcome. A member that outlives the 600s
  foreground budget is detached by the coordinator and KEEPS RUNNING;
  reporting it as failed invites the model to relaunch its item, putting
  a second agent on the same files. It is never offered for resume.
- `InFlightGuard` cancels live members on Drop. Send-now cancels the turn
  WITHOUT cancelling subagents and aborts the task; the dropped receivers
  read as "parent gone" and each child re-attaches itself. There is no
  cooperative path to use instead — `Cancellation` is constructed nowhere
  in the tree — so Drop is the only seam that fires.
- Retries and wall clock are both bounded. The swarm blocks the caller's
  turn, so every wait needs a ceiling it cannot argue past; stragglers at
  the deadline are reported as still-running, with their ids.
- `ToolKind::AgentSwarm` is its own variant: `TemplateRenderer`'s
  `by_kind` map holds one tool name per kind, so sharing `Task` would
  silently redirect `${{ tools.by_kind.task }}` in other tools' prompts.
- An explicitly requested model that cannot be validated is refused, as
  the task tool already does — one loud error beats `items.len()` quiet
  ones. Depth stays capped at 1: upstream's unlimited nesting is a
  hazard, not a feature.
- `SubagentResult.rate_limited` is classified where the typed ACP error
  code is still in hand; a scheduler re-deriving it from a formatted
  string would stop adapting the day the wording changed.
- Aggregate output is clamped per member (head+tail, loss stated):
  native tool output is truncated nowhere downstream.

42 agent_swarm tests. The fake backend awaits, so the concurrency and
ordering assertions can actually fail; the cap test also proves the
fixture can exceed the cap.
2026-07-27 01:22:52 -04:00
ZacharyZhang-NY ed8049cf77 fix(memory): scope embedding credentials to the endpoint that may receive them
`MemoryBackendParams` carried the primary session `AuthManager` and the
session api-key provider unconditionally, while `embed_base_url` is the
CURRENT MODEL's endpoint and `AuthRetryMiddleware` stamps `Authorization`
on every request it wraps. A user who enables `[memory.embedding] model`
while running a BYOK or subscription-OAuth model therefore sent the Kimi
session bearer to that third party.

`EndpointScopedCredentials` binds the credential to the one endpoint it
may reach: `for_endpoint` drops the handle unless the caller vouches for
the URL, and `approved_for` re-checks at provider-build time in release
too, because `MemoryBackendParams` is `Clone` and callers rewrite fields
on the copy.

The shell decides through `CredentialAuthority::manager_for` rather than
a second URL predicate — it answers both whether a credential may ride
and which manager governs it, so a subscription-OAuth platform gets its
own pooled manager. The session's `SharedApiKeyProvider` is not
forwarded at all: it is hard-wired to the primary manager, so at a
pooled platform's host it would resolve the wrong bearer. A platform's
own `embed_api_key` is untouched and keeps serving its own endpoint.

The background reindex built a second provider straight from
`ApiEmbeddingProvider::from_session`, outside the chokepoint and without
401 refresh; it now embeds through the session's own params.

Test strength verified by mutation: with the guard reverted, exactly
`session_credentials_are_withheld_from_a_foreign_endpoint` and
`a_cloned_param_set_cannot_redirect_scoped_credentials` fail.
2026-07-26 23:41:45 -04:00
ZacharyZhang-NY 867b3e110b docs(comments): trim paste-fix commentary to the crucial constraints 2026-07-24 13:09:35 -04:00
190 changed files with 16629 additions and 5578 deletions

No files matched your search

+375
View File
@@ -0,0 +1,375 @@
name: Release
# PRD F8, Gitea edition. On a v* tag push, build the single-file kigi binary
# for the five supported targets, package kigi-<version>-<target-triple>.
# {tar.gz|zip} (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), and publish
# them as a Gitea Release alongside SHA256SUMS. install.sh, install.ps1 and
# auto_update::release_asset_name() all resolve these exact names — keep the
# naming in lockstep with them.
#
# RUNNERS. Gitea has no hosted runners; every label below must be a runner you
# registered yourself (docs/RELEASE.md has the register commands):
#
# ubuntu-latest Linux x86_64, container mode — the label the Dokploy box's
# runner already advertises. Builds both Linux targets;
# aarch64 is a cross-compile, so this leg installs the
# aarch64 GNU toolchain. Because the image is a bare
# runner-image, the Linux legs provision their own apt
# packages and rustup (see the provision steps below).
# Registering a dedicated `linux-x86_64:docker://...` runner
# instead is fine — swap the three labels back if you do.
# macos-arm64 Apple Silicon Mac, host mode. Builds both Darwin targets;
# x86_64 is a cross-compile against the same SDK. macOS
# CANNOT be built anywhere else — Apple's SDK licence
# restricts it to Apple hardware.
# windows-x86_64 Windows x86_64, host mode, with VS 2022 Build Tools
# (C++ workload). Needed because the tree links ring,
# mimalloc, zstd-sys and libz-sys against a static MSVC CRT
# (-C target-feature=+crt-static).
#
# SHELLS. Every step names its shell explicitly and the Windows leg never uses
# bash. On a normal Windows box `bash` resolves to WSL (not Git Bash), and
# `pwsh` (PowerShell 7) is usually absent — so Windows steps use `powershell`,
# which is the built-in 5.1. Keep those steps 5.1-compatible: no `&&`, no
# ternary, no null-coalescing.
#
# Note: because this file exists, Gitea ignores .github/workflows entirely.
#
# PUBLISH ORDER. The release is created as a draft and only made public once
# SHA256SUMS is attached, so `releases/latest` never hands the installer a
# release whose checksum manifest is still missing.
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
# Matches the version pinned by the dotslash launcher in bin/protoc. That
# launcher is not used here: it has no Windows entry and needs a `cargo
# install dotslash` first, so every platform fetches protoc directly.
PROTOC_VERSION: "29.3"
jobs:
prepare:
name: prepare draft release
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Create or reuse draft release
id: draft
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
# The runner-image is bare; jq and curl are both used below.
if ! command -v jq >/dev/null || ! command -v curl >/dev/null; then
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
fi
# Idempotent so a re-run of a failed tag build reuses the same draft
# instead of erroring on a duplicate tag_name.
existing="$(curl -sS -H "Authorization: token $TOKEN" \
"$API/releases/tags/$GITHUB_REF_NAME")"
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
prerelease=false
case "$GITHUB_REF_NAME" in *-*) prerelease=true ;; esac
created="$(curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\",\"draft\":true,\"prerelease\":$prerelease}" \
"$API/releases")"
release_id="$(printf '%s' "$created" | jq -r '.id')"
echo "Created draft release $release_id (prerelease=$prerelease)"
else
echo "Reusing release $release_id for $GITHUB_REF_NAME"
fi
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
build:
name: build (${{ matrix.target }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
- target: aarch64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
cross_gnu: true
- target: aarch64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-pc-windows-msvc
runner: windows-x86_64
protoc: win64
ext: zip
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
# ── linux container provisioning ──────────────────────────────────────
# The Linux legs run in a bare gitea runner-image, which ships git and
# node for the JavaScript actions but no compiler, no unzip (protoc) and
# no rustup. Host-mode macOS and Windows already have their toolchains
# installed, so this step is Linux-only.
- name: Provision Linux build deps
if: matrix.linux
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends \
build-essential pkg-config ca-certificates curl unzip tar jq
# ── protoc ────────────────────────────────────────────────────────────
- name: Install protoc (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${{ matrix.protoc }}.zip"
curl -fsSL -o protoc.zip "$url"
rm -rf "$HOME/protoc"
unzip -q -o protoc.zip -d "$HOME/protoc"
chmod +x "$HOME/protoc/bin/protoc"
echo "$HOME/protoc/bin" >> "$GITHUB_PATH"
- name: Install protoc (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v$env:PROTOC_VERSION/protoc-$env:PROTOC_VERSION-${{ matrix.protoc }}.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
$dest = Join-Path $env:USERPROFILE "protoc"
if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
Expand-Archive protoc.zip -DestinationPath $dest -Force
Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $dest "bin")
# ── toolchain ─────────────────────────────────────────────────────────
# rust-toolchain.toml pins the channel (1.97.0) and preinstalls both
# Linux targets; `rustup show` materialises exactly that.
- name: Install toolchain (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
# No-op on the macOS host runner, which has rustup installed already.
# `--default-toolchain none` leaves the channel choice to
# rust-toolchain.toml, which `rustup show` then materialises.
if ! command -v rustup >/dev/null; then
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal --default-toolchain none
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
fi
rustup show
rustup target add ${{ matrix.target }}
- name: Install toolchain (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
rustup show
rustup target add ${{ matrix.target }}
# ring, mimalloc, zstd-sys and libz-sys are all cc-rs builds, so the
# cross target needs its own C toolchain — cc-rs picks these up from the
# target-suffixed vars.
- name: Install aarch64 cross toolchain
if: matrix.cross_gnu
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
{
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc"
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc"
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++"
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar"
} >> "$GITHUB_ENV"
# ── build ─────────────────────────────────────────────────────────────
- name: Build kigi (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Build kigi (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# ── package, upload, checksum ─────────────────────────────────────────
# Archives go straight to the release rather than through the artifact
# store: they are large, and the release is the only place install.sh
# can read them from anyway. Only the checksum line travels as an
# artifact, for the publish job to concatenate.
- name: Package and upload (unix)
if: matrix.ext == 'tar.gz'
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@${archive}" \
"$API/releases/$RELEASE_ID/assets?name=${archive}" > /dev/null
echo "Uploaded $archive"
if command -v sha256sum >/dev/null; then
sha256sum "$archive" > "checksum-${{ matrix.target }}.txt"
else
shasum -a 256 "$archive" > "checksum-${{ matrix.target }}.txt"
fi
cat "checksum-${{ matrix.target }}.txt"
- name: Package and upload (windows)
if: matrix.ext == 'zip'
shell: powershell
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
$ErrorActionPreference = "Stop"
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = Join-Path $env:RUNNER_TEMP "staging"
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
New-Item -ItemType Directory -Force -Path $staging | Out-Null
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive -Force
# System32\curl.exe, not PowerShell's `curl` alias for
# Invoke-WebRequest, which cannot do multipart uploads.
$api = "$env:API/releases/$env:RELEASE_ID/assets?name=$archive"
& "$env:SystemRoot\System32\curl.exe" -fsS -X POST `
-H "Authorization: token $env:TOKEN" `
-F "attachment=@$archive" $api
if ($LASTEXITCODE -ne 0) { throw "asset upload failed ($LASTEXITCODE)" }
Write-Host "Uploaded $archive"
# sha256sum's exact format, so the publish job can just concatenate.
# ASCII keeps the BOM out — a BOM would corrupt the first hash.
$hash = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant()
"$hash $archive" | Out-File -FilePath "checksum-${{ matrix.target }}.txt" -Encoding ascii
Get-Content "checksum-${{ matrix.target }}.txt"
- uses: actions/upload-artifact@v3
with:
name: checksum-${{ matrix.target }}
path: checksum-${{ matrix.target }}.txt
if-no-files-found: error
publish:
name: publish release
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
path: checksums
- name: Assemble SHA256SUMS
shell: bash
run: |
set -eu
# download-artifact v3 nests each artifact in its own directory. The
# Windows line arrives CRLF-terminated; strip CR so every line is LF
# for install.sh's `read -r hash name`.
find checksums -name 'checksum-*.txt' -exec cat {} + \
| tr -d '\r' | grep -v '^$' | sort -k2 > SHA256SUMS
lines="$(wc -l < SHA256SUMS)"
if [ "$lines" -ne 5 ]; then
echo "expected 5 checksum lines, got $lines" >&2
cat SHA256SUMS >&2
exit 1
fi
cat SHA256SUMS
- name: Attach SHA256SUMS and publish
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@SHA256SUMS" \
"$API/releases/$RELEASE_ID/assets?name=SHA256SUMS" > /dev/null
# Flip the draft public only now that every asset install.sh looks
# for is attached.
curl -fsS -X PATCH \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"draft":false}' \
"$API/releases/$RELEASE_ID" > /dev/null
echo "Published $GITHUB_REF_NAME"
-147
View File
@@ -1,147 +0,0 @@
name: Release
# PRD F8: on tag push v*, build the single-file kigi binary for the five
# supported targets, package per-target archives named
# kigi-<version>-<target-triple>.{tar.gz|zip} (binary + LICENSE + NOTICE +
# THIRD-PARTY-NOTICES), generate SHA256SUMS, and publish everything as a
# GitHub Release. install.sh / install.ps1 and the in-app self-updater
# (kigi-update) both resolve these exact asset names — keep the naming in
# lockstep with auto_update::release_asset_name().
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: build (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
# macos-14 runners are arm64; the x86_64 slice is a cross-compile
# against the same SDK (macos-13 Intel runners are deprecated).
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
# Windows cannot exec the dotslash launcher directly (no shebang
# support → os error 193), and the build script's documented fallback
# is a protoc on PATH — install the same version the dotslash file
# pins (v29.3).
- name: Install protoc (Windows PATH fallback)
if: runner.os == 'Windows'
shell: pwsh
run: |
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Package archive (tar.gz)
if: runner.os != 'Windows'
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
shasum -a 256 "$archive" || sha256sum "$archive"
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
- name: Package archive (zip)
if: runner.os == 'Windows'
shell: pwsh
run: |
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP "staging")
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive
Get-FileHash -Algorithm SHA256 $archive
Add-Content -Path $env:GITHUB_ENV -Value "ARCHIVE=$archive"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARCHIVE }}
path: ${{ env.ARCHIVE }}
if-no-files-found: error
release:
name: publish GitHub Release
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Generate SHA256SUMS
working-directory: dist
run: |
ls -l
sha256sum kigi-* > SHA256SUMS
cat SHA256SUMS
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
fail_on_unmatched_files: true
-85
View File
@@ -1,85 +0,0 @@
name: Warm build cache
# Release builds run on TAG refs, and GitHub Actions cache isolation only
# lets a run restore caches created on its OWN ref or the DEFAULT branch.
# No workflow ran on `main`, so every release compiled the whole workspace
# cold on all five targets (~50 min wall clock, gated by Windows). This
# workflow builds the same `release-dist` profile on `main` so tag builds
# restore a warm default-branch cache.
#
# Triggers: dependency-affecting pushes to main (each release's version-bump
# commit warms the cache for the NEXT release), a weekly refresh so the
# cache never hits GitHub's 7-day unused-eviction, and manual dispatch.
#
# The setup steps mirror release.yml's build job (toolchain, target,
# dotslash/protoc, rust-cache key) — keep them in lockstep, or the cache
# key won't match and releases go back to cold builds.
on:
push:
branches: ["main"]
paths:
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".github/workflows/warm-cache.yml"
schedule:
- cron: "17 5 * * 1"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
concurrency:
group: warm-cache
cancel-in-progress: true
jobs:
warm:
name: warm (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- name: Install protoc (Windows PATH fallback)
if: runner.os == 'Windows'
shell: pwsh
run: |
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
# Same key as release.yml so tag builds restore this cache verbatim.
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
+7
View File
@@ -1,3 +1,10 @@
/target
**/*.rs.bk
.DS_Store
# frontend/ (kigicli.dev worker)
.wrangler/
node_modules/
# Runner-setup prompt; holds a Gitea registration token once filled in.
/prompt.txt
+73 -8
View File
@@ -14,16 +14,24 @@ official `kimi` CLI: binary `kigi`, config dir `~/.kigi`
Never read or write `~/.kimi` (except the explicit one-time read-only
import) or any `KIMI_*` env var.
Upstream sync record (update on every sync): fork baseline is upstream
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
the 0.1.13 cycle. Upstream is daily "Synced from monorepo" snapshots —
the commit BODIES carry per-change bullet lists, and
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
start any future sync from those two, not from raw diffs.
## Hard constraints
- **Zero egress**: outbound connections are limited to
`auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`, `api.moonshot.ai`,
GitHub Releases domains, user-configured MCP servers, the endpoints of
provider platforms the user has credentialed, and `models.dev` (model
metadata refresh — reached ONLY when an enabled platform's `/models` wire
lacks metadata, `wire_serves_metadata=false`; Kimi/Moonshot never trigger
it; `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics, ever.
`crates/codegen/kigi-env` is the single home of first-party endpoints.
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
`api.moonshot.ai`, GitHub Releases, user-configured MCP servers, credentialed
provider endpoints, and `models.dev` (metadata refresh, ONLY when an enabled
platform's `/models` lacks it — `wire_serves_metadata=false`, never
Kimi/Moonshot, `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics,
ever. `crates/codegen/kigi-env` is the single home of first-party endpoints.
- **One SSRF policy**: `ssrf::is_blocked_for_host` gates every model/settings
URL (`web_fetch` + hooks), per redirect hop. Never write a second copy.
- **Toolchain**: Rust 1.97.0 (rust-toolchain.toml), edition 2024.
- **Gates** (all must stay green):
`cargo check --workspace --all-targets`,
@@ -164,6 +172,63 @@ edges stay deterministic Rust. The harness appends a terminal
the replan cap; `{"ops": []}` is a respected free no-op; failures
degrade.
## Swarm (`agent_swarm` tool + `/swarm` mode)
Cheap, unstructured fan-out — the one thing `/graph` does not offer. One
`prompt_template` containing `{{item}}` is expanded over an `items` list
into up to `MAX_AGENT_SWARM_MEMBERS` (128) ordinary subagents, run to
completion, and returned as ONE aggregate. Ported from kimi-code; entirely
client-side, no backend surface.
- Engine (`kigi-tools/.../kigi/agent_swarm/`): `plan.rs` validates and
expands (every fault is reported BEFORE a member starts; expanded
prompts must be pairwise distinct), `schedule.rs` is the launch ramp as
pure arithmetic (5 immediate, then 1 per 700ms; capacity shrinks on a
provider rate limit and recovers after a quiet window;
`KIGI_AGENT_SWARM_MAX_CONCURRENCY` caps it and a malformed value is a
hard error), `run.rs` drives it against the EXISTING single-spawn
`SubagentBackend`, `tool.rs` is the tool.
- `SwarmMemberOutcome::Backgrounded` is load-bearing: a member that
outlives the 600s foreground budget is detached by the coordinator and
KEEPS RUNNING. It is never reported `Failed` and never offered for
resume — relaunching its item would put a second agent on the same
files. Members share the caller's tree with NO isolation; the
distinct-prompt rule is the only thing keeping them apart.
- `InFlightGuard` (run.rs) cancels live members when the runner's future
is dropped. Send-now cancels the turn WITHOUT cancelling subagents and
aborts the task; the dropped receivers read as "parent gone" and each
child re-attaches itself. There is no cooperative path to use instead —
`kigi_tool_runtime::Cancellation` is constructed nowhere.
- Both retries (`MAX_RATE_LIMIT_RETRIES`) and wall clock
(`MAX_SWARM_RUNTIME`) are bounded: the swarm blocks the caller's turn,
so every wait needs a ceiling. Stragglers at the deadline are reported
as still-running WITH their ids.
- Underneath every fan-out (swarm, graph, raw Task) sits the
session-scoped spawn bound: `handle_subagent_request` holds one permit
from the parent session's semaphore for the whole child run
(kigi-tools `task/admission`: 32 default via
`KIGI_MAX_CONCURRENT_SUBAGENTS`, queue|fail via
`KIGI_SUBAGENT_LIMIT_BEHAVIOR`), so wide fan-outs queue instead of
exhausting file descriptors.
- `ToolKind::AgentSwarm` is its own variant because `TemplateRenderer`'s
`by_kind` map holds ONE tool name per kind — sharing `Task` would
silently redirect `${{ tools.by_kind.task }}` in other tools' prompts.
It gates exactly as `Task` does (`capability.rs`), and `builder.rs`
strips it wherever `task` is stripped. `MAX_SUBAGENT_DEPTH` stays 1:
upstream's unlimited nesting is a hazard, not a feature.
- `/swarm | /swarm on|off | /swarm <task>` (`BuiltinGate::Swarm` = the
tool is in the toolset) arms a doctrine reminder via the existing
`push_system_reminder`. Two triggers only: `Manual` persists until
switched off, `Task` auto-expires at turn end via `SwarmTurnGuard` — a
DROP guard, because a user interrupt aborts the turn future and never
reaches post-loop code. `enter` is a total no-op while armed so the
`/swarm <task>` shorthand cannot downgrade a standing `/swarm on`.
- The mode is deliberately NOT persisted (unlike `/goal` and `/graph`,
which strand real work if lost): it is a prompt hint whose recovery is
retyping one command. The injected doctrine IS durable, so an explicit
`/swarm off` retracts UNCONDITIONALLY — a resumed or compacted session
can read as "off" with the instruction still in context.
## Provider registry & API-key auth (post-0.1.3 expansion)
- The platform registry is compiled-in spec rows in `kigi-models`
Generated
+83 -83
View File
@@ -228,7 +228,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -251,7 +251,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1810,7 +1810,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -2654,7 +2654,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2980,7 +2980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3958,7 +3958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b"
dependencies = [
"bstr",
"hashbrown 0.17.1",
"hashbrown 0.15.5",
]
[[package]]
@@ -4497,9 +4497,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"async-openai",
"async-stream",
@@ -6154,7 +6154,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6171,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"chrono",
@@ -6192,7 +6192,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6230,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6367,7 +6367,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6392,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6403,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6418,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6426,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6457,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.9"
version = "0.1.13"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6472,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6500,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6577,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6590,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.9"
version = "0.1.13"
[[package]]
name = "kigi-tty-utils"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6604,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6691,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6720,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6806,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"base64",
"chrono",
@@ -7305,7 +7305,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7576,7 +7576,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7997,7 +7997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -8840,7 +8840,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8858,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.9"
version = "0.1.13"
dependencies = [
"anyhow",
"axum",
@@ -8992,7 +8992,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9617,7 +9617,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9687,7 +9687,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -10536,7 +10536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -10937,7 +10937,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -11882,7 +11882,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -12414,15 +12414,15 @@ dependencies = [
[[package]]
name = "webbrowser"
version = "1.2.1"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
"core-foundation 0.10.1",
"jni",
"log",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.2",
"url",
"web-sys",
@@ -12561,7 +12561,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.9"
version = "0.1.13"
edition = "2024"
license = "Apache-2.0"
+4 -4
View File
@@ -36,12 +36,12 @@ key? OpenAI, Anthropic, Google, DeepSeek, Groq, Moonshot and
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | bash
curl -fsSL https://kigicli.dev/install.sh | bash
```
```powershell
# Windows PowerShell
irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1 | iex
irm https://kigicli.dev/install.ps1 | iex
```
```sh
@@ -176,8 +176,8 @@ keyring entry, and its own `KIGI_*` env vars, and never touches what the
official `kimi` CLI installed. `kigi import-kimi` copies your old config over
once, read-only.
**Zero telemetry.** It talks to the APIs you configured, GitHub Releases, and
your own MCP servers. Nothing else.
**Zero telemetry.** It talks to the APIs you configured, the Kigi release
server, and your own MCP servers. Nothing else.
## License
+5 -3
View File
@@ -1,8 +1,10 @@
# Security Policy
Kigi is a community build; report vulnerabilities via
[GitHub private vulnerability reporting](https://github.com/ZacharyZhang-NY/Kigi-CLI/security/advisories/new)
on this repository. Please do not open public issues for security reports.
Kigi is a community build; report vulnerabilities on the
[Kigi issue tracker](https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues).
Issues there are public, so please leave out any detail that would make a
report directly exploitable — open with the affected area and impact, and a
maintainer will follow up for the rest.
Dependency advisories are gated in CI with `cargo deny check advisories`;
documented exceptions live in `deny.toml`.
+5936 -4507
View File
File diff suppressed because it is too large. Load diff
+33 -4
View File
@@ -711,9 +711,19 @@ impl AgentBuilder {
kigi_tools::types::tool::ToolNamespace::Kigi,
"task"
);
// The swarm is a fan-out of subagent spawns, so it lives and dies with
// the task tool: any condition that leaves no subagent to spawn leaves
// the swarm with nothing to fan out to.
let swarm_tool_id = format!(
"{}:{}",
kigi_tools::types::tool::ToolNamespace::Kigi,
"agent_swarm"
);
let mut task_stripped = false;
if !self.subagents_enabled {
tool_config.tools.retain(|tc| tc.id != task_tool_id);
tool_config
.tools
.retain(|tc| tc.id != task_tool_id && tc.id != swarm_tool_id);
task_stripped = true;
} else {
let subagents = crate::discovery::all_subagents_with_plugins(
@@ -722,7 +732,9 @@ impl AgentBuilder {
self.plugin_registry.as_deref(),
);
if subagents.is_empty() {
tool_config.tools.retain(|tc| tc.id != task_tool_id);
tool_config
.tools
.retain(|tc| tc.id != task_tool_id && tc.id != swarm_tool_id);
task_stripped = true;
} else if self.prompt_audience == crate::prompt::context::PromptAudience::Subagent {
if let Some(task_tc) = tool_config
@@ -810,7 +822,13 @@ impl AgentBuilder {
.tools
.iter()
.any(|t| AGENT_TASK_CLASSIFIER_RE.is_match(t));
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
let task_deps = [
"task",
"agent_swarm",
"get_task_output",
"kill_task",
"wait_tasks",
];
let registered_tool_ids = tool_bridge_builder.known_tool_ids();
let present_kinds: std::collections::HashSet<ToolKind> =
tool_config.tools.iter().filter_map(|tc| tc.kind).collect();
@@ -925,7 +943,13 @@ impl AgentBuilder {
}
}
if definition.allowed_subagent_types.as_deref() == Some(&[]) {
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
let task_deps = [
"task",
"agent_swarm",
"get_task_output",
"kill_task",
"wait_tasks",
];
tool_config
.tools
.retain(|tc| !task_deps.contains(&short_tool_name(&tc.id)));
@@ -1601,6 +1625,11 @@ mod tests {
has_task, *subagents,
"[{label}] spawn_subagent presence should match subagents_enabled={subagents}; got tools: {names:?}"
);
let has_swarm = names.contains(&"agent_swarm");
assert_eq!(
has_swarm, *subagents,
"[{label}] agent_swarm fans out to subagents, so it must follow the same gate as spawn_subagent; got tools: {names:?}"
);
assert!(
names.contains(&"enter_plan_mode"),
"[{label}] enter_plan_mode must always be present (TUI plan-mode keybind needs it); got tools: {names:?}"
+10
View File
@@ -154,6 +154,11 @@ fn task_tool_config() -> ToolConfig {
.with_name("spawn_subagent")
.with_param_rename("run_in_background", "background")
}
/// Swarm tool. Keeps its registry name: unlike `task` it has no CLI-specific
/// alias, and the name is what the model is told to reuse for `resume_agent_ids`.
fn agent_swarm_tool_config() -> ToolConfig {
ToolConfig::from(&kigi::AgentSwarmTool)
}
/// Task output tool renamed for clarity:
/// `get_task_output` → `get_command_or_subagent_output`.
fn task_output_tool_config() -> ToolConfig {
@@ -270,6 +275,7 @@ fn default_kigi_toolset() -> ToolServerConfig {
task_output_tool_config(),
wait_tasks_tool_config(),
task_tool_config(),
agent_swarm_tool_config(),
(&kigi::SchedulerCreateTool).into(),
(&kigi::SchedulerDeleteTool).into(),
(&kigi::SchedulerListTool).into(),
@@ -318,6 +324,7 @@ pub fn kigi_hashline_toolset(
task_output_tool_config(),
wait_tasks_tool_config(),
task_tool_config(),
agent_swarm_tool_config(),
(&kigi::WebSearchTool).into(),
(&kigi::SchedulerCreateTool).into(),
(&kigi::SchedulerDeleteTool).into(),
@@ -400,6 +407,7 @@ fn kigi_plan_toolset() -> ToolServerConfig {
(&kigi::TodoWriteTool).into(),
task_output_tool_config(),
task_tool_config(),
agent_swarm_tool_config(),
(&kigi::SchedulerCreateTool).into(),
(&kigi::SchedulerDeleteTool).into(),
(&kigi::SchedulerListTool).into(),
@@ -428,6 +436,7 @@ fn orchestrator_toolset() -> ToolServerConfig {
(&kigi::ListDirTool).into(),
(&kigi::GrepTool).into(),
task_tool_config(),
agent_swarm_tool_config(),
task_output_tool_config(),
wait_tasks_tool_config(),
kill_task_tool_config(),
@@ -497,6 +506,7 @@ fn kigi_ask_user_toolset() -> ToolServerConfig {
task_output_tool_config(),
wait_tasks_tool_config(),
task_tool_config(),
agent_swarm_tool_config(),
(&kigi::SchedulerCreateTool).into(),
(&kigi::SchedulerDeleteTool).into(),
(&kigi::SchedulerListTool).into(),
+5
View File
@@ -17,6 +17,11 @@ pub enum AgentBuildError {
#[error("IO error during agent construction: {0}")]
IoError(#[from] std::io::Error),
/// The per-session runtime could not be built — fd or thread
/// exhaustion (EMFILE/EAGAIN), not a definition problem.
#[error("failed to build the session runtime: {0}")]
RuntimeBuild(std::io::Error),
/// Carries template line numbers and surrounding context.
#[error("template rendering error: {0}")]
MiniJinjaError(#[from] minijinja::Error),
@@ -32,5 +32,5 @@ fn get_global_gitignore_path() -> Option<PathBuf> {
git2::Config::open_default()
.ok()
.and_then(|cfg| cfg.get_path("core.excludesFile").ok())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".gitignore")))
.or_else(|| std::env::home_dir().map(|h| h.join(".gitignore")))
}
+18 -2
View File
@@ -1221,10 +1221,26 @@ fn main() {
"Found crashed sessions from a previous run"
);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
// Capped workers: tokio's per-core default pins 100+ thread slots on
// many-core shared hosts and later spawns die with EAGAIN.
let runtime = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(kigi_tty_utils::runtime::capped_worker_threads().get())
.enable_all()
.build()
.unwrap_or_else(|e| panic!("failed to start tokio runtime: {e}"));
{
Ok(runtime) => runtime,
Err(e) => {
// A host out of threads (RLIMIT_NPROC / pids.max) is an
// environment problem, not a bug — report it, don't panic.
kigi_tty_utils::restore_native_stderr();
eprintln!("Error: failed to start the async runtime: {e}");
eprintln!(
"The host may be out of threads (RLIMIT_NPROC or the cgroup pids ceiling); \
close other processes or raise the limit, then retry."
);
std::process::exit(1);
}
};
let result = run_and_shutdown(runtime, async_main(), RUNTIME_SHUTDOWN_GRACE);
kigi_log::debug_log::flush();
if let Err(e) = result {
@@ -668,6 +668,11 @@ pub struct RemoteSettings {
/// remote settings verbatim-input flag; env (`KIGI_COMPACTION_VERBATIM_INPUT`) and config override it. `None` = default (true).
#[serde(default)]
pub compaction_verbatim_input: Option<bool>,
/// Compaction summarizer `tool_choice` (`auto` | `none`) from remote
/// settings. Env (`KIGI_COMPACTION_TOOL_CHOICE`) and config override it.
/// Some backends reject `tool_choice: "none"` with tools attached.
#[serde(default)]
pub compaction_tool_choice: Option<String>,
/// Master switch for jemalloc heap sampling + threshold dumps.
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
#[serde(default)]
+11 -13
View File
@@ -78,11 +78,10 @@ pub fn detect_windows_shell() -> &'static WindowsShell {
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.arg("pwsh.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
tracing::info!("Windows shell: pwsh");
return WindowsShell::Pwsh;
}
} && output.status.success()
{
tracing::info!("Windows shell: pwsh");
return WindowsShell::Pwsh;
}
// powershell.exe (Windows PowerShell 5.1).
@@ -131,14 +130,13 @@ fn find_git_bash() -> Option<String> {
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.arg("bash.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.to_ascii_lowercase().contains("git") {
return Some(line.to_string());
}
} && output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.to_ascii_lowercase().contains("git") {
return Some(line.to_string());
}
}
}
@@ -616,7 +616,7 @@ mod win {
}
let signal = exception_to_signal(exception_code);
let si_code = exception_code as i32;
let si_code = exception_code;
// ExceptionInformation[1] holds the faulting address for ACCESS_VIOLATION.
let si_addr = if exception_code == EXCEPTION_ACCESS_VIOLATION
+4 -3
View File
@@ -11,7 +11,8 @@ pub struct KigiEndpoints {
pub coding_api_base_url: &'static str,
/// OAuth device-flow host for Kimi Code subscription login.
pub oauth_host: &'static str,
/// GitHub Releases API endpoint the self-updater polls.
/// Releases API endpoint the self-updater polls (GitHub-Releases-shaped;
/// Gitea's `/api/v1` serves the same wire format).
pub update_base_url: &'static str,
/// Human-facing page for subscription upgrade guidance.
pub upgrade_page_url: &'static str,
@@ -20,7 +21,7 @@ pub struct KigiEndpoints {
pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
coding_api_base_url: "https://api.kimi.com/coding/v1",
oauth_host: "https://auth.kimi.com",
update_base_url: "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases",
update_base_url: "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases",
upgrade_page_url: "https://www.kimi.com/code/",
};
@@ -29,7 +30,7 @@ pub const CODE_BASE_URL_ENV: &str = "KIGI_CODE_BASE_URL";
/// Env var overriding [`oauth_host`] (PRD F1).
pub const OAUTH_HOST_ENV: &str = "KIGI_OAUTH_HOST";
/// Env var overriding [`update_base_url`] (PRD F8). Points the self-updater
/// at an alternate GitHub-Releases-shaped API (mirrors, tests).
/// at an alternate GitHub/Gitea-Releases-shaped API (mirrors, tests).
pub const UPDATE_BASE_URL_ENV: &str = "KIGI_UPDATE_BASE_URL";
fn resolve(var: &str, compiled: &'static str) -> String {
@@ -76,6 +76,7 @@ mod tests {
assert_eq!(std::fs::read(&dest).unwrap(), data);
}
#[cfg(unix)]
#[test]
fn test_clone_preserves_permissions() {
use std::os::unix::fs::PermissionsExt;
@@ -341,12 +341,14 @@ pub fn resolve_kigi_home() -> Result<PathBuf> {
if let Ok(v) = std::env::var("KIGI_SHARE_DIR") {
return Ok(PathBuf::from(v));
}
let home =
PathBuf::from(std::env::var("HOME").context("neither $KIGI_SHARE_DIR nor $HOME is set")?);
// Canonicalize the home dir so worktree paths share the same physical .kigi
// tree as trust/hooks even when it is symlinked. The dunce canonicalization
// must stay in sync with kigi_config::default_kigi_home();
// home resolution deliberately differs ($HOME here vs std::env::home_dir()).
// Standard home-dir lookup (`USERPROFILE`/Known Folders on Windows, where
// `HOME` is usually unset outside Git Bash) — the same resolution as
// kigi_config::default_kigi_home(), so worktree paths land in the same
// physical .kigi tree as trust/hooks.
let home = std::env::home_dir()
.context("neither $KIGI_SHARE_DIR nor a home directory could be resolved")?;
// Canonicalize so a symlinked home still maps to one physical tree; the
// dunce canonicalization must stay in sync with default_kigi_home().
Ok(dunce::canonicalize(&home).unwrap_or(home).join(".kigi"))
}
@@ -37,6 +37,7 @@ pub(crate) fn execute_plan(plan: WorktreePlan) -> Result<CreateWorktreeResult> {
#[cfg(test)]
mod tests {
#[cfg(unix)]
use super::*;
use crate::{IgnoredFilesMode, WorkingTreeMode, WorktreeBuilder};
use kigi_test_utils::git::{git_commit_all, init_git_repo};
@@ -89,10 +89,10 @@ fn is_platform_system_dir(cwd: &Path) -> bool {
#[cfg(target_os = "windows")]
fn is_platform_system_dir(cwd: &Path) -> bool {
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP")) {
if cwd.starts_with(&temp) {
return true;
}
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP"))
&& cwd.starts_with(&temp)
{
return true;
}
let path_lower = cwd.to_string_lossy().to_lowercase();
@@ -103,7 +103,7 @@ fn is_platform_system_dir(cwd: &Path) -> bool {
return true;
}
if cwd.parent().map_or(false, |p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
if cwd.parent().is_some_and(|p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
return true;
}
+8 -2
View File
@@ -971,7 +971,7 @@ pub(crate) fn start_with_timeout(
let progress_for_thread = progress.clone();
let thread = std::thread::spawn(move || {
let watcher_loop = move || {
let update_stage = |stage: &'static str| {
if let Ok(mut p) = progress_for_thread.lock() {
p.set_stage(stage);
@@ -1351,7 +1351,13 @@ pub(crate) fn start_with_timeout(
let _ = ready_tx.send(Err(Box::new(e)));
}
}
});
};
// Recoverable spawn: a host out of threads must surface as an error the
// caller can degrade on, not a `std::thread::spawn` panic.
let thread = std::thread::Builder::new()
.name("fsnotify-watcher".into())
.spawn(watcher_loop)
.map_err(|e| crate::FsNotifyError::WatcherStart(Box::new(e)))?;
// Wait for watcher to be ready (with timeout)
if let Ok(mut p) = progress.lock() {
+56 -135
View File
@@ -26,68 +26,14 @@ struct HttpHookOutput {
reason: Option<String>,
}
/// CWE-918: Returns `true` if an IP address is in a private, link-local,
/// or cloud metadata range that should be blocked to prevent SSRF attacks.
/// CWE-918: whether this address is blocked for a hook.
///
/// Loopback (`127.x` / `::1`) is allowed for local development servers.
fn is_blocked_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
if octets[0] == 127 {
// loopback — allowed for local dev
return false;
}
if octets[0] == 10 {
// RFC 1918: 10.0.0.0/8
return true;
}
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
// RFC 1918: 172.16.0.0/12
return true;
}
if octets[0] == 192 && octets[1] == 168 {
// RFC 1918: 192.168.0.0/16
return true;
}
if octets[0] == 169 && octets[1] == 254 {
// RFC 3927: 169.254.0.0/16 (link-local, cloud metadata)
return true;
}
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
// RFC 6598: 100.64.0.0/10 (CGNAT)
return true;
}
if v4.is_unspecified() {
// 0.0.0.0
return true;
}
false
}
IpAddr::V6(v6) => {
if v6.is_loopback() {
// ::1 — allowed for local dev
return false;
}
if v6.is_unspecified() {
// ::
return true;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return is_blocked_ip(&IpAddr::V4(v4));
}
let segments = v6.segments();
if segments[0] & 0xffc0 == 0xfe80 {
// fe80::/10 — link-local
return true;
}
if segments[0] & 0xfe00 == 0xfc00 {
// fc00::/7 — unique local (ULA)
return true;
}
false
}
}
/// Delegates to the `web_fetch` policy, so one implementation
/// governs every outbound URL. `allow_local` is on: a loopback hook
/// receiver is legitimate, but only when named literally, so
/// rebinding through a public name stays blocked.
fn is_blocked_ip(ip: &IpAddr, host: &str) -> bool {
kigi_tools::implementations::kigi::web_fetch::ssrf::is_blocked_for_host(ip, host, true)
}
/// CWE-918: Validate a hook URL to prevent SSRF.
@@ -112,7 +58,7 @@ async fn validate_hook_url(url: &str) -> Result<(), String> {
// If host is a literal IP, check it directly.
if let Ok(ip) = host.parse::<IpAddr>() {
if is_blocked_ip(&ip) {
if is_blocked_ip(&ip, host) {
return Err(format!("URL resolves to blocked private/internal IP: {ip}"));
}
return Ok(());
@@ -131,7 +77,7 @@ async fn validate_hook_url(url: &str) -> Result<(), String> {
}
for addr in &addrs {
if is_blocked_ip(&addr.ip()) {
if is_blocked_ip(&addr.ip(), host) {
return Err(format!(
"URL host {host} resolves to blocked private/internal IP: {}",
addr.ip()
@@ -571,76 +517,53 @@ mod tests {
}
}
// SSRF protection: is_blocked_ip tests
// SSRF protection: hook-side policy tests
//
// Range coverage lives with the shared predicate in `web_fetch::ssrf`;
// these pin what the runner adds on top.
/// Every range the shared policy knows is refused here.
#[test]
fn ssrf_blocks_rfc1918_10x() {
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap()));
fn ssrf_delegates_to_the_shared_policy() {
for ip in [
"10.0.0.1",
"172.16.0.1",
"192.168.0.1",
"169.254.169.254",
"100.64.0.1",
"0.0.0.0",
"::",
"fe80::1",
"fc00::1",
"::ffff:10.0.0.1",
// Ranges the old hand-rolled copy missed entirely.
"198.18.0.1",
"192.0.2.1",
"203.0.113.1",
"240.0.0.1",
"64:ff9b::a9fe:a9fe",
] {
let ip: IpAddr = ip.parse().unwrap();
assert!(is_blocked_ip(&ip, &ip.to_string()), "{ip}");
}
for ip in ["1.1.1.1", "8.8.8.8", "172.32.0.1", "100.63.0.1"] {
let ip: IpAddr = ip.parse().unwrap();
assert!(!is_blocked_ip(&ip, &ip.to_string()), "{ip}");
}
}
/// A local hook receiver stays reachable when named literally.
#[test]
fn ssrf_blocks_rfc1918_172x() {
assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap()));
assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap()));
assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap()));
fn ssrf_allows_a_literal_loopback_hook_target() {
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap(), "127.0.0.1"));
assert!(!is_blocked_ip(&"::1".parse().unwrap(), "localhost"));
}
/// A public name resolving to loopback is rebinding.
#[test]
fn ssrf_blocks_rfc1918_192168() {
assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap()));
}
#[test]
fn ssrf_blocks_link_local_metadata() {
assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap()));
}
#[test]
fn ssrf_blocks_cgnat() {
assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap()));
assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap()));
}
#[test]
fn ssrf_blocks_unspecified() {
assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap()));
assert!(is_blocked_ip(&"::".parse().unwrap()));
}
#[test]
fn ssrf_allows_loopback() {
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap()));
assert!(!is_blocked_ip(&"::1".parse().unwrap()));
}
#[test]
fn ssrf_allows_public_ips() {
assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap()));
assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap()));
}
#[test]
fn ssrf_blocks_ipv6_link_local() {
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
}
#[test]
fn ssrf_blocks_ipv6_unique_local() {
assert!(is_blocked_ip(&"fc00::1".parse().unwrap()));
assert!(is_blocked_ip(&"fd00::1".parse().unwrap()));
}
#[test]
fn ssrf_blocks_ipv4_mapped_ipv6_private() {
assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_blocked_ip(
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
));
fn ssrf_blocks_a_public_name_that_resolves_to_loopback() {
let ip: IpAddr = "127.0.0.1".parse().unwrap();
assert!(is_blocked_ip(&ip, "evil.example.com"));
}
// SSRF protection: validate_hook_url tests
@@ -839,14 +762,15 @@ mod tests {
/// the secret does NOT appear in the returned error message.
#[tokio::test]
async fn run_http_hook_scrubs_url_from_reqwest_error() {
// Use a TEST-NET-1 host (RFC 5737, "MUST NOT be used in
// public networks"). It is not RFC1918 so SSRF validation
// will let it through, but no real DNS or connection will
// succeed -- reqwest will surface a connection error whose
// default Display includes the URL.
// A closed loopback port: allowed as a literal local host,
// and refused at once. TEST-NET-1 is now blocked by policy.
let dead = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap()
};
let secret = "ghp_VERY_REAL_SECRET_TOKEN_42";
let mut extra_env = std::collections::HashMap::new();
extra_env.insert("RUNTIME_HOST".to_string(), "192.0.2.1".to_string());
extra_env.insert("RUNTIME_HOST".to_string(), dead.to_string());
extra_env.insert("MY_TOKEN".to_string(), secret.to_string());
let raw = "https://${RUNTIME_HOST}/check?token=${MY_TOKEN}";
@@ -928,10 +852,7 @@ mod tests {
// debugging). The wire-DTO consumer must prefer raw_url for
// display -- documented in the HttpInfo rustdoc.
let info = info.expect("HttpInfo should be present for connection failures too");
assert_eq!(
info.url,
"https://192.0.2.1/check?token=ghp_VERY_REAL_SECRET_TOKEN_42"
);
assert_eq!(info.url, format!("https://{dead}/check?token={secret}"));
assert_eq!(info.raw_url.as_deref(), Some(raw));
}
@@ -318,6 +318,7 @@ mod tests {
assert!(matches!(state, FileContentState::TooLarge { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn test_read_file_bounded_symlink() {
let dir = tempfile::tempdir().unwrap();
+203 -77
View File
@@ -18,6 +18,76 @@ use super::embedding::EmbeddingProvider as _;
use super::storage::MemoryStorage;
use super::watcher::MemoryFileWatcher;
/// The session's embedding credentials, bound to the one endpoint they may
/// reach. Only [`Self::for_endpoint`] retains a live handle; the default fails
/// closed.
///
/// `embed_base_url` is the CURRENT MODEL's endpoint, so a session on a BYOK or
/// subscription-OAuth model aims memory embeddings at that provider's host —
/// and [`kigi_auth::AuthRetryMiddleware`] stamps `Authorization` on every
/// request it wraps, with no idea where the request is going. The caller that
/// owns the credential rule decides `trusted` once, here; a platform's own
/// `embed_api_key` is unaffected and keeps serving its own endpoint.
#[derive(Clone, Default)]
pub struct EndpointScopedCredentials {
endpoint: Option<reqwest::Url>,
auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
}
// Redacts the credential handles; only their presence is printable.
impl std::fmt::Debug for EndpointScopedCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EndpointScopedCredentials")
.field("endpoint", &self.endpoint)
.field("has_auth_credentials", &self.auth_credentials.is_some())
.finish()
}
}
impl EndpointScopedCredentials {
/// No session credential may ride — the state every non-session caller wants.
pub fn none() -> Self {
Self::default()
}
/// Retains the handles only for a `trusted`, parsable `endpoint`.
pub fn for_endpoint(
endpoint: &str,
trusted: bool,
auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
) -> Self {
if trusted && let Ok(url) = reqwest::Url::parse(endpoint) {
return Self {
endpoint: Some(url),
auth_credentials,
};
}
if auth_credentials.is_some() {
tracing::info!(
target: kigi_log::memory_log::TARGET,
endpoint,
"memory embeddings: session credentials withheld from this endpoint; \
its own key, if any, still applies"
);
}
Self::none()
}
pub fn is_empty(&self) -> bool {
self.auth_credentials.is_none()
}
/// Enforced at request-build time, in release too: [`MemoryBackendParams`]
/// is `Clone` and callers rewrite fields on the copy, so construction-time
/// scoping alone would not survive a rewritten `embed_base_url`.
fn approved_for(&self, base_url: &str) -> bool {
match &self.endpoint {
None => self.is_empty(),
Some(endpoint) => reqwest::Url::parse(base_url).is_ok_and(|url| &url == endpoint),
}
}
}
/// All configuration needed to build a fully-wired [`MemoryBackendImpl`] for a live session.
///
/// Grouping these in one struct ensures every call site — ToolBridge, first-turn
@@ -30,7 +100,8 @@ pub struct MemoryBackendParams {
pub session_id: String,
/// Embedding provider config — `None` forces FTS-only fallback everywhere.
pub embed_config: Option<kigi_config_types::MemoryEmbeddingConfig>,
/// Base URL for embedding API calls (CLI proxy).
/// Base URL for embedding API calls (CLI proxy). Must match the endpoint
/// `embedding_credentials` was scoped to; a mismatch fails closed.
pub embed_base_url: String,
/// API key for embedding API calls.
pub embed_api_key: Option<String>,
@@ -47,10 +118,8 @@ pub struct MemoryBackendParams {
/// - `"injection"` — first-turn memory context injection
/// - `"compaction_recovery"` — post-compaction context re-injection
pub search_source: &'static str,
/// Dynamic API key provider — when set, `make_embedding_provider()` resolves
/// the key per-call instead of using the static `embed_api_key`.
pub api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
pub auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
/// The session credentials, and the single endpoint they may reach.
pub embedding_credentials: EndpointScopedCredentials,
}
impl MemoryBackendParams {
@@ -59,8 +128,7 @@ impl MemoryBackendParams {
pub async fn make_embedding_provider(&self) -> Option<super::embedding::ApiEmbeddingProvider> {
build_embedding_provider(
self.embed_config.as_ref(),
self.auth_credentials.as_ref(),
self.api_key_provider.as_ref(),
&self.embedding_credentials,
self.embed_api_key.as_deref(),
&self.embed_base_url,
)
@@ -70,8 +138,7 @@ impl MemoryBackendParams {
async fn build_embedding_provider(
config: Option<&kigi_config_types::MemoryEmbeddingConfig>,
auth_credentials: Option<&Arc<dyn kigi_auth::AuthCredentialProvider>>,
api_key_provider: Option<&kigi_tools::types::SharedApiKeyProvider>,
credentials: &EndpointScopedCredentials,
static_api_key: Option<&str>,
base_url: &str,
) -> Option<super::embedding::ApiEmbeddingProvider> {
@@ -80,9 +147,19 @@ async fn build_embedding_provider(
return None;
}
let approved = credentials.approved_for(base_url);
if !approved {
tracing::error!(
target: kigi_log::memory_log::TARGET,
base_url,
approved_endpoint = ?credentials.endpoint,
"memory embeddings: scoped credentials do not match the request URL; dropping them"
);
}
// Prefer the refresh-capable credential provider — the middleware gives
// 401 retry for free without any per-call key resolution.
if let Some(creds) = auth_credentials {
if approved && let Some(creds) = credentials.auth_credentials.as_ref() {
let client = super::embedding::build_middleware_client(creds.clone());
return super::embedding::ApiEmbeddingProvider::from_config(
config,
@@ -91,14 +168,13 @@ async fn build_embedding_provider(
);
}
// Fallback: resolve API key per-call, wrap in a static middleware client
// (no 401 refresh, but auth header is still stamped by middleware).
let api_key = match api_key_provider {
Some(p) => p.current_api_key_async().await,
None => None,
}
.or_else(|| static_api_key.map(|s| s.to_owned()))?;
super::embedding::ApiEmbeddingProvider::from_session(config, base_url.to_owned(), api_key)
// The platform's own configured key, wrapped in a static middleware client
// (no 401 refresh, but the auth header is still stamped by middleware).
super::embedding::ApiEmbeddingProvider::from_session(
config,
base_url.to_owned(),
static_api_key?.to_owned(),
)
}
/// `MemoryBackend` implementation backed by hybrid search (FTS5 + vector KNN).
@@ -129,10 +205,8 @@ pub struct MemoryBackendImpl {
/// Only the ToolBridge backend's counter is shared back to the session actor;
/// injection and compaction-recovery backends use their own local counters.
pub search_counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
/// Dynamic API key provider for embedding requests.
api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
/// Refresh-capable credential provider for embedding HTTP middleware.
auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
/// The session credentials, and the single endpoint they may reach.
embedding_credentials: EndpointScopedCredentials,
}
impl MemoryBackendImpl {
@@ -150,8 +224,7 @@ impl MemoryBackendImpl {
stale_claim_secs: 60,
session_id: String::new(),
search_source: "tool",
api_key_provider: None,
auth_credentials: None,
embedding_credentials: EndpointScopedCredentials::none(),
search_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
@@ -200,8 +273,7 @@ impl MemoryBackendImpl {
async fn make_embedding_provider(&self) -> Option<super::embedding::ApiEmbeddingProvider> {
build_embedding_provider(
self.embed_config.as_ref(),
self.auth_credentials.as_ref(),
self.api_key_provider.as_ref(),
&self.embedding_credentials,
self.embed_api_key.as_deref(),
&self.embed_base_url,
)
@@ -232,8 +304,7 @@ impl MemoryBackendImpl {
if let Some(w) = &params.watcher {
backend = backend.with_watcher(w.clone(), params.stale_claim_secs);
}
backend.api_key_provider = params.api_key_provider.clone();
backend.auth_credentials = params.auth_credentials.clone();
backend.embedding_credentials = params.embedding_credentials.clone();
backend
}
}
@@ -479,8 +550,7 @@ mod factory_tests {
watcher: None,
stale_claim_secs: 60,
search_source: "tool",
api_key_provider: None,
auth_credentials: None,
embedding_credentials: EndpointScopedCredentials::none(),
}
}
@@ -1038,72 +1108,128 @@ mod factory_tests {
);
}
/// Regression: provider build must use `current_api_key_async`,
/// never sync. Prevents memory_search 401s on rotated tokens.
#[tokio::test]
async fn make_embedding_provider_uses_async_api_key_resolution() {
use kigi_tools::types::ApiKeyProvider;
use std::sync::atomic::{AtomicU32, Ordering};
struct AsyncProbe {
sync_calls: Arc<AtomicU32>,
async_calls: Arc<AtomicU32>,
struct ProbeCredentials;
impl kigi_auth::HttpAuth for ProbeCredentials {
fn apply(
&self,
builder: reqwest::RequestBuilder,
_base_url: &str,
) -> reqwest::RequestBuilder {
builder.bearer_auth("session-bearer")
}
impl ApiKeyProvider for AsyncProbe {
fn current_api_key(&self) -> Option<String> {
self.sync_calls.fetch_add(1, Ordering::SeqCst);
Some("sync-stale".into())
}
fn current_api_key_async(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + '_>>
{
let counter = self.async_calls.clone();
Box::pin(async move {
counter.fetch_add(1, Ordering::SeqCst);
Some("async-fresh".into())
})
}
#[async_trait::async_trait]
impl kigi_auth::AuthCredentialProvider for ProbeCredentials {
fn snapshot(&self) -> kigi_auth::CredentialSnapshot {
kigi_auth::CredentialSnapshot {
token: Some("session-bearer".into()),
..Default::default()
}
}
async fn refresh_after_unauthorized(&self) -> bool {
false
}
}
let sync_calls = Arc::new(AtomicU32::new(0));
let async_calls = Arc::new(AtomicU32::new(0));
let probe: kigi_tools::types::SharedApiKeyProvider = Arc::new(AsyncProbe {
sync_calls: sync_calls.clone(),
async_calls: async_calls.clone(),
});
const SESSION_ENDPOINT: &str = "https://api.kimi.com/coding/v1";
const FOREIGN_ENDPOINT: &str = "https://api.anthropic.com/v1";
let params = MemoryBackendParams {
fn params_at(base_url: &str, credentials: EndpointScopedCredentials) -> MemoryBackendParams {
MemoryBackendParams {
session_id: "s1".into(),
embed_config: Some(MemoryEmbeddingConfig {
model: Some("test-embed-model".into()),
..Default::default()
}),
embed_base_url: "http://example/v1".into(),
embed_api_key: Some("static-fallback".into()),
embed_base_url: base_url.into(),
embed_api_key: None,
search_config: MemorySearchConfig::default(),
watcher: None,
stale_claim_secs: 60,
search_source: "tool",
api_key_provider: Some(probe),
// No auth_credentials — forces the api_key_provider fallback path.
auth_credentials: None,
};
embedding_credentials: credentials,
}
}
let provider = params.make_embedding_provider().await;
/// The session bearer must never ride to a third-party embedding host.
///
/// `embed_base_url` is the CURRENT MODEL's endpoint, so a session on a BYOK
/// or subscription-OAuth model aims memory embeddings at that provider —
/// and the auth middleware stamps `Authorization` unconditionally.
#[tokio::test]
async fn session_credentials_are_withheld_from_a_foreign_endpoint() {
let scoped = EndpointScopedCredentials::for_endpoint(
FOREIGN_ENDPOINT,
false,
Some(Arc::new(ProbeCredentials)),
);
assert!(
scoped.is_empty(),
"an untrusted endpoint must drop both handles"
);
let provider = params_at(FOREIGN_ENDPOINT, scoped)
.make_embedding_provider()
.await;
assert!(
provider.is_none(),
"no credential may ride to a foreign endpoint, and there is no static key to fall back to"
);
}
/// The trusted-endpoint path still builds a credentialed provider.
#[tokio::test]
async fn session_credentials_ride_their_own_endpoint() {
let scoped = EndpointScopedCredentials::for_endpoint(
SESSION_ENDPOINT,
true,
Some(Arc::new(ProbeCredentials)),
);
assert!(
!scoped.is_empty(),
"a trusted endpoint must retain the handles"
);
let provider = params_at(SESSION_ENDPOINT, scoped)
.make_embedding_provider()
.await;
assert!(
provider.is_some(),
"provider must be built when model is set"
"the session endpoint keeps its refresh-capable credential"
);
assert_eq!(
async_calls.load(Ordering::SeqCst),
1,
"must call current_api_key_async exactly once per provider build"
}
/// The runtime re-check, not construction alone, is what guards the wire:
/// `MemoryBackendParams` is `Clone` and callers rewrite fields on the copy.
#[tokio::test]
async fn a_cloned_param_set_cannot_redirect_scoped_credentials() {
let scoped = EndpointScopedCredentials::for_endpoint(
SESSION_ENDPOINT,
true,
Some(Arc::new(ProbeCredentials)),
);
assert_eq!(
sync_calls.load(Ordering::SeqCst),
0,
"sync current_api_key must NOT be called — the async path is the contract"
let redirected = MemoryBackendParams {
embed_base_url: FOREIGN_ENDPOINT.into(),
..params_at(SESSION_ENDPOINT, scoped)
};
assert!(
redirected.make_embedding_provider().await.is_none(),
"credentials scoped to one endpoint must not follow a rewritten base_url"
);
}
/// A platform's own API key is not a session credential: it is resolved for
/// that platform and must keep serving that platform's endpoint.
#[tokio::test]
async fn a_platform_api_key_still_serves_its_own_endpoint() {
let params = MemoryBackendParams {
embed_api_key: Some("platform-key".into()),
..params_at(FOREIGN_ENDPOINT, EndpointScopedCredentials::none())
};
assert!(
params.make_embedding_provider().await.is_some(),
"withholding the session credential must not disable BYOK embeddings"
);
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ pub mod storage;
pub mod text_utils;
pub mod watcher;
pub use backend::{MemoryBackendImpl, MemoryBackendParams};
pub use backend::{EndpointScopedCredentials, MemoryBackendImpl, MemoryBackendParams};
pub use index::{MemoryIndex, init_sqlite_vec};
pub use storage::{MemoryScope, MemoryStorage};
+1
View File
@@ -150,6 +150,7 @@ fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::time::Instant;
/// Write an executable `#!/bin/sh` fake `mmdc` and return (dir-guard, path).
@@ -179,9 +179,12 @@ fn reap_process_group(_child: &Child) {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::process::Stdio;
#[cfg(unix)]
use std::time::Instant;
#[cfg(unix)]
fn detached(mut cmd: Command) -> Command {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
+6 -6
View File
@@ -1574,7 +1574,7 @@ impl PlatformId {
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
/// set) because OpenAI exposes no stable public models endpoint for the
/// ChatGPT Codex backend. Each entry carries context window + per-model
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`/`ultra`
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
/// tiers), so the fetch path maps them through the SAME
/// `platform_wire_model_to_entry` output as a live listing — no new type.
pub fn hardcoded_catalog(self) -> Option<Vec<WireModel>> {
@@ -1617,13 +1617,13 @@ fn openai_codex_wire_models() -> Vec<WireModel> {
codex_wire_model(
"gpt-5.6-sol",
"GPT-5.6-Sol",
&["low", "medium", "high", "xhigh", "max", "ultra"],
&["low", "medium", "high", "xhigh", "max"],
"low",
),
codex_wire_model(
"gpt-5.6-terra",
"GPT-5.6-Terra",
&["low", "medium", "high", "xhigh", "max", "ultra"],
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
@@ -2655,7 +2655,7 @@ mod tests {
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
/// models, keyed by slug, ctx 272000, each exposing its exact supported
/// efforts (incl. the codex-only `xhigh`/`max`/`ultra` tiers). The
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The
/// list-but-broken / hidden models are absent. Every other platform serves
/// NO hardcoded catalog (its models come from the live wire).
#[test]
@@ -2702,11 +2702,11 @@ mod tests {
};
assert_eq!(
efforts("gpt-5.6-sol"),
["low", "medium", "high", "xhigh", "max", "ultra"]
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-5.6-terra"),
["low", "medium", "high", "xhigh", "max", "ultra"]
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-5.6-luna"),
+16 -11
View File
@@ -1251,6 +1251,10 @@ mod tests {
use ratatui::layout::Rect;
use similar::ChangeTag;
// A sibling test engages the terminal-native lock, which flattens
// every diff background process-wide. Hold the shared theme lock.
let _theme = kigi_tui::theme::cache::pin_theme();
let hunk = vec![
DiffLine {
text: "let x = 1;\n".into(),
@@ -1293,23 +1297,24 @@ mod tests {
// The committed edit uses a flat background (terminal transparency), but
// must still paint the per-line diff backgrounds — otherwise an added /
// removed line is indistinguishable from context.
let mut saw_insert = false;
let mut saw_delete = false;
//
// Theme-agnostic: the line cache is keyed on the global theme,
// so an exact-RGB match would race. Colors pinned in tool::edit.
let mut bands = std::collections::BTreeSet::new();
for y in 0..h {
for x in 0..width {
if let Some(cell) = buf.cell((x, y)) {
saw_insert |= cell.bg == theme.diff_insert_bg;
saw_delete |= cell.bg == theme.diff_delete_bg;
if let Some(cell) = buf.cell((x, y))
&& cell.bg != ratatui::style::Color::Reset
{
bands.insert(format!("{:?}", cell.bg));
}
}
}
assert!(
saw_insert,
"committed edit lost the insert (green) diff background"
);
assert!(
saw_delete,
"committed edit lost the delete (red) diff background"
bands.len() >= 2,
"committed edit lost its insert/delete diff bands: \
theme={:?} h={h} bands={bands:?}",
Theme::current_kind(),
);
}
@@ -214,6 +214,7 @@ pub fn open_url_if_safe(url: &str, filter: SchemeFilter) -> bool {
mod tests {
use super::*;
#[cfg(not(target_os = "windows"))]
#[test]
fn open_path_command_passes_path_as_a_single_arg() {
// Path with spaces must be one argument, never shell-interpolated.
@@ -10,6 +10,7 @@
//! portable re-injection exists (TIOCSTI is blocked); accepted loss.
use std::io::Write;
#[cfg(unix)]
use std::time::Duration;
/// Bounds the reply buffer against terminals that stream without a terminator.
+47 -7
View File
@@ -5,8 +5,11 @@
//!
//! # Retry behavior summary
//!
//! **Retried** (up to [`DEFAULT_MAX_RETRIES`] = 15, ~6 min with 30s backoff cap):
//! - 500, 502, 503, 504, 520 (server errors)
//! **Retried** (up to [`DEFAULT_MAX_RETRIES`] = 15, ~6 min with the
//! [`MAX_RETRY_BACKOFF`] cap — a server `Retry-After` is clamped to it too):
//! - Any 5xx except Cloudflare 525/526 — covers the Cloudflare edge pages
//! (520-524 origin unreachable/timed out, 530 edge 1xxx) and upstream
//! overload (529); see `is_retryable_api_status`
//! - Connection errors (timeout, refused, reset)
//! - `EventStreamError` / `StreamError` (mid-stream failures)
//! - `EmptyResponse` (model returned no content/tool calls)
@@ -77,16 +80,35 @@ pub fn doom_loop_backoff(retry_count: u32) -> Duration {
Duration::from_millis(hasher.finish() % 251)
}
/// Exponential backoff (2s, 4s, 8s, ..., capped 30s) with +/-20% jitter
/// to prevent thundering-herd retry storms.
/// Longest single wait on the generic retry path — the exponential-backoff
/// ceiling, and the clamp for a server `Retry-After`. Cloudflare answers 52x
/// with `Retry-After: 60`-`120`; honoring that verbatim across the retry
/// budget would stall a turn ~28 min instead of the ~6 min budget. The 429
/// path deliberately waits the full `Retry-After` instead, bounded by
/// [`RATE_LIMIT_RETRY_THRESHOLD`] attempts (and by the parse-level 120s cap
/// on the header).
pub const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(30);
/// Exponential backoff (2s, 4s, 8s, ..., capped at [`MAX_RETRY_BACKOFF`])
/// with +/-20% jitter to prevent thundering-herd retry storms.
pub fn retry_backoff_with_jitter(retry_count: u32) -> Duration {
let shift = retry_count.saturating_sub(1);
let base_ms = 2000u64
.checked_shl(shift)
.unwrap_or(u64::MAX)
.min(MAX_RETRY_BACKOFF.as_millis() as u64);
jittered(Duration::from_millis(base_ms))
}
/// +/-20% jitter around `base`, de-syncing clients that failed at the
/// same instant (e.g. a mass Cloudflare 52x event during an origin outage).
fn jittered(base: Duration) -> Duration {
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
static JITTER_SEQ: AtomicU64 = AtomicU64::new(0);
let shift = retry_count.saturating_sub(1);
let base_ms = 2000u64.checked_shl(shift).unwrap_or(u64::MAX).min(30_000);
let base_ms = base.as_millis() as u64;
let jitter_range = base_ms / 5;
let mut hasher = std::hash::DefaultHasher::new();
JITTER_SEQ.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
@@ -210,9 +232,11 @@ pub fn classify_error(
if next_attempt >= max_retries {
return RetryDecision::Fatal(clone_error(err));
}
// A server `Retry-After` is clamped: Cloudflare 52x pages ask for
// 60-120s, which across the budget would stall a turn ~28 min.
let backoff = err
.retry_after()
.map(Duration::from_secs)
.map(|secs| jittered(Duration::from_secs(secs).min(MAX_RETRY_BACKOFF)))
.unwrap_or_else(|| retry_backoff_with_jitter(next_attempt));
if next_attempt == 1 {
return RetryDecision::RetryWithClientRebuild { backoff };
@@ -595,6 +619,22 @@ mod tests {
}
}
#[test]
fn classify_5xx_retry_after_is_clamped_to_backoff_cap() {
// Cloudflare answers 52x with Retry-After: 60-120; honoring it
// verbatim across the retry budget would stall a turn ~28 min.
let err = api_err_with_retry_after(StatusCode::from_u16(522).unwrap(), 120);
match classify_error(&err, 1, 15, RATE_LIMIT_RETRY_THRESHOLD) {
RetryDecision::Retry { backoff } => {
assert!(
backoff <= MAX_RETRY_BACKOFF.mul_f64(1.2),
"52x Retry-After must clamp to ~MAX_RETRY_BACKOFF, got {backoff:?}"
);
}
other => panic!("expected Retry, got {other:?}"),
}
}
#[test]
fn classify_5xx_first_retry_rebuilds_client() {
let err = api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom");
@@ -239,9 +239,7 @@ impl SamplingError {
SamplingError::InvalidConfiguration(_) => false,
SamplingError::Http(err) => is_retryable_reqwest(err),
SamplingError::Serialization(_) => false,
SamplingError::Api { status, .. } => {
matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504 | 520)
}
SamplingError::Api { status, .. } => is_retryable_api_status(status.as_u16()),
SamplingError::EventStreamError(_) => true,
SamplingError::StreamError { .. } => true,
SamplingError::IdleTimeout { .. } => false,
@@ -342,6 +340,15 @@ pub fn try_parse_stream_error(data: &str) -> Option<SamplingError> {
})
}
/// 429 and any 5xx except Cloudflare 525/526 (origin TLS handshake /
/// invalid certificate — a broken origin certificate never clears on its
/// own). The 5xx sweep covers the Cloudflare edge pages (520-524 origin
/// unreachable/timed out, 530 edge 1xxx) and upstream overload (529),
/// which an outage serves in place of the API's own statuses.
pub fn is_retryable_api_status(status: u16) -> bool {
status == 429 || ((500..=599).contains(&status) && !matches!(status, 525 | 526))
}
/// True when an error message indicates a context-window overflow. Backends report
/// this inconsistently with no stable error code, so we match the message text; it's
/// deterministic (re-sending the same payload always fails), so callers must not retry.
@@ -352,6 +359,7 @@ pub fn is_context_length_error(message: &str) -> bool {
|| m.contains("maximum prompt length")
|| m.contains("maximum context length")
|| m.contains("context_length_exceeded")
|| (m.contains("current message") && m.contains("exceeds budget"))
}
/// Decide whether a [`reqwest::Error`] is worth retrying.
@@ -378,6 +386,27 @@ pub fn is_retryable_reqwest(err: &reqwest::Error) -> bool {
mod tests {
use super::*;
#[test]
fn retryable_api_status_covers_edge_5xx_and_excludes_broken_tls() {
for status in [
429u16, 500, 502, 503, 504, 520, 521, 522, 523, 524, 529, 530, 599,
] {
assert!(is_retryable_api_status(status), "should retry {status}");
}
// Cloudflare 525/526: origin TLS handshake / invalid certificate —
// a broken origin certificate never clears on its own.
for status in [525u16, 526, 400, 401, 403, 404, 408, 422, 200] {
assert!(!is_retryable_api_status(status), "must not retry {status}");
}
let api = SamplingError::Api {
status: StatusCode::from_u16(522).unwrap(),
message: "cloudflare: connection timed out".into(),
model_metadata: None,
retry_after_secs: None,
};
assert!(api.is_retryable(), "522 edge page must be retryable");
}
#[test]
fn context_length_error_matches_backend_variants() {
for msg in [
@@ -387,6 +416,7 @@ mod tests {
"This model's maximum context length is 200000 tokens",
"invalid_request_error: prompt is too long: 300000 tokens > 200000 maximum",
"error type: context_length_exceeded",
"the current message exceeds budget: 120000 < 250000",
] {
assert!(is_context_length_error(msg), "should match: {msg}");
}
+12 -36
View File
@@ -911,11 +911,6 @@ pub enum ReasoningEffort {
/// Messages both accept `xhigh` AND `max` as separate levels in 2026;
/// the Kimi wire spells its top tier `max` with no `xhigh`).
Max,
/// Codex-only top tier above `max` (the ChatGPT Codex backend exposes an
/// `ultra` reasoning effort on its flagship models). Reachable ONLY via a
/// model's server-declared effort menu (openai-codex); no built-in fallback
/// menu offers it, so other providers never emit it.
Ultra,
}
impl ReasoningEffort {
@@ -944,15 +939,12 @@ impl ReasoningEffort {
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
Self::Ultra => "ultra",
}
}
/// Anthropic Messages API effort string; `None` for unsupported variants.
/// `xhigh` and `max` are distinct levels on the 2026 Messages API (both
/// appear in `GET /v1/models` `capabilities.effort`). `ultra` is codex-only
/// and never selected on an Anthropic model, but maps to its own string for
/// completeness (the Responses path writes effort via `as_str`, not this).
/// appear in `GET /v1/models` `capabilities.effort`).
pub fn to_messages_api(self) -> Option<&'static str> {
match self {
Self::None | Self::Minimal => None,
@@ -961,7 +953,6 @@ impl ReasoningEffort {
Self::High => Some("high"),
Self::Xhigh => Some("xhigh"),
Self::Max => Some("max"),
Self::Ultra => Some("ultra"),
}
}
}
@@ -984,9 +975,8 @@ impl std::str::FromStr for ReasoningEffort {
"high" => Ok(Self::High),
"xhigh" => Ok(Self::Xhigh),
"max" => Ok(Self::Max),
"ultra" => Ok(Self::Ultra),
_ => Err(format!(
"invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max, ultra)"
"invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max)"
)),
}
}
@@ -1878,30 +1868,16 @@ mod tests {
assert_eq!(ReasoningEffort::Max.to_messages_api(), Some("max"));
}
/// The codex-only `ultra` tier parses, serializes, and patches onto a
/// Responses body as `reasoning.effort = "ultra"` (the crux of surfacing a
/// codex model's full thinking menu). It is a DISTINCT level above `max`.
/// `ultra` is not a tier any backend accepts.
///
/// The Codex Responses endpoint rejects it with a 400 listing its menu,
/// which tops out at `max`. Parsing it would only send it again.
#[test]
fn reasoning_effort_ultra_is_a_distinct_codex_tier() {
assert_eq!(
"ultra".parse::<ReasoningEffort>().unwrap(),
ReasoningEffort::Ultra
);
assert_eq!(
"ULTRA".parse::<ReasoningEffort>().unwrap(),
ReasoningEffort::Ultra
);
assert_ne!(ReasoningEffort::Ultra, ReasoningEffort::Max);
assert_eq!(ReasoningEffort::Ultra.as_str(), "ultra");
let json = serde_json::to_string(&ReasoningEffort::Ultra).unwrap();
assert_eq!(json, "\"ultra\"");
assert_eq!(
serde_json::from_str::<ReasoningEffort>("\"ultra\"").unwrap(),
ReasoningEffort::Ultra
);
let mut body = serde_json::json!({ "model": "gpt-5.6-sol" });
patch_reasoning_effort(&mut body, Some(ReasoningEffort::Ultra));
assert_eq!(body["reasoning"]["effort"], "ultra");
fn reasoning_effort_ultra_is_not_a_tier() {
assert!("ultra".parse::<ReasoningEffort>().is_err());
assert!("ULTRA".parse::<ReasoningEffort>().is_err());
assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
assert_eq!(ReasoningEffort::Max.as_str(), "max");
}
/// The account id is decoded STATELESSLY from the bearer JWT payload's
@@ -2122,7 +2098,7 @@ mod tests {
);
let bad_type = as_map(serde_json::json!({"reasoningEffort": 3}));
assert_eq!(parse_reasoning_effort_meta(Some(&bad_type)), None);
// `ultra` is a real codex tier; a genuinely-unknown token still None.
// A genuinely-unknown token is dropped, not guessed at.
let unknown = as_map(serde_json::json!({"reasoningEffort": "MEGA"}));
assert_eq!(parse_reasoning_effort_meta(Some(&unknown)), None);
}
@@ -14,9 +14,19 @@
//! cargo run -p kigi-sandbox --example sandbox_smoke_test -- read-only
//! ```
// Kernel enforcement is unix-only (Seatbelt / Landlock); Windows builds a
// stub so `--all-targets` stays green everywhere.
#[cfg(unix)]
use kigi_sandbox::{ProfileName, SandboxManager};
#[cfg(unix)]
use std::path::Path;
#[cfg(not(unix))]
fn main() {
eprintln!("sandbox_smoke_test exercises kernel enforcement and only runs on Unix.");
}
#[cfg(unix)]
fn main() {
let profile_name = std::env::args()
.nth(1)
@@ -121,6 +131,7 @@ fn main() {
println!("\n✅ Smoke test complete");
}
#[cfg(unix)]
fn test_read(label: &str, path: &Path) {
if path.is_file() {
match std::fs::read(path) {
@@ -150,6 +161,7 @@ fn test_read(label: &str, path: &Path) {
}
}
#[cfg(unix)]
fn test_write(label: &str, path: &Path) {
match std::fs::write(path, b"sandbox-test") {
Ok(()) => {
+9 -10
View File
@@ -1164,6 +1164,7 @@ mod platform {
#[cfg(not(target_os = "macos"))]
mod platform {
use super::ImageData;
#[cfg(target_os = "linux")]
use std::process::{Command, Stdio};
/// No subprocess-free pasteboard probe exists off-macOS.
@@ -1926,19 +1927,18 @@ mod platform {
// Public API
pub fn get_text() -> anyhow::Result<Option<String>> {
let mut arboard_error = None;
match arboard_get_text() {
let arboard_error = match arboard_get_text() {
Ok(Some(text)) => return Ok(Some(text)),
// Wayland-only: arboard's empty answer is not authoritative,
// fall through to wl-paste (see `wayland_tool_selected`).
#[cfg(target_os = "linux")]
Ok(None) if wayland_tool_selected(linux_tool_spec()) => {}
Ok(None) if wayland_tool_selected(linux_tool_spec()) => None,
Ok(None) => return Ok(None),
Err(e) => {
tracing::debug!("arboard get_text failed: {e}");
arboard_error = Some(e);
Some(e)
}
}
};
#[cfg(target_os = "linux")]
if let Some(spec) = linux_tool_spec() {
let bytes = run_capture_out_checked(spec.read_text, CLI_READ_WAIT)?;
@@ -2039,19 +2039,18 @@ mod platform {
}
pub fn get_image() -> anyhow::Result<Option<ImageData>> {
let mut arboard_error = None;
match arboard_get_image() {
let arboard_error = match arboard_get_image() {
Ok(Some(image)) => return Ok(Some(image)),
// Wayland-only: arboard's empty answer is not authoritative,
// fall through to wl-paste (see `wayland_tool_selected`).
#[cfg(target_os = "linux")]
Ok(None) if wayland_tool_selected(linux_tool_spec()) => {}
Ok(None) if wayland_tool_selected(linux_tool_spec()) => None,
Ok(None) => return Ok(None),
Err(e) => {
tracing::debug!("arboard get_image failed: {e}");
arboard_error = Some(e);
Some(e)
}
}
};
#[cfg(target_os = "linux")]
if let Some(spec) = linux_tool_spec()
&& let Some(argv) = spec.read_png
@@ -26,7 +26,7 @@ use std::io::{self, Write};
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
/// Creates or opens a file with secure permissions (owner read/write only).
///
@@ -58,17 +58,20 @@ pub fn write_secure_file(path: &Path, contents: &[u8]) -> io::Result<()> {
file.write_all(contents)?;
file.flush()?;
// On Windows, we need to set permissions after file creation
#[cfg(windows)]
{
set_windows_secure_permissions(path)?;
}
// Re-assert owner-only bits: `OpenOptions::mode` only applies on create,
// so an existing world-readable file would otherwise keep open perms.
ensure_owner_only_permissions(path)?;
Ok(())
}
/// Opens a file for writing with secure permissions set during creation (Unix)
/// or prepares it for permission setting after creation (Windows).
///
/// Callers that write secret material should also call
/// [`ensure_owner_only_permissions`] after the write (or use
/// [`write_secure_file`]), because `mode(0o600)` only applies when the file
/// is newly created — not when truncating an existing path.
pub fn open_secure_file(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.truncate(true).write(true).create(true);
@@ -82,6 +85,44 @@ pub fn open_secure_file(path: &Path) -> io::Result<File> {
options.open(path)
}
/// Ensure `path` is owner-read/write only (Unix `0o600` / Windows user ACL).
///
/// Best-effort on missing files (`NotFound` is ignored). Other errors
/// propagate so callers can fail closed when tightening a secret store.
///
/// Use on **load** of credential files so a hand-copied or restored
/// world-readable `auth.json` is tightened before the process continues.
pub fn ensure_owner_only_permissions(path: &Path) -> io::Result<()> {
match ensure_owner_only_permissions_inner(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn ensure_owner_only_permissions_inner(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
let metadata = std::fs::metadata(path)?;
let mode = metadata.permissions().mode();
if mode & 0o777 != 0o600 {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
#[cfg(windows)]
{
set_windows_secure_permissions(path)
}
#[cfg(not(any(unix, windows)))]
{
let _ = path;
Ok(())
}
}
/// Sets Windows-specific secure permissions on a file.
///
/// This function modifies the file's ACL to:
@@ -219,8 +260,6 @@ mod tests {
#[cfg(unix)]
#[test]
fn test_unix_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test_perms.txt");
@@ -231,4 +270,43 @@ mod tests {
// Check that only owner has read/write (0o600), ignoring file type bits
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn ensure_owner_only_tightens_world_readable_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("loose.txt");
fs::write(&file_path, b"secret").unwrap();
let mut loose = fs::metadata(&file_path).unwrap().permissions();
loose.set_mode(0o644);
fs::set_permissions(&file_path, loose).unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o644
);
ensure_owner_only_permissions(&file_path).unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn write_secure_file_tightens_existing_world_readable_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("existing.txt");
fs::write(&file_path, b"old").unwrap();
let mut loose = fs::metadata(&file_path).unwrap().permissions();
loose.set_mode(0o666);
fs::set_permissions(&file_path, loose).unwrap();
write_secure_file(&file_path, b"new secret").unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(fs::read_to_string(&file_path).unwrap(), "new secret");
}
}
+2
View File
@@ -1309,6 +1309,7 @@ output_byte_limit = 65536 # max output size (64KB)
[toolset.web_fetch]
proxy_endpoint = "https://proxy.example.com" # egress proxy URL (all requests routed through it)
allowed_domains = ["docs.rs", "x.ai"] # override the built-in ~84-domain allowlist
allow_local = false # true = reach an explicit localhost / 127.0.0.0/8 / ::1 URL
[shortcuts]
send = ["Enter"]
@@ -2384,6 +2385,7 @@ The agent persists all session updates automatically. Clients can reconnect and
| `KIGI_AGENT` | Custom agent definition path or name (see [Agent Profiles](#agent-profiles)) |
| `KIGI_WEB_FETCH` | Enable (`1`) or disable (`0`) the `web_fetch` tool |
| `KIGI_WEB_FETCH_PROXY` | Egress proxy URL for `web_fetch` requests (overridden by `[toolset.web_fetch] proxy_endpoint`) |
| `KIGI_WEB_FETCH_ALLOW_LOCAL` | `1` lets `web_fetch` reach an explicit loopback URL; private and metadata ranges stay blocked |
| `KIGI_RESPECT_GITIGNORE` | Disable `.gitignore` filtering in tools when set to `0` |
| `KIGI_FEEDBACK_ENABLED` | Enable (`1`) or disable (`0`) feedback system independently from telemetry |
| `KIGI_DEPLOYMENT_KEY` | Management API key for enterprise deployments |
@@ -2171,6 +2171,19 @@ impl Config {
)
.with_segment_detail(self.resolve_compaction_detail())
}
/// Resolve summarizer `tool_choice`: env `KIGI_COMPACTION_TOOL_CHOICE` >
/// config > remote settings > default (`auto`).
pub(crate) fn resolve_compaction_tool_choice(
&self,
) -> crate::util::config::CompactionToolChoice {
crate::util::config::resolve_compaction_tool_choice_from(
env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE).as_deref(),
self.features.compaction_tool_choice.as_deref(),
self.remote_settings
.as_ref()
.and_then(|r| r.compaction_tool_choice.as_deref()),
)
}
/// Resolve verbatim-input flag: env `KIGI_COMPACTION_VERBATIM_INPUT` > config > remote settings > default `true`.
pub(crate) fn resolve_compaction_verbatim_input(&self) -> bool {
BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT")
@@ -3696,6 +3709,9 @@ pub struct Features {
/// Feed the summarizer the verbatim conversation instead of the lossy rewrite; `None` = defer to env/remote settings/default (true).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_verbatim_input: Option<bool>,
/// Summarizer `tool_choice` (`auto` | `none`); `None` = defer to env/remote settings/default (`auto`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_tool_choice: Option<String>,
/// Snapshot a completed subagent's isolated worktree into a durable git ref
/// and delete its directory (resume rehydrates from the ref). This is the
/// per-deployment rollout lever (set in managed_config.toml `[features]`).
@@ -3,7 +3,7 @@
//! Port of kimi-cli's `/feedback` slash command (kimi-cli
//! `src/kimi_cli/ui/shell/slash.py`, `feedback()`): subscription (OAuth)
//! sessions POST the user's feedback text to `{coding_api_base_url}/feedback`
//! with a Bearer token; everyone else is pointed at the GitHub issue tracker.
//! with a Bearer token; everyone else is pointed at the project issue tracker.
//! The request body carries exactly the fields kimi-cli sends:
//! `session_id`, `content`, `version`, `os`, `model`.
@@ -12,7 +12,8 @@ use std::sync::Arc;
use serde::Serialize;
/// Where non-subscription users (no OAuth session) submit feedback instead.
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
pub const FEEDBACK_ISSUES_URL: &str =
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues";
/// HTTP error from the feedback endpoint with a preserved status code, so
/// callers can distinguish auth failures (401) without string matching.
+54 -41
View File
@@ -1938,7 +1938,7 @@ fn resolve_prefetch_env(kimi_code_config: Option<KimiCodeConfig>) -> Option<Pref
/// credentials from disk.
pub fn start_early_prefetch_with_auth(auth: Option<KimiAuth>) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env_with_auth(auth)?;
Some(spawn_prefetch_thread(env))
spawn_prefetch_thread(env)
}
/// Start the model-catalog prefetch on a background thread.
@@ -1949,51 +1949,64 @@ pub fn start_early_prefetch(
kimi_code_config: Option<KimiCodeConfig>,
) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env(kimi_code_config)?;
Some(spawn_prefetch_thread(env))
spawn_prefetch_thread(env)
}
fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
std::thread::spawn(move || {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url();
timer.with_field("endpoint", proxy_endpoint.as_str());
// Resolve each stored subscription-OAuth session's bearer (refreshed
// on expiry) so those platforms are part of the STARTUP fetch plan —
// otherwise a claude-pro-max-only user boots onto the bundled Kimi
// table until some later async refresh happens to run, and the cache
// origin (which encodes enabled platforms) never matches the
// async-path's claude-inclusive origin.
let oauth_tokens = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map(|rt| {
rt.block_on(crate::agent::models_fetch::resolve_generic_oauth_tokens(
&crate::auth::oauth_registry::pool_home(),
))
})
.unwrap_or_default();
let models = prefetch_models_blocking(
&env.endpoints,
env.auth.as_ref(),
&oauth_tokens,
env.model_fetch_auth,
&env.platform_keys,
);
if (env.endpoints.deployment_key.is_some() || crate::managed_config::has_active_team_auth())
&& crate::config::is_managed_config_stale_for(
&crate::managed_config::current_serving_identity(),
)
&& crate::managed_config::is_fetch_enabled()
&& let Ok(rt) = tokio::runtime::Builder::new_current_thread()
/// `None` when the OS refuses a thread: the prefetch is an optimization and
/// runs before the runtime builds, so a spawn panic here would preempt the
/// runtime's own out-of-threads diagnostic.
fn spawn_prefetch_thread(env: PrefetchEnv) -> Option<EarlyPrefetchHandle> {
let spawned = std::thread::Builder::new()
.name("early-prefetch".into())
.spawn(move || {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url();
timer.with_field("endpoint", proxy_endpoint.as_str());
// Resolve each stored subscription-OAuth session's bearer (refreshed
// on expiry) so those platforms are part of the STARTUP fetch plan —
// otherwise a claude-pro-max-only user boots onto the bundled Kimi
// table until some later async refresh happens to run, and the cache
// origin (which encodes enabled platforms) never matches the
// async-path's claude-inclusive origin.
let oauth_tokens = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
crate::managed_config::clear_orphan();
let _ = rt.block_on(crate::managed_config::sync());
}
.map(|rt| {
rt.block_on(crate::agent::models_fetch::resolve_generic_oauth_tokens(
&crate::auth::oauth_registry::pool_home(),
))
})
.unwrap_or_default();
let models = prefetch_models_blocking(
&env.endpoints,
env.auth.as_ref(),
&oauth_tokens,
env.model_fetch_auth,
&env.platform_keys,
);
if (env.endpoints.deployment_key.is_some()
|| crate::managed_config::has_active_team_auth())
&& crate::config::is_managed_config_stale_for(
&crate::managed_config::current_serving_identity(),
)
&& crate::managed_config::is_fetch_enabled()
&& let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
crate::managed_config::clear_orphan();
let _ = rt.block_on(crate::managed_config::sync());
}
EarlyPrefetchResult { models }
})
EarlyPrefetchResult { models }
});
match spawned {
Ok(handle) => Some(handle),
Err(e) => {
tracing::error!(error = %e, "early prefetch thread spawn failed; skipping prefetch");
None
}
}
}
/// Map a model id (catalog key or routing slug) to its catalog key.
@@ -1475,7 +1475,7 @@ mod tests {
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
/// the fetch returns exactly the 4 compiled-in models keyed
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`/`ultra`).
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
/// otherwise fail against an unroutable host).
#[tokio::test(flavor = "multi_thread")]
@@ -1556,10 +1556,10 @@ mod tests {
.iter()
.map(|o| o.id.as_str())
.collect::<Vec<_>>(),
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
"sol exposes the full codex effort menu incl. ultra"
vec!["low", "medium", "high", "xhigh", "max"],
"sol exposes the full codex effort menu"
);
// gpt-5.5 tops out at xhigh (no max/ultra).
// gpt-5.5 tops out at xhigh (no max).
let five_five = result
.models
.iter()
@@ -1725,8 +1725,8 @@ impl acp::Agent for MvpAgent {
return Ok(acp::PromptResponse::new(acp::StopReason::EndTurn));
}
}
let intake_lock = self.prompt_intake_lock(&arguments.session_id);
let intake_guard = intake_lock.lock().await;
let dispatch_lock = self.dispatch_lock(&arguments.session_id);
let dispatch_guard = dispatch_lock.lock().await;
let meta_prompt_mode = arguments
.meta
.as_ref()
@@ -1830,7 +1830,7 @@ impl acp::Agent for MvpAgent {
acp::Error::internal_error()
.data(format!("failed to dispatch prompt to session: {e}"))
})?;
drop(intake_guard);
drop(dispatch_guard);
self.push_roster_activity_delta(
&arguments.session_id,
crate::agent::roster::RosterActivity::Working,
@@ -2025,6 +2025,11 @@ impl acp::Agent for MvpAgent {
.and_then(|m| m.get("rewindIfPristine"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Ordered behind any in-flight prompt intake: a cancel that
// overtakes the prompt it targets lands on an idle actor, and the
// prompt then starts a turn nothing will ever cancel.
let dispatch_lock = self.dispatch_lock(&args.session_id);
let _dispatch_guard = dispatch_lock.lock().await;
let _ = handle
.cmd_tx
.send(SessionCommand::Cancel {
@@ -357,6 +357,11 @@ impl MvpAgent {
// time, so advertise pre-session; the in-session path
// re-checks the live toolset.
graph: goal && self.cfg.borrow().resolve_graph().value,
// Tool-dependent, so fail closed like every other tool gate: the
// builder strips `agent_swarm` whenever subagents are unavailable,
// and advertising it then offers a menu entry that resolves to
// literal prompt text.
swarm: self.cfg.borrow().subagents_enabled,
..crate::session::slash_commands::CommandAvailability::default()
}
}
@@ -973,7 +978,10 @@ impl MvpAgent {
sessions: RefCell::new(HashMap::new()),
activity,
loading_sessions: RefCell::new(HashMap::new()),
prompt_intake_locks: RefCell::new(HashMap::new()),
dispatch_locks: RefCell::new(HashMap::new()),
subagent_limits:
kigi_tools::implementations::kigi::task::admission::SubagentLimits::from_env(),
subagent_spawn_permits: RefCell::new(HashMap::new()),
session_threads: RefCell::new(HashMap::new()),
resident_roster_titles: RefCell::new(HashMap::new()),
initialize_request: OnceLock::new(),
@@ -2178,6 +2186,7 @@ impl MvpAgent {
.cfg
.borrow()
.resolve_compaction_verbatim_input();
let compaction_tool_choice = self.cfg.borrow().resolve_compaction_tool_choice();
let two_pass_enabled = self.cfg.borrow().is_two_pass_compaction_enabled();
let auto_update = self.cfg.borrow().cli.auto_update;
let client_type = *self.client_type.borrow();
@@ -2563,6 +2572,7 @@ impl MvpAgent {
system_prompt_label,
compaction_mode,
compaction_verbatim_input,
compaction_tool_choice,
two_pass_enabled,
buffering_settings,
origin_client.clone(),
@@ -462,21 +462,30 @@ pub struct MvpAgent {
loading_sessions: RefCell<
HashMap<acp::SessionId, tokio::sync::watch::Receiver<bool>>,
>,
/// Per-session prompt-intake serialization lock. LEADER-SAFE(per-session):
/// keyed by SessionId, mirrors `sessions` lifecycle.
///
/// Each incoming `session/prompt` RPC is dispatched as its own task by the
/// ACP message loop, and [`Self::prompt`] runs an async preamble (prompt-mode
/// query, trace context, model lookup) BEFORE it enqueues
/// `SessionCommand::Prompt` onto the actor's FIFO mailbox. Without
/// serialization those preambles interleave across tasks, so the mailbox —
/// and therefore the authoritative prompt queue — receives prompts out of
/// submission order. `prompt()` holds this lock across the preamble and
/// releases it immediately after the enqueue (the turn itself runs unlocked),
/// which makes intake order match arrival order.
prompt_intake_locks: RefCell<
/// Per-session lock ordering dispatch onto the actor's mailbox:
/// [`Self::prompt`] holds it across its intake preamble (prompt-mode
/// query, trace context, model lookup) and releases it right after the
/// enqueue, so prompts land in submission order; `cancel` holds it
/// around its `Cancel` send, so a cancel cannot overtake the prompt it
/// targets and wedge the turn slot (see
/// `cancel_never_overtakes_in_flight_prompt_intake`). Cancels wait out
/// preambles held ahead of them — keep preambles lean.
/// LEADER-SAFE(per-session): mirrors `sessions` lifecycle.
dispatch_locks: RefCell<
HashMap<acp::SessionId, std::rc::Rc<tokio::sync::Mutex<()>>>,
>,
/// Session-scoped subagent spawn limits, read once from env at
/// construction (composition root).
subagent_limits: kigi_tools::implementations::kigi::task::admission::SubagentLimits,
/// Per-parent-session spawn semaphores sized to `subagent_limits`; a
/// wide fan-out queues on the semaphore instead of exhausting file
/// descriptors. Keyed by session id string (the coordinator's
/// `parent_session_id`). Entries deliberately live for the whole
/// process: a removed-then-resumed session keeps its id, so sharing one
/// semaphore across generations keeps the limit correct while old
/// children still run. Growth is bounded by distinct sessions per
/// process and each entry is tiny. LEADER-SAFE(per-session).
subagent_spawn_permits: RefCell<HashMap<String, std::sync::Arc<tokio::sync::Semaphore>>>,
/// LEADER-SAFE(per-session): keyed by SessionId. Mirrors `sessions` lifecycle.
session_threads: RefCell<HashMap<acp::SessionId, SessionThread>>,
/// Title per resident session id, refreshed each `build_roster`. Lets the
@@ -717,10 +726,18 @@ pub struct MvpAgent {
/// sampling client is discarded, but the TLS root certificates it loads
/// are cached at the process level by `rustls-native-certs`.
pub fn warm_async_http_client() {
std::thread::spawn(|| {
let _timer = crate::instrumentation_timer!("startup.async_http_warmup");
let _ = crate::http::shared_client();
});
// Recoverable spawn: the warmup is an optimization that runs before the
// runtime builds — an out-of-threads host must reach the runtime's own
// diagnostic, not panic here.
let spawned = std::thread::Builder::new()
.name("http-warmup".into())
.spawn(|| {
let _timer = crate::instrumentation_timer!("startup.async_http_warmup");
let _ = crate::http::shared_client();
});
if let Err(e) = spawned {
tracing::warn!(error = %e, "async HTTP warmup thread spawn failed; first request warms instead");
}
}
pub(crate) fn resolve_required_agent_type(
model_agent_type: Option<&str>,
@@ -38,19 +38,16 @@ impl MvpAgent {
/// drops the `session_live_state` entry so that map stays bounded.
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
self.sessions.borrow_mut().remove(id);
self.prompt_intake_locks.borrow_mut().remove(id);
self.dispatch_locks.borrow_mut().remove(id);
self.session_threads.borrow_mut().remove(id);
self.session_index_claims.borrow_mut().remove(id);
self.require_gateway_sessions.borrow_mut().remove(id);
self.session_live_state.borrow_mut().remove(id);
}
/// Get-or-create the per-session prompt-intake lock (see
/// [`Self::prompt_intake_locks`]).
pub(super) fn prompt_intake_lock(
&self,
id: &acp::SessionId,
) -> std::rc::Rc<tokio::sync::Mutex<()>> {
self.prompt_intake_locks
/// Get-or-create the per-session dispatch lock (see
/// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`.
pub(super) fn dispatch_lock(&self, id: &acp::SessionId) -> std::rc::Rc<tokio::sync::Mutex<()>> {
self.dispatch_locks
.borrow_mut()
.entry(id.clone())
.or_default()
@@ -272,6 +272,19 @@ impl MvpAgent {
self.try_build_subagent_spawn_context(parent_session_id)
.expect("parent session must exist when spawning subagents")
}
/// Get-or-create the parent session's spawn semaphore (see
/// [`Self::subagent_spawn_permits`]). Cheap clone of the shared `Arc`.
fn subagent_spawn_permit(&self, parent_session_id: &str) -> std::sync::Arc<tokio::sync::Semaphore> {
self.subagent_spawn_permits
.borrow_mut()
.entry(parent_session_id.to_string())
.or_insert_with(|| {
std::sync::Arc::new(tokio::sync::Semaphore::new(
self.subagent_limits.effective_max_concurrent(),
))
})
.clone()
}
/// Fallible variant of [`Self::build_subagent_spawn_context`]: returns
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
/// instead of panicking, so read-only paths that can race a teardown can
@@ -387,6 +400,8 @@ impl MvpAgent {
};
Some(crate::agent::subagent::SubagentSpawnContext {
lsp: parent_lsp,
spawn_limits: self.subagent_limits,
spawn_permits: self.subagent_spawn_permit(parent_session_id),
gateway: self.gateway.clone(),
client_hooks: Default::default(),
sampling_config: self.sampling_config.borrow().clone(),
@@ -2152,6 +2152,59 @@ fn cancel_does_not_forward_to_bridge_in_local_mode() {
);
});
}
/// Regression (post-cancel slot hang): a cancel racing the prompt it targets
/// must land on the actor mailbox AFTER that prompt, or it hits an idle
/// actor as a no-op and the prompt then starts a turn nothing cancels.
/// The dispatch lock (`dispatch_locks`) provides that ordering.
#[test]
fn cancel_never_overtakes_in_flight_prompt_intake() {
use crate::session::SessionCommand;
use acp::Agent as _;
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-cancel-intake-race");
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let order: std::rc::Rc<std::cell::RefCell<Vec<&'static str>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let (intake_parked_tx, intake_parked_rx) = tokio::sync::oneshot::channel::<()>();
let driver_order = order.clone();
tokio::task::spawn_local(async move {
let mut intake_parked_tx = Some(intake_parked_tx);
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
SessionCommand::GetCurrentPromptMode { .. } => {
if let Some(tx) = intake_parked_tx.take() {
let _ = tx.send(());
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
SessionCommand::Prompt { .. } => driver_order.borrow_mut().push("prompt"),
SessionCommand::Cancel { .. } => driver_order.borrow_mut().push("cancel"),
_ => {}
}
}
});
let prompt_fut = agent.prompt(acp::PromptRequest::new(
sid.clone(),
vec![acp::ContentBlock::from("hi")],
));
let cancel_fut = async {
intake_parked_rx
.await
.expect("prompt intake reaches the fake actor");
let _ = agent
.cancel(acp::CancelNotification::new(sid.clone()))
.await;
};
let _ = futures::join!(prompt_fut, cancel_fut);
assert_eq!(
order.borrow().as_slice(),
["prompt", "cancel"],
"cancel must land on the actor mailbox after the prompt it targets"
);
});
}
use crate::session::SessionCommand as TestSessionCommand;
/// Build a session handle wired to a *live* command channel. Returns the
/// handle (move into `sessions`) plus a probe `cmd_tx`/`cmd_rx` so a test
@@ -60,7 +60,6 @@ fn effort_label(effort: ReasoningEffort) -> String {
ReasoningEffort::High => "High",
ReasoningEffort::Xhigh => "X-High",
ReasoningEffort::Max => "Max",
ReasoningEffort::Ultra => "Ultra",
}
.to_string()
}
@@ -81,6 +81,64 @@ pub(crate) async fn handle_subagent_request(
}
_ => {}
}
// Session-scoped concurrency admission: hold one permit for the whole
// child run so a wide fan-out queues (default) or fails (configured)
// instead of exhausting file descriptors. Queued spawns are not yet
// visible in the coordinator; they admit in FIFO order.
use kigi_tools::implementations::kigi::task::admission::LimitBehavior;
let _spawn_permit = match ctx.spawn_limits.behavior {
LimitBehavior::Fail => match ctx.spawn_permits.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
let limit = ctx.spawn_limits.effective_max_concurrent();
kigi_log::unified_log::warn(
"subagent.spawn.rejected_at_concurrent_limit",
Some(&ctx.parent_session_id),
Some(serde_json::json!({ "limit": limit })),
);
let msg = format!(
"Session subagent limit reached ({limit} running); \
retry after one finishes, or raise KIGI_MAX_CONCURRENT_SUBAGENTS"
);
send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway);
return;
}
},
LimitBehavior::Queue => {
if ctx.spawn_permits.available_permits() == 0 {
kigi_log::unified_log::warn(
"subagent.spawn.queued_at_concurrent_limit",
Some(&ctx.parent_session_id),
Some(serde_json::json!({
"limit": ctx.spawn_limits.effective_max_concurrent(),
"subagent_id": request.id,
})),
);
}
ctx.spawn_permits
.clone()
.acquire_owned()
.await
.expect("subagent spawn semaphore is never closed")
}
};
// The parent can end while a spawn is queued on the semaphore (a queued
// request is not yet in the coordinator, so the teardown cancel sweep
// cannot see it). A closed parent command channel is the teardown
// signal. The channel closes when the actor finishes shutting down, so a
// permit granted during that brief drain can still slip through — the
// same in-flight window an ordinary spawn racing teardown already has;
// closing it fully would require queueing inside the coordinator.
if ctx.parent_cmd_tx.as_ref().is_some_and(|tx| tx.is_closed()) {
send_pre_spawn_failure(
request,
"Parent session ended while the spawn was queued at the subagent limit.",
coordinator,
&ctx,
gateway,
);
return;
}
let run_in_background = request.run_in_background
|| definition.background.unwrap_or(false);
let cancel_token = CancellationToken::new();
@@ -396,11 +454,14 @@ pub(crate) async fn handle_subagent_request(
let child_depth = ctx.parent_depth + 1;
if child_depth >= MAX_SUBAGENT_DEPTH {
let before = definition.tool_config.tools.len();
definition.tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task));
definition
.tool_config
.tools
.retain(|tc| !matches!(tc.kind, Some(ToolKind::Task | ToolKind::AgentSwarm)));
if definition.tool_config.tools.len() < before {
tracing::info!(
subagent_id = % request.id, child_depth, max_depth =
MAX_SUBAGENT_DEPTH, "Stripped task tool from child at max depth"
MAX_SUBAGENT_DEPTH, "Stripped subagent-spawning tools from child at max depth"
);
}
prune_orphaned_background_task_tools(&mut definition.tool_config);
@@ -1003,6 +1064,7 @@ pub(crate) async fn handle_subagent_request(
kigi_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
kigi_chat_state::CompactionMode::Summary,
ctx.resolve_compaction_verbatim_input(),
ctx.resolve_compaction_tool_choice(),
false,
None,
None,
@@ -1322,6 +1384,7 @@ pub(crate) async fn handle_subagent_request(
SubagentResult {
success: false,
cancelled: true,
rate_limited: false,
error: Some(reason),
output: if final_text.is_empty() {
std::sync::Arc::from(
@@ -1359,6 +1422,7 @@ pub(crate) async fn handle_subagent_request(
SubagentResult {
success: false,
cancelled: true,
rate_limited: false,
error: Some(format!("max turns reached (limit: {limit})")),
output: if final_text.is_empty() {
std::sync::Arc::from(
@@ -1413,6 +1477,9 @@ pub(crate) async fn handle_subagent_request(
SubagentResult {
success: false,
cancelled: was_cancelled,
rate_limited: !was_cancelled
&& i32::from(e.code)
== crate::sampling::error::RATE_LIMITED_ERROR_CODE,
error: Some(
if was_cancelled {
"Subagent was cancelled".to_string()
@@ -133,6 +133,13 @@ impl AutoCompactThresholdTiers {
pub(crate) struct SubagentSpawnContext {
/// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal.
pub lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
/// Session-scoped spawn limits (env-resolved once at the composition root).
pub spawn_limits: kigi_tools::implementations::kigi::task::admission::SubagentLimits,
/// Parent session's spawn semaphore, sized to
/// `spawn_limits.effective_max_concurrent()`. A permit is held for the
/// whole child run, so a wide fan-out queues (or fails, per behavior)
/// instead of exhausting file descriptors.
pub spawn_permits: std::sync::Arc<tokio::sync::Semaphore>,
#[expect(
dead_code,
reason = "unused in production; remove expect when wired or delete the item"
@@ -397,6 +404,21 @@ impl SubagentSpawnContext {
.resolve()
.value
}
/// Subagent summarizer `tool_choice`, mirroring
/// `Config::resolve_compaction_tool_choice` (env > config > remote
/// settings > default `auto`).
pub fn resolve_compaction_tool_choice(&self) -> crate::util::config::CompactionToolChoice {
crate::util::config::resolve_compaction_tool_choice_from(
crate::agent::config::env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE)
.as_deref(),
self.agent_config
.as_ref()
.and_then(|c| c.features.compaction_tool_choice.as_deref()),
self.remote_settings
.as_ref()
.and_then(|r| r.compaction_tool_choice.as_deref()),
)
}
/// Whether a completed subagent's worktree is snapshotted into a durable ref
/// and its directory deleted. Resolution mirrors the other subagent gates
/// (env > config > remote settings > default). Default `false` so it ships dark;
@@ -83,6 +83,35 @@ impl AuthCredentialProvider for ShellAuthCredentialProvider {
self.auth_manager.try_recover_unauthorized().await
}
}
/// The memory-embedding credentials for `embed_base_url`, decided by the ONE
/// authority rather than re-derived here (C1).
///
/// `embed_base_url` is the session model's own endpoint, so on a BYOK or
/// subscription-OAuth model it points at that provider's host — and
/// [`kigi_auth::AuthRetryMiddleware`] stamps `Authorization` on every request
/// it wraps. Asking [`CredentialAuthority::manager_for`] answers both halves at
/// once: whether a session credential may ride there at all, and WHICH manager
/// governs it (a subscription-OAuth platform's pooled manager at its own host,
/// the primary at the session's coding endpoint). No manager means no session
/// credential at all; the platform's own `embed_api_key` is untouched and keeps
/// serving its own endpoint.
///
/// The session's `SharedApiKeyProvider` is deliberately NOT forwarded: it is
/// hard-wired to the PRIMARY manager, so at a pooled platform's host — where a
/// credential may ride, but only that platform's own — it would resolve the
/// wrong bearer.
pub(crate) fn embedding_session_credentials(
embed_base_url: &str,
platform: Option<kigi_models::PlatformId>,
authority: &crate::auth::credential_authority::CredentialAuthority,
) -> kigi_memory::EndpointScopedCredentials {
let auth_credentials = authority.manager_for(platform, embed_base_url).map(|am| {
Arc::new(ShellAuthCredentialProvider::new(am, None, None))
as Arc<dyn AuthCredentialProvider>
});
let may_ride = auth_credentials.is_some();
kigi_memory::EndpointScopedCredentials::for_endpoint(embed_base_url, may_ride, auth_credentials)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -253,6 +282,38 @@ mod tests {
"snapshot must reflect refreshed token for subsequent apply() calls"
);
}
/// C1: memory embeddings follow the credential authority, not the session.
///
/// A session whose model is a BYOK platform aims `embed_base_url` at that
/// provider's host; the authority answers "no manager governs a credential
/// there", so nothing rides. The session's own coding endpoint still does.
#[test]
fn embedding_credentials_follow_the_credential_authority() {
let _guard = EarlyInvalidationGuard::pin_to_default();
let dir = tempfile::tempdir().unwrap();
let mgr = make_manager(
&dir,
Some(make_auth("session-bearer", ChronoDuration::hours(1))),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let coding_endpoint = endpoints.proxy_url();
let authority =
crate::auth::credential_authority::CredentialAuthority::new(endpoints, Some(mgr));
assert!(
!embedding_session_credentials(&coding_endpoint, None, &authority).is_empty(),
"the session's own coding endpoint keeps its credential"
);
assert!(
embedding_session_credentials(
"https://api.anthropic.com/v1",
kigi_models::PlatformId::parse("anthropic"),
&authority,
)
.is_empty(),
"an API-key platform's host must receive no session credential"
);
}
/// Deployment-key path has no recovery (operator owns the bearer).
#[tokio::test]
async fn refresh_after_unauthorized_is_noop_for_deployment_key() {
+154 -12
View File
@@ -224,6 +224,18 @@ impl AuthFileLock {
pub fn read_auth_json(auth_file: &Path) -> std::io::Result<AuthStore> {
let mut file = File::open(auth_file)?;
// Tighten world-readable copies (hand-restored, umask edge cases, etc.)
// right after open, so even an unreadable/invalid file gets fixed.
// Best-effort: a chmod failure must not block login/read paths.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
path = %auth_file.display(),
error = %e,
"auth: failed to enforce owner-only permissions on auth.json"
);
}
let mut contents = String::new();
file.read_to_string(&mut contents)?;
@@ -289,6 +301,14 @@ pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option<PathBuf> {
match std::fs::rename(path, &backup) {
Ok(()) => {
// Corrupt backups still hold token material — keep them owner-only.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(&backup) {
tracing::warn!(
path = %backup.display(),
error = %e,
"auth: failed to tighten permissions on corrupt-auth backup"
);
}
tracing::warn!(
original = %path.display(),
backup = %backup.display(),
@@ -421,18 +441,59 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
.into_inner()
.map_err(|e| e.into_error())?
.sync_all()?;
#[cfg(windows)]
{
crate::util::secure_file::set_windows_secure_permissions(path)?;
// `open_secure_file` mode bits apply only on create; tighten existing
// paths. Best-effort after durable content: a chmod-only failure must not
// look like a failed write — the in-place fallback restores the prior
// snapshot on any `write_store_to` Err, which would discard freshly
// written tokens. The load path re-tightens on the next read.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(path) {
tracing::warn!(
error = %e,
path = %path.display(),
"auth: failed to ensure owner-only permissions after write"
);
}
Ok(())
}
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`).
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
write_store_to(&tmp, auth_store)?;
crate::util::fs::replace_file(&tmp, auth_file)
write_auth_json_atomic_with(auth_file, auth_store, write_store_to)
}
/// Inner of [`write_auth_json_atomic`] with the tmp-file writer injectable so
/// the torn-tmp-write path is unit-testable without real I/O faults.
fn write_auth_json_atomic_with(
auth_file: &Path,
auth_store: &AuthStore,
write: fn(&Path, &AuthStore) -> std::io::Result<()>,
) -> std::io::Result<()> {
// Unique per write (pid + monotonic seq): two concurrent in-process
// writers (e.g. background mint + proactive refresher) must not share
// one tmp path, or each can tear the other's half-written bytes.
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let tmp = auth_file.with_extension(format!(
"json.{}.{}.tmp",
std::process::id(),
TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
if let Err(e) = write(&tmp, auth_store) {
// Reclaim the partial tmp; the unique name otherwise accumulates
// one orphan per failed write. `replace_file` cleans its own leg.
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
crate::util::fs::replace_file(&tmp, auth_file)?;
// Re-assert on the final path (covers rename edge cases / FS quirks).
// Best-effort: the replace already published the new tokens.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
error = %e,
path = %auth_file.display(),
"auth: failed to ensure owner-only permissions after replace"
);
}
Ok(())
}
/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
@@ -486,10 +547,7 @@ fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file = open_secure_file(auth_file)?;
file.write_all(bytes)?;
file.sync_all()?;
#[cfg(windows)]
{
crate::util::secure_file::set_windows_secure_permissions(auth_file)?;
}
crate::util::secure_file::ensure_owner_only_permissions(auth_file)?;
Ok(())
}
@@ -804,7 +862,87 @@ mod write_fallback_tests {
);
}
/// Rollback after a failed write must keep the file owner-only (0o600).
/// An in-place rewrite over a pre-existing world-readable auth.json must
/// tighten it to 0o600 — `open_secure_file` mode bits apply only on
/// create, so truncating an existing loose file keeps its old mode. The
/// in-place path is the one that reuses the inode (the atomic path
/// replaces it with a fresh 0o600 temp file).
#[cfg(unix)]
#[test]
fn in_place_write_tightens_preexisting_world_readable_auth_json() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, b"{}").unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
write_auth_json_in_place(&path, &sample_store()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"in-place rewrite must tighten preexisting open perms"
);
}
/// Simulates a torn tmp write: partial bytes land at the target path,
/// then the write fails — the temp file must not be left behind.
fn fake_partial_write_then_fail(path: &Path, _: &AuthStore) -> std::io::Result<()> {
std::fs::write(path, b"{ torn")?;
Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
}
/// A tmp write that fails partway must reclaim the temp file; the unique
/// tmp name otherwise accumulates one orphan per failed write.
#[test]
fn atomic_write_reclaims_tmp_when_store_write_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let err = write_auth_json_atomic_with(&path, &sample_store(), fake_partial_write_then_fail)
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::StorageFull);
let orphans: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
orphans.is_empty(),
"failed tmp write must not leave temp files behind: {orphans:?}"
);
}
/// The replace step failing (target path occupied by a directory) must
/// also leave no temp file — `replace_file` reclaims its tmp on failure.
#[test]
fn atomic_write_reclaims_tmp_when_replace_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::create_dir(&path).unwrap();
assert!(
write_auth_json_atomic(&path, &sample_store()).is_err(),
"replacing a directory must fail"
);
let orphans: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
orphans.is_empty(),
"failed replace must not leave temp files behind: {orphans:?}"
);
}
/// Rollback after a failed write must leave the file owner-only (0o600)
/// even when the file had loose permissions going in — the restore path
/// truncates the existing inode, which keeps its old mode unless
/// explicitly tightened.
#[cfg(unix)]
#[test]
fn in_place_restore_is_owner_only() {
@@ -812,9 +950,13 @@ mod write_fallback_tests {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
write_auth_json_in_place(&path, &sample_store()).unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
let _ = write_auth_json_in_place_with(&path, &sample_store(), fake_truncate_then_fail);
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "restored file must stay 0o600");
assert_eq!(mode & 0o777, 0o600, "restored file must be 0o600");
}
}
@@ -121,6 +121,10 @@ mod model_switch;
mod prompt_queue;
#[path = "acp_session_impl/slash_exec.rs"]
mod slash_exec;
#[path = "acp_session_impl/stationarity_seam.rs"]
mod stationarity_seam;
#[path = "acp_session_impl/swarm.rs"]
mod swarm;
use super::PromptOrigin;
use super::acp_types;
use super::chat_persistence;
@@ -616,6 +620,9 @@ pub(crate) struct SessionActor {
/// layered over the goal engine. Modeled after `goal_tracker` above;
/// all graph state logic lives in `graph_tracker.rs`.
pub(crate) graph_tracker: Arc<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
/// Swarm mode: a standing instruction to fan work out, independent of the
/// goal/graph engines — it steers tool choice, it does not drive turns.
pub(crate) swarm_mode: std::cell::Cell<crate::session::swarm_mode::SwarmMode>,
/// Max graph nodes running concurrently (1 = serial G0 behavior).
/// Cached at actor construction from `resolve_graph_concurrency`.
pub(crate) graph_concurrency: u32,
@@ -1033,6 +1040,9 @@ impl SessionActor {
// Graph rides the goal harness: nodes execute as goals, so
// `/graph` is only real when `/goal` is.
graph: self.graph_enabled && goal,
swarm: tool_names
.iter()
.any(|n| n == kigi_tools::implementations::kigi::AGENT_SWARM_TOOL_NAME),
}
}
/// Names of every tool registered with the session's tool bridge.
@@ -8,7 +8,12 @@ pub(super) fn turn_result_to_hook_outcome(
) -> kigi_tool_protocol::turn_hook::TurnHookOutcome {
use kigi_tool_protocol::turn_hook::TurnHookOutcome;
match result {
Ok(TurnOutcome::Completed { .. }) => TurnHookOutcome::Completed,
// A stationarity halt is a completed turn for hook purposes: nobody
// cancelled it, and a `Cancelled` outcome would tell every Stop hook
// the user interrupted the model.
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
TurnHookOutcome::Completed
}
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => {
TurnHookOutcome::Cancelled
}
@@ -709,6 +709,36 @@ impl SessionActor {
BuiltinAction::GoalSet { .. } => {
unreachable!("GoalSet is intercepted in handle_prompt")
}
BuiltinAction::SwarmSet { enabled } => {
let msg = self.apply_swarm_mode(
enabled.then_some(crate::session::swarm_mode::SwarmTrigger::Manual),
);
self.send_slash_command_output(&msg).await;
ok_end_turn(0, None)
}
BuiltinAction::SwarmToggle => {
let turning_on = !self.swarm_mode.get().is_active();
let msg = self.apply_swarm_mode(
turning_on.then_some(crate::session::swarm_mode::SwarmTrigger::Manual),
);
self.send_slash_command_output(&msg).await;
ok_end_turn(0, None)
}
// `/swarm <task>` is handled before dispatch (it has to seed the
// turn with the task text); reaching here means the interception
// was bypassed, so report rather than silently dropping the task.
BuiltinAction::SwarmTask { prompt } => {
tracing::warn!(
prompt_len = prompt.len(),
"/swarm <task> reached the builtin executor; the turn seam did not intercept it"
);
self.send_slash_command_output(
"Could not start a swarm turn for that task. Run `/swarm on` and send the \
task as a normal message.",
)
.await;
ok_end_turn(0, None)
}
BuiltinAction::GoalStatus => {
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
let goal_tokens = self.goal_tokens_used(current_tokens);
@@ -112,6 +112,7 @@ pub(crate) async fn spawn_session_actor(
system_prompt_label: String,
compaction_mode: kigi_chat_state::CompactionMode,
compaction_verbatim_input: bool,
compaction_tool_choice: crate::util::config::CompactionToolChoice,
two_pass_enabled: bool,
buffering_settings: Option<BufferingSettings>,
origin_client: Option<crate::http::OriginClientInfo>,
@@ -311,6 +312,18 @@ pub(crate) async fn spawn_session_actor(
};
let embed_base_url = sampling_config.base_url.clone();
let embed_api_key = sampling_config.api_key.clone();
// The platform behind the endpoint memory embeddings will call, resolved
// through the SAME session-key disambiguation the actor is seeded with, so
// a slug that collides across platforms cannot resolve to the twin (H-b).
let embed_platform = {
let models = models_manager.models();
crate::agent::models::platform_for_slug(
&models,
crate::agent::models::selected_catalog_key_for_spawn(&models, &session_model_id)
.as_deref(),
&sampling_config.model,
)
};
let session_pruning_config: crate::config::PruningConfig = memory_config.as_ref().map_or_else(
|| crate::config::PruningConfig {
enabled: false,
@@ -618,16 +631,11 @@ pub(crate) async fn spawn_session_actor(
watcher,
stale_claim_secs: watcher_config.stale_claim_secs,
search_source: "tool",
api_key_provider: api_key_provider.clone(),
auth_credentials: auth_manager.as_ref().map(|am| {
std::sync::Arc::new(
crate::auth::credential_provider::ShellAuthCredentialProvider::new(
am.clone(),
None,
None,
),
) as std::sync::Arc<dyn kigi_auth::AuthCredentialProvider>
}),
embedding_credentials: crate::auth::credential_provider::embedding_session_credentials(
&embed_base_url,
embed_platform,
&models_manager.credential_authority(),
),
};
let backend = crate::session::memory::MemoryBackendImpl::from_session_params(
storage.clone(),
@@ -1025,6 +1033,7 @@ pub(crate) async fn spawn_session_actor(
previous_model: std::cell::Cell::new(None),
compaction_mode,
verbatim_input: compaction_verbatim_input,
tool_choice: compaction_tool_choice,
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -1108,6 +1117,7 @@ pub(crate) async fn spawn_session_actor(
goal_tracker,
graph_enabled,
graph_tracker,
swarm_mode: std::cell::Cell::new(Default::default()),
graph_concurrency: effective_config.resolve_graph_concurrency(),
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
@@ -1287,8 +1297,11 @@ pub(crate) async fn spawn_session_actor(
.map(|mc| mc.embedding.clone())
.unwrap_or_default();
let embed_dims = embed_config.dimensions;
let sampling_base_url = embed_base_url.clone();
let sampling_api_key = embed_api_key.clone();
// The session's own params, so the background reindex embeds through
// the same endpoint-scoped credential as every foreground path — a
// second locally-built provider would re-derive credentials outside
// the chokepoint and would carry a static key with no 401 refresh.
let reindex_params = session.memory.backend_params.clone();
let session_id_for_reindex = session_info.id.to_string();
let chunks_added_counter = session.memory.chunks_added.clone();
tokio::task::spawn_local(async move {
@@ -1311,13 +1324,8 @@ pub(crate) async fn spawn_session_actor(
target : kigi_log::memory_log::TARGET, files = files.len(),
"MEMORY_REINDEX: background reindex complete"
);
if let Some(api_key) = sampling_api_key
&& let Some(provider) =
crate::session::memory::embedding::ApiEmbeddingProvider::from_session(
&embed_config,
sampling_base_url,
api_key,
)
if let Some(ref params) = reindex_params
&& let Some(provider) = params.make_embedding_provider().await
{
crate::session::memory::embed_missing_chunks(&index, &provider).await;
}
@@ -1529,6 +1537,7 @@ pub(crate) async fn spawn_session_on_thread(
system_prompt_label: String,
compaction_mode: kigi_chat_state::CompactionMode,
compaction_verbatim_input: bool,
compaction_tool_choice: crate::util::config::CompactionToolChoice,
two_pass_enabled: bool,
buffering_settings: Option<BufferingSettings>,
origin_client: Option<crate::http::OriginClientInfo>,
@@ -1630,10 +1639,23 @@ pub(crate) async fn spawn_session_on_thread(
};
(initial_last_compaction, initial_prompt_texts)
};
let rt = tokio::runtime::Builder::new_current_thread()
// Runtime construction acquires fds (epoll/kqueue, waker) and
// fails with EMFILE/EAGAIN under resource pressure — report
// through the init channel instead of panicking the thread.
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("session runtime");
{
Ok(rt) => rt,
Err(e) => {
tracing::error!(
error = %e,
"failed to build session runtime (resource exhaustion?)"
);
let _ = init_tx.send(Err(kigi_agent::AgentBuildError::RuntimeBuild(e)));
return;
}
};
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
let _trace_span = parent_traceparent.as_ref().map(|tp| {
@@ -1673,6 +1695,7 @@ pub(crate) async fn spawn_session_on_thread(
system_prompt_label,
compaction_mode,
compaction_verbatim_input,
compaction_tool_choice,
two_pass_enabled,
buffering_settings,
origin_client,
@@ -1753,8 +1776,19 @@ pub(crate) async fn spawn_session_on_thread(
}));
let _ = session_done_rx.await;
});
})
.expect("spawn session thread");
});
let join_handle = match join_handle {
Ok(handle) => handle,
Err(e) => {
tracing::error!(
error = %e,
"failed to spawn session thread (thread/PID limit or memory pressure?)"
);
return Err(
acp::Error::internal_error().data(format!("failed to spawn session thread: {e}"))
);
}
};
let init = init_rx
.await
.map_err(|_| {
@@ -0,0 +1,120 @@
//! Turn-loop seam for the stationarity detector.
use super::*;
use crate::session::stationarity::{
IdenticalToolCallRun, NUDGE_AFTER_IDENTICAL_TOOL_CALLS, command_is_true, hash_batch,
};
/// Sent once, naming the blocking wait a poller should use.
const STATIONARITY_NUDGE: &str = "\
You have called the same tool with the same arguments repeatedly and are in a \
polling loop. Stop repeating that call. If you are waiting on a background \
task, block on it with the wait-tasks tool instead of re-checking its output; \
if you are waiting on anything else, sleep once and check once. If you cannot \
make progress, stop and tell the user what you are waiting for. This turn will \
be halted automatically if the identical call keeps repeating.";
impl SessionActor {
/// Enforces the ceilings; `Some` halts the turn. Silent but logged.
pub(crate) async fn observe_tool_call_stationarity(
self: &Arc<Self>,
run: &mut IdenticalToolCallRun,
tool_calls: &[kigi_sampling_types::conversation::ToolCall],
loop_index: usize,
) -> Option<StationarityHalt> {
let batch_hash = hash_batch(
tool_calls
.iter()
.map(|tc| (tc.name.as_str(), tc.arguments.as_ref())),
);
let tool_name = tool_calls
.first()
.map(|tc| tc.name.clone())
.unwrap_or_default();
let is_true_noop = self.is_run_true_step(tool_calls).await;
let run_len = run.observe(batch_hash, &tool_name, is_true_noop);
if run_len == NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
tracing::warn!(
tool_name = %run.tool_name(),
run_len,
loop_index,
"action stationarity: nudging a repeating tool call"
);
kigi_log::unified_log::warn(
"shell.turn.action_stationarity_nudge",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!({
"tool_name": run.tool_name(),
"run_len": run_len,
"loop_index": loop_index,
})),
);
self.push_system_reminder(STATIONARITY_NUDGE);
}
if run_len < run.hard_stop_threshold() {
return None;
}
tracing::warn!(
tool_name = %run.tool_name(),
run_len,
loop_index,
true_noop = run.is_true_noop_run(),
"action stationarity: halting the turn"
);
kigi_log::unified_log::warn(
"shell.turn.action_stationarity_stop",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!({
"tool_name": run.tool_name(),
"run_len": run_len,
"loop_index": loop_index,
"true_noop": run.is_true_noop_run(),
})),
);
Some(StationarityHalt {
tool_name: run.tool_name().to_string(),
run_len,
true_noop: run.is_true_noop_run(),
})
}
/// Whether this batch is a single shell call that does nothing.
///
/// Size-gated, not name-gated: the shell tool is renamed
/// `run_terminal_command`, so a name gate would silently disable this.
/// Parse failure fails open; multi-call batches use the ordinary ceiling.
async fn is_run_true_step(
&self,
tool_calls: &[kigi_sampling_types::conversation::ToolCall],
) -> bool {
/// Bound on `{"command":"true"}` plus sibling fields.
const MAX_NOOP_ARGS_BYTES: usize = 512;
let [tc] = tool_calls else {
return false;
};
if tc.arguments.as_ref().len() > MAX_NOOP_ARGS_BYTES {
return false;
}
let Ok(args) = serde_json::from_str::<serde_json::Value>(tc.arguments.as_ref()) else {
return false;
};
let Ok(input) = self.tool_bridge_handle().try_parse(&tc.name, args).await else {
return false;
};
matches!(
input,
kigi_tools::types::tool_io::ToolInput::Bash(ref b) if command_is_true(&b.command)
)
}
}
/// Why a turn was halted, carried to its outcome.
pub(crate) struct StationarityHalt {
pub(crate) tool_name: String,
pub(crate) run_len: u32,
pub(crate) true_noop: bool,
}
@@ -0,0 +1,80 @@
//! Swarm-mode seam: arming/disarming the mode and the reminder it injects.
use super::*;
use crate::session::swarm_mode::{SWARM_ENTER_REMINDER, SWARM_EXIT_REMINDER, SwarmTrigger};
impl SessionActor {
/// Arms (`Some(trigger)`) or disarms (`None`) swarm mode, injecting or
/// retracting the doctrine exactly once, and returns the line to show.
pub(crate) fn apply_swarm_mode(self: &Arc<Self>, trigger: Option<SwarmTrigger>) -> String {
let mut mode = self.swarm_mode.get();
let message = match trigger {
Some(trigger) => {
if mode.enter(trigger) {
self.inject_swarm_reminder(SWARM_ENTER_REMINDER);
}
"Swarm mode on: work will be split across a fleet of subagents. \
`/swarm off` to stop."
}
None => {
// Unconditional, unlike the automatic expiry: the doctrine
// rides the conversation and therefore survives a resume, a
// fork and a compaction that the in-memory flag does not. If
// the user explicitly asks for it off, the retraction has to
// reach the model even when this session never saw it armed.
mode.exit();
self.inject_swarm_reminder(SWARM_EXIT_REMINDER);
"Swarm mode off."
}
};
self.swarm_mode.set(mode);
message.to_string()
}
/// A turn-scoped guard that disarms a per-turn swarm mode however the turn
/// ends.
///
/// The post-loop call site is not enough: a user interrupt ABORTS the turn
/// future (`cancel_running_task` → `JoinHandle::abort`), dropping it at its
/// current await point, and several `?` paths return before the loop's end.
/// Each of those leaks a `/swarm <task>` mode into the user's next,
/// unrelated prompt. The goal engine hit the same class and compensates
/// inside the cancel path; a guard is the version that cannot be forgotten
/// at a new exit.
pub(crate) fn swarm_turn_guard(self: &Arc<Self>) -> SwarmTurnGuard {
SwarmTurnGuard {
session: self.clone(),
}
}
/// Disarms at a turn boundary when the trigger was per-turn.
pub(crate) fn expire_swarm_mode_at_turn_end(self: &Arc<Self>) {
if !self.swarm_mode.get().expires_at_turn_end() {
return;
}
let mut mode = self.swarm_mode.get();
if mode.exit() {
self.inject_swarm_reminder(SWARM_EXIT_REMINDER);
}
self.swarm_mode.set(mode);
}
/// The doctrine rides the session's existing `<system-reminder>` channel,
/// so it is tagged the same way every other reminder is and needs no
/// second injection path of its own.
fn inject_swarm_reminder(self: &Arc<Self>, text: &str) {
self.push_system_reminder(text);
}
}
/// Runs [`SessionActor::expire_swarm_mode_at_turn_end`] on every turn exit,
/// including an aborted future.
pub(crate) struct SwarmTurnGuard {
session: Arc<SessionActor>,
}
impl Drop for SwarmTurnGuard {
fn drop(&mut self) {
self.session.expire_swarm_mode_at_turn_end();
}
}
@@ -1546,6 +1546,18 @@ impl SessionActor {
vec![],
vec![],
),
// Without an explicit arm the catch-all below titles this "Tool
// call" — for an entry that can hold the turn for the whole swarm.
ToolInput::AgentSwarm(swarm) => (
format!(
"{} ({} members)",
swarm.description,
swarm.items.len() + swarm.resume_agent_ids.len()
),
acp::ToolKind::Other,
vec![],
vec![],
),
ToolInput::EnterPlanMode(_) => (
"Plan: Enter".to_string(),
acp::ToolKind::Other,
@@ -217,6 +217,10 @@ impl SessionActor {
persist_ack: Option<oneshot::Sender<()>>,
) -> PromptTurnResult {
let handle_prompt_start = std::time::Instant::now();
// Armed before anything can arm swarm mode, so a `/swarm <task>` mode
// is disarmed on EVERY exit from this turn — including the abort a
// user interrupt performs, which never reaches post-loop code.
let _swarm_turn_guard = self.swarm_turn_guard();
let prompt_length: usize = prompt_blocks
.iter()
.map(|b| match b {
@@ -301,6 +305,13 @@ impl SessionActor {
span.record("command_source", "builtin");
}
match action {
// `/swarm <task>` arms the mode for this turn only and
// sends the task as the prompt, so the doctrine is in the
// conversation before the model reads the work.
BuiltinAction::SwarmTask { prompt } => {
self.apply_swarm_mode(Some(crate::session::swarm_mode::SwarmTrigger::Task));
vec![text_block(prompt)]
}
BuiltinAction::GoalSet {
objective,
token_budget,
@@ -779,7 +790,7 @@ impl SessionActor {
let turn_tool_count = self.events.tool_count_this_turn();
let bridge_outcome = turn_result_to_hook_outcome(&result);
match &result {
Ok(TurnOutcome::Completed { .. }) => {
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
self.emit_turn_ended(
crate::session::events::TurnOutcomeLabel::Completed,
None,
@@ -869,7 +880,9 @@ impl SessionActor {
let doom_tally = std::mem::take(&mut *self.doom_loop_turn_tally.lock());
doom_tally.fired();
let stop_reason_str = match &result {
Ok(TurnOutcome::Completed { .. }) => "end_turn",
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
"end_turn"
}
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => {
"cancelled"
}
@@ -885,7 +898,7 @@ impl SessionActor {
)
.await;
match &result {
Ok(TurnOutcome::Completed { .. }) => {
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
for contributor in self.extension_registry.turn_lifecycle_contributors() {
contributor
.on_turn_done(&kigi_agent_lifecycle::TurnDoneInput)
@@ -970,6 +983,26 @@ impl SessionActor {
PromptCompletionKind::MaxTurnsReached { limit },
None,
),
// `EndTurn`, not `Cancelled`: the client must not render
// an interrupted turn for something nobody interrupted.
// The halt's detail rides `completion_kind`, which is
// where a bug report can still read it.
TurnOutcome::StationarityHalted {
snapshot,
tool_name,
run_len,
true_noop,
..
} => (
acp::StopReason::EndTurn,
*snapshot,
PromptCompletionKind::StationarityHalted {
tool_name,
run_len,
true_noop,
},
None,
),
};
if let Some(snapshot) = snapshot.as_mut() {
self.apply_prompt_modes_to_snapshot(snapshot);
@@ -1271,7 +1304,11 @@ impl SessionActor {
let mut result = self
.process_conversation_turn(req_id, json_schema.clone())
.await;
if matches!(result, Ok(TurnOutcome::MaxTurnsReached { .. })) {
// Harness stopped the turn; retrying re-enters the same wall.
if matches!(
result,
Ok(TurnOutcome::MaxTurnsReached { .. }) | Ok(TurnOutcome::StationarityHalted { .. })
) {
return result;
}
if let Ok(TurnOutcome::Completed {
@@ -1588,6 +1625,10 @@ impl SessionActor {
self.record_turn_model().await;
let mut metrics_drop_guard = TurnMetrics::new();
let mut turn_tools_called: Vec<String> = Vec::new();
let mut identical_tool_calls =
crate::session::stationarity::IdenticalToolCallRun::default();
// Retained across execute: observed only after results land.
let mut last_batch: Vec<kigi_sampling_types::conversation::ToolCall> = Vec::new();
let mut tool_turn_count: usize = 1;
let mut loop_index: u32 = 0;
let mut todo_gate_fires: u32 = 0;
@@ -2004,6 +2045,8 @@ impl SessionActor {
}
turn_tools_called.push(tc.name.clone());
}
last_batch.clear();
last_batch.extend(tool_calls.iter().cloned());
let tool_call_responses: Vec<ToolCallResponse> = tool_calls
.into_iter()
.map(|tc| ToolCallResponse {
@@ -2046,6 +2089,34 @@ impl SessionActor {
}
_ => {}
}
// After execute: every call has a result, so nothing dangles
// and the nudge cannot trigger the "cancelled" repair.
// Ok-gated: one `?` inside can leave a call resultless.
if execute_tool_calls_result.is_ok()
&& let Some(halt) = self
.observe_tool_call_stationarity(
&mut identical_tool_calls,
&last_batch,
tool_turn_count,
)
.await
{
let snapshot = self
.finalize_turn_bookkeeping(
req_id,
conv_turn_start,
&turn_span_totals,
model_fingerprint.clone(),
)
.await;
return Ok(TurnOutcome::StationarityHalted {
snapshot: Box::new(snapshot),
tools_called: std::mem::take(&mut turn_tools_called),
tool_name: halt.tool_name,
run_len: halt.run_len,
true_noop: halt.true_noop,
});
}
let next_turn = tool_turn_count + 1;
if let Some(limit) = self.max_turns
&& next_turn > limit
@@ -62,6 +62,19 @@ pub(crate) enum TurnOutcome {
},
/// The `--max-turns` limit was reached after a tool-execution cycle.
MaxTurnsReached { limit: usize },
/// One tool call repeated past its ceiling; the turn was halted.
///
/// Groups with [`Self::Completed`], NOT [`Self::Cancelled`]: nobody
/// cancelled anything. As a cancellation it would report
/// `StopReason::Cancelled`, fire the abort lifecycle, kill the turn's
/// subagents, grow the goal back-off streak, and let recovery re-run it.
StationarityHalted {
snapshot: Box<Option<TurnDeltaSnapshot>>,
tools_called: Vec<String>,
tool_name: String,
run_len: u32,
true_noop: bool,
},
}
#[derive(Debug)]
@@ -106,6 +106,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
tokio_util::sync::CancellationToken::new(),
);
let actor = Arc::new(SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info,
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
@@ -156,6 +157,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -555,11 +557,11 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
watcher: None,
stale_claim_secs: 60,
search_source: "tool",
api_key_provider: None,
auth_credentials: None,
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
};
let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
let actor = Arc::new(SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: session_info.clone(),
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
@@ -610,6 +612,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -821,6 +824,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
)
.await;
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-cancel"),
cwd: cwd.as_str().to_string(),
@@ -869,6 +873,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -1814,6 +1819,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
)
.await;
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-cancel-sampler"),
cwd: cwd.as_str().to_string(),
@@ -1862,6 +1868,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -115,6 +115,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-idle-resume"),
cwd: cwd.as_str().to_string(),
@@ -175,6 +176,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -64,6 +64,7 @@ async fn create_test_actor(
);
chat_state_handle.record_token_usage(total_tokens);
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-auto-compact"),
cwd: cwd.as_str().to_string(),
@@ -111,6 +112,7 @@ async fn create_test_actor(
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -393,8 +395,7 @@ fn initial_injection_backend_params_use_override_min_score() {
watcher: None,
stale_claim_secs: 60,
search_source: "tool",
api_key_provider: None,
auth_credentials: None,
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
};
let initial_injection = crate::config::MemoryInitialInjectionConfig {
enabled: true,
@@ -422,8 +423,7 @@ fn initial_injection_backend_params_preserve_default_zero_min_score() {
watcher: None,
stale_claim_secs: 60,
search_source: "tool",
api_key_provider: None,
auth_credentials: None,
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
};
let (adjusted, effective_min_score) = build_initial_injection_backend_params(
&params,
@@ -495,6 +495,7 @@ async fn create_test_actor_with_memory(
.as_ref()
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-memory"),
cwd: cwd.as_str().to_string(),
@@ -539,6 +540,7 @@ async fn create_test_actor_with_memory(
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -1237,6 +1239,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-idle-resume"),
cwd: cwd.as_str().to_string(),
@@ -1297,6 +1300,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -18,8 +18,7 @@ fn initial_injection_backend_params_use_override_min_score() {
watcher: None,
stale_claim_secs: 60,
search_source: "tool",
api_key_provider: None,
auth_credentials: None,
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
};
let initial_injection = crate::config::MemoryInitialInjectionConfig {
enabled: true,
@@ -47,8 +46,7 @@ fn initial_injection_backend_params_preserve_default_zero_min_score() {
watcher: None,
stale_claim_secs: 60,
search_source: "tool",
api_key_provider: None,
auth_credentials: None,
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
};
let (adjusted, effective_min_score) = build_initial_injection_backend_params(
&params,
@@ -121,6 +119,7 @@ async fn create_test_actor_with_memory(
.as_ref()
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-memory"),
cwd: cwd.as_str().to_string(),
@@ -164,6 +163,7 @@ async fn create_test_actor_with_memory(
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -524,8 +524,7 @@ async fn create_injection_ready_actor(
watcher: None,
stale_claim_secs: 60,
search_source: "tool",
api_key_provider: None,
auth_credentials: None,
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
});
actor
.chat_state_handle
@@ -72,6 +72,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
});
let (event_tx, event_rx) = mpsc::unbounded_channel::<SessionEvent>();
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-session"),
cwd: cwd.as_str().to_string(),
@@ -115,6 +116,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -182,6 +182,7 @@ pub(crate) async fn create_test_actor_ex(
chat_state_handle.record_token_usage(total_tokens);
let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel();
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-actor"),
cwd: cwd.as_str().to_string(),
@@ -225,6 +226,7 @@ pub(crate) async fn create_test_actor_ex(
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -29,6 +29,13 @@ pub enum PromptCompletionKind {
MaxTurnsReached {
limit: usize,
},
/// One tool call repeated past its ceiling. Reported as `EndTurn`:
/// the turn ended, nobody interrupted it.
StationarityHalted {
tool_name: String,
run_len: u32,
true_noop: bool,
},
Rewound,
/// A queued prompt was removed (or cleared) from the server-authoritative
/// queue before it ever ran. Used to resolve the still-pending
@@ -212,6 +212,7 @@ impl SessionActor {
&sampling_config,
self.inference_idle_timeout,
wall_clock_budget_secs,
self.compaction.tool_choice,
)
.await
{
@@ -983,6 +984,7 @@ impl SessionActor {
sampling_config.clone(),
self.inference_idle_timeout,
wall_clock_budget_secs,
self.compaction.tool_choice,
);
let observer =
crate::session::helpers::full_replace_compaction::ShellFullReplaceObserver::new(
@@ -2154,6 +2156,7 @@ mod inline_auto_compact_flow_tests {
);
chat_state_handle.record_token_usage(total_tokens);
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-auto-compact"),
cwd: cwd.as_str().to_string(),
@@ -2197,6 +2200,7 @@ mod inline_auto_compact_flow_tests {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -146,6 +146,9 @@ pub struct CompactionConfig {
pub compaction_mode: kigi_chat_state::CompactionMode,
/// When `true`, feed the summarizer the verbatim conversation instead of the lossy rewrite (the retry loop may still fall back).
pub verbatim_input: bool,
/// `tool_choice` for summarizer requests with tools attached (`auto` |
/// `none`); some backends reject `"none"`.
pub tool_choice: crate::util::config::CompactionToolChoice,
/// Prefire two-pass state (background NOTE₁ cache + in-flight guard).
/// `Default` (empty cache, not in-flight).
pub prefire: PrefireState,
@@ -70,6 +70,8 @@ pub(crate) struct ShellCompactionSampler {
/// Wall-clock budget (secs) forwarded to `generate_session_compact` as the
/// reasoning-runaway backstop; `0` disables it.
wall_clock_budget_secs: u64,
/// Summarizer `tool_choice` forwarded to `generate_session_compact`.
tool_choice: crate::util::config::CompactionToolChoice,
/// Full output of the most recent successful sample (for L5 telemetry).
last_success: Mutex<Option<CompactOutput>>,
}
@@ -86,6 +88,7 @@ impl ShellCompactionSampler {
sampling_config: SamplingConfig,
idle_timeout: Duration,
wall_clock_budget_secs: u64,
tool_choice: crate::util::config::CompactionToolChoice,
) -> Self {
Self {
use_short_prompt,
@@ -97,6 +100,7 @@ impl ShellCompactionSampler {
sampling_config,
idle_timeout,
wall_clock_budget_secs,
tool_choice,
last_success: Mutex::new(None),
}
}
@@ -135,6 +139,7 @@ impl CompactionSampler for ShellCompactionSampler {
&self.sampling_config,
self.idle_timeout,
self.wall_clock_budget_secs,
self.tool_choice,
)
.await
{
@@ -339,9 +339,10 @@ where
/// prefix and force a full prefill on the summarizer call — attaching them
/// keeps the request prefix byte-identical to the turn requests so the
/// engine reuses the session's KV cache (the whole point of the verbatim
/// input path). Tool *use* is forbidden via `tool_choice: none` where the
/// backend can express it (ChatCompletions, Responses); the Messages wire
/// enum has no `none`, so that path relies on the prompt instruction alone.
/// input path). Tool *use* is forbidden by the prompt instruction;
/// `tool_choice` says what goes on the wire next to the attached tools
/// (ChatCompletions, Responses — some servers reject `"none"`, hence the
/// `auto` default). The Messages wire enum has no `none` either way.
///
/// Errors carry a [`CompactFailure`] classification so the caller can
/// short-circuit retries on deterministic failures (4xx schema violations,
@@ -356,8 +357,17 @@ pub(crate) async fn generate_session_compact(
sampling_config: &SamplingConfig,
idle_timeout: std::time::Duration,
wall_clock_budget_secs: u64,
tool_choice: crate::util::config::CompactionToolChoice,
) -> Result<CompactOutput, CompactFailure> {
let num_messages = chat_history.len();
let wire_tool_choice = match tool_choice {
crate::util::config::CompactionToolChoice::Auto => ToolChoice::auto(),
crate::util::config::CompactionToolChoice::None => ToolChoice::none(),
};
let conversation_tool_choice = match tool_choice {
crate::util::config::CompactionToolChoice::Auto => ConversationToolChoice::Auto,
crate::util::config::CompactionToolChoice::None => ConversationToolChoice::None,
};
let output = match sampling_config.api_backend {
ApiBackend::ChatCompletions => {
let chat_messages: Vec<ChatRequestMessage> =
@@ -373,7 +383,7 @@ pub(crate) async fn generate_session_compact(
.map(|t| ToolDefinition::function(t.name, t.description, t.parameters))
.collect(),
)
.with_tool_choice(ToolChoice::none());
.with_tool_choice(wire_tool_choice);
}
let sid = session_id.to_string();
message.x_kigi_conv_id = Some(sid.clone());
@@ -466,7 +476,10 @@ pub(crate) async fn generate_session_compact(
ApiBackend::Responses => {
let request = ConversationRequest {
items: chat_history,
tool_choice: (!tools.is_empty()).then_some(ConversationToolChoice::None),
// Hosted tools count: a hosted-only request with the choice
// omitted would leave the hosted tool callable mid-compact.
tool_choice: (!tools.is_empty() || !hosted_tools.is_empty())
.then_some(conversation_tool_choice),
tools,
hosted_tools,
model: Some(sampling_config.model.to_owned()),
@@ -1573,6 +1586,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await
.unwrap_or_else(|_| panic!("compaction must succeed"));
@@ -1661,6 +1675,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
let output = result
@@ -1669,11 +1684,11 @@ mod reasoning_compaction_regression_tests {
let _ = shutdown_tx.send(());
}
/// The compaction request must carry the turn loop's tool definitions
/// (prompt-prefix/KV-cache alignment) with `tool_choice: "none"`, and
/// must omit both keys when no tools are passed (Chat Completions rejects a bare
/// `tool_choice`).
/// (prompt-prefix/KV-cache alignment) with the configured `tool_choice`
/// (`auto` default; `none` when configured), and must omit both keys when
/// no tools are passed (Chat Completions rejects a bare `tool_choice`).
#[tokio::test]
async fn chat_completions_compaction_attaches_tools_with_tool_choice_none() {
async fn chat_completions_compaction_attaches_tools_with_configured_tool_choice() {
use std::sync::{Arc, Mutex};
let captured: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(Vec::new()));
let cap = captured.clone();
@@ -1726,12 +1741,13 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await
.unwrap_or_else(|_| panic!("compaction with tools must succeed"));
let client = Client::new(config.clone()).unwrap();
generate_session_compact(
chat_history,
chat_history.clone(),
vec![],
vec![],
client,
@@ -1739,16 +1755,36 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await
.unwrap_or_else(|_| panic!("compaction without tools must succeed"));
let tools = vec![ToolSpec {
name: "read_file".to_string(),
description: Some("Reads a file".to_string()),
parameters: json!({ "type" : "object", "properties" : {} }),
}];
let client = Client::new(config.clone()).unwrap();
generate_session_compact(
chat_history,
tools,
vec![],
client,
acp::SessionId::new("test-session"),
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::None,
)
.await
.unwrap_or_else(|_| panic!("compaction with tool_choice none must succeed"));
let bodies = captured.lock().unwrap();
assert_eq!(bodies.len(), 2, "mock must have served both requests");
assert_eq!(bodies.len(), 3, "mock must have served all three requests");
let with_tools = &bodies[0];
assert_eq!(
with_tools["tool_choice"],
json!("none"),
"tool use must be disabled at decode time"
json!("auto"),
"default tool_choice is auto — some backends reject none; the prompt forbids tool use"
);
let sent_tools = with_tools["tools"]
.as_array()
@@ -1764,6 +1800,12 @@ mod reasoning_compaction_regression_tests {
without_tools.get("tool_choice").is_none(),
"tool_choice without tools is rejected by OpenAI-compat backends"
);
let none_configured = &bodies[2];
assert_eq!(
none_configured["tool_choice"],
json!("none"),
"configured none must reach the wire"
);
let _ = shutdown_tx.send(());
}
#[tokio::test]
@@ -1802,6 +1844,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_millis(150),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
match result {
@@ -1869,6 +1912,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_millis(150),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
match result {
@@ -1940,6 +1984,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_millis(150),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
match result {
@@ -2008,6 +2053,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_millis(150),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
match result {
@@ -326,9 +326,11 @@ pub mod restore;
pub mod result;
pub mod signals;
pub(crate) mod slash_commands;
pub(crate) mod stationarity;
pub mod storage;
pub(crate) mod streaming_capture;
pub(crate) mod summary;
pub mod swarm_mode;
pub(crate) mod telemetry;
pub mod tool_index;
pub(crate) mod turn_completion;
@@ -45,6 +45,10 @@ pub(crate) enum BuiltinGate {
/// available (graph nodes execute as goals, so `/graph` needs
/// everything `/goal` needs).
Graph,
/// The `agent_swarm` tool is in the session toolset. The mode is only a
/// standing instruction to use that tool, so without it the command would
/// advertise a doctrine the model has no way to follow.
Swarm,
}
/// All built-in slash commands. Order here = display order in autocomplete.
@@ -303,6 +307,26 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
}
},
},
BuiltinCommand {
name: "swarm",
description: "Delegate aggressively: split the work across a fleet of subagents",
argument_hint: Some("[on | off | <task>]"),
aliases: &[],
gate: BuiltinGate::Swarm,
resolve: |args| {
let trimmed = args.trim();
match trimmed.to_lowercase().as_str() {
"on" => BuiltinAction::SwarmSet { enabled: true },
"off" => BuiltinAction::SwarmSet { enabled: false },
"" => BuiltinAction::SwarmToggle,
// Anything else is the task itself: arm the mode for exactly
// this turn and send the text as the prompt.
_ => BuiltinAction::SwarmTask {
prompt: trimmed.to_string(),
},
}
},
},
];
/// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
@@ -437,6 +461,8 @@ pub(crate) struct CommandAvailability {
/// `/graph` gate: the graph feature flag AND the goal harness (nodes
/// execute as goals) are both available.
pub graph: bool,
/// `/swarm` gate: the `agent_swarm` tool is in the active toolset.
pub swarm: bool,
}
impl CommandAvailability {
@@ -452,6 +478,7 @@ impl CommandAvailability {
BuiltinGate::Plugins => self.plugins,
BuiltinGate::Goal => self.goal,
BuiltinGate::Graph => self.graph,
BuiltinGate::Swarm => self.swarm,
}
}
@@ -468,6 +495,7 @@ impl CommandAvailability {
plugins: true,
goal: true,
graph: true,
swarm: true,
}
}
}
@@ -705,6 +733,17 @@ pub(super) enum BuiltinAction {
token_budget: Option<i64>,
},
GoalStatus,
/// `/swarm on|off` — arm or disarm the standing delegate-aggressively
/// instruction. Survives turns until switched off.
SwarmSet {
enabled: bool,
},
/// `/swarm` with no argument.
SwarmToggle,
/// `/swarm <task>` — arm for this turn only, then send `prompt`.
SwarmTask {
prompt: String,
},
GoalPause,
GoalResume,
GoalClear,
@@ -757,6 +796,9 @@ impl BuiltinAction {
| BuiltinAction::GraphPause
| BuiltinAction::GraphResume { .. }
| BuiltinAction::GraphClear => "graph",
BuiltinAction::SwarmSet { .. }
| BuiltinAction::SwarmToggle
| BuiltinAction::SwarmTask { .. } => "swarm",
}
}
@@ -795,6 +837,8 @@ impl BuiltinAction {
| BuiltinAction::GraphShow
| BuiltinAction::GraphPause
| BuiltinAction::GraphClear => false,
BuiltinAction::SwarmToggle => false,
BuiltinAction::SwarmSet { .. } | BuiltinAction::SwarmTask { .. } => true,
}
}
}
@@ -1584,6 +1628,7 @@ mod tests {
"feedback",
"goal",
"graph",
"swarm",
"loop",
"commit",
"deploy",
@@ -1694,6 +1739,51 @@ mod tests {
);
}
/// Without the `agent_swarm` tool the mode has nothing to steer toward, so
/// the command must fall through as ordinary prompt text rather than
/// arming a doctrine the model cannot act on.
#[test]
fn swarm_does_not_resolve_when_gate_off() {
let availability = CommandAvailability {
swarm: false,
..CommandAvailability::all_enabled()
};
assert!(
resolve(
vec![text_block("/swarm on")],
&[],
availability,
SkillSlashRewrite::default(),
)
.is_ok(),
"expected pass-through (Ok), got an outcome",
);
}
#[test]
fn swarm_resolves_each_form_to_its_own_action() {
assert!(matches!(
resolve_builtin("swarm", "on").expect("/swarm on must resolve"),
BuiltinAction::SwarmSet { enabled: true }
));
assert!(matches!(
resolve_builtin("swarm", "off").expect("/swarm off must resolve"),
BuiltinAction::SwarmSet { enabled: false }
));
assert!(matches!(
resolve_builtin("swarm", "").expect("bare /swarm must resolve"),
BuiltinAction::SwarmToggle
));
// Anything else is the task, NOT an unknown subcommand: mis-parsing it
// would silently drop the user's work instead of running it.
match resolve_builtin("swarm", "split the auth refactor")
.expect("/swarm <task> must resolve")
{
BuiltinAction::SwarmTask { prompt } => assert_eq!(prompt, "split the auth refactor"),
other => panic!("expected SwarmTask, got {}", other.command_name()),
}
}
#[test]
fn graph_resolves_subcommands_and_budget() {
let set = resolve_builtin("graph", "ship the feature --budget 5000")
@@ -0,0 +1,216 @@
//! Detects a model stuck repeating one identical tool call.
/// Consecutive identical batches after which the turn is halted.
pub(crate) const MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS: u32 = 16;
/// Nudge threshold; half the budget remains after it.
pub(crate) const NUDGE_AFTER_IDENTICAL_TOOL_CALLS: u32 = 8;
/// Below the nudge on purpose: no-op runs get none.
pub(crate) const MAX_CONSECUTIVE_TRUE_NOOPS: u32 = 4;
const _: () = assert!(NUDGE_AFTER_IDENTICAL_TOOL_CALLS < MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS);
const _: () = assert!(MAX_CONSECUTIVE_TRUE_NOOPS < NUDGE_AFTER_IDENTICAL_TOOL_CALLS);
/// A shell command that does nothing whatsoever.
pub(crate) fn command_is_true(cmd: &str) -> bool {
cmd.trim().eq_ignore_ascii_case("true")
}
/// Hashes name+args per call; separators prevent concatenation collisions.
pub(crate) fn hash_batch<'a>(calls: impl IntoIterator<Item = (&'a str, &'a str)>) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for (name, args) in calls {
name.hash(&mut hasher);
'\u{1f}'.hash(&mut hasher);
args.hash(&mut hasher);
'\u{1e}'.hash(&mut hasher);
}
hasher.finish()
}
/// One identity, so re-spelling a no-op cannot reset it.
const TRUE_NOOP_HASH: u64 = u64::MAX;
/// How many times the current batch has repeated unchanged.
#[derive(Default)]
pub(crate) struct IdenticalToolCallRun {
/// Hash only: signatures hold raw tool arguments.
last_hash: Option<u64>,
tool_name: String,
run_len: u32,
is_true_noop_run: bool,
}
impl IdenticalToolCallRun {
/// Records one batch and returns the length of the run it belongs to.
pub(crate) fn observe(&mut self, batch_hash: u64, tool_name: &str, is_true_noop: bool) -> u32 {
let hash = if is_true_noop {
TRUE_NOOP_HASH
} else {
batch_hash
};
if self.last_hash == Some(hash) {
self.run_len += 1;
} else {
self.run_len = 1;
self.last_hash = Some(hash);
self.is_true_noop_run = is_true_noop;
}
self.tool_name = tool_name.to_string();
self.run_len
}
/// The run length at which this turn must be halted.
pub(crate) fn hard_stop_threshold(&self) -> u32 {
if self.is_true_noop_run {
MAX_CONSECUTIVE_TRUE_NOOPS
} else {
MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS
}
}
pub(crate) fn tool_name(&self) -> &str {
&self.tool_name
}
pub(crate) fn is_true_noop_run(&self) -> bool {
self.is_true_noop_run
}
}
#[cfg(test)]
mod tests {
use super::*;
fn call(name: &str, args: &str) -> u64 {
hash_batch([(name, args)])
}
#[test]
fn an_unchanged_batch_accumulates_a_run() {
let mut run = IdenticalToolCallRun::default();
let h = call("read_file", "a.rs");
assert_eq!(run.observe(h, "read_file", false), 1);
assert_eq!(run.observe(h, "read_file", false), 2);
assert_eq!(run.observe(h, "read_file", false), 3);
}
#[test]
fn any_change_in_the_arguments_restarts_the_run() {
let mut run = IdenticalToolCallRun::default();
run.observe(call("read_file", "a.rs"), "read_file", false);
run.observe(call("read_file", "a.rs"), "read_file", false);
assert_eq!(
run.observe(call("read_file", "b.rs"), "read_file", false),
1,
"a different argument is a different action"
);
}
#[test]
fn two_batches_cannot_collide_by_concatenation() {
// Unseparated these would hash identically.
assert_ne!(
hash_batch([("ab", "cd")]),
hash_batch([("a", "b"), ("c", "d")])
);
}
#[test]
fn no_ops_share_one_run_however_they_are_spelled() {
let mut run = IdenticalToolCallRun::default();
assert_eq!(run.observe(call("bash", "true"), "bash", true), 1);
assert_eq!(
run.observe(call("bash", " TRUE "), "bash", true),
2,
"re-spelling a no-op must not reset the tighter ceiling"
);
assert_eq!(run.hard_stop_threshold(), MAX_CONSECUTIVE_TRUE_NOOPS);
}
#[test]
fn a_real_call_after_a_noop_run_restores_the_ordinary_ceiling() {
let mut run = IdenticalToolCallRun::default();
run.observe(call("bash", "true"), "bash", true);
assert_eq!(run.hard_stop_threshold(), MAX_CONSECUTIVE_TRUE_NOOPS);
run.observe(call("read_file", "a.rs"), "read_file", false);
assert_eq!(
run.hard_stop_threshold(),
MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS
);
}
#[test]
fn an_ordinary_repeated_call_is_halted_at_its_ceiling_and_not_before() {
let mut run = IdenticalToolCallRun::default();
let h = call("read_file", "a.rs");
for expected in 1..MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS {
let len = run.observe(h, "read_file", false);
assert_eq!(len, expected);
assert!(len < run.hard_stop_threshold(), "must not halt early");
}
assert_eq!(
run.observe(h, "read_file", false),
run.hard_stop_threshold(),
"the turn halts on the 16th identical call"
);
}
#[test]
fn a_repeated_no_op_is_halted_far_sooner_and_without_a_nudge() {
let mut run = IdenticalToolCallRun::default();
let h = call("bash", "true");
let mut len = 0;
while len < run.hard_stop_threshold() {
len = run.observe(h, "bash", true);
}
assert_eq!(len, MAX_CONSECUTIVE_TRUE_NOOPS);
assert!(
len < NUDGE_AFTER_IDENTICAL_TOOL_CALLS,
"documented: a no-op run is halted before any nudge could fire"
);
}
#[test]
fn the_nudge_lands_with_budget_left_to_act_on_it() {
let mut run = IdenticalToolCallRun::default();
let h = call("read_file", "a.rs");
let mut len = 0;
while len < NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
len = run.observe(h, "read_file", false);
}
assert!(
len < run.hard_stop_threshold(),
"a warning the model cannot act on is not a warning"
);
}
#[test]
fn work_interleaved_with_repeats_is_never_halted() {
let mut run = IdenticalToolCallRun::default();
for i in 0..MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS * 2 {
let h = if i % 2 == 0 {
call("read_file", "a.rs")
} else {
call("read_file", "b.rs")
};
let len = run.observe(h, "read_file", false);
assert!(
len < run.hard_stop_threshold(),
"alternating calls are progress, not a loop"
);
}
}
#[test]
fn only_a_bare_true_counts_as_a_no_op() {
assert!(command_is_true("true"));
assert!(command_is_true(" true "));
assert!(command_is_true("TRUE"));
assert!(!command_is_true("true && make"));
assert!(!command_is_true("truely"));
assert!(!command_is_true(""));
}
}
@@ -0,0 +1,178 @@
//! Swarm mode: a standing instruction to split work across a fleet.
//!
//! The `agent_swarm` tool works with the mode off; what the mode adds is the
//! doctrine — decompose finely, give every member a disjoint scope, do not do
//! the work yourself. Kept as pure state so the turn loop decides when to
//! inject and the session decides when to persist.
use serde::{Deserialize, Serialize};
/// Why the mode is on, which is what decides when it turns off.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SwarmTrigger {
/// `/swarm on` (or a bare `/swarm` toggle): stays until switched off.
Manual,
/// `/swarm <task>`: armed for exactly that turn.
Task,
}
/// The session's swarm-mode state. `None` means off.
///
/// Deliberately NOT persisted, unlike `/goal` and `/graph`: those drive
/// autonomous multi-turn work that is stranded if it is lost, whereas this is
/// a prompt hint whose worst-case recovery is typing `/swarm on` again. The
/// injected doctrine IS durable (it rides the conversation), so a resumed
/// session can read as "off" with the instruction still in context — which is
/// exactly why an explicit `/swarm off` always retracts (see
/// [`SessionActor::apply_swarm_mode`]) rather than trusting a remembered flag
/// that a restore, a compaction or a fork can each falsify.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SwarmMode {
trigger: Option<SwarmTrigger>,
}
impl SwarmMode {
pub fn is_active(self) -> bool {
self.trigger.is_some()
}
pub fn trigger(self) -> Option<SwarmTrigger> {
self.trigger
}
/// Arms the mode, and does NOTHING if it is already armed.
///
/// Total no-op, not just "don't re-inject": overwriting the trigger would
/// let the `/swarm <task>` shorthand downgrade a standing `/swarm on` into
/// a per-turn mode, which then disarms itself at that turn's end — the
/// user's deliberate choice, silently undone.
///
/// Returns whether the caller should inject the enter reminder.
pub fn enter(&mut self, trigger: SwarmTrigger) -> bool {
if self.trigger.is_some() {
return false;
}
self.trigger = Some(trigger);
true
}
/// Disarms. Returns whether the mode had been armed — which is what an
/// AUTOMATIC expiry keys its retraction off. An explicit `/swarm off` must
/// retract regardless (see the type docs).
pub fn exit(&mut self) -> bool {
self.trigger.take().is_some()
}
/// Whether a turn ending now should disarm the mode.
///
/// Only the `Task` trigger auto-exits: `/swarm on` is a standing choice the
/// user made and a turn boundary is not a reason to undo it.
pub fn expires_at_turn_end(self) -> bool {
self.trigger == Some(SwarmTrigger::Task)
}
}
/// The doctrine injected when the mode is armed.
///
/// Deliberately short: it is re-read on every turn it is live, and the tool's
/// own description already carries the mechanics.
pub const SWARM_ENTER_REMINDER: &str = "\
Swarm mode is on. Explore only as far as you must to identify the work, then \
split it: use the agent_swarm tool with one item per independent scope rather \
than doing the work yourself. Decompose finely — do not try to conserve \
members. Every member must own a disjoint scope; members share one working \
tree, so two members told to touch the same file will corrupt each other. \
Read-only scopes may overlap. If the work genuinely does not split, say so and \
carry on alone.";
/// Injected when the mode is switched off mid-conversation, so the earlier
/// doctrine does not keep steering the model.
/// Deliberately as emphatic as the enter doctrine it revokes: a one-line
/// "mode is off" is the weaker of the two texts in context and the likelier to
/// be summarised away, leaving the fan-out directives still steering.
pub const SWARM_EXIT_REMINDER: &str = "\
Swarm mode is off. The swarm instructions above no longer apply — you are not \
required to split work across subagents, and you should not decompose a task \
just because they said to. Decide how to approach each new request from the \
request itself. Delegate only where it clearly helps.";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_session_has_the_mode_off() {
let mode = SwarmMode::default();
assert!(!mode.is_active());
assert!(!mode.expires_at_turn_end());
}
#[test]
fn arming_asks_for_the_doctrine_once_not_once_per_command() {
let mut mode = SwarmMode::default();
assert!(mode.enter(SwarmTrigger::Manual), "first arm injects");
assert!(
!mode.enter(SwarmTrigger::Manual),
"a repeated /swarm on must not stack a second copy"
);
assert!(mode.is_active());
}
#[test]
fn only_the_task_trigger_expires_at_a_turn_boundary() {
let mut manual = SwarmMode::default();
manual.enter(SwarmTrigger::Manual);
assert!(
!manual.expires_at_turn_end(),
"/swarm on is a standing choice, not a per-turn one"
);
let mut task = SwarmMode::default();
task.enter(SwarmTrigger::Task);
assert!(task.expires_at_turn_end());
}
#[test]
fn exiting_retracts_exactly_once() {
let mut mode = SwarmMode::default();
mode.enter(SwarmTrigger::Manual);
assert!(mode.exit(), "the live doctrine must be retracted");
assert!(!mode.is_active());
assert!(
!mode.exit(),
"a second /swarm off has nothing left to retract"
);
}
/// `/swarm <task>` under a standing `/swarm on` must seed the turn and
/// nothing more — it must not convert the standing mode into a per-turn
/// one that disarms itself when that turn ends.
#[test]
fn the_task_shorthand_never_downgrades_a_standing_mode() {
let mut mode = SwarmMode::default();
mode.enter(SwarmTrigger::Manual);
assert!(
!mode.enter(SwarmTrigger::Task),
"the doctrine is already in the conversation"
);
assert_eq!(mode.trigger(), Some(SwarmTrigger::Manual));
assert!(
!mode.expires_at_turn_end(),
"the user's standing /swarm on must survive the turn"
);
}
/// An automatic expiry has nothing to retract once the mode is already
/// off; only an explicit `/swarm off` retracts unconditionally, and that
/// rule lives at the call site, not here.
#[test]
fn exit_reports_whether_it_actually_disarmed_something() {
let mut armed = SwarmMode::default();
armed.enter(SwarmTrigger::Task);
assert!(armed.exit());
let mut idle = SwarmMode::default();
assert!(!idle.exit());
}
}
@@ -8,13 +8,66 @@ use tokio::time;
use crate::terminal::runner::{
AsyncTerminalRunner, TerminalError, TerminalRunRequest, TerminalRunResult,
};
use kigi_tty_utils::KILL_REAP_TIMEOUT;
pub struct LocalTerminalRunner;
async fn read_stream(mut stream: impl AsyncReadExt + Unpin) -> Vec<u8> {
let mut buffer = Vec::new();
let _ = stream.read_to_end(&mut buffer).await;
buffer
/// A pipe reader whose buffer is shared with the spawner, so bytes read
/// before an abort are not lost with the task.
struct PipeReader {
task: tokio::task::JoinHandle<()>,
buffer: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
}
impl PipeReader {
fn spawn(mut stream: impl AsyncReadExt + Unpin + Send + 'static) -> Self {
let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let task_buffer = std::sync::Arc::clone(&buffer);
let task = tokio::spawn(async move {
let mut chunk = [0u8; 8192];
loop {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => Self::lock_buffer(&task_buffer).extend_from_slice(&chunk[..n]),
}
}
});
Self { task, buffer }
}
/// Never panics: a poisoned lock just means a holder panicked mid-append —
/// the buffer is plain bytes and stays consistent, so recover it.
fn lock_buffer(buffer: &std::sync::Mutex<Vec<u8>>) -> std::sync::MutexGuard<'_, Vec<u8>> {
buffer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn take_buffer(self) -> Vec<u8> {
std::mem::take(&mut Self::lock_buffer(&self.buffer))
}
/// Wait for EOF (pipe closed by all writers) at most [`KILL_REAP_TIMEOUT`],
/// then return everything read so far.
///
/// Callers only join after the child was reaped or killed, so EOF is
/// normally immediate; the bound is a backstop for writers that outlive
/// the command and keep the pipe open — a background descendant
/// (`printf hi; sleep 30 &`) or a process wedged in uninterruptible
/// kernel I/O (D-state), which not even SIGKILL moves.
async fn join_bounded(mut self) -> Vec<u8> {
if time::timeout(KILL_REAP_TIMEOUT, &mut self.task)
.await
.is_err()
{
self.task.abort();
// Await the cancellation: abort() is asynchronous, and a reader
// past its await point could otherwise append between the take
// below and its own completion, losing those bytes.
let _ = (&mut self.task).await;
}
self.take_buffer()
}
}
/// Truncate buffer to keep only the last `limit` bytes (drops oldest bytes).
@@ -66,12 +119,35 @@ impl AsyncTerminalRunner for LocalTerminalRunner {
// Detach from the controlling terminal so child processes
// (e.g. GPG pinentry) cannot open /dev/tty and corrupt the TUI.
// (This also makes the child its own session/group leader, which the
// ProcessGroup attach below relies on.)
kigi_tools::util::detach_command(&mut cmd);
// process-group-killed on the request timeout; an unreapable (D-state)
// child is abandoned to the runtime's orphan reaper
let mut child = cmd
.spawn()
.map_err(|e| TerminalError::Other(format!("Failed to start shell: {e}")))?;
// Own the whole tree, not just the direct shell: on timeout the group
// is killed so grandchildren can't keep running (and can't hold the
// output pipes open past the kill). Group teardown is an enhancement
// over the direct-kill floor, so both enrollment failures degrade
// softly instead of dropping an already-running shell.
let process_group = match kigi_tty_utils::ProcessGroup::new() {
Ok(mut group) => {
if let Err(e) = group.attach(&child) {
// e.g. the child already exited.
tracing::debug!("ProcessGroup::attach failed: {e}");
}
Some(group)
}
Err(e) => {
tracing::warn!("Failed to create process group: {e}");
None
}
};
let stdout = child
.stdout
.take()
@@ -81,28 +157,46 @@ impl AsyncTerminalRunner for LocalTerminalRunner {
.take()
.ok_or_else(|| TerminalError::Other("Failed to capture stderr".into()))?;
let stdout_task = tokio::spawn(read_stream(stdout));
let stderr_task = tokio::spawn(read_stream(stderr));
let stdout_reader = PipeReader::spawn(stdout);
let stderr_reader = PipeReader::spawn(stderr);
let mut timed_out = false;
let wait_result = time::timeout(request.timeout, child.wait()).await;
let exit_status = match wait_result {
let exit_code = match wait_result {
Ok(status_res) => status_res
.map_err(|e| TerminalError::Other(format!("Failed to wait for process: {e}")))?,
.map_err(|e| TerminalError::Other(format!("Failed to wait for process: {e}")))?
.code(),
Err(_) => {
timed_out = true;
// Kill the direct child AND the whole group (grandchildren),
// then return the synthetic timeout result without waiting
// for the corpse: `timed_out: true` already tells the caller
// everything, and a D-state child would never become reapable
// anyway. Both kills are unconditional — `kill()` on a group
// whose attach failed is a silent no-op, so the direct
// `start_kill` is the guaranteed floor (same as
// `util/subprocess.rs`). The kills close the pipes, so the
// bounded joins below return immediately in the normal case;
// the abandoned child goes to tokio's orphan reaper.
if let Err(e) = child.start_kill() {
tracing::warn!("Failed to kill timed-out process: {e}");
}
child.wait().await.map_err(|e| {
TerminalError::Other(format!("Failed to wait for killed process: {e}"))
})?
if let Some(group) = &process_group
&& let Err(e) = group.kill()
{
tracing::warn!("Failed to kill timed-out process group: {e}");
}
None
}
};
let stdout_result = stdout_task.await.unwrap_or_else(|_| Vec::new());
let stderr_result = stderr_task.await.unwrap_or_else(|_| Vec::new());
// Join concurrently (worst case one reap bound, not one per pipe).
// Bounded on the natural-exit arm too: a background descendant that
// outlives the shell (`printf hi; sleep 30 &`) holds the pipes open
// past the child's exit, and an unbounded join stalls on it.
let (stdout_result, stderr_result) =
tokio::join!(stdout_reader.join_bounded(), stderr_reader.join_bounded());
let mut combined = stdout_result;
combined.extend(stderr_result);
@@ -112,7 +206,7 @@ impl AsyncTerminalRunner for LocalTerminalRunner {
Ok(TerminalRunResult {
combined_output,
exit_code: exit_status.code(),
exit_code,
truncated,
signal: None,
timed_out,
@@ -181,4 +275,56 @@ mod tests {
assert_eq!(result.combined_output.trim(), "hello");
assert_eq!(result.exit_code, Some(0));
}
/// Timing out a command whose grandchild inherited the output pipes must
/// (a) return promptly — before the group kill, the joins waited for the
/// grandchild's EOF (the full sleep here; forever for a writer wedged in
/// uninterruptible kernel I/O, the production incident this guards
/// against) — and (b) actually kill the grandchild via the group kill,
/// not merely stop waiting for it.
#[tokio::test]
#[cfg(unix)]
async fn test_timeout_kills_grandchildren_and_returns_promptly() {
let mut request = make_request("sleep 5 & echo bgpid=$!; sleep 5");
request.timeout = std::time::Duration::from_millis(300);
let started = std::time::Instant::now();
let result = LocalTerminalRunner.run(request).await.unwrap();
assert!(result.timed_out, "run should report the timeout");
// Prompt return: request timeout (0.3s) + pipe EOF from the group
// kill (normally instant; KILL_REAP_TIMEOUT-bounded worst case) —
// anything near the 5s sleeps means we waited for the grandchild.
assert!(
started.elapsed() < std::time::Duration::from_secs(4),
"timeout path must not wait for the killed tree (took {:?})",
started.elapsed()
);
// Outcome: the background grandchild must actually be dead, proving
// the group kill reached it. Poll briefly — the SIGKILL is delivered
// synchronously but init may reap the orphan a beat later.
let bg_pid = result
.combined_output
.lines()
.find_map(|l| l.trim().strip_prefix("bgpid="))
.and_then(|p| p.trim().parse::<u32>().ok())
.expect("background pid should have been echoed before the kill");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let alive = std::process::Command::new("kill")
.args(["-0", &bg_pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !alive {
break;
}
assert!(
std::time::Instant::now() < deadline,
"background grandchild {bg_pid} survived the group kill"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
}
@@ -33,6 +33,11 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
let (tx, _rx) = mpsc::unbounded_channel();
SubagentSpawnContext {
lsp: None,
spawn_limits: kigi_tools::implementations::kigi::task::admission::SubagentLimits::default(),
spawn_permits: std::sync::Arc::new(tokio::sync::Semaphore::new(
kigi_tools::implementations::kigi::task::admission::SubagentLimits::default()
.effective_max_concurrent(),
)),
parent_max_turns: None,
gateway: test_gateway(),
client_hooks: Default::default(),
@@ -108,6 +108,10 @@ pub struct WebFetchToolConfig {
/// default allowlist. An explicit empty list blocks all fetches.
/// Resolution: TOML > remote settings > built-in defaults.
pub allowed_domains: Option<Vec<String>>,
/// Allow fetches to explicit loopback hosts only (`localhost` /
/// `127.0.0.0/8` / `::1`). Private and metadata ranges stay blocked.
/// Resolution: TOML > `KIGI_WEB_FETCH_ALLOW_LOCAL` env > false.
pub allow_local: Option<bool>,
}
impl WebFetchToolConfig {
@@ -137,10 +141,15 @@ impl WebFetchToolConfig {
.cloned()
.or_else(|| remote_domains.map(|d| d.to_vec()));
let allow_local = self
.allow_local
.or_else(|| kigi_config::env_bool("KIGI_WEB_FETCH_ALLOW_LOCAL"));
kigi_tools::implementations::kigi::web_fetch::WebFetchParams {
proxy_endpoint,
allowed_domains,
context_window_tokens,
allow_local,
..Default::default()
}
}
@@ -484,6 +493,7 @@ mod tests {
let local = WebFetchToolConfig {
proxy_endpoint: Some("https://toml-proxy.example.com".to_owned()),
allowed_domains: Some(vec!["toml.example.com".to_owned()]),
allow_local: Some(true),
};
let params = local.resolve_params(
Some("https://remote-proxy.example.com"),
@@ -498,6 +508,7 @@ mod tests {
params.allowed_domains,
Some(vec!["toml.example.com".to_owned()])
);
assert!(params.allow_local(), "the opt-in must reach the tool");
}
#[test]
@@ -524,6 +535,7 @@ mod tests {
let params = local.resolve_params(None, None, None);
assert!(params.proxy_endpoint.is_none());
assert!(params.allowed_domains.is_none());
assert!(!params.allow_local(), "local access is off by default");
}
#[test]
@@ -531,6 +543,7 @@ mod tests {
let local = WebFetchToolConfig {
proxy_endpoint: None,
allowed_domains: Some(vec![]),
allow_local: None,
};
let params = local.resolve_params(None, Some(&["remote.example.com".to_owned()]), None);
assert_eq!(params.allowed_domains, Some(vec![]));
@@ -1,6 +1,42 @@
/// Default auto-compact threshold (% of context window) when no source sets it.
pub const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT: u8 = 85;
/// `tool_choice` sent on compaction summarizer requests when tools are
/// attached (prefix-cache alignment keeps the tool definitions in the
/// request). Default `Auto` because some OpenAI-compatible servers reject
/// `tool_choice: "none"`; the summarization prompt itself forbids tool use.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CompactionToolChoice {
#[default]
Auto,
None,
}
impl std::str::FromStr for CompactionToolChoice {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"none" => Ok(Self::None),
_ => Err(()),
}
}
}
pub(crate) const ENV_COMPACTION_TOOL_CHOICE: &str = "KIGI_COMPACTION_TOOL_CHOICE";
pub(crate) fn resolve_compaction_tool_choice_from(
env: Option<&str>,
config: Option<&str>,
remote: Option<&str>,
) -> CompactionToolChoice {
env.and_then(|s| s.parse().ok())
.or_else(|| config.and_then(|s| s.parse().ok()))
.or_else(|| remote.and_then(|s| s.parse().ok()))
.unwrap_or_default()
}
/// Env-var override for `auto_compact_threshold_percent`. Parsed as `u8`;
/// out-of-range or unparseable values are ignored.
pub(crate) const ENV_AUTO_COMPACT_THRESHOLD_PERCENT: &str = "KIGI_AUTO_COMPACT_THRESHOLD_PERCENT";
@@ -127,6 +163,37 @@ pub fn resolve_compaction_wall_clock_budget_secs(gb_global: Option<u64>) -> u64
resolved
}
#[cfg(test)]
mod compaction_tool_choice_tests {
use super::{CompactionToolChoice, resolve_compaction_tool_choice_from as resolve};
#[test]
fn precedence_env_config_remote_default() {
assert_eq!(resolve(None, None, None), CompactionToolChoice::Auto);
assert_eq!(
resolve(None, None, Some("none")),
CompactionToolChoice::None
);
assert_eq!(
resolve(None, Some("auto"), Some("none")),
CompactionToolChoice::Auto
);
assert_eq!(
resolve(Some("none"), Some("auto"), Some("auto")),
CompactionToolChoice::None
);
// Unparseable tiers fall through instead of masking lower tiers.
assert_eq!(
resolve(Some("bogus"), None, Some("none")),
CompactionToolChoice::None
);
assert_eq!(
resolve(Some("NONE "), None, None),
CompactionToolChoice::None
);
}
}
#[cfg(test)]
mod compaction_wall_clock_budget_tests {
use super::resolve_compaction_wall_clock_budget_secs as resolve;
@@ -36,7 +36,7 @@
],
"definitions": {
"ToolKind": {
"description": "Categorizes what a tool does at a high level. Open set — consumers must tolerate unknown values (Rust deserializes them to `other` via `#[serde(other)]`). Known values: `read`, `edit`, `delete`, `list_dir`, `write`, `move`, `search`, `lsp`, `execute`, `plan`, `web_search`, `web_fetch`, `background_task_action`, `wait_tasks_action`, `kill_task_action`, `list`, `skill`, `memory_search`, `memory_get`, `task`, `enter_plan`, `exit_plan`, `ask_user`, `deploy_app`, `search_tool`, `use_tool`, `monitor`, `goal_update`, `other`.",
"description": "Categorizes what a tool does at a high level. Open set — consumers must tolerate unknown values (Rust deserializes them to `other` via `#[serde(other)]`). Known values: `read`, `edit`, `delete`, `list_dir`, `write`, `move`, `search`, `lsp`, `execute`, `plan`, `web_search`, `web_fetch`, `background_task_action`, `wait_tasks_action`, `kill_task_action`, `list`, `skill`, `memory_search`, `memory_get`, `task`, `agent_swarm`, `enter_plan`, `exit_plan`, `ask_user`, `deploy_app`, `search_tool`, `use_tool`, `monitor`, `goal_update`, `other`.",
"type": "string"
},
"ToolNamespace": {
@@ -148,7 +148,6 @@ mod linux {
}
let err = std::io::Error::last_os_error();
match err.kind() {
// The fd is non-blocking: the queue is empty.
std::io::ErrorKind::WouldBlock => break,
std::io::ErrorKind::Interrupted => continue,
_ => return Err(err),
@@ -2653,7 +2653,7 @@ fn spawn_shell_command(
};
#[cfg(not(unix))]
let mut build_cmd = |with_breakaway: bool| {
let build_cmd = |with_breakaway: bool| {
use windows::Win32::System::Threading::{
CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW,
};
@@ -0,0 +1,7 @@
//! `agent_swarm` tool — one prompt template over many items, run as a fleet.
pub mod plan;
pub mod run;
pub mod schedule;
pub mod tool;
pub use tool::{AGENT_SWARM_TOOL_NAME, AgentSwarmTool};
@@ -0,0 +1,377 @@
//! Turning `agent_swarm` input into member specs, and member results back
//! into one tool result. Pure: no spawning, no I/O.
use kigi_tool_types::{
AgentSwarmToolInput, MAX_AGENT_SWARM_MEMBERS, PROMPT_TEMPLATE_PLACEHOLDER, SwarmMemberOutcome,
SwarmMemberResult,
};
/// One member's launch instructions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberSpec {
/// The `items` entry, or the agent id for a resume — the label the caller
/// sees in the result and reuses to retry this exact member.
pub item: String,
pub prompt: String,
/// Set for a resume; `None` spawns a fresh member.
pub resume_from: Option<String>,
}
/// Everything wrong with the input is reported before anything is spawned:
/// a half-launched swarm is far more expensive to recover from than a refused
/// tool call.
pub fn plan_members(input: &AgentSwarmToolInput) -> Result<Vec<MemberSpec>, String> {
let resumes = input.resume_agent_ids.len();
if input.items.is_empty() && resumes == 0 {
return Err(
"agent_swarm needs `items` (with `prompt_template`) or `resume_agent_ids`; \
use the task tool for a single subagent"
.to_string(),
);
}
if resumes == 0 && input.items.len() < 2 {
return Err(
"agent_swarm runs 2 or more members; use the task tool for a single subagent"
.to_string(),
);
}
if input.items.len() + resumes > MAX_AGENT_SWARM_MEMBERS {
return Err(format!(
"agent_swarm runs at most {MAX_AGENT_SWARM_MEMBERS} members, got {}",
input.items.len() + resumes
));
}
// Models emit `""`/`"null"`/`"none"` where they mean "no id"; accepting one
// spawns a resume that can only die inside the coordinator.
if let Some(bad) = input
.resume_agent_ids
.keys()
.find(|id| !crate::implementations::kigi::task::types::is_valid_resume_id(id))
{
return Err(format!(
"`resume_agent_ids` key {bad:?} is not a subagent id; use the agent_id \
values from a previous agent_swarm result"
));
}
// Resumes first: they already hold context, so they reach the provider
// before the fresh members compete for the same rate-limit budget.
let mut specs: Vec<MemberSpec> = input
.resume_agent_ids
.iter()
.map(|(agent_id, prompt)| MemberSpec {
item: agent_id.clone(),
prompt: prompt.clone(),
resume_from: Some(agent_id.clone()),
})
.collect();
if !input.items.is_empty() {
let template = input
.prompt_template
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.ok_or("agent_swarm requires `prompt_template` whenever `items` is given")?;
if !template.contains(PROMPT_TEMPLATE_PLACEHOLDER) {
return Err(format!(
"`prompt_template` must contain the literal {PROMPT_TEMPLATE_PLACEHOLDER}, \
which is replaced by each entry of `items`"
));
}
for item in &input.items {
if item.trim().is_empty() {
return Err("`items` entries must be non-empty".to_string());
}
specs.push(MemberSpec {
item: item.clone(),
prompt: template.replace(PROMPT_TEMPLATE_PLACEHOLDER, item),
resume_from: None,
});
}
}
// Two members handed the same prompt do the same work twice and, if it
// writes, race each other over the same files.
let mut seen = std::collections::HashSet::with_capacity(specs.len());
for spec in &specs {
if !seen.insert(spec.prompt.as_str()) {
return Err(format!(
"two members would run an identical prompt (from item {:?}); \
give every member a distinct scope",
spec.item
));
}
}
Ok(specs)
}
/// Per-member ceiling on rendered summary text.
///
/// 128 members' full outputs concatenated can exceed the context window at
/// exactly the moment the results matter. Native tool output is not truncated
/// anywhere downstream — only the MCP dispatcher does that — so the budget has
/// to live here. Each member keeps its head and its tail: the head says what it
/// did, the tail usually holds the verdict.
const MEMBER_SUMMARY_BUDGET: usize = 4_000;
/// Trims to [`MEMBER_SUMMARY_BUDGET`] on a char boundary, keeping both ends and
/// saying plainly how much was dropped.
fn clamp_summary(summary: &str) -> String {
if summary.len() <= MEMBER_SUMMARY_BUDGET {
return summary.to_string();
}
let keep = MEMBER_SUMMARY_BUDGET / 2;
let head_end = (0..=keep)
.rev()
.find(|i| summary.is_char_boundary(*i))
.unwrap_or(0);
let tail_start = (summary.len() - keep..summary.len())
.find(|i| summary.is_char_boundary(*i))
.unwrap_or(summary.len());
format!(
"{}\n… {} bytes omitted; read this member's full output with the task-output tool …\n{}",
&summary[..head_end],
summary.len() - head_end - (summary.len() - tail_start),
&summary[tail_start..]
)
}
/// Renders the fleet's results as one tool result.
///
/// A failed member is reported inside the aggregate rather than failing the
/// call: the caller needs the members that DID succeed, and needs to know
/// precisely which ones to retry.
pub fn render_results(results: &[SwarmMemberResult]) -> String {
let count = |wanted: SwarmMemberOutcome| results.iter().filter(|r| r.outcome == wanted).count();
let completed = count(SwarmMemberOutcome::Completed);
let failed = count(SwarmMemberOutcome::Failed);
let aborted = count(SwarmMemberOutcome::Aborted);
let backgrounded = count(SwarmMemberOutcome::Backgrounded);
let mut out = String::from("<agent_swarm_result>\n");
out.push_str(&format!(
"<summary>completed: {completed}, failed: {failed}, aborted: {aborted}, \
still running: {backgrounded}</summary>\n"
));
if backgrounded > 0 {
out.push_str(
"<still_running>Some members outlived the foreground budget and are still \
working. Do NOT re-launch their items — a second agent on the same files \
corrupts both. Poll them with the task-output tool instead.</still_running>\n",
);
}
if results.iter().any(SwarmMemberResult::is_resumable) {
out.push_str(
"<resume_hint>Call agent_swarm again with resume_agent_ids mapping the agent_id \
values below to a follow-up prompt to continue unfinished work.</resume_hint>\n",
);
}
for result in results {
out.push_str("<member");
if let Some(id) = &result.agent_id {
out.push_str(&format!(" agent_id=\"{}\"", escape_attr(id)));
}
out.push_str(&format!(
" item=\"{}\" state=\"{}\" outcome=\"{}\"",
escape_attr(&result.item),
if result.started() {
"started"
} else {
"not_started"
},
result.outcome.as_str()
));
if result.resumed {
out.push_str(" mode=\"resume\"");
}
out.push_str(">\n");
out.push_str(clamp_summary(result.summary.trim()).trim());
out.push_str("\n</member>\n");
}
out.push_str("</agent_swarm_result>");
out
}
/// Attribute-safe: an item is a model-supplied string and routinely contains
/// quotes or angle brackets (file paths, globs, shell fragments).
fn escape_attr(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
fn input(items: &[&str], template: Option<&str>) -> AgentSwarmToolInput {
AgentSwarmToolInput {
description: "test swarm".into(),
subagent_type: "general-purpose".into(),
prompt_template: template.map(str::to_string),
items: items.iter().map(|s| s.to_string()).collect(),
resume_agent_ids: Default::default(),
model: None,
}
}
#[test]
fn each_item_becomes_one_member_with_the_placeholder_substituted() {
let specs = plan_members(&input(&["a.rs", "b.rs"], Some("Review {{item}} for bugs")))
.expect("valid input");
assert_eq!(
specs,
vec![
MemberSpec {
item: "a.rs".into(),
prompt: "Review a.rs for bugs".into(),
resume_from: None,
},
MemberSpec {
item: "b.rs".into(),
prompt: "Review b.rs for bugs".into(),
resume_from: None,
},
]
);
}
#[test]
fn a_single_item_is_refused_in_favour_of_the_task_tool() {
let err = plan_members(&input(&["only.rs"], Some("Do {{item}}"))).unwrap_err();
assert!(err.contains("2 or more"), "{err}");
}
#[test]
fn a_template_without_the_placeholder_is_refused() {
let err = plan_members(&input(&["a", "b"], Some("Do the work"))).unwrap_err();
assert!(err.contains(PROMPT_TEMPLATE_PLACEHOLDER), "{err}");
}
#[test]
fn items_without_a_template_are_refused() {
let err = plan_members(&input(&["a", "b"], None)).unwrap_err();
assert!(err.contains("prompt_template"), "{err}");
}
#[test]
fn duplicate_expansions_are_refused_before_anything_spawns() {
let err = plan_members(&input(&["same", "same"], Some("Do {{item}}"))).unwrap_err();
assert!(err.contains("identical prompt"), "{err}");
}
#[test]
fn more_members_than_the_ceiling_are_refused() {
let items: Vec<String> = (0..=MAX_AGENT_SWARM_MEMBERS)
.map(|i| format!("item-{i}"))
.collect();
let mut spec = input(&[], Some("Do {{item}}"));
spec.items = items;
let err = plan_members(&spec).unwrap_err();
assert!(err.contains(&MAX_AGENT_SWARM_MEMBERS.to_string()), "{err}");
}
#[test]
fn resumes_are_planned_before_fresh_members() {
let mut spec = input(&["fresh.rs"], Some("Do {{item}}"));
spec.resume_agent_ids
.insert("agent-1".into(), "keep going".into());
let specs = plan_members(&spec).expect("a resume lifts the two-item floor");
assert_eq!(specs[0].resume_from.as_deref(), Some("agent-1"));
assert_eq!(specs[0].prompt, "keep going");
assert_eq!(specs[1].item, "fresh.rs");
}
#[test]
fn an_empty_call_is_refused() {
let err = plan_members(&input(&[], None)).unwrap_err();
assert!(err.contains("resume_agent_ids"), "{err}");
}
fn member(item: &str, outcome: SwarmMemberOutcome, id: Option<&str>) -> SwarmMemberResult {
SwarmMemberResult {
item: item.into(),
agent_id: id.map(str::to_string),
resumed: false,
outcome,
summary: format!("summary for {item}"),
}
}
#[test]
fn the_aggregate_counts_outcomes_and_labels_every_member() {
let rendered = render_results(&[
member("a.rs", SwarmMemberOutcome::Completed, Some("id-a")),
member("b.rs", SwarmMemberOutcome::Failed, Some("id-b")),
]);
assert!(rendered.contains("completed: 1, failed: 1, aborted: 0, still running: 0"));
assert!(
rendered.contains(r#"agent_id="id-a" item="a.rs" state="started" outcome="completed""#)
);
assert!(rendered.contains(r#"outcome="failed""#));
assert!(rendered.contains("summary for b.rs"));
}
#[test]
fn the_resume_hint_appears_only_when_something_resumable_is_unfinished() {
let all_done = render_results(&[member("a", SwarmMemberOutcome::Completed, Some("id-a"))]);
assert!(!all_done.contains("resume_hint"));
let never_started = render_results(&[member("a", SwarmMemberOutcome::Aborted, None)]);
assert!(
!never_started.contains("resume_hint"),
"a member with no agent_id has nothing to resume"
);
let retryable = render_results(&[member("a", SwarmMemberOutcome::Failed, Some("id-a"))]);
assert!(retryable.contains("resume_hint"));
}
#[test]
fn an_item_containing_markup_cannot_break_out_of_its_attribute() {
let rendered = render_results(&[member(
r#"a" onload="x"#,
SwarmMemberOutcome::Completed,
None,
)]);
assert!(!rendered.contains(r#"item="a" onload="#), "{rendered}");
assert!(rendered.contains("&quot;"), "{rendered}");
}
}
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn a_huge_member_output_is_clamped_but_keeps_both_ends() {
let summary = format!("HEAD{}TAIL", "x".repeat(MEMBER_SUMMARY_BUDGET * 2));
let clamped = clamp_summary(&summary);
assert!(clamped.len() < summary.len(), "must shrink");
assert!(clamped.starts_with("HEAD"), "the head says what it did");
assert!(clamped.ends_with("TAIL"), "the tail holds the verdict");
assert!(clamped.contains("bytes omitted"), "the loss must be stated");
}
#[test]
fn a_summary_inside_the_budget_is_untouched() {
assert_eq!(clamp_summary("short"), "short");
}
#[test]
fn clamping_never_splits_a_character() {
// Multi-byte throughout, so a naive byte slice would panic.
let summary = "café ".repeat(MEMBER_SUMMARY_BUDGET);
let clamped = clamp_summary(&summary);
assert!(clamped.len() < summary.len());
}
}
@@ -0,0 +1,811 @@
//! Drives planned members through the backend at the pace
//! [`super::schedule::LaunchPacer`] allows, and collects their results.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
// One clock for the whole runner: `tokio::time::Instant` is the clock the
// sleeps below advance. Mixing it with `std::time::Instant` makes the retry
// windows and the timer disagree — under a paused clock they never converge
// and the loop spins forever.
use tokio::time::Instant;
use futures::stream::{FuturesUnordered, StreamExt};
use kigi_tool_types::{SwarmMemberOutcome, SwarmMemberResult};
use super::plan::MemberSpec;
use super::schedule::{
LAUNCH_INTERVAL, LaunchDecision, LaunchPacer, MAX_RATE_LIMIT_RETRIES, MAX_SWARM_RUNTIME,
retry_backoff,
};
use crate::implementations::kigi::task::backend::SubagentBackend;
use crate::implementations::kigi::task::types::{
ModelOverrideProvenance, SubagentRequest, SubagentResult, SubagentRuntimeOverrides,
};
/// Everything the runner needs that is not per-member.
pub struct SwarmRunConfig {
pub subagent_type: String,
pub description: String,
pub parent_session_id: String,
pub parent_prompt_id: Option<String>,
pub model: Option<String>,
pub cwd: Option<String>,
pub max_concurrency: Option<usize>,
}
/// A member waiting for its turn, plus how often the provider has refused it.
struct Pending {
index: usize,
spec: MemberSpec,
attempts: u32,
/// Earliest instant this member may be retried after a rate limit.
not_before: Option<Instant>,
}
struct Finished {
agent_id: Option<String>,
result: SubagentResult,
}
/// Cancels members that are still running if the swarm's future goes away.
///
/// A send-now interrupt cancels the turn WITHOUT cancelling subagents, then
/// aborts the turn task — which drops this future and closes every member's
/// result channel. The coordinator reads a closed channel as "parent gone" and
/// re-attaches the child as a background task, so without this guard an
/// interrupt silently leaves up to `MAX_AGENT_SWARM_MEMBERS` agents editing the
/// caller's tree with no way to list them. `Drop` cannot await, so the cancels
/// are handed to the runtime; if there is no runtime left to hand them to,
/// nothing can be done and the ids are logged instead of lost silently.
struct InFlightGuard {
backend: Arc<dyn SubagentBackend>,
live: Arc<std::sync::Mutex<std::collections::BTreeSet<String>>>,
}
impl InFlightGuard {
fn track(&self, id: &str) {
self.live
.lock()
.expect("not poisoned")
.insert(id.to_string());
}
fn release(&self, id: &str) {
self.live.lock().expect("not poisoned").remove(id);
}
}
impl Drop for InFlightGuard {
fn drop(&mut self) {
let ids: Vec<String> = self
.live
.lock()
.map(|live| live.iter().cloned().collect())
.unwrap_or_default();
if ids.is_empty() {
return;
}
let backend = self.backend.clone();
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
handle.spawn(async move {
for id in ids {
backend.cancel(&id).await;
}
});
}
Err(_) => tracing::warn!(
orphans = ?ids,
"agent_swarm dropped with no runtime to cancel its members on"
),
}
}
}
/// Runs every member to a terminal state and returns their results in the
/// order they were planned.
pub async fn run_swarm(
backend: Arc<dyn SubagentBackend>,
specs: Vec<MemberSpec>,
config: SwarmRunConfig,
) -> Vec<SwarmMemberResult> {
let started_at = Instant::now();
let total = specs.len();
let labels: Vec<String> = specs.iter().map(|s| s.item.clone()).collect();
let resumed: Vec<bool> = specs.iter().map(|s| s.resume_from.is_some()).collect();
let mut queue: Vec<Pending> = specs
.into_iter()
.enumerate()
.map(|(index, spec)| Pending {
index,
spec,
attempts: 0,
not_before: None,
})
.collect();
queue.reverse(); // pop() takes the earliest-planned member first
let mut pacer = LaunchPacer::new(total, config.max_concurrency);
let mut in_flight = FuturesUnordered::new();
let mut done: HashMap<usize, Finished> = HashMap::with_capacity(total);
// The id each member was last launched under, so one abandoned at the wall
// clock is still reportable as a live agent rather than an anonymous gap.
let mut launched: Vec<Option<String>> = vec![None; total];
let guard = InFlightGuard {
backend: backend.clone(),
live: Arc::new(std::sync::Mutex::new(std::collections::BTreeSet::new())),
};
while done.len() < total {
let now = started_at.elapsed();
if now >= MAX_SWARM_RUNTIME {
tracing::warn!(
elapsed_s = now.as_secs(),
unfinished = total - done.len(),
"agent_swarm hit its wall clock; reporting unfinished members"
);
break;
}
let decision = if queue.is_empty() {
LaunchDecision::Drained
} else {
pacer.poll(now)
};
match decision {
LaunchDecision::Launch => {
// A member cooling off after a rate limit is not eligible yet;
// rotate it behind one that is rather than idling the fleet.
let Some(pending) = take_ready(&mut queue) else {
// Every queued member is cooling off. Whichever comes first
// — a slot freeing or the soonest retry opening — is the
// event worth waking for; awaiting only the in-flight side
// would park a member with a 3s backoff behind one with ten
// minutes left to run.
let wait = soonest_retry(&queue);
if in_flight.is_empty() {
match wait {
Some(wait) => tokio::time::sleep(wait).await,
// Unreachable: a member that is not ready has a
// `not_before`. Break rather than spin if it ever is.
None => break,
}
continue;
}
tokio::select! {
() = tokio::time::sleep(wait.unwrap_or(LAUNCH_INTERVAL)) => {}
Some(item) = in_flight.next() => {
settle(item, &guard, &mut pacer, &mut queue, &mut done, started_at);
}
}
continue;
};
let request = build_request(&pending, &config);
let agent_id = request.id.clone();
let index = pending.index;
let attempts = pending.attempts;
let spec = pending.spec;
let backend = backend.clone();
guard.track(&agent_id);
launched[index] = Some(agent_id.clone());
pacer.on_launched(now);
in_flight.push(async move {
let result = backend.spawn(request).await;
(index, agent_id, attempts, spec, result)
});
}
LaunchDecision::Wait(delay) => {
if in_flight.is_empty() {
tokio::time::sleep(delay).await;
} else {
// A finishing member frees a slot sooner than the timer.
tokio::select! {
() = tokio::time::sleep(delay) => {}
Some(item) = in_flight.next() => {
settle(item, &guard, &mut pacer, &mut queue, &mut done, started_at);
}
}
}
}
LaunchDecision::Drained => {
if in_flight.is_empty() {
break;
}
collect_one(
&mut in_flight,
&guard,
&mut pacer,
&mut queue,
&mut done,
started_at,
)
.await;
}
}
}
(0..total)
.map(|index| match done.remove(&index) {
Some(finished) => to_member_result(
labels[index].clone(),
resumed[index],
finished.agent_id,
finished.result,
),
// Reached when the swarm hit its wall clock: the member is alive
// and unaccounted for, which is exactly `Backgrounded` — never
// offer it for resume, and never invite a relaunch of its item.
None => SwarmMemberResult {
item: labels[index].clone(),
agent_id: launched[index].clone(),
resumed: resumed[index],
outcome: SwarmMemberOutcome::Backgrounded,
summary: "Still running when the swarm reached its time limit.".to_string(),
},
})
.collect()
}
type InFlight = (
usize,
String,
u32,
MemberSpec,
Result<SubagentResult, kigi_tool_runtime::ToolError>,
);
async fn collect_one(
in_flight: &mut FuturesUnordered<impl Future<Output = InFlight>>,
guard: &InFlightGuard,
pacer: &mut LaunchPacer,
queue: &mut Vec<Pending>,
done: &mut HashMap<usize, Finished>,
started_at: Instant,
) {
if let Some(item) = in_flight.next().await {
settle(item, guard, pacer, queue, done, started_at);
}
}
/// Files one finished member: either terminal, or re-queued because the
/// provider — not the work — refused it.
fn settle(
(index, agent_id, attempts, spec, outcome): InFlight,
guard: &InFlightGuard,
pacer: &mut LaunchPacer,
queue: &mut Vec<Pending>,
done: &mut HashMap<usize, Finished>,
started_at: Instant,
) {
guard.release(&agent_id);
let now = started_at.elapsed();
let mut transport_failed = false;
let result = match outcome {
Ok(result) => result,
Err(err) => {
transport_failed = true;
SubagentResult {
success: false,
error: Some(err.to_string()),
..Default::default()
}
}
};
if result.rate_limited && attempts < MAX_RATE_LIMIT_RETRIES {
pacer.on_rate_limited(now);
queue.push(Pending {
index,
spec,
attempts: attempts + 1,
not_before: Some(Instant::now() + retry_backoff(attempts)),
});
return;
}
pacer.on_finished();
// A transport failure means no child was ever created, so there is no id
// to resume; prefer the coordinator's own id when it minted one.
let agent_id = match (transport_failed, result.subagent_id.as_str()) {
(true, _) => None,
(false, "") => Some(agent_id),
(false, minted) => Some(minted.to_string()),
};
done.insert(index, Finished { agent_id, result });
}
/// The earliest-planned member whose retry window has opened.
fn take_ready(queue: &mut Vec<Pending>) -> Option<Pending> {
let now = Instant::now();
let position = (0..queue.len())
.rev()
.find(|&i| queue[i].not_before.is_none_or(|at| at <= now))?;
Some(queue.remove(position))
}
fn soonest_retry(queue: &[Pending]) -> Option<Duration> {
let now = Instant::now();
queue
.iter()
.filter_map(|p| p.not_before)
.map(|at| at.saturating_duration_since(now))
.min()
}
fn build_request(pending: &Pending, config: &SwarmRunConfig) -> SubagentRequest {
let (result_tx, _) = tokio::sync::oneshot::channel();
let resume_from = pending.spec.resume_from.clone();
SubagentRequest {
id: uuid::Uuid::now_v7().to_string(),
prompt: pending.spec.prompt.clone(),
// The ITEM, not the swarm's description: every member would otherwise
// render as an identical subagent block and the user could not tell
// which one is running, or which one failed.
description: pending.spec.item.clone(),
subagent_type: config.subagent_type.clone(),
parent_session_id: config.parent_session_id.clone(),
parent_prompt_id: config.parent_prompt_id.clone(),
cwd: config.cwd.clone(),
runtime_overrides: SubagentRuntimeOverrides {
// A resume inherits the source member's model, so an override here
// would be dropped by the coordinator anyway.
model: resume_from
.is_none()
.then(|| config.model.clone())
.flatten(),
model_override_provenance: ModelOverrideProvenance::Tool,
reasoning_effort: None,
persona: None,
capability_mode: None,
// Members share the caller's tree: 128 worktrees is not viable, and
// the distinct-prompt rule is what keeps them off each other's files.
isolation: None,
harness_agent_type: None,
},
resume_from,
// The swarm owns its members' lifetimes: it awaits every one of them
// before returning, so none may outlive the tool call.
run_in_background: false,
surface_completion: true,
fork_context: false,
result_tx,
}
}
fn to_member_result(
item: String,
resumed: bool,
agent_id: Option<String>,
result: SubagentResult,
) -> SwarmMemberResult {
let outcome = if result.backgrounded {
// Checked FIRST: the coordinator reports a detached member with
// `success: false` and no output, which is indistinguishable from a
// failure by every other field.
SwarmMemberOutcome::Backgrounded
} else if result.success {
SwarmMemberOutcome::Completed
} else if result.cancelled {
SwarmMemberOutcome::Aborted
} else {
SwarmMemberOutcome::Failed
};
let output = result.output.trim();
let summary = match (&result.error, outcome) {
(_, SwarmMemberOutcome::Backgrounded) => format!(
"Still running in the background; its result is not part of this call.{}",
if output.is_empty() {
String::new()
} else {
format!("\nProgress so far:\n{output}")
}
),
// Why it ended is the load-bearing half for anything that did not
// complete — "max turns reached" must not be swallowed by whatever
// text the member happened to emit last.
(Some(error), SwarmMemberOutcome::Failed | SwarmMemberOutcome::Aborted) => {
if output.is_empty() {
error.clone()
} else {
format!("{error}\n{output}")
}
}
_ if output.is_empty() => "Member produced no output.".to_string(),
_ => output.to_string(),
};
SwarmMemberResult {
item,
agent_id,
resumed,
outcome,
summary,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::implementations::kigi::task::backend::SubagentBackend;
use crate::implementations::kigi::task::types::{
SubagentCancelOutcome, SubagentDescribeOutcome, SubagentSnapshot,
SubagentValidateTypeOutcome,
};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Records every prompt it is asked to run, and answers from a script
/// keyed by prompt so a member can be refused once and then succeed.
#[derive(Default)]
struct FakeBackend {
seen: Mutex<Vec<String>>,
/// prompt -> remaining rate-limit rejections before it succeeds.
refuse: Mutex<HashMap<String, u32>>,
/// prompts that always fail outright.
fail: Mutex<Vec<String>>,
/// prompts the coordinator detaches instead of finishing.
background: Mutex<Vec<String>>,
/// prompt -> how long it occupies its slot, so members can overlap.
duration_ms: Mutex<HashMap<String, u64>>,
cancelled: Mutex<Vec<String>>,
peak_in_flight: AtomicUsize,
in_flight: AtomicUsize,
}
impl FakeBackend {
fn prompts(&self) -> Vec<String> {
self.seen.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl SubagentBackend for FakeBackend {
async fn spawn(
&self,
request: SubagentRequest,
) -> Result<SubagentResult, kigi_tool_runtime::ToolError> {
let live = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
self.peak_in_flight.fetch_max(live, Ordering::SeqCst);
self.seen.lock().unwrap().push(request.prompt.clone());
let refuse_now = {
let mut refuse = self.refuse.lock().unwrap();
match refuse.get_mut(&request.prompt) {
Some(remaining) if *remaining > 0 => {
*remaining -= 1;
true
}
_ => false,
}
};
let fails = self.fail.lock().unwrap().contains(&request.prompt);
let backgrounds = self.background.lock().unwrap().contains(&request.prompt);
let hold = self
.duration_ms
.lock()
.unwrap()
.get(&request.prompt)
.copied()
.unwrap_or(0);
// Yield for a real (virtual) interval so `FuturesUnordered` can
// actually interleave: without an await point every member runs to
// completion inside its own poll and nothing overlaps.
tokio::time::sleep(Duration::from_millis(hold.max(1))).await;
self.in_flight.fetch_sub(1, Ordering::SeqCst);
if backgrounds {
return Ok(SubagentResult {
success: false,
backgrounded: true,
subagent_id: request.id.clone(),
child_session_id: "child".into(),
..Default::default()
});
}
if refuse_now {
return Ok(SubagentResult {
success: false,
rate_limited: true,
child_session_id: "child".into(),
error: Some("Session error: Rate limited".into()),
..Default::default()
});
}
if fails {
return Ok(SubagentResult {
success: false,
error: Some("it broke".into()),
..Default::default()
});
}
Ok(SubagentResult {
success: true,
output: Arc::from(format!("done: {}", request.prompt)),
subagent_id: request.id.clone(),
child_session_id: "child".into(),
..Default::default()
})
}
async fn query(&self, _: &str, _: bool, _: Option<u64>) -> Option<SubagentSnapshot> {
None
}
async fn cancel(&self, id: &str) -> SubagentCancelOutcome {
self.cancelled.lock().unwrap().push(id.to_string());
SubagentCancelOutcome::NotFound
}
async fn validate_type(&self, _: &str, _: &str) -> SubagentValidateTypeOutcome {
SubagentValidateTypeOutcome::Ok
}
async fn describe_subagent_type(
&self,
_: &str,
_: Option<&str>,
_: &str,
) -> SubagentDescribeOutcome {
SubagentDescribeOutcome::Unavailable
}
}
fn specs(items: &[&str]) -> Vec<MemberSpec> {
items
.iter()
.map(|item| MemberSpec {
item: (*item).to_string(),
prompt: format!("work on {item}"),
resume_from: None,
})
.collect()
}
fn config(max_concurrency: Option<usize>) -> SwarmRunConfig {
SwarmRunConfig {
subagent_type: "general-purpose".into(),
description: "test swarm".into(),
parent_session_id: "parent".into(),
parent_prompt_id: None,
model: None,
cwd: None,
max_concurrency,
}
}
#[tokio::test(start_paused = true)]
async fn every_member_runs_and_results_come_back_in_plan_order() {
let backend = Arc::new(FakeBackend::default());
// Finish in the exact reverse of plan order, so a runner that reported
// completion order instead would fail this.
for (item, ms) in [("a", 300), ("b", 200), ("c", 100)] {
backend
.duration_ms
.lock()
.unwrap()
.insert(format!("work on {item}"), ms);
}
let results = run_swarm(backend.clone(), specs(&["a", "b", "c"]), config(None)).await;
assert_eq!(
results.iter().map(|r| r.item.as_str()).collect::<Vec<_>>(),
vec!["a", "b", "c"],
"results must be ordered as planned, not by completion"
);
assert!(
results
.iter()
.all(|r| r.outcome == SwarmMemberOutcome::Completed)
);
assert_eq!(backend.prompts().len(), 3);
}
#[tokio::test(start_paused = true)]
async fn a_failing_member_does_not_sink_the_others() {
let backend = Arc::new(FakeBackend::default());
backend.fail.lock().unwrap().push("work on b".into());
let results = run_swarm(backend, specs(&["a", "b", "c"]), config(None)).await;
assert_eq!(results[0].outcome, SwarmMemberOutcome::Completed);
assert_eq!(results[1].outcome, SwarmMemberOutcome::Failed);
assert_eq!(results[2].outcome, SwarmMemberOutcome::Completed);
assert!(results[1].summary.contains("it broke"));
}
#[tokio::test(start_paused = true)]
async fn a_rate_limited_member_is_retried_not_discarded() {
let backend = Arc::new(FakeBackend::default());
backend.refuse.lock().unwrap().insert("work on b".into(), 1);
let results = run_swarm(backend.clone(), specs(&["a", "b"]), config(None)).await;
assert!(
results
.iter()
.all(|r| r.outcome == SwarmMemberOutcome::Completed),
"the refused member must succeed on its retry: {results:?}"
);
assert_eq!(
backend
.prompts()
.iter()
.filter(|p| *p == "work on b")
.count(),
2,
"the refused member must be attempted exactly twice"
);
}
#[tokio::test(start_paused = true)]
async fn the_operator_cap_bounds_concurrency() {
let hold_all = || {
let backend = Arc::new(FakeBackend::default());
for item in ["a", "b", "c", "d"] {
backend
.duration_ms
.lock()
.unwrap()
.insert(format!("work on {item}"), 100);
}
backend
};
let capped = hold_all();
run_swarm(
capped.clone(),
specs(&["a", "b", "c", "d"]),
config(Some(2)),
)
.await;
assert!(
capped.peak_in_flight.load(Ordering::SeqCst) <= 2,
"cap of 2 exceeded: peak was {}",
capped.peak_in_flight.load(Ordering::SeqCst)
);
let uncapped = hold_all();
run_swarm(uncapped.clone(), specs(&["a", "b", "c", "d"]), config(None)).await;
assert!(
uncapped.peak_in_flight.load(Ordering::SeqCst) > 2,
"the fixture must be able to exceed the cap, or the assertion above proves nothing"
);
}
/// A member the coordinator detached is still running: reporting it as a
/// failure invites the model to relaunch its item, putting a second agent
/// on the same files.
#[tokio::test(start_paused = true)]
async fn a_backgrounded_member_is_not_reported_as_failed_or_offered_for_resume() {
let backend = Arc::new(FakeBackend::default());
backend.background.lock().unwrap().push("work on b".into());
let results = run_swarm(backend, specs(&["a", "b"]), config(None)).await;
assert_eq!(results[1].outcome, SwarmMemberOutcome::Backgrounded);
assert!(
!results[1].is_resumable(),
"a live member must never be offered for resume"
);
assert!(
results[1].summary.contains("Still running"),
"{}",
results[1].summary
);
}
/// Each member must be identifiable while it runs: the TUI renders one
/// subagent block per member from this description, so a shared one leaves
/// the user staring at N identical rows.
#[tokio::test(start_paused = true)]
async fn each_member_is_labelled_with_its_own_item() {
let backend = Arc::new(FakeBackend::default());
let seen = Arc::new(Mutex::new(Vec::new()));
let recorder = seen.clone();
let specs = specs(&["a.rs", "b.rs"]);
let config = SwarmRunConfig {
description: "review files".into(),
..config(None)
};
// `build_request` is the only place the label is set, so assert on it
// directly rather than through the backend's prompt log.
for (index, spec) in specs.iter().enumerate() {
let pending = Pending {
index,
spec: spec.clone(),
attempts: 0,
not_before: None,
};
recorder
.lock()
.unwrap()
.push(build_request(&pending, &config).description);
}
assert_eq!(
*seen.lock().unwrap(),
vec!["a.rs".to_string(), "b.rs".to_string()],
"each member must carry its own item, not the swarm description"
);
drop(backend);
}
/// Dropping the runner is what a send-now interrupt does; the members must
/// be cancelled rather than silently detached onto the user's tree.
#[tokio::test(start_paused = true)]
async fn dropping_the_swarm_cancels_its_live_members() {
let backend = Arc::new(FakeBackend::default());
for item in ["a", "b"] {
backend
.duration_ms
.lock()
.unwrap()
.insert(format!("work on {item}"), 10_000);
}
tokio::select! {
_ = run_swarm(backend.clone(), specs(&["a", "b"]), config(None)) => {
panic!("members hold their slots for 10s; the swarm cannot finish first")
}
() = tokio::time::sleep(Duration::from_millis(50)) => {}
}
// The guard hands its cancels to the runtime; let them run.
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(
!backend.cancelled.lock().unwrap().is_empty(),
"a dropped swarm must cancel the members it started"
);
}
#[tokio::test(start_paused = true)]
async fn a_permanently_refused_member_fails_instead_of_hanging_the_turn() {
let backend = Arc::new(FakeBackend::default());
// Refuses far more often than the retry bound allows.
backend
.refuse
.lock()
.unwrap()
.insert("work on a".into(), 100);
let results = run_swarm(backend.clone(), specs(&["a", "b"]), config(None)).await;
assert_eq!(
results[0].outcome,
SwarmMemberOutcome::Failed,
"the swarm blocks the caller's turn, so retries must be bounded"
);
assert_eq!(
results[1].outcome,
SwarmMemberOutcome::Completed,
"one exhausted member must not sink its siblings"
);
assert_eq!(
backend
.prompts()
.iter()
.filter(|p| *p == "work on a")
.count() as u32,
MAX_RATE_LIMIT_RETRIES + 1,
"the first attempt plus exactly the retry budget"
);
}
#[tokio::test(start_paused = true)]
async fn a_resume_member_carries_its_source_id() {
let backend = Arc::new(FakeBackend::default());
let specs = vec![
MemberSpec {
item: "agent-7".into(),
prompt: "keep going".into(),
resume_from: Some("agent-7".into()),
},
MemberSpec {
item: "fresh".into(),
prompt: "start here".into(),
resume_from: None,
},
];
let results = run_swarm(backend, specs, config(None)).await;
assert!(results[0].resumed);
assert!(!results[1].resumed);
}
}
@@ -0,0 +1,348 @@
//! Launch pacing for a swarm, as pure state: no I/O, no clock, no tasks.
//!
//! A fan-out of N members hits ONE provider at once, so the launch order is
//! the difference between a swarm that runs and a swarm that 429s itself to
//! death. The runner asks [`LaunchPacer`] when it may start the next member
//! and reports rate limits back; everything here is decided arithmetically so
//! the policy is unit-testable without spawning an agent.
use std::time::Duration;
/// Members allowed to start with no wait at all.
pub const INITIAL_LAUNCH_BURST: usize = 5;
/// Spacing between launches once the burst is spent.
pub const LAUNCH_INTERVAL: Duration = Duration::from_millis(700);
/// First wait before re-attempting a rate-limited member.
pub const RETRY_MIN_BACKOFF: Duration = Duration::from_secs(3);
/// Cap on a single member's retry wait; a provider window outlasting this is
/// better spent letting other members through than sleeping longer.
pub const RETRY_MAX_BACKOFF: Duration = Duration::from_secs(120);
/// Quiet period after which the fleet regains one lost capacity slot.
pub const CAPACITY_RECOVERY: Duration = Duration::from_secs(180);
/// Shortest gap between two capacity reductions, so one provider window that
/// rejects several members in a burst costs one slot, not all of them.
pub const CAPACITY_SHRINK_COOLDOWN: Duration = Duration::from_secs(2);
/// Env override for the concurrency ceiling; unset means the ramp is the only
/// brake. A value that does not parse as a positive integer is a hard error:
/// silently ignoring it would run an unbounded fan-out the operator forbade.
pub const MAX_CONCURRENCY_ENV: &str = "KIGI_AGENT_SWARM_MAX_CONCURRENCY";
/// Resolves [`MAX_CONCURRENCY_ENV`], failing loudly on a malformed value.
pub fn max_concurrency_from_env(raw: Option<&str>) -> Result<Option<usize>, String> {
let Some(raw) = raw.map(str::trim).filter(|v| !v.is_empty()) else {
return Ok(None);
};
match raw.parse::<usize>() {
Ok(0) | Err(_) => Err(format!(
"{MAX_CONCURRENCY_ENV} must be a positive integer, got {raw:?}"
)),
Ok(value) => Ok(Some(value)),
}
}
/// What the runner should do right now.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchDecision {
/// Start the next queued member immediately.
Launch,
/// Nothing may start yet; wait this long and ask again.
Wait(Duration),
/// Every member has been started.
Drained,
}
/// The launch ramp plus the fleet's rate-limit response.
///
/// Time is injected as a monotonic `now` so tests drive it directly.
#[derive(Debug)]
pub struct LaunchPacer {
queued: usize,
in_flight: usize,
started: usize,
interval: Duration,
hard_cap: Option<usize>,
/// `None` until the first rate limit: before that the ramp alone paces us.
capacity: Option<usize>,
last_launch: Option<Duration>,
last_shrink: Option<Duration>,
last_rate_limit: Option<Duration>,
}
impl LaunchPacer {
pub fn new(queued: usize, hard_cap: Option<usize>) -> Self {
Self {
queued,
in_flight: 0,
started: 0,
interval: LAUNCH_INTERVAL,
hard_cap,
capacity: None,
last_launch: None,
last_shrink: None,
last_rate_limit: None,
}
}
/// The ceiling in force now: the operator's cap and the rate-limit-derived
/// capacity both apply, whichever is lower.
fn ceiling(&self) -> Option<usize> {
match (self.hard_cap, self.capacity) {
(Some(a), Some(b)) => Some(a.min(b)),
(only, None) | (None, only) => only,
}
}
pub fn poll(&mut self, now: Duration) -> LaunchDecision {
self.recover_capacity(now);
if self.queued == 0 {
return LaunchDecision::Drained;
}
if let Some(ceiling) = self.ceiling()
&& self.in_flight >= ceiling
{
// Held by capacity, not by the clock: only a member finishing (or
// the recovery timer) can release this, so poll on the recovery
// grain rather than spinning.
return LaunchDecision::Wait(self.recovery_wait(now));
}
if self.started < INITIAL_LAUNCH_BURST {
return LaunchDecision::Launch;
}
match self.last_launch {
Some(last) if now.saturating_sub(last) < self.interval => {
LaunchDecision::Wait(self.interval - now.saturating_sub(last))
}
_ => LaunchDecision::Launch,
}
}
/// Record that the runner acted on a [`LaunchDecision::Launch`].
pub fn on_launched(&mut self, now: Duration) {
self.queued = self.queued.saturating_sub(1);
self.in_flight += 1;
self.started += 1;
self.last_launch = Some(now);
}
/// Record that a member reached a terminal state.
pub fn on_finished(&mut self) {
self.in_flight = self.in_flight.saturating_sub(1);
}
/// Record that a member was rejected for rate limiting and re-queued.
///
/// The fleet loses one slot (never below one) at most once per
/// [`CAPACITY_SHRINK_COOLDOWN`].
///
/// Upstream also doubles the launch interval when the rejected member never
/// reached the provider. Kigi cannot observe that: every rejection arrives
/// through a child session that DID start, so the branch would be dead code
/// — and it ratchets one way, with no path back from a two-minute interval.
pub fn on_rate_limited(&mut self, now: Duration) {
self.queued += 1;
self.in_flight = self.in_flight.saturating_sub(1);
self.last_rate_limit = Some(now);
let cooling = self
.last_shrink
.is_some_and(|last| now.saturating_sub(last) < CAPACITY_SHRINK_COOLDOWN);
if !cooling {
let current = self.capacity.unwrap_or(self.in_flight.max(1));
self.capacity = Some(current.saturating_sub(1).max(1));
self.last_shrink = Some(now);
}
}
/// One slot back per quiet [`CAPACITY_RECOVERY`] window, until the cap is
/// no longer the binding constraint.
fn recover_capacity(&mut self, now: Duration) {
let (Some(capacity), Some(last)) = (self.capacity, self.last_rate_limit) else {
return;
};
if now.saturating_sub(last) < CAPACITY_RECOVERY {
return;
}
self.last_rate_limit = Some(now);
self.capacity = Some(capacity + 1);
}
fn recovery_wait(&self, now: Duration) -> Duration {
let elapsed = self
.last_rate_limit
.map(|last| now.saturating_sub(last))
.unwrap_or_default();
CAPACITY_RECOVERY.saturating_sub(elapsed).max(self.interval)
}
}
/// Ceiling on the whole swarm's wall clock.
///
/// Per-member bounds do not bound the fleet: a member may hold its slot for the
/// full foreground budget and then be re-queued, so a large swarm can otherwise
/// hold the caller's turn for hours. At the deadline the runner stops launching
/// and reports whatever has not finished, with ids, rather than waiting on.
pub const MAX_SWARM_RUNTIME: Duration = Duration::from_secs(30 * 60);
/// Rejections a single member may absorb before the runner gives up on it.
///
/// The swarm blocks the caller's turn, so every retry path needs a bound it
/// cannot argue its way past: a provider that refuses one member indefinitely
/// must surface as a failed member the caller can retry deliberately, never as
/// a turn that hangs.
pub const MAX_RATE_LIMIT_RETRIES: u32 = 5;
/// Per-member exponential backoff, capped. `attempt` counts prior rejections.
pub fn retry_backoff(attempt: u32) -> Duration {
RETRY_MIN_BACKOFF
.saturating_mul(2u32.saturating_pow(attempt.min(6)))
.min(RETRY_MAX_BACKOFF)
}
#[cfg(test)]
mod tests {
use super::*;
fn ms(v: u64) -> Duration {
Duration::from_millis(v)
}
#[test]
fn the_first_five_members_launch_without_waiting() {
let mut pacer = LaunchPacer::new(10, None);
for _ in 0..INITIAL_LAUNCH_BURST {
assert_eq!(pacer.poll(ms(0)), LaunchDecision::Launch);
pacer.on_launched(ms(0));
}
assert_eq!(
pacer.poll(ms(0)),
LaunchDecision::Wait(LAUNCH_INTERVAL),
"the sixth member must wait out the ramp interval"
);
}
#[test]
fn the_ramp_admits_one_member_per_interval() {
let mut pacer = LaunchPacer::new(10, None);
for _ in 0..INITIAL_LAUNCH_BURST {
pacer.on_launched(ms(0));
}
assert_eq!(pacer.poll(ms(699)), LaunchDecision::Wait(ms(1)));
assert_eq!(pacer.poll(ms(700)), LaunchDecision::Launch);
}
#[test]
fn an_operator_cap_binds_before_the_ramp() {
let mut pacer = LaunchPacer::new(10, Some(2));
pacer.on_launched(ms(0));
pacer.on_launched(ms(0));
assert!(
matches!(pacer.poll(ms(0)), LaunchDecision::Wait(_)),
"a cap of 2 must not admit a third member even inside the burst"
);
pacer.on_finished();
assert_eq!(pacer.poll(ms(0)), LaunchDecision::Launch);
}
#[test]
fn a_rate_limit_requeues_the_member_and_costs_one_slot() {
let mut pacer = LaunchPacer::new(4, None);
for _ in 0..4 {
pacer.on_launched(ms(0));
}
pacer.on_rate_limited(ms(1_000));
assert_eq!(pacer.capacity, Some(2), "3 in flight, minus the lost slot");
assert!(
matches!(pacer.poll(ms(1_000)), LaunchDecision::Wait(_)),
"3 in flight against a capacity of 2 must not admit the requeued member"
);
}
#[test]
fn a_burst_of_rejections_costs_one_slot_not_all_of_them() {
let mut pacer = LaunchPacer::new(6, None);
for _ in 0..6 {
pacer.on_launched(ms(0));
}
pacer.on_rate_limited(ms(1_000));
let after_first = pacer.capacity;
pacer.on_rate_limited(ms(1_500));
assert_eq!(
pacer.capacity, after_first,
"a second rejection inside the cooldown must not shrink again"
);
pacer.on_rate_limited(ms(4_000));
assert_eq!(
pacer.capacity,
after_first.map(|c| c - 1),
"past the cooldown the fleet gives up another slot"
);
}
#[test]
fn capacity_never_reaches_zero() {
let mut pacer = LaunchPacer::new(3, None);
pacer.on_launched(ms(0));
for i in 0..10 {
pacer.on_rate_limited(Duration::from_secs(10 * (i + 1)));
}
assert_eq!(
pacer.capacity,
Some(1),
"a fleet with no slots could never make progress"
);
}
#[test]
fn quiet_time_returns_a_lost_slot() {
let mut pacer = LaunchPacer::new(4, None);
for _ in 0..3 {
pacer.on_launched(ms(0));
}
pacer.on_rate_limited(ms(1_000));
let shrunk = pacer.capacity.expect("shrunk");
pacer.poll(ms(1_000) + CAPACITY_RECOVERY);
assert_eq!(pacer.capacity, Some(shrunk + 1));
}
#[test]
fn the_retry_bound_is_reachable_within_the_backoff_cap() {
// The bound must terminate in bounded time, not merely be finite.
let worst: Duration = (0..MAX_RATE_LIMIT_RETRIES).map(retry_backoff).sum();
assert!(
worst <= RETRY_MAX_BACKOFF * MAX_RATE_LIMIT_RETRIES,
"worst-case retry time {worst:?} must stay inside the per-attempt cap"
);
}
#[test]
fn draining_is_reported_once_every_member_has_started() {
let mut pacer = LaunchPacer::new(1, None);
pacer.on_launched(ms(0));
assert_eq!(pacer.poll(ms(0)), LaunchDecision::Drained);
}
#[test]
fn backoff_grows_then_stops_at_the_cap() {
assert_eq!(retry_backoff(0), RETRY_MIN_BACKOFF);
assert_eq!(retry_backoff(1), RETRY_MIN_BACKOFF * 2);
assert_eq!(retry_backoff(2), RETRY_MIN_BACKOFF * 4);
assert_eq!(retry_backoff(30), RETRY_MAX_BACKOFF);
}
#[test]
fn a_malformed_concurrency_cap_is_refused_not_ignored() {
assert_eq!(max_concurrency_from_env(None), Ok(None));
assert_eq!(max_concurrency_from_env(Some(" ")), Ok(None));
assert_eq!(max_concurrency_from_env(Some("4")), Ok(Some(4)));
assert!(max_concurrency_from_env(Some("0")).is_err());
assert!(max_concurrency_from_env(Some("many")).is_err());
assert!(max_concurrency_from_env(Some("-1")).is_err());
}
}
@@ -0,0 +1,502 @@
//! The `agent_swarm` tool: validate, then run every member to completion.
use kigi_tool_types::AgentSwarmToolInput;
use super::plan::{plan_members, render_results};
use super::run::{SwarmRunConfig, run_swarm};
use super::schedule::{MAX_CONCURRENCY_ENV, max_concurrency_from_env};
use crate::implementations::kigi::task::MAX_SUBAGENT_DEPTH;
use crate::implementations::kigi::task::backend::SubagentBackendResource;
use crate::implementations::kigi::task::types::{
CurrentPromptIdResource, SessionIdResource, SubagentDepthCounter, SubagentValidateTypeOutcome,
TaskModelValidator,
};
use crate::types::output::ToolOutput;
use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::tool::{ToolKind, ToolNamespace};
const DESCRIPTION: &str = "\
Run one prompt over many independent work items at once, as a fleet of subagents.
Give a `prompt_template` containing the literal {{item}} and an `items` list; each entry \
becomes one subagent whose prompt is the template with {{item}} substituted. The call \
returns only when every member has finished, with each member's result labelled by its item.
Use this when the work splits into 2 or more INDEPENDENT scopes — separate files, separate \
directories, separate questions. Every member must have a distinct scope: members share one \
working tree with no isolation, so two members told to edit the same file will corrupt each \
other's work. Read-only scopes may overlap freely.
For a single item, use the subagent (task) tool instead. To continue members from an earlier \
swarm, pass `resume_agent_ids` mapping the agent_id values from that swarm's result to a \
follow-up prompt.";
/// Registry name, so gating code matches on one definition rather than a
/// literal that can drift from the tool id.
pub const AGENT_SWARM_TOOL_NAME: &str = "agent_swarm";
#[derive(Debug, Default)]
pub struct AgentSwarmTool;
impl crate::types::tool_metadata::ToolMetadata for AgentSwarmTool {
fn kind(&self) -> ToolKind {
ToolKind::AgentSwarm
}
fn tool_namespace(&self) -> ToolNamespace {
ToolNamespace::Kigi
}
fn description_template(&self) -> &str {
DESCRIPTION
}
fn requires_expr(&self) -> Expr<ToolRequirement> {
// Members are subagents, so the same background-task management tools
// the `task` tool depends on must be present.
Expr::And(vec![
Expr::Value(ToolRequirement::tool_kind(ToolKind::BackgroundTaskAction)),
Expr::Value(ToolRequirement::tool_kind(ToolKind::KillTaskAction)),
])
}
fn is_read_only(&self) -> bool {
false
}
}
impl kigi_tool_runtime::Tool for AgentSwarmTool {
type Args = AgentSwarmToolInput;
type Output = ToolOutput;
fn id(&self) -> kigi_tool_protocol::ToolId {
kigi_tool_protocol::ToolId::new(AGENT_SWARM_TOOL_NAME).expect("valid tool id")
}
fn description(
&self,
_ctx: &::kigi_tool_runtime::ListToolsContext,
) -> kigi_tool_types::ToolDescription {
kigi_tool_types::ToolDescription::new(AGENT_SWARM_TOOL_NAME, DESCRIPTION)
}
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
kigi_tool_protocol::ToolCapabilities {
is_read_only: false,
tool_scope: Some(kigi_tool_protocol::ToolScope::Write),
..Default::default()
}
}
#[tracing::instrument(
name = "tool.agent_swarm",
skip_all,
fields(
subagent_type = %input.subagent_type,
members = input.items.len() + input.resume_agent_ids.len(),
)
)]
async fn run(
&self,
ctx: kigi_tool_runtime::ToolCallContext,
input: AgentSwarmToolInput,
) -> Result<ToolOutput, kigi_tool_runtime::ToolError> {
use crate::types::tool_metadata::shared_resources;
let resources = shared_resources(&ctx)?;
let (depth, backend, model_validator, parent_session_id, parent_prompt_id) = {
let res = resources.lock().await;
let depth = res.get::<SubagentDepthCounter>().map(|d| d.0).unwrap_or(0);
let model_validator = res.get::<TaskModelValidator>().cloned();
let backend = res
.get::<SubagentBackendResource>()
.ok_or_else(|| {
kigi_tool_runtime::ToolError::custom(
"missing_resource",
"SubagentBackendResource (subagent support not initialized)",
)
})?
.clone();
let parent_session_id = res
.get::<SessionIdResource>()
.map(|s| s.0.clone())
.unwrap_or_default();
let parent_prompt_id = res
.get::<CurrentPromptIdResource>()
.map(|p| p.0.clone())
.filter(|prompt_id| !prompt_id.is_empty());
(
depth,
backend,
model_validator,
parent_session_id,
parent_prompt_id,
)
};
if depth >= MAX_SUBAGENT_DEPTH {
return Err(kigi_tool_runtime::ToolError::invalid_arguments(format!(
"Subagent depth limit exceeded (current depth: {depth}, max: {MAX_SUBAGENT_DEPTH}). \
A subagent cannot start a swarm."
)));
}
let max_concurrency =
max_concurrency_from_env(std::env::var(MAX_CONCURRENCY_ENV).ok().as_deref())
.map_err(kigi_tool_runtime::ToolError::invalid_arguments)?;
// Every input fault is reported before a single member starts: a
// half-launched swarm costs real tokens to unwind, and one bad model
// slug would otherwise fan out into as many failures as there are items.
let model = kigi_tool_types::sanitize_optional_arg(input.model.clone());
if let Some(requested) = model.as_deref() {
// Same contract as the `task` tool: an explicitly requested model
// that cannot be checked is refused, not waved through. Skipping
// silently would trade one loud error for `items.len()` quiet ones.
let validator = model_validator.ok_or_else(|| {
kigi_tool_runtime::ToolError::custom(
"validation_unavailable",
"Cannot validate agent_swarm.model: model catalog validator is unavailable.",
)
})?;
if let Some(error) = validator.error_for(requested) {
return Err(kigi_tool_runtime::ToolError::invalid_arguments(error));
}
}
let specs =
plan_members(&input).map_err(kigi_tool_runtime::ToolError::invalid_arguments)?;
match backend
.0
.validate_type(&input.subagent_type, &parent_session_id)
.await
{
SubagentValidateTypeOutcome::Ok => {}
SubagentValidateTypeOutcome::Unknown { available } => {
let suffix = if available.is_empty() {
String::new()
} else {
format!(". Available types: {}", available.join(", "))
};
return Err(kigi_tool_runtime::ToolError::invalid_arguments(format!(
"Unknown subagent type: {}{suffix}",
input.subagent_type
)));
}
SubagentValidateTypeOutcome::Disabled => {
return Err(kigi_tool_runtime::ToolError::invalid_arguments(format!(
"Subagent '{}' is disabled via [subagents.toggle] in config.toml",
input.subagent_type
)));
}
SubagentValidateTypeOutcome::NotAllowed { allowed } => {
return Err(kigi_tool_runtime::ToolError::invalid_arguments(format!(
"agent can only spawn: {}; '{}' not allowed",
allowed.join(", "),
input.subagent_type
)));
}
SubagentValidateTypeOutcome::ValidationUnavailable => {
// `custom` (not `invalid_arguments`) so the model doesn't
// retry with a different name on transport faults.
return Err(kigi_tool_runtime::ToolError::custom(
"validation_unavailable",
format!(
"Cannot validate subagent type '{}': the subagent coordinator is \
unreachable. Retry shortly or notify ops.",
input.subagent_type
),
));
}
}
let config = SwarmRunConfig {
subagent_type: input.subagent_type.clone(),
description: input.description.clone(),
parent_session_id,
parent_prompt_id,
model,
// Members share the caller's tree; see `run::build_request`.
cwd: None,
max_concurrency,
};
let results = run_swarm(backend.0.clone(), specs, config).await;
Ok(ToolOutput::Text(render_results(&results).into()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::implementations::kigi::task::backend::ChannelBackend;
use crate::implementations::kigi::task::types::{SubagentEvent, SubagentResult};
use crate::types::resources::Resources;
use crate::types::tool_metadata::test_ctx;
use kigi_env::EnvVarGuard;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::mpsc;
/// What the coordinator was actually asked to do — a refusal that still
/// reached the backend is the failure these tests exist to catch.
#[derive(Default)]
struct Seen {
validations: AtomicUsize,
spawns: AtomicUsize,
}
impl Seen {
fn validations(&self) -> usize {
self.validations.load(Ordering::SeqCst)
}
fn spawns(&self) -> usize {
self.spawns.load(Ordering::SeqCst)
}
}
/// `run` resolves the operator cap from the process environment, which every
/// test in this binary shares. Each test therefore pins the variable for its
/// duration through [`EnvVarGuard`], whose lock also serializes them against
/// each other. The tests are synchronous so that lock never spans an await.
fn cap_unset() -> EnvVarGuard {
EnvVarGuard::remove(MAX_CONCURRENCY_ENV)
}
fn swarm_input(items: &[&str]) -> AgentSwarmToolInput {
AgentSwarmToolInput {
description: "test swarm".into(),
subagent_type: "general-purpose".into(),
prompt_template: Some("Review {{item}} for bugs".into()),
items: items.iter().map(|s| (*s).to_string()).collect(),
resume_agent_ids: Default::default(),
model: None,
}
}
/// Drives one whole tool call against a live channel backend that answers
/// `ValidateType` with `validate` and completes every spawn.
fn call(
depth: u32,
validate: SubagentValidateTypeOutcome,
input: AgentSwarmToolInput,
) -> (Result<ToolOutput, String>, Arc<Seen>) {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let mut resources = Resources::new();
resources.insert(SubagentBackendResource(Arc::new(ChannelBackend::new(tx))));
resources.insert(SubagentDepthCounter(depth));
resources.insert(SessionIdResource("parent-session".to_string()));
resources.insert(CurrentPromptIdResource("prompt-1".to_string()));
let seen = Arc::new(Seen::default());
let recorder = seen.clone();
let result = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime")
.block_on(async move {
let pump = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
SubagentEvent::ValidateType(req) => {
recorder.validations.fetch_add(1, Ordering::SeqCst);
let _ = req.respond_to.send(validate.clone());
}
SubagentEvent::Spawn(req) => {
recorder.spawns.fetch_add(1, Ordering::SeqCst);
let _ = req.result_tx.send(SubagentResult {
success: true,
output: Arc::from(format!("done: {}", req.prompt)),
subagent_id: req.id.clone(),
child_session_id: req.id.clone(),
..Default::default()
});
}
_ => {}
}
}
});
let out = kigi_tool_runtime::Tool::run(
&AgentSwarmTool,
test_ctx(resources.into_shared()),
input,
)
.await
.map_err(|e| e.to_string());
pump.abort();
out
});
(result, seen)
}
#[test]
fn every_member_runs_and_the_call_returns_one_aggregate() {
let _cap = cap_unset();
let (result, seen) = call(
0,
SubagentValidateTypeOutcome::Ok,
swarm_input(&["a.rs", "b.rs"]),
);
match result.expect("a valid swarm runs") {
ToolOutput::Text(text) => {
assert!(
text.text
.contains("completed: 2, failed: 0, aborted: 0, still running: 0"),
"{}",
text.text
);
assert!(text.text.contains(r#"item="a.rs""#), "{}", text.text);
assert!(text.text.contains(r#"item="b.rs""#), "{}", text.text);
}
other => panic!("expected Text output, got {other:?}"),
}
assert_eq!(seen.spawns(), 2, "one member per item");
}
/// A swarm child must never start its own swarm: kigi caps subagent nesting
/// at one level, and a fan-out tool is exactly how that cap would be lost —
/// 128 members each starting 128 more.
#[test]
fn a_child_at_the_depth_ceiling_cannot_start_a_swarm() {
let _cap = cap_unset();
let (result, seen) = call(
MAX_SUBAGENT_DEPTH,
SubagentValidateTypeOutcome::Ok,
swarm_input(&["a.rs", "b.rs"]),
);
let err = result.expect_err("a child must not fan out");
assert!(err.contains("depth limit exceeded"), "error: {err}");
assert_eq!(seen.spawns(), 0, "no member may reach the coordinator");
assert_eq!(
seen.validations(),
0,
"the coordinator must not even be asked to validate"
);
}
/// An operator who set a concurrency ceiling must never silently get an
/// unbounded fan-out because the value failed to parse.
#[test]
fn a_malformed_operator_cap_fails_the_call_rather_than_being_ignored() {
let _cap = EnvVarGuard::set(MAX_CONCURRENCY_ENV, "lots");
let (result, seen) = call(
0,
SubagentValidateTypeOutcome::Ok,
swarm_input(&["a.rs", "b.rs"]),
);
let err = result.expect_err("a cap that does not parse must reject the call");
assert!(err.contains(MAX_CONCURRENCY_ENV), "error: {err}");
assert!(err.contains("positive integer"), "error: {err}");
assert_eq!(
seen.spawns(),
0,
"an unparseable cap must stop the swarm before anything spawns"
);
}
/// A well-formed cap is honoured rather than rejected — the fail-fast path
/// above must not swallow valid operator configuration.
#[test]
fn a_well_formed_operator_cap_still_runs_the_swarm() {
let _cap = EnvVarGuard::set(MAX_CONCURRENCY_ENV, "1");
let (result, seen) = call(
0,
SubagentValidateTypeOutcome::Ok,
swarm_input(&["a.rs", "b.rs"]),
);
assert!(result.is_ok(), "a valid cap must not fail the call");
assert_eq!(seen.spawns(), 2, "every member still runs, just serially");
}
/// `plan_members` rejections reach the model as invalid_arguments, and
/// nothing spawns.
/// An explicitly requested model that cannot be checked is refused, so a
/// bad slug fails once here instead of once per member.
#[test]
fn an_unvalidatable_model_fails_the_call_before_any_member_spawns() {
let _guard = cap_unset();
let input = AgentSwarmToolInput {
model: Some("nonexistent/model".into()),
..swarm_input(&["a.rs", "b.rs"])
};
let (result, seen) = call(0, SubagentValidateTypeOutcome::Ok, input);
let err = result.expect_err("no validator resource is registered in this fixture");
assert!(err.contains("validate"), "{err}");
assert_eq!(
seen.spawns(),
0,
"nothing may spawn on an unvalidated model"
);
}
/// The discriminating partner: with no model requested the same call runs.
#[test]
fn omitting_the_model_leaves_the_swarm_runnable() {
let _guard = cap_unset();
let (result, seen) = call(
0,
SubagentValidateTypeOutcome::Ok,
swarm_input(&["a.rs", "b.rs"]),
);
assert!(result.is_ok(), "{result:?}");
assert_eq!(seen.spawns(), 2);
}
#[test]
fn a_single_item_is_refused_in_favour_of_the_task_tool() {
let _cap = cap_unset();
let (result, seen) = call(
0,
SubagentValidateTypeOutcome::Ok,
swarm_input(&["only.rs"]),
);
let err = result.expect_err("one item is a task, not a swarm");
assert!(err.contains("task tool"), "error: {err}");
assert_eq!(seen.spawns(), 0, "nothing may spawn");
}
#[test]
fn an_unknown_subagent_type_is_refused_before_any_member_spawns() {
let _cap = cap_unset();
let mut input = swarm_input(&["a.rs", "b.rs"]);
input.subagent_type = "invented-agent".into();
let (result, seen) = call(
0,
SubagentValidateTypeOutcome::Unknown {
available: vec!["general-purpose".to_string(), "explore".to_string()],
},
input,
);
let err = result.expect_err("an unknown type must reject");
assert!(
err.contains("Unknown subagent type: invented-agent"),
"error: {err}"
);
assert!(err.contains("explore"), "error: {err}");
assert_eq!(seen.validations(), 1, "the type is validated exactly once");
assert_eq!(seen.spawns(), 0, "no member may spawn");
}
#[test]
fn a_missing_backend_is_reported_rather_than_silently_skipped() {
let _cap = cap_unset();
let result = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime")
.block_on(kigi_tool_runtime::Tool::run(
&AgentSwarmTool,
test_ctx(Resources::new().into_shared()),
swarm_input(&["a.rs", "b.rs"]),
));
let err = result.expect_err("no backend must error").to_string();
assert!(err.contains("SubagentBackendResource"), "error: {err}");
}
}
@@ -358,18 +358,20 @@ impl kigi_tool_runtime::Tool for GrepTool {
tracing::Span::current().record("wall_ms", started.elapsed().as_millis() as u64);
tracing::warn!(timeout_secs = timeout.as_secs(), "grep timed out");
let _ = child.start_kill();
let _ = child.wait().await;
crate::util::reap_killed_search_child(&mut child).await;
return Ok(grep_timeout_output(timeout.as_secs()));
}
};
// When `stdout_truncated`, `rg` is already killed (inside the timeout
// block, before the stderr drain); this only reaps it.
let status = child.wait().await.ok();
// Truncated output means `rg` was already killed (inside the timeout
// block, before the stderr drain) — bounded reap, and the exit code is
// defined as 0. A natural EOF means rg is exiting, so the plain wait
// is prompt.
let exit_code = if stdout_truncated {
crate::util::reap_killed_search_child(&mut child).await;
0
} else {
status.and_then(|s| s.code()).unwrap_or(-1)
child.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1)
};
tracing::Span::current().record("early_kill", stdout_truncated);
@@ -529,7 +531,7 @@ fn grep_progress_stream(
tracing::warn!(timeout_secs = secs, "grep timed out");
});
let _ = child.start_kill();
let _ = child.wait().await;
crate::util::reap_killed_search_child(&mut child).await;
// Finalize what was read (marked truncated) plus an explicit
// notice, so the terminal card does not contradict what was already
// streamed; with nothing streamed, the timeout-only card suffices.
@@ -578,11 +580,14 @@ fn grep_progress_stream(
.await;
}
let status = child.wait().await.ok();
// Truncated output means `rg` was already killed above — bounded reap,
// and the exit code is defined as 0. A natural EOF means rg is
// exiting, so the plain wait is prompt.
let exit_code = if stdout_truncated {
crate::util::reap_killed_search_child(&mut child).await;
0
} else {
status.and_then(|s| s.code()).unwrap_or(-1)
child.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1)
};
let wall_ms = stream_started.elapsed().as_millis() as u64;
@@ -1,5 +1,6 @@
//! Tool implementations built on the `NewTool` trait; the sibling
//! `implementations/<tool>/` modules hold the `Tool`-trait counterparts.
pub mod agent_swarm;
pub mod ask_user_question;
pub mod bash;
#[path = "deploy_app_stub.rs"]
@@ -21,6 +22,7 @@ pub mod todo;
pub mod update_goal;
pub mod web_fetch;
pub mod web_search;
pub use agent_swarm::{AGENT_SWARM_TOOL_NAME, AgentSwarmTool};
pub use ask_user_question::AskUserQuestionTool;
pub use bash::BashTool;
pub use deploy_app::{AppBuilderDeployerConfig, DEPLOY_APP_TOOL_NAME};
@@ -0,0 +1,117 @@
//! Session-scoped subagent spawn limits, enforced by the shell's spawn path.
use crate::util::env::parse_positive_env;
pub const DEFAULT_MAX_CONCURRENT: usize = 32;
/// What happens to a spawn that arrives at the concurrent limit.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LimitBehavior {
#[default]
Queue,
Fail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubagentLimits {
/// Subagents one session may run at once. Zero does not disable the
/// limit: [`Self::effective_max_concurrent`] clamps it to 1.
pub max_concurrent: usize,
pub behavior: LimitBehavior,
}
impl Default for SubagentLimits {
fn default() -> Self {
Self {
max_concurrent: DEFAULT_MAX_CONCURRENT,
behavior: LimitBehavior::Queue,
}
}
}
impl SubagentLimits {
/// Read once at the composition root; inject everywhere else.
pub fn from_env() -> Self {
Self::from_lookup(|var| std::env::var(var).ok())
}
/// A limit can be adjusted but never disabled.
fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
let default = Self::default();
let max_concurrent = parse_positive_env(
"KIGI_MAX_CONCURRENT_SUBAGENTS",
lookup("KIGI_MAX_CONCURRENT_SUBAGENTS"),
)
.unwrap_or(default.max_concurrent);
let behavior = match lookup("KIGI_SUBAGENT_LIMIT_BEHAVIOR") {
None => LimitBehavior::Queue,
Some(value) if value.eq_ignore_ascii_case("fail") => LimitBehavior::Fail,
Some(value) if value.eq_ignore_ascii_case("queue") => LimitBehavior::Queue,
Some(value) => {
tracing::warn!(
%value,
"KIGI_SUBAGENT_LIMIT_BEHAVIOR is neither `queue` nor `fail`; keeping `queue`"
);
LimitBehavior::Queue
}
};
Self {
max_concurrent,
behavior,
}
}
/// The semaphore size: a zero limit must still admit one child, or
/// queued spawns starve.
pub fn effective_max_concurrent(&self) -> usize {
self.max_concurrent.max(1)
}
}
#[cfg(test)]
mod tests {
use super::{LimitBehavior, SubagentLimits};
#[test]
fn limits_accept_plain_positive_digits_and_ignore_everything_else() {
let from = |concurrent: Option<&str>, behavior: Option<&str>| {
SubagentLimits::from_lookup(|var| {
match var {
"KIGI_MAX_CONCURRENT_SUBAGENTS" => concurrent,
"KIGI_SUBAGENT_LIMIT_BEHAVIOR" => behavior,
other => panic!("unexpected lookup: {other}"),
}
.map(str::to_owned)
})
};
assert_eq!(from(None, None), SubagentLimits::default());
assert_eq!(
from(Some("5"), Some("FAIL")),
SubagentLimits {
max_concurrent: 5,
behavior: LimitBehavior::Fail,
}
);
// Adjustable but never disabled: zero, negatives, non-digits,
// scientific notation, digit separators, and out-of-range values all
// keep defaults.
for ignored in ["0", "-1", "abc", "1e3", "20_000", "18446744073709551616"] {
assert_eq!(
from(Some(ignored), Some("nonsense")),
SubagentLimits::default(),
"value {ignored:?} should fall back to the defaults"
);
}
}
#[test]
fn a_zero_limit_is_clamped_to_one_not_disabled() {
let limits = SubagentLimits {
max_concurrent: 0,
behavior: LimitBehavior::Queue,
};
assert_eq!(limits.effective_max_concurrent(), 1);
assert_eq!(SubagentLimits::default().effective_max_concurrent(), 32);
}
}
@@ -12,6 +12,7 @@
//! - `SessionIdResource` — current session ID for parent scoping (optional)
//! - `TaskModelValidator` — validates explicit model slugs before spawn
pub mod admission;
pub mod backend;
pub mod types;
@@ -149,10 +149,12 @@ pub trait SubagentCapabilityModeExt {
pub fn prune_orphaned_background_task_tools(config: &mut crate::registry::types::ToolServerConfig) {
use crate::types::tool::ToolKind;
// A swarm member can be auto-backgrounded by the coordinator just like a
// `task` child, so either spawner keeps the lifecycle tools alive.
let has_task_tool = config
.tools
.iter()
.any(|tc| tc.kind == Some(ToolKind::Task));
.any(|tc| matches!(tc.kind, Some(ToolKind::Task | ToolKind::AgentSwarm)));
let has_background_capable_bash = config.tools.iter().any(is_background_capable_bash_tool);
if has_task_tool || has_background_capable_bash {
return;
@@ -209,6 +211,7 @@ impl SubagentCapabilityModeExt for SubagentCapabilityMode {
ToolKind::BackgroundTaskAction,
ToolKind::KillTaskAction,
ToolKind::Task,
ToolKind::AgentSwarm,
ToolKind::EnterPlan,
ToolKind::ExitPlan,
ToolKind::AskUser,
@@ -232,6 +235,7 @@ impl SubagentCapabilityModeExt for SubagentCapabilityMode {
ToolKind::BackgroundTaskAction,
ToolKind::KillTaskAction,
ToolKind::Task,
ToolKind::AgentSwarm,
ToolKind::EnterPlan,
ToolKind::ExitPlan,
ToolKind::AskUser,
@@ -252,6 +256,7 @@ impl SubagentCapabilityModeExt for SubagentCapabilityMode {
ToolKind::BackgroundTaskAction,
ToolKind::KillTaskAction,
ToolKind::Task,
ToolKind::AgentSwarm,
ToolKind::EnterPlan,
ToolKind::ExitPlan,
ToolKind::AskUser,
@@ -276,6 +281,7 @@ impl SubagentCapabilityModeExt for SubagentCapabilityMode {
ToolKind::BackgroundTaskAction,
ToolKind::KillTaskAction,
ToolKind::Task,
ToolKind::AgentSwarm,
ToolKind::EnterPlan,
ToolKind::ExitPlan,
ToolKind::AskUser,
@@ -315,6 +321,12 @@ pub struct SubagentResult {
/// `get_command_or_subagent_output`), so the tool returns a `task_id` notice
/// instead of a completion. Never set for natively backgrounded subagents.
pub backgrounded: bool,
/// The child's turn ended because the PROVIDER refused it for rate
/// limiting, not because the work failed. Classified where the typed ACP
/// error code is still in hand: a fleet scheduler that had to re-derive
/// this from the formatted `error` string would silently stop adapting
/// the day that wording changes.
pub rate_limited: bool,
}
impl Default for SubagentResult {
@@ -324,6 +336,7 @@ impl Default for SubagentResult {
output: Arc::from(""),
error: None,
cancelled: false,
rate_limited: false,
subagent_id: String::new(),
child_session_id: String::new(),
tool_calls: 0,
@@ -155,6 +155,9 @@ impl WebFetchClient {
}
}
// Before any egress: the service must not see this.
ssrf::check_ssrf(&url, self.params.allow_local()).await?;
// Kimi fetch service first (OAuth sessions); local pipeline is the
// fallback on any service failure (kimi-cli fetch.py `__call__`).
if let Some(service_url) = self.params.service_url.clone() {
@@ -182,10 +185,15 @@ impl WebFetchClient {
}
}
ssrf::check_ssrf(&url).await?;
let http = self.http.get_or_rebuild()?;
let result = match fetch_url(&http, &url, self.params.max_content_length()).await {
let result = match fetch_url(
&http,
&url,
self.params.max_content_length(),
self.params.allow_local(),
)
.await
{
Ok(result) => result,
Err(e @ WebFetchError::HttpRequest(_)) => {
self.http.invalidate();
@@ -380,6 +388,8 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
if let Some(host) = parsed.host_str()
&& host.split('.').count() < 2
// `localhost` is single-label; SSRF still gates it on allow_local.
&& !ssrf::is_explicit_local_host(host)
{
return Err(WebFetchError::SingleLabelHost {
host: host.to_string(),
@@ -390,9 +400,16 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
}
fn upgrade_to_https(url: &mut Url) {
if url.scheme() == "http" {
let _ = url.set_scheme("https");
if url.scheme() != "http" {
return;
}
// Local dev servers rarely serve TLS; SSRF still gates them.
if let Some(host) = url.host_str()
&& ssrf::is_explicit_local_host(host)
{
return;
}
let _ = url.set_scheme("https");
}
enum FetchResult {
@@ -409,15 +426,21 @@ enum FetchResult {
}
/// Fetch a URL with manual same-host redirect handling.
///
/// Every hop is re-checked, so a rebinding name cannot pass.
/// Partial: reqwest hides the peer IP of the live connection.
async fn fetch_url(
client: &reqwest::Client,
url: &Url,
max_content_length: usize,
allow_local: bool,
) -> Result<FetchResult, WebFetchError> {
let mut current_url = url.clone();
let mut hops = 0;
loop {
ssrf::check_ssrf(&current_url, allow_local).await?;
let resp = client
.get(current_url.as_str())
.header(USER_AGENT, USER_AGENT_STRING)
@@ -439,10 +462,13 @@ async fn fetch_url(
if let Some(location) = resp.headers().get("location") {
let location_str = location.to_str().unwrap_or("");
let next_url = current_url
let mut next_url = current_url
.join(location_str)
.map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?;
if is_same_host(&current_url, &next_url) {
// An absolute `http://` Location would downgrade the hop.
upgrade_to_https(&mut next_url);
// check_ssrf runs at the top of the next iteration.
current_url = next_url;
continue;
}
@@ -479,13 +505,11 @@ async fn fetch_url(
}
}
/// Exact host equality, no `www.` stripping.
///
/// A `www` sibling has separate records, so it is cross-host.
fn is_same_host(a: &Url, b: &Url) -> bool {
fn strip_www(h: &str) -> &str {
h.strip_prefix("www.").unwrap_or(h)
}
let host_a = a.host_str().unwrap_or("");
let host_b = b.host_str().unwrap_or("");
strip_www(host_a) == strip_www(host_b)
a.host_str() == b.host_str()
}
fn require_media_session_folder(session_folder: Option<&Path>) -> Result<&Path, WebFetchError> {
@@ -963,6 +987,78 @@ mod tests {
assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}");
}
/// A blocked target must never reach the remote fetch service.
///
/// It egresses elsewhere, so posting leaks an internal URL.
#[tokio::test]
async fn a_blocked_url_never_reaches_the_fetch_service() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/fetch"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&server)
.await;
let provider = crate::types::api_key_provider::test_support::fixed_provider("t");
let params = WebFetchParams {
service_url: Some(format!("{}/fetch", server.uri())),
..WebFetchParams::default()
};
let client = WebFetchClient::new(&params, Some(provider)).unwrap();
let Err(err) = client
.fetch(
"http://localhost:8080/admin?token=secret",
"c",
None,
None,
None,
)
.await
else {
panic!("a loopback target must be blocked");
};
assert!(matches!(err, WebFetchError::SsrfBlocked { .. }), "{err}");
assert!(
server.received_requests().await.unwrap().is_empty(),
"the internal URL must never be posted anywhere"
);
}
/// `fetch_url` gates its own target, not trusting its caller.
///
/// Only hop one is covered: same-host hops share one verdict,
/// so rebinding between them needs a live resolver to observe.
#[tokio::test]
async fn fetch_url_blocks_a_loopback_target_without_allow_local() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/x"))
.respond_with(ResponseTemplate::new(200).set_body_string("<p>local</p>"))
.mount(&server)
.await;
let url = Url::parse(&format!("{}/x", server.uri())).unwrap();
let http = HttpClient::new(&WebFetchParams::default())
.unwrap()
.get_or_rebuild()
.unwrap();
let Err(err) = fetch_url(&http, &url, 1_000_000, false).await else {
panic!("a blocked host must not be fetched");
};
assert!(matches!(err, WebFetchError::SsrfBlocked { .. }), "{err}");
assert!(
server.received_requests().await.unwrap().is_empty(),
"a blocked host must not be contacted at all"
);
let ok = fetch_url(&http, &url, 1_000_000, true).await.unwrap();
assert!(matches!(ok, FetchResult::Content { .. }));
}
fn test_converter() -> htmd::HtmlToMarkdown {
htmd::HtmlToMarkdown::builder()
.skip_tags(vec![
@@ -1024,9 +1120,12 @@ mod tests {
#[test]
fn validate_url_rejects_single_label_hosts() {
assert!(validate_url("http://localhost:8080/foo").is_err());
assert!(validate_url("http://intranet/foo").is_err());
assert!(validate_url("http://metadata/computeMetadata").is_err());
assert!(
validate_url("http://localhost:8080/foo").is_ok(),
"localhost reaches the SSRF gate, which blocks it unless opted in"
);
}
#[test]
@@ -1068,6 +1167,19 @@ mod tests {
assert_eq!(url.scheme(), "https");
}
/// Upgrading a local host breaks the only target `allow_local` opens.
#[test]
fn upgrade_to_https_skips_explicit_local_hosts() {
for raw in ["http://127.0.0.1:8080/", "http://localhost:3000/"] {
let mut url = Url::parse(raw).unwrap();
upgrade_to_https(&mut url);
assert_eq!(url.scheme(), "http", "{raw}");
}
let mut public = Url::parse("http://example.com/").unwrap();
upgrade_to_https(&mut public);
assert_eq!(public.scheme(), "https");
}
#[test]
fn same_host_exact_match() {
let a = Url::parse("https://example.com/a").unwrap();
@@ -1075,12 +1187,24 @@ mod tests {
assert!(is_same_host(&a, &b));
}
/// An absolute `http://` Location must not downgrade a followed hop.
#[test]
fn same_host_www_stripping() {
fn same_host_redirect_location_reupgrades_http() {
let origin = Url::parse("https://example.com/start").unwrap();
let mut next = origin.join("http://example.com/next").unwrap();
assert_eq!(next.scheme(), "http");
assert!(is_same_host(&origin, &next));
upgrade_to_https(&mut next);
assert_eq!(next.as_str(), "https://example.com/next");
}
/// A `www` sibling is a separate name, with separate records.
#[test]
fn www_subdomain_is_cross_host() {
let a = Url::parse("https://example.com/a").unwrap();
let c = Url::parse("https://www.example.com/a").unwrap();
assert!(is_same_host(&a, &c));
assert!(is_same_host(&c, &a));
assert!(!is_same_host(&a, &c));
assert!(!is_same_host(&c, &a));
}
#[test]
@@ -10,7 +10,7 @@ use crate::register_resource;
pub const MAX_URL_LENGTH: usize = 2_000;
pub const MAX_REDIRECTS: usize = 10;
pub const USER_AGENT_STRING: &str =
"Mozilla/5.0 (compatible; kigi-agent/1.0; +https://github.com/ZacharyZhang-NY/Kigi-CLI)";
"Mozilla/5.0 (compatible; kigi-agent/1.0; +https://kigicli.dev)";
/// Runtime-configurable parameters for the `web_fetch` tool.
///
@@ -47,12 +47,20 @@ pub struct WebFetchParams {
/// on any failure (kimi-cli `tools/web/fetch.py FetchURL.__call__`).
#[serde(default)]
pub service_url: Option<String>,
/// Opt-in for loopback targets; off means no local access.
#[serde(default)]
pub allow_local: Option<bool>,
}
register_resource!("kigi", "WebFetch", WebFetchParams);
// Keep defaults here so call-sites don't have to manage unwrapping.
impl WebFetchParams {
/// From config or `KIGI_WEB_FETCH_ALLOW_LOCAL`, never tool input.
pub fn allow_local(&self) -> bool {
self.allow_local.unwrap_or(false)
}
pub fn cache_ttl_secs(&self) -> Duration {
Duration::from_secs(self.cache_ttl_secs.unwrap_or(15 * 60))
}
@@ -14,7 +14,7 @@ pub mod domain;
pub mod error;
mod http;
pub(crate) mod overflow;
mod ssrf;
pub mod ssrf;
pub use client::WebFetchClient;
pub use config::WebFetchParams;
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more