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.
56 lines
4.0 KiB
JSON
56 lines
4.0 KiB
JSON
{
|
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
"title": "CanonicalToolMeta",
|
|
"description": "The canonical tool-identity envelope, attached to a tool-call event `_meta`\nas one nested object under [`TOOL_META_KEY`].\n\n```json\n\"kigi/tool\": {\n \"version\": 1,\n \"name\": \"read_file\",\n \"kind\": \"read\",\n \"namespace\": \"kigi\",\n \"label\": \"Read\",\n \"read_only\": true,\n \"input\": { \"path\": \"...\" }\n}\n```\n\nConsumer contract:\n- **`label`** is the cross-harness grouping/display key: equivalent tools\n share it (kigi `read_file` → `\"Read\"`).\n- **`kind`** is a finer discriminator (`metadata.kind()`), *not* guaranteed\n equal for equivalent ops across harnesses (listing is `list` in one\n toolset, `list_dir` in another); prefer `label` to join, tolerate unknowns.\n- **`name`** is the harness-specific model-facing name; for diagnostics.\n For harness-initiated events (e.g. the `bash_mode` marker), `raw_input`\n is not guaranteed to match `name`'s schema.\n- **`input`** is a canonical *projection*, not a mirror: cross-harness keys\n only, so some raw fields are intentionally dropped (e.g. grep flags,\n `replace_all`), and bulky payload\n fields (edit `old_string`/`new_string`, full write contents) are never\n projected — read them from `raw_input`. It is omitted entirely\n when no stable shape exists (MCP / dynamic / out-of-scope). When a field or\n the whole dict is absent, fall back to `raw_input` on this or an earlier\n update for the same `tool_call_id` (some updates, e.g. a parse failure,\n carry neither and rely on the merge below).\n- **Lifecycle:** updates for one call share a `tool_call_id` — merge across\n them (last write wins); `input` may arrive on a later update.\n- **Versioning:** additive changes (new object fields, new `kind` / `label`\n values) don't bump `version`. Unknown `kind` degrades to `\"other\"`;\n `namespace` is a closed enum (no `other` sink), so a new toolset fails\n strict typed deserialization of the whole envelope — intentional, to force\n typed consumers with exhaustive matches to update. Out-of-tree consumers\n should read `namespace` loosely (as a string) and, on any `kigi/tool`\n parse failure, treat it as absent and fall back to `raw_input` + the ACP\n `kind`. `version` bumps only on removal or meaning change.",
|
|
"type": "object",
|
|
"properties": {
|
|
"version": {
|
|
"type": "integer",
|
|
"format": "uint32",
|
|
"minimum": 0
|
|
},
|
|
"name": {
|
|
"type": "string"
|
|
},
|
|
"kind": {
|
|
"$ref": "#/definitions/ToolKind"
|
|
},
|
|
"namespace": {
|
|
"$ref": "#/definitions/ToolNamespace"
|
|
},
|
|
"label": {
|
|
"type": "string"
|
|
},
|
|
"read_only": {
|
|
"type": "boolean"
|
|
},
|
|
"input": true
|
|
},
|
|
"required": [
|
|
"version",
|
|
"name",
|
|
"kind",
|
|
"namespace",
|
|
"label",
|
|
"read_only"
|
|
],
|
|
"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`, `agent_swarm`, `enter_plan`, `exit_plan`, `ask_user`, `deploy_app`, `search_tool`, `use_tool`, `monitor`, `goal_update`, `other`.",
|
|
"type": "string"
|
|
},
|
|
"ToolNamespace": {
|
|
"description": "The toolset a tool belongs to.\n\nSerializes to snake_case (`kigi`, `mcp`, …) for the\ncanonical tool `_meta` wire contract. PascalCase aliases are accepted on\ndeserialize so legacy persisted/manifest values still parse. The\n`Display` impl remains PascalCase for existing qualified id strings\n(e.g. `\"Kigi:read_file\"`); only the serde form goes on the wire.",
|
|
"type": "string",
|
|
"enum": [
|
|
"kigi",
|
|
"kigi_concise",
|
|
"kigi_hashline",
|
|
"codex",
|
|
"opencode",
|
|
"mcp"
|
|
]
|
|
}
|
|
}
|
|
}
|