feat(coding-agent): removed resume support and switched task execution to spawn-only

- Removed `resume` from task params and schema, requiring agent and assignment inputs.
- Dropped resume continuation paths in task execution and call rendering, always spawning a new agent.
- Removed the `irc.enabled` setting and computed IRC availability by task-depth rules.
- Updated task follow-up guidance to use IRC messaging/history links instead of `task(resume:)`.
This commit is contained in:
can1357
2026-06-10 23:00:03 +02:00
parent 9d5587be81
commit 6ac73655c8
20 changed files with 159 additions and 560 deletions
+4 -4
View File
@@ -11,7 +11,7 @@
- `packages/coding-agent/src/registry/agent-lifecycle.ts` — revival of parked recipients on direct send.
- `packages/coding-agent/src/session/agent-session.ts` — `deliverIrcMessage(...)`: recipient-side injection and wake turns.
- `packages/coding-agent/src/prompts/system/irc-incoming.md` — incoming-message rendering for the recipient.
- `packages/coding-agent/src/config/settings-schema.ts` — `irc.enabled`, `irc.timeoutMs`.
- `packages/coding-agent/src/config/settings-schema.ts` — `irc.timeoutMs`.
- `packages/coding-agent/src/modes/controllers/event-controller.ts` — renders IRC events into chat UI.
## Inputs
@@ -37,7 +37,7 @@
- `details: IrcDetails`: `{ op, from?, to?, receipts?, waited?, inbox?, peers? }`. `waited` is `null` when a wait timed out; `receipts` carry `{ to, outcome, error? }`.
## Flow
1. `IrcTool.createIf` constructs the tool only when `irc.enabled` is on and the session has both an `AgentRegistry` and `getAgentId`. There is no longer a main-agent gate on `async.enabled` — the main agent is never sync-blocked.
1. `IrcTool.createIf` constructs the tool only when `isIrcEnabled` passes and the session has both an `AgentRegistry` and `getAgentId`. There is no `irc.enabled` setting: availability is derived — true for every subagent (`taskDepth > 0`; a parent always exists) and for any session that can still spawn subagents through the task tool. Only a top-level session with task spawning unavailable has no peers, hence no irc.
2. `execute` resolves the registry and sender id; missing either returns a text error result instead of throwing.
3. `op: "list"`: `registry.list()` minus self and minus `aborted` agents — `parked` peers ARE listed. Each row includes the unread count from `IrcBus.unreadCount(...)` and last activity.
4. `op: "send"` validates `to`/`message`, rejects self-sends, and rejects `await` with `to: "all"`.
@@ -75,7 +75,7 @@
- No direct filesystem writes in the tool itself; recipient turns persist to their session JSONL as usual.
## Limits & Caps
- Availability gates: `irc.enabled` (default `true`), an `AgentRegistry`, and a caller agent id.
- Availability gates: `isIrcEnabled` (running as a subagent, or task spawning available — there is no `irc.enabled` setting), an `AgentRegistry`, and a caller agent id.
- Mailboxes are bounded at 100 messages per agent (`MAILBOX_CAP` in `packages/coding-agent/src/irc/bus.ts`); oldest messages are dropped beyond the cap.
- `irc.timeoutMs` defaults to `120_000` and is the default `wait` / `send await:true` timeout; `0` disables the timeout, non-finite or negative values fall back to the default, positive values are truncated and clamped to at least `1` ms.
- Broadcast scope: live peers only (`running`/`idle`) via `listVisibleTo`; direct sends address any non-aborted agent, including parked ones.
@@ -94,6 +94,6 @@
## Notes
- This is IRC-like naming only: no servers, sockets, channels, or join/part state. Addressing is by exact registry agent id.
- Replies are real turns by the recipient — the old ephemeral no-tools auto-reply (`awaitReply` / `respondAsBackground`) no longer exists. A recipient may keep working before answering; check `inbox` or `wait` again rather than re-sending.
- Wake-on-message is the revive primitive: messaging a parked agent is equivalent to resuming it (same `ensureLive` path as `task(resume:)` and the Agent Hub).
- Wake-on-message is the only resume primitive: messaging a parked agent revives it (same `ensureLive` path as the Agent Hub). The task tool has no `resume` parameter.
- Message ids are Snowflakes; pass them as `replyTo` to thread an answer to a specific message.
- Persistence is per recipient history: the sender gets receipts in the tool result; the recipient sees the injected `irc:incoming` message in its own transcript (visible via `history://<id>`).
+1 -1
View File
@@ -82,7 +82,7 @@ Spawn paths that produce jobs:
- `async: true` always registers a `type: "bash"` job with `AsyncJobManager.register(...)` and returns a start message.
- auto-background mode (`bash.autoBackground.enabled`) starts the same managed job path for non-PTY commands, waits up to `min(bash.autoBackground.thresholdMs, timeoutMs - 1000)`, and if the command is still running returns a background-job start result instead of inline command output.
- `packages/coding-agent/src/task/index.ts`
- every `task` call (spawn or resume) registers one `type: "task"` job, unless the session has no job manager or the agent definition declares `blocking: true` (sync fallback).
- every `task` call registers one `type: "task"` job, unless the session has no job manager or the agent definition declares `blocking: true` (sync fallback).
Lifecycle and exact state names:
- Conceptual scheduling path: `pending` (only task-progress bookkeeping before work starts) → `running` → `completed` / `failed`; cancellation changes a running async job to `cancelled`.
+30 -41
View File
@@ -1,6 +1,6 @@
# task
> Spawn one subagent per call to work in the background, or resume an existing one.
> Spawn one subagent per call to work in the background.
## Source
- Entry: `packages/coding-agent/src/task/index.ts`
@@ -9,7 +9,7 @@
- `packages/coding-agent/src/task/types.ts` — dynamic schema, progress/result types, output caps.
- `packages/coding-agent/src/task/discovery.ts` — discover project/user/plugin/bundled agents.
- `packages/coding-agent/src/task/agents.ts` — bundled agent definitions and frontmatter parsing.
- `packages/coding-agent/src/task/executor.ts` — create child sessions, run/resume subagents, collect output, hand finished sessions to the lifecycle manager.
- `packages/coding-agent/src/task/executor.ts` — create child sessions, run subagents, collect output, hand finished sessions to the lifecycle manager.
- `packages/coding-agent/src/registry/agent-lifecycle.ts` — idle-TTL parking and revival of finished subagents.
- `packages/coding-agent/src/registry/agent-registry.ts` — process-global agent directory (`running | idle | parked | aborted`).
- `packages/coding-agent/src/async/job-manager.ts` — background job registration, progress, and result delivery.
@@ -27,17 +27,16 @@
## Inputs
One call spawns (or resumes) exactly one subagent. There is no batch parameter and no shared `context` parameter — shared background goes into a `local://` file (e.g. `local://ctx.md`) that each assignment references; subagents share the parent's `local://` root.
One call spawns exactly one subagent. There is no batch parameter and no shared `context` parameter — shared background goes into a `local://` file (e.g. `local://ctx.md`) that each assignment references; subagents share the parent's `local://` root.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `agent` | `string` | Conditional | Agent type to spawn. Required unless `resume` is set; providing both is a validation error. |
| `resume` | `string` | Conditional | Existing agent id — revive the agent if parked and run a follow-up assignment in its existing session. Cannot be combined with `agent` or `isolated`. |
| `agent` | `string` | Yes | Agent type to spawn. |
| `id` | `string` | No | Stable agent id, schema max length 48. Defaults to a generated AdjectiveNoun name. Uniquified per session by `AgentOutputManager`. |
| `description` | `string` | No | UI label only; the subagent never sees it. |
| `assignment` | `string` | Yes | The work — complete, self-contained instructions. Empty-after-trim is rejected. |
| `schema` | `string` | No | JSON-encoded JTD schema for the expected `yield` payload. Field exists only when `task.simple = "default"`. |
| `isolated` | `boolean` | No | Run in an isolated workspace and return patches. Field exists only when `task.isolation.mode` is not `none`. Isolated agents are NOT resumable. |
| `isolated` | `boolean` | No | Run in an isolated workspace and return patches. Field exists only when `task.isolation.mode` is not `none`. Isolated agents are torn down at completion — not revivable. |
Simple-mode gating (`task.simple`, one axis): `default` accepts the per-call `schema` override; `schema-free` and `independent` reject it (`validateTaskModeParams(...)`). `independent` additionally renders the subagent user prompt with the independent-mode flag. Agent frontmatter and inherited session schemas work in every mode.
@@ -46,9 +45,9 @@ Simple-mode gating (`task.simple`, one axis): `default` accepts the per-call `sc
The tool returns one text block plus `details: TaskToolDetails`.
Immediate (async) response — the normal case:
- `content`: `` Spawned agent `<id>` (job `<jobId>`). The result will be delivered when it yields. ... `` (or `Resumed agent ...`), plus a coordination hint (`irc` DM when enabled, otherwise `job`).
- `content`: `` Spawned agent `<id>` (job `<jobId>`). The result will be delivered when it yields. ... `` plus a coordination hint (`irc` DM when enabled, otherwise `job`).
- `details`: `{ projectAgentsDir: null, results: [], totalDurationMs: 0, progress: [<seeded AgentProgress>], async: { state: "running", jobId, type: "task" } }`.
- Live progress keeps streaming into the same tool block via `onUpdate(...)`; the final result arrives later as an async-result injection into the parent conversation. The delivery text appends a resume hint: `` <id> is now idle — task(resume:"<id>") to continue it, transcript at history://<id> `` (aborted variant points at the transcript only).
- Live progress keeps streaming into the same tool block via `onUpdate(...)`; the final result arrives later as an async-result injection into the parent conversation. The delivery text appends a follow-up hint: `` <id> is now idle — message it via `irc` to follow up; transcript at history://<id> `` (aborted variant points at the transcript only).
Settled (sync-fallback or job-body) response:
- `content`: summary rendered from `packages/coding-agent/src/prompts/tools/task-summary.md` with a preview capped at 5000 chars; `agent://<id>` holds the full output.
@@ -62,48 +61,40 @@ Settled (sync-fallback or job-body) response:
- extracted tool data: `extractedToolData?` from registered subprocess tool handlers such as `yield` and `report_finding`
Artifacts and side channels:
- Every subagent with an artifacts dir writes `<id>.md`; `agent://<id>` resolves to that file. Resumes overwrite it per assignment.
- Every subagent with an artifacts dir writes `<id>.md`; `agent://<id>` resolves to that file.
- If the output file is JSON, `agent://<id>/<path>` and `agent://<id>?q=<query>` perform JSON extraction.
- Each subagent gets `<id>.jsonl` session history when the parent persists artifacts; `history://<id>` renders it as a concise transcript (works for live and parked agents).
- Isolated patch mode writes `<id>.patch` before merge.
## Flow
1. `TaskTool.create(...)` discovers agents once per cwd through a process-level memo (`discoverAgentsForCreate`) to render the dynamic prompt description.
2. `execute(...)` repairs raw params (`repairTaskParams`), then validates: schema gating per `task.simple`, `agent` XOR `resume`, `resume` excludes `isolated`, non-empty `assignment`.
2. `execute(...)` repairs raw params (`repairTaskParams`), then validates: schema gating per `task.simple`, non-empty `agent`, non-empty `assignment`.
3. Sync fallback only when the session has no `AsyncJobManager` (orphaned host) or the selected agent definition declares `blocking: true`; the call then runs `#executeSync(...)` inline under the session-scoped semaphore.
4. Otherwise execution is always async:
- the agent id is resolved up front — `resume` must name a registered agent (else `ToolError` pointing at `irc` op:"list" and `history://<id>`); spawns allocate via `AgentOutputManager.allocate(params.id || generateTaskName())`;
- the agent id is allocated up front via `AgentOutputManager.allocate(params.id || generateTaskName())`;
- one `type: "task"` job is registered with `session.asyncJobManager` (`id` = agent id, `queued: true`, `ownerId` = caller agent id) and the tool returns immediately;
- the job body acquires the session-scoped `Semaphore` (one per `TaskTool` instance, sized from `task.maxConcurrency` at first use), marks the job running, runs `#executeSync(...)`, and reports progress through `buildAsyncDetails`/`onUpdate`;
- a failed or aborted run throws `TaskJobError` so the job lands `failed`, but the agent itself stays registered and interrogable.
5. `#executeSync(...)` dispatches: `resume` → `#executeResume(...)`, else `#runSpawn(...)`.
6. Resume path (`#executeResume`):
- `AgentLifecycleManager.global().ensureLive(resumeId)` returns the live session, reviving a parked one from its session JSONL; unknown ids or parked-without-reviver throw a `ToolError`;
- `resumeSubprocess(...)` in `packages/coding-agent/src/task/executor.ts` injects the rendered follow-up through the session's normal prompt path and drives it through the same monitor/yield/finalize pipeline as a spawn;
- the session is never disposed here — registry status settles back to `idle` (even on failure/abort) and the lifecycle manager re-arms the idle TTL.
7. Spawn path (`#runSpawn`) rediscovers agents from disk, so runtime resolution can differ from the create-time description.
8. It resolves the requested agent, rejects unknown or settings-disabled agents, and enforces parent spawn policy plus `PI_BLOCKED_AGENT` self-recursion prevention.
9. Output schema priority: task call `schema` (when `task.simple` allows) → agent frontmatter `output` → inherited parent session schema.
10. Plan mode swaps in an `effectiveAgent` with a read-only tool subset and plan-mode prompt; `runSubprocess(...)` receives the effective agent.
11. If `isolated`, it requires a git repo (`getRepoRoot(...)` / `captureBaseline(...)`) and resolves the backend through isolation-backend resolution with platform fallback.
12. Artifacts dir comes from the parent session file when available, otherwise a temp dir. When the session is executing an approved plan, the plan reference is handed to the subagent.
13. Non-isolated spawns call `runSubprocess(...)` directly with parent cwd; isolated spawns run inside the isolation workspace, then commit to a branch (`mergeMode === "branch"`) or capture a patch, and always clean up the workspace.
14. `runSubprocess(...)` creates a child agent session with an isolated settings snapshot (forcing `async.enabled = false` and `bash.autoBackground.enabled = false` — subagents are internally synchronous), child `agentId` equal to the allocated id, child internal URL router/`AgentOutputManager`, output schema, and the IRC peer roster in the system prompt.
15. Child tool availability: explicit `agent.tools` if provided; auto-add `task` when the agent has `spawns` and depth allows; strip `task` at `task.maxRecursionDepth`; expand `exec` to `eval` + `bash`; strip parent-owned `todo`.
16. The child must finish through the hidden `yield` tool; up to 3 reminder prompts, the last forcing `toolChoice = yield` when supported. `finalizeSubprocessOutput(...)` reconciles raw text, `yield` payloads, structured schemas, `report_finding` data, and abort states.
17. End-of-run lifecycle (keep-alive, in `runSubprocess`'s finalizer):
5. `#executeSync(...)` runs the spawn path (`#runSpawn`), which rediscovers agents from disk, so runtime resolution can differ from the create-time description.
6. It resolves the requested agent, rejects unknown or settings-disabled agents, and enforces parent spawn policy plus `PI_BLOCKED_AGENT` self-recursion prevention.
7. Output schema priority: task call `schema` (when `task.simple` allows) → agent frontmatter `output` → inherited parent session schema.
8. Plan mode swaps in an `effectiveAgent` with a read-only tool subset and plan-mode prompt; `runSubprocess(...)` receives the effective agent.
9. If `isolated`, it requires a git repo (`getRepoRoot(...)` / `captureBaseline(...)`) and resolves the backend through isolation-backend resolution with platform fallback.
10. Artifacts dir comes from the parent session file when available, otherwise a temp dir. When the session is executing an approved plan, the plan reference is handed to the subagent.
11. Non-isolated spawns call `runSubprocess(...)` directly with parent cwd; isolated spawns run inside the isolation workspace, then commit to a branch (`mergeMode === "branch"`) or capture a patch, and always clean up the workspace.
12. `runSubprocess(...)` creates a child agent session with an isolated settings snapshot (forcing `async.enabled = false` and `bash.autoBackground.enabled = false` — subagents are internally synchronous), child `agentId` equal to the allocated id, child internal URL router/`AgentOutputManager`, output schema, and the IRC peer roster in the system prompt.
13. Child tool availability: explicit `agent.tools` if provided; auto-add `task` when the agent has `spawns` and depth allows; strip `task` at `task.maxRecursionDepth`; expand `exec` to `eval` + `bash`; strip parent-owned `todo`.
14. The child must finish through the hidden `yield` tool; up to 3 reminder prompts, the last forcing `toolChoice = yield` when supported. `finalizeSubprocessOutput(...)` reconciles raw text, `yield` payloads, structured schemas, `report_finding` data, and abort states.
15. End-of-run lifecycle (keep-alive, in `runSubprocess`'s finalizer):
- hard abort (caller signal / wall-clock / budget) → registry status `aborted`, session disposed — terminal;
- isolated run → status `parked` without a reviver (workspace is merged + cleaned, so the session is not resumable; transcript stays readable via `history://`), then session disposed and detached;
- isolated run → status `parked` without a reviver (workspace is merged + cleaned, so the session is not revivable; transcript stays readable via `history://`), then session disposed and detached;
- everything else (success and failure alike) → status `idle` with the live session attached, and `AgentLifecycleManager.global().adopt(id, { idleTtlMs, revive })` arms the park timer. The reviver reopens the session JSONL (park closed the writer, so the single-writer lock is taken cleanly).
18. Lifecycle thereafter: `idle` agents are parked after `task.agentIdleTtlMs` (session disposed; `AgentRef` + session file retained); messaging (`irc`), `task(resume:)`, or the Agent Hub revives them back to `idle`. `"Main"` is never parked.
16. Lifecycle thereafter: `idle` agents are parked after `task.agentIdleTtlMs` (session disposed; `AgentRef` + session file retained); messaging (`irc`) or the Agent Hub revives them back to `idle`. `"Main"` is never parked.
## Modes / Variants
- Execution mode
- Always-async background job — default; spawn and resume both go through `AsyncJobManager`.
- Always-async background job — default; spawns go through `AsyncJobManager`.
- Sync inline fallback — only when no job manager exists or the agent definition has `blocking: true`.
- Spawn vs resume
- `agent: "<type>"` — fresh subagent with a new (or caller-provided) id.
- `resume: "<id>"` — follow-up assignment in an existing session; revives a parked agent first. Transcript accretes; `agent://<id>` is overwritten per assignment.
- Simple mode (`task.simple`)
- `default` — accepts per-call `schema`.
- `schema-free` / `independent` — reject `schema`; `independent` also flags the subagent user prompt as independent-mode.
@@ -126,7 +117,7 @@ Artifacts and side channels:
- Registers one async job per call in `session.asyncJobManager`; completion is injected into the parent as an async-result message.
- Arms idle-TTL timers in `AgentLifecycleManager` (unref'd; they never hold the process open).
- Emits `task:subagent:event`, `task:subagent:progress`, and `task:subagent:lifecycle` on the parent event bus.
- Allocates session-scoped output ids through `AgentOutputManager` so `agent://` stays unique across invocations and resumes.
- Allocates session-scoped output ids through `AgentOutputManager` so `agent://` stays unique across invocations.
- Shares the parent `local://` root and `ArtifactManager` with subagents.
- Background work / cancellation
- `job cancel` (or parent tool-call abort) cancels the job; a hard-aborted run lands `aborted` and is torn down.
@@ -139,29 +130,27 @@ Artifacts and side channels:
- Progress coalescing: `PROGRESS_COALESCE_MS = 150`; recent-output tail: `RECENT_OUTPUT_TAIL_BYTES = 8 * 1024` (last 8 non-empty lines).
- Missing-`yield` reminder retries: `MAX_YIELD_RETRIES = 3`; MCP proxy timeout: `MCP_CALL_TIMEOUT_MS = 60_000` — both in `packages/coding-agent/src/task/executor.ts`.
- Agent id schema cap: `id` `maxLength: 48` in `packages/coding-agent/src/task/types.ts`. Prompt text says ids should be `≤32` chars; this mismatch is real.
- Soft request budget (`task.softRequestBudget`) and wall clock (`task.maxRuntimeMs`) apply to spawns and resumes alike.
- Soft request budget (`task.softRequestBudget`) and wall clock (`task.maxRuntimeMs`) apply to every spawn.
- Recursion depth gate: `task.maxRecursionDepth`; `packages/coding-agent/src/tools/index.ts` hides the `task` tool at or beyond the limit, and `runSubprocess(...)` also strips child `task` access at max depth.
- Final inline summary preview uses `fullOutputThreshold = 5000` chars in `packages/coding-agent/src/task/index.ts`; `agent://<id>` points to the full artifact.
## Errors
- Parameter validation failures are returned as normal tool text with empty `results`:
- `schema` outside `task.simple = "default"`
- both or neither of `agent` / `resume`
- `resume` combined with `isolated`
- missing/empty `agent`
- missing/empty `assignment`
- unknown or settings-disabled agent, spawn-policy denial, requesting `isolated` while isolation mode is `none`
- `resume` of an id not in the registry throws a `ToolError` naming `irc` op:"list" and `history://<id>`.
- `ensureLive(...)` failures (agent parked without a reviver — e.g. an isolated run — or torn down) surface as `` Cannot resume "<id>": ... `` `ToolError`s.
- Isolated execution without a git repo returns `Isolated task execution requires a git repository. ...`; backend resolution can hard-error (ProjFS init) or warn and fall back to `worktree`.
- Job registration failure returns `Failed to start background task job: ...`.
- Child failures surface as `SingleResult.exitCode = 1` with `stderr`/`error` populated; the async job is marked failed but the delivery text still carries the output plus a resume/transcript hint.
- Child failures surface as `SingleResult.exitCode = 1` with `stderr`/`error` populated; the async job is marked failed but the delivery text still carries the output plus a follow-up/transcript hint.
- If the child omits `yield`, `finalizeSubprocessOutput(...)` injects warnings such as `SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.`
- `agent://<id>` resolution errors are model-visible when another tool reads them: no session, no artifacts dir, missing id, conflicting extraction syntax, or invalid JSON for extraction.
## Notes
- Parallelism is parallel `task` calls in one assistant message; the session-scoped semaphore bounds the fan-out. There is no batch array.
- Shared background convention: write it once to a `local://` file and reference that path in each assignment — subagents share the parent's `local://` root. This replaces the removed `context` parameter.
- Prefer `resume` over a fresh spawn for follow-up work: the resumed agent already holds the relevant context. `irc` op:"list" shows idle/parked candidates; `history://<id>` shows what an agent has done.
- Prefer messaging an existing agent (`irc`) over a fresh spawn for follow-up work: it already holds the relevant context. `irc` op:"list" shows idle/parked candidates; messaging a parked agent revives it. `history://<id>` shows what an agent has done.
- `irc` availability is derived, not configured (`isIrcEnabled` in `packages/coding-agent/src/tools/irc.ts`): it exists exactly when there is someone to message — the session can spawn subagents, or it is a subagent itself. Messaging is the only follow-up path to a finished subagent, so task without irc would strand idle agents.
- Subagents are internally synchronous: the executor forces `async.enabled = false` and `bash.autoBackground.enabled = false` in the child settings snapshot, so there are no fire-and-forget grandchildren.
- Agent discovery precedence is first-wins by exact name: project dirs before user dirs within a source family, plugin agent dirs after config dirs, bundled agents last. Create-time discovery is memoized per cwd for the prompt description; execution-time discovery stays fresh.
- Child sessions do not inherit conversation history. Built-in carry-over is the workspace tree/skills/context files, the shared `local://` root, and the approved-plan reference when one exists.
+3 -6
View File
@@ -1,9 +1,10 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed the `resume` option from the `task` tool API and its resume execution path; continue work on finished subagents by sending follow-up messages via `irc` instead
- Removed the `irc.enabled` setting: irc availability is now derived — the tool exists exactly when there is someone to message (the session can spawn subagents through `task`, or it is a subagent itself). A stale `irc.enabled` key in config is ignored
- The `task` tool now spawns exactly one subagent per call and always runs it in the background: the batch `tasks[]` array and shared `context` parameter are removed — fan out with parallel `task` calls, share background via a `local://` file referenced in each assignment, and receive results as async job deliveries (block with `job poll` only when genuinely needed)
- Reworked `irc` to `send`/`wait`/`inbox`/`list` ops over a per-agent mailbox bus: the blocking `awaitReply` auto-reply turn is removed — `send` is fire-and-forget with delivery receipts, and replies are real turns by the recipient observed via `wait` (or the `send` `await: true` sugar)
- Removed the `context` argument from eval `agent()` in both the JS and Python preludes: pass shared background via a `local://` file referenced in the prompt
@@ -21,8 +22,7 @@
- Added a hard inline byte cap (~50KB) at the bash and browser tool-result boundaries with head/tail elision and an `artifact://` footer for the full output, closing paths that previously let 100KB+ results land inline
- Added the Agent Hub overlay (`ctrl+s`, `alt+a`, or double-tap left arrow on an empty editor): a live table of registered subagents (status, unread IRC count, current task, last activity) with per-agent chat — Enter opens a transcript + input line that steers a running agent, prompts an idle one, and revives a parked one; `r` revives and `x` aborts/releases the selected agent
- Added the `snapcompact` compaction strategy (`compaction.strategy: "snapcompact"`): history is archived onto dense bitmap "snapcompact" frames a vision model reads back directly, instead of an LLM-generated summary — instant, free, and verbatim. Auto compaction (including overflow recovery) and manual `/compact` both honor it; falls back to context-full with a visible warning notice when the current model is text-only (e.g. Codex API surfaces) or when `/compact` is given custom instructions. Frames survive context rebuilds and later compactions (budget eviction is middle-out: the session-head frame is pinned); the expanded compaction message notes the attached frame count
- Added a persistent subagent lifecycle: finished subagents stay live as `idle`, are parked to disk after `task.agentIdleTtlMs` (default 7 minutes; `0` keeps them live until exit), and are revived automatically when messaged, resumed, or prompted from the Agent Hub
- Added `task(resume: "<id>")` to revive an idle or parked subagent and run a follow-up assignment in its existing session, keeping its accumulated context
- Added a persistent subagent lifecycle: finished subagents stay live as `idle`, are parked to disk after `task.agentIdleTtlMs` (default 7 minutes; `0` keeps them live until exit), and are revived automatically when messaged or prompted from the Agent Hub
- Added the `history://` protocol: `history://` lists every registered agent and `history://<agentId>` renders a concise markdown transcript (tool calls collapsed to one line each, thinking elided) for live and parked agents alike
- Added an IRC mailbox bus with bounded per-agent inboxes: `irc` `wait` blocks until a matching message arrives, `inbox` drains or peeks pending messages, and sending to an idle or parked agent wakes or revives it for a real turn
- Added a dedicated TUI renderer for the `irc` tool: directional send/receive headers with delivery-outcome coloring, quoted message bodies with expand-aware truncation, per-recipient receipt trees for broadcasts and failures, and status-badged peer listings with unread counts
@@ -43,9 +43,6 @@
- Fixed the CLI smoke-test command to start the stats server and verify dashboard HTML is served, catching bundled-asset regressions
- Added verification of a `<div id="root"></div>` and `index.js` in smoke-test dashboard responses
- Restored the checkmark glyph on ask-tool custom answers and the multi-select "Done selecting" option, which a status-glyph sweep had swapped for the ask tool icon
### Fixed
- Fixed the `thinking.autoPending` statusbar indicator using question-mark glyphs (`▣?`, nf-md-help_box, `[?]`) in every symbol preset, which made the auto-thinking pending state indistinguishable from a terminal missing-glyph fallback. Replaced with clear loading indicators (`⟳`, fa-circle-o-notch, `[~]`) ([#2267](https://github.com/can1357/oh-my-pi/issues/2267)).
## [15.10.12] - 2026-06-10
@@ -142,64 +142,6 @@ export const agenticFixtures: Record<string, GalleryFixture> = {
},
},
// Resume: follow-up assignment into an existing (idle or parked) agent.
task_resume: {
label: "Task (resume)",
customRendered: true,
renderer: "task",
// Streaming: resume target known; the follow-up assignment still landing.
streamingArgs: {
resume: "AuthLoader",
assignment: "Follow up: does the sliding-expiration TODO affect",
},
args: {
resume: "AuthLoader",
assignment:
"Follow up: does the sliding-expiration TODO at session.ts:88 affect the refresh-token path? Document the answer.",
},
result: {
content: [{ type: "text", text: "Agent AuthLoader completed." }],
details: {
projectAgentsDir: null,
totalDurationMs: 22_400,
usage: fixtureUsage({ input: 30_200, output: 4_100 }, 0.07),
results: [
{
index: 0,
id: "AuthLoader",
agent: "task",
agentSource: "bundled",
task: "Follow up: does the sliding-expiration TODO at session.ts:88 affect the refresh-token path?",
assignment:
"Follow up: does the sliding-expiration TODO at session.ts:88 affect the refresh-token path? Document the answer.",
exitCode: 0,
output:
"No — refresh tokens bypass the sliding window: refreshSession() re-issues the cookie unconditionally (session.ts:131).",
stderr: "",
truncated: false,
durationMs: 19_700,
tokens: 34_300,
requests: 4,
contextTokens: 31_800,
contextWindow: 200_000,
resolvedModel: "anthropic/claude-sonnet",
usage: fixtureUsage({ input: 30_200, output: 4_100 }, 0.07),
outputMeta: { lineCount: 1, charCount: 118 },
},
],
} satisfies TaskToolDetails,
},
errorResult: {
isError: true,
content: [
{
type: "text",
text: 'No agent "AuthLoader" to resume — it ran isolated and is not revivable. See history:// for the agent index.',
},
],
},
},
irc: {
label: "IRC",
// Streaming: recipient known; the message body still arriving.
@@ -38,7 +38,7 @@ export interface GalleryFixture {
customRendered?: boolean;
/**
* Renderer-registry key to use when the fixture key is a variant of a tool
* (e.g. `task_resume` → `task`). Defaults to the fixture key.
* (e.g. `irc_wait` → `irc`). Defaults to the fixture key.
*/
renderer?: string;
/**
@@ -2305,16 +2305,6 @@ export const SETTINGS_SCHEMA = {
},
},
"irc.enabled": {
type: "boolean",
default: true,
ui: {
tab: "tools",
label: "IRC",
description: "Enable agent-to-agent IRC messaging via the irc tool",
},
},
"irc.timeoutMs": {
type: "number",
default: 120_000,
@@ -986,12 +986,7 @@ export class AgentHubOverlayComponent extends Container {
case "ast_edit":
return args.path ? `path: ${args.path}` : "";
case "task": {
const target =
typeof args.resume === "string" && args.resume
? `resume ${args.resume}`
: typeof args.agent === "string"
? args.agent
: "";
const target = typeof args.agent === "string" ? args.agent : "";
const id = typeof args.id === "string" && args.id ? ` ${args.id}` : "";
return `${target}${id}`.trim();
}
@@ -1,4 +1,4 @@
Spawns ONE subagent per call to work in the background, or resumes an existing one.
Spawns ONE subagent per call to work in the background.
- Spawning is non-blocking: the call returns immediately with the agent id and a job id; the result is delivered automatically when the agent yields.
- Parallelism = multiple `task` calls in one assistant message. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session.
@@ -8,19 +8,17 @@ Spawns ONE subagent per call to work in the background, or resumes an existing o
{{/if}}
<lifecycle>
- Finished agents stay alive: `idle` first, then `parked` after a TTL — both remain addressable and revivable.
- `resume: "<id>"` revives an idle/parked agent and runs a follow-up assignment in its existing session. **Prefer resuming an agent that already holds the relevant context over spawning fresh**{{#if ircEnabled}} — check `irc` op:"list" for candidates{{/if}}.
- Finished agents stay alive: `idle` first, then `parked` after a TTL.{{#if ircEnabled}} Both remain addressable and revivable: messaging one via `irc` wakes it and runs your message as a follow-up turn. **Prefer messaging an agent that already holds the relevant context over spawning fresh** — check `irc` op:"list" for candidates.{{/if}}
- `history://<id>` is the agent's transcript; `agent://<id>` its latest output artifact.
</lifecycle>
<parameters>
- `agent`: agent type to spawn; omit when `resume` is set
- `resume`: existing agent id — continue that agent instead of spawning (cannot combine with `agent` or `isolated`)
- `agent`: agent type to spawn
- `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted
- `description`: UI label only — subagent never sees it
- `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED
{{#if customSchemaEnabled}}- `schema`: JTD schema for expected structured output (do not put format rules in assignments){{/if}}
{{#if isolationEnabled}}- `isolated`: run in isolated env; returns patches. Isolated agents are NOT resumable{{/if}}
{{#if isolationEnabled}}- `isolated`: run in isolated env; returns patches. Isolated agents are torn down at completion — not addressable afterwards{{/if}}
</parameters>
<rules>
@@ -45,7 +43,7 @@ Test: can task B run correctly without seeing A's output? If no, sequence A →
Sequential when one task produces a contract (types, API, schema, core module) the other consumes.
Parallel when tasks touch disjoint files or are independent refactors/tests.
{{/if}}
Sequenced follow-ups SHOULD `resume` the agent that produced the prerequisite — it already holds the context.
{{#if ircEnabled}}Sequenced follow-ups SHOULD message the agent that produced the prerequisite — it already holds the context.{{/if}}
</parallelization>
<assignment-fmt>
+8 -155
View File
@@ -37,6 +37,7 @@ import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
import { SessionManager } from "../session/session-manager";
import { truncateTail } from "../session/streaming-output";
import type { ContextFileEntry } from "../tools";
import { isIrcEnabled } from "../tools/irc";
import { normalizeSchema } from "../tools/jtd-to-json-schema";
import {
buildOutputValidator,
@@ -640,7 +641,7 @@ export function createSubagentSettings(
type AbortReason = "signal" | "terminate" | "timeout" | "budget";
/** Inputs for the shared run monitor used by both fresh spawns and resumes. */
/** Inputs for the run monitor driving one subagent assignment. */
interface RunMonitorArgs {
index: number;
id: string;
@@ -661,9 +662,9 @@ interface RunMonitorArgs {
}
/**
* The run-monitoring core shared by {@link runSubprocess} and
* {@link resumeSubprocess}: progress tracking, event processing, abort/budget
* machinery, usage accumulation, and output capture for one assignment run.
* The run-monitoring core of {@link runSubprocess}: progress tracking, event
* processing, abort/budget machinery, usage accumulation, and output capture
* for one assignment run.
*/
interface SubagentRunMonitor {
readonly progress: AgentProgress;
@@ -1270,7 +1271,7 @@ const MAX_YIELD_RETRIES = 3;
/**
* Drive one assignment through a live session: send the prompt, wait for idle,
* remind the agent to `yield` (up to {@link MAX_YIELD_RETRIES} times), then
* classify the terminal assistant state. Shared by spawn and resume paths.
* classify the terminal assistant state.
*/
async function driveSessionToYield(
session: AgentSession,
@@ -1418,7 +1419,7 @@ interface FinalizeRunArgs {
* Turn a settled run into a {@link SingleResult}: resolve the yield payload via
* {@link finalizeSubprocessOutput}, salvage cancelled-run output, write the
* `<id>.md` output artifact, flush final progress, and emit the lifecycle end
* event. Shared by spawn and resume paths.
* event.
*/
async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
const { monitor, done, index, id, agent, task, assignment, signal, modelOverride } = args;
@@ -1659,7 +1660,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
: agent.spawns.join(",");
const lspEnabled = enableLsp ?? true;
const ircEnabled = subagentSettings.get("irc.enabled") === true;
const ircEnabled = isIrcEnabled(subagentSettings, childDepth);
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
const monitor = createSubagentRunMonitor({
@@ -2127,151 +2128,3 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
startTime,
});
}
/** Options for resuming an existing live subagent session with a follow-up assignment. */
export interface ResumeExecutorOptions {
/** Live session, e.g. from `AgentLifecycleManager.global().ensureLive(id)`. */
session: AgentSession;
/** Registry agent id being resumed. */
id: string;
/** Agent definition for progress labels and soft budgets; a minimal stub is acceptable. */
agent: AgentDefinition;
/** Rendered follow-up prompt, injected via the session's normal prompt path. */
task: string;
assignment?: string;
description?: string;
index: number;
parentToolCallId?: string;
/** Optional schema validating this follow-up's yield payload. */
outputSchema?: unknown;
signal?: AbortSignal;
onProgress?: (progress: AgentProgress) => void;
eventBus?: EventBus;
settings?: Settings;
/** Where the `<id>.md` output artifact is (over)written for this assignment. */
artifactsDir?: string;
}
/**
* Run a follow-up assignment on an EXISTING live agent session through the same
* monitoring/finalize pipeline as a fresh spawn. The session is never created
* or disposed here: it stays alive (and adopted by the lifecycle manager from
* its original spawn) afterwards — registry status flips via the session's
* registry status sync, and the idle TTL re-arms via the lifecycle manager's
* registry subscription. Each resume overwrites the `agent://<id>` output
* artifact; the transcript accretes in the session JSONL.
*/
export async function resumeSubprocess(options: ResumeExecutorOptions): Promise<SingleResult> {
const { session, id, agent, task, assignment, index, signal } = options;
const startTime = Date.now();
if (signal?.aborted) {
return {
index,
id,
agent: agent.name,
agentSource: agent.source,
task,
assignment,
description: options.description,
exitCode: 1,
output: "",
stderr: "Cancelled before start",
truncated: false,
durationMs: 0,
tokens: 0,
requests: 0,
error: "Cancelled before start",
aborted: true,
abortReason: "Cancelled before start",
};
}
const settings = options.settings ?? Settings.isolated();
const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
const configuredDefaultBudget = Math.max(
0,
Math.trunc(Number(settings.get("task.softRequestBudget") ?? SOFT_REQUEST_BUDGET.default) || 0),
);
const softRequestBudget =
configuredDefaultBudget === 0 ? 0 : (SOFT_REQUEST_BUDGET[agent.name] ?? configuredDefaultBudget);
const sessionFile = AgentRegistry.global().get(id)?.sessionFile ?? undefined;
const monitor = createSubagentRunMonitor({
index,
id,
agent,
task,
assignment,
description: options.description,
signal,
onProgress: options.onProgress,
eventBus: options.eventBus,
parentToolCallId: options.parentToolCallId,
sessionFile,
softRequestBudget,
maxRuntimeMs,
});
monitor.setActiveSession(session);
const unsubscribe = monitor.attach(session);
if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id,
agent: agent.name,
parentToolCallId: options.parentToolCallId,
agentSource: agent.source,
description: options.description,
status: "started",
sessionFile,
index,
});
}
let outcome: DriveOutcome = { exitCode: 1, aborted: false };
try {
outcome = await driveSessionToYield(session, monitor, task);
} finally {
try {
unsubscribe();
} catch {
// Ignore unsubscribe errors
}
const live = monitor.takeActiveSession();
if (live) {
monitor.captureSalvage(live);
}
// The resumed session stays alive and adopted: the registry status sync
// installed at spawn/revive flips running/idle from session events, and
// the lifecycle manager re-arms the idle TTL on the registry's
// status_changed → idle event. Abort paths may skip agent_end, so settle
// the status explicitly — a resumed agent is never torn down here, even
// on failure/abort; it stays interrogable.
AgentRegistry.global().setStatus(id, "idle");
}
monitor.finish();
return finalizeRunResult({
monitor,
done: {
exitCode: outcome.exitCode,
error: outcome.error,
aborted: outcome.aborted,
abortReason: outcome.aborted ? outcome.abortReasonText : undefined,
durationMs: Date.now() - startTime,
},
index,
id,
agent,
task,
assignment,
description: options.description,
outputSchema: options.outputSchema,
signal,
artifactsDir: options.artifactsDir,
eventBus: options.eventBus,
parentToolCallId: options.parentToolCallId,
sessionFile,
startTime,
});
}
+22 -121
View File
@@ -27,6 +27,7 @@ import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.m
import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" };
import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" };
import { truncateForPrompt } from "../tools/approval";
import { isIrcEnabled } from "../tools/irc";
import { formatBytes, formatDuration } from "../tools/render-utils";
import {
type AgentDefinition,
@@ -41,14 +42,11 @@ import {
import "../tools/review";
import type { LocalProtocolOptions } from "../internal-urls";
import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
import { AgentLifecycleManager } from "../registry/agent-lifecycle";
import { AgentRegistry } from "../registry/agent-registry";
import type { AgentSession } from "../session/agent-session";
import { ToolError } from "../tools/tool-errors";
import { generateCommitMessage } from "../utils/commit-message-generator";
import * as git from "../utils/git";
import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
import { resumeSubprocess, runSubprocess } from "./executor";
import { runSubprocess } from "./executor";
import { generateTaskName } from "./name-generator";
import { AgentOutputManager } from "./output-manager";
import { Semaphore } from "./parallel";
@@ -203,21 +201,13 @@ function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams):
}
/**
* Validate the spawn/resume parameter contract: `agent` XOR `resume`,
* `resume` excludes `isolated`, and `assignment` is always required.
* Returns a problem description, or undefined when valid.
* Validate the spawn parameter contract: `agent` and `assignment` are both
* required. Returns a problem description, or undefined when valid.
*/
function validateSpawnParams(params: TaskParams): string | undefined {
const resume = typeof params.resume === "string" ? params.resume.trim() : "";
const agent = typeof params.agent === "string" ? params.agent.trim() : "";
if (resume && agent) {
return "Provide either `agent` (spawn a new subagent) or `resume` (continue an existing one), not both.";
}
if (!resume && !agent) {
return "Missing `agent`. Provide `agent` to spawn a subagent, or `resume` with an existing agent id.";
}
if (resume && params.isolated === true) {
return "`resume` cannot be combined with `isolated` — isolated agents are not resumable.";
if (!agent) {
return "Missing `agent`. Provide an agent type to spawn.";
}
if (typeof params.assignment !== "string" || params.assignment.trim() === "") {
return "Missing `assignment`. Provide complete, self-contained instructions for the agent.";
@@ -276,9 +266,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
readonly formatApprovalDetails = (args: unknown): string[] => {
const params = args as Partial<TaskParams>;
const lines: string[] = [];
if (typeof params.resume === "string" && params.resume.trim()) {
lines.push(`Resume: ${truncateForPrompt(params.resume)}`);
} else if (typeof params.agent === "string") {
if (typeof params.agent === "string") {
lines.push(`Agent: ${truncateForPrompt(params.agent)}`);
}
if (typeof params.id === "string" && params.id.trim()) {
@@ -327,7 +315,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
isolationMode !== "none",
disabledAgents,
this.#getTaskSimpleMode(),
this.session.settings.get("irc.enabled") === true,
isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0),
this.session.getSessionSpawns() ?? "*",
);
}
@@ -369,8 +357,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
return createTaskModeError(validationError);
}
const isResume = typeof params.resume === "string" && params.resume.trim().length > 0;
const selectedAgent = isResume ? undefined : this.#discoveredAgents.find(agent => agent.name === params.agent);
const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
const manager = this.session.asyncJobManager;
if (!manager || selectedAgent?.blocking === true) {
// Sync fallback: orphaned host that never wired a job manager, or an
@@ -389,24 +376,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}
// Resolve the agent id up front so the immediate result can name it.
let agentId: string;
if (isResume) {
agentId = params.resume!.trim();
if (!AgentRegistry.global().get(agentId)) {
throw new ToolError(
`Unknown agent "${agentId}" — nothing to resume. Use \`irc\` op:"list" to see live agent ids; past transcripts are readable at history://${agentId}.`,
);
}
} else {
const outputManager =
this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
agentId = await outputManager.allocate(params.id?.trim() || generateTaskName());
}
const outputManager =
this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
const agentId = await outputManager.allocate(params.id?.trim() || generateTaskName());
const assignment = (params.assignment ?? "").trim();
const agentLabel = isResume
? (AgentRegistry.global().get(agentId)?.displayName ?? "task")
: (params.agent ?? "task");
const agentLabel = params.agent ?? "task";
const progress: AgentProgress = {
index: 0,
id: agentId,
@@ -433,11 +408,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
async: { state, jobId, type: "task" },
});
const buildResumeHint = (aborted: boolean): string => {
const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
const buildFollowUpHint = (aborted: boolean): string => {
if (aborted) {
return `\n\n${agentId} was aborted — transcript at history://${agentId}`;
}
return `\n\n${agentId} is now idle — task(resume:"${agentId}") to continue it, transcript at history://${agentId}`;
const followUp = ircEnabled ? "message it via `irc` to follow up; " : "";
return `\n\n${agentId} is now idle — ${followUp}transcript at history://${agentId}`;
};
let jobId: string;
@@ -491,7 +468,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
content: [{ type: "text", text: statusText }],
details: buildAsyncDetails(resultFailed ? "failed" : "completed", ownJobId),
});
const deliveryText = `${finalText}${buildResumeHint(singleResult?.aborted === true)}`;
const deliveryText = `${finalText}${buildFollowUpHint(singleResult?.aborted === true)}`;
if (resultFailed) {
// Mark the job itself failed; the failed agent stays interrogable.
throw new TaskJobError(deliveryText);
@@ -513,7 +490,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
details: buildAsyncDetails("failed", ownJobId),
});
const message = error instanceof Error ? error.message : String(error);
const hint = AgentRegistry.global().get(agentId) ? buildResumeHint(false) : "";
const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : "";
throw new TaskJobError(`${message}${hint}`);
} finally {
semaphore.release();
@@ -538,15 +515,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
};
}
const ircEnabled = this.session.settings.get("irc.enabled") === true;
const coordinationHint = ircEnabled
? `DM \`${agentId}\` via \`irc\` to coordinate while it runs; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
: `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`;
const verb = isResume ? "Resumed" : "Spawned";
const descriptionSuffix = params.description ? ` — ${params.description}` : "";
onUpdate?.({
content: [{ type: "text", text: `${verb} agent \`${agentId}\`...` }],
content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }],
details: buildAsyncDetails("running", jobId),
});
@@ -554,7 +529,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
content: [
{
type: "text",
text: `${verb} agent \`${agentId}\` (job \`${jobId}\`)${descriptionSuffix}. The result will be delivered when it yields. ${coordinationHint}`,
text: `Spawned agent \`${agentId}\` (job \`${jobId}\`)${descriptionSuffix}. The result will be delivered when it yields. ${coordinationHint}`,
},
],
details: {
@@ -568,7 +543,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}
/**
* Synchronous execution of one spawn or resume. Used as the body of every
* Synchronous execution of one spawn. Used as the body of every
* async job and directly by the sync fallback (no job manager / blocking
* agent) and by in-process callers that need the result inline (e.g. the
* commit flow's analyze_files tool).
@@ -580,83 +555,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
preAllocatedId?: string,
): Promise<AgentToolResult<TaskToolDetails>> {
if (typeof params.resume === "string" && params.resume.trim().length > 0) {
return this.#executeResume(toolCallId, params, signal, onUpdate);
}
return this.#runSpawn(toolCallId, params, signal, onUpdate, preAllocatedId);
}
/**
* Resume an existing agent: revive it if parked, inject the follow-up
* assignment through the session's normal prompt path, and run it through
* the same yield/finalize pipeline as a spawn. The session stays alive
* (idle, TTL re-armed) afterwards.
*/
async #executeResume(
toolCallId: string,
params: TaskParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
): Promise<AgentToolResult<TaskToolDetails>> {
const startTime = Date.now();
const resumeId = params.resume!.trim();
const simpleMode = this.#getTaskSimpleMode();
const { customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
const assignment = (params.assignment ?? "").trim();
let session: AgentSession;
try {
session = await AgentLifecycleManager.global().ensureLive(resumeId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new ToolError(
`Cannot resume "${resumeId}": ${message} Use \`irc\` op:"list" to see live agent ids; transcripts are readable at history://${resumeId}.`,
);
}
const agentName = AgentRegistry.global().get(resumeId)?.displayName ?? "task";
const agentDef: AgentDefinition = getAgent(this.#discoveredAgents, agentName) ?? {
name: agentName,
description: "",
systemPrompt: "",
source: "bundled",
};
// Resumed output artifacts overwrite agent://<id> in the parent's
// artifacts dir; the transcript accretes in the session JSONL.
const sessionFile = this.session.getSessionFile();
const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : undefined;
const result = await resumeSubprocess({
session,
id: resumeId,
agent: agentDef,
task: renderSubagentUserPrompt(assignment, simpleMode),
assignment,
description: params.description,
index: 0,
parentToolCallId: toolCallId,
outputSchema: customSchemaEnabled ? params.schema : undefined,
signal,
onProgress: progress => {
onUpdate?.({
content: [{ type: "text", text: `Resuming ${resumeId}...` }],
details: {
projectAgentsDir: null,
results: [],
totalDurationMs: Date.now() - startTime,
progress: [{ ...progress, recentTools: progress.recentTools.slice() }],
},
});
},
eventBus: this.session.eventBus,
settings: this.session.settings,
artifactsDir,
});
return this.#buildResultPayload(result, null, Date.now() - startTime, "");
}
/** Spawn a fresh subagent and run it to completion. */
async #runSpawn(
toolCallId: string,
+9 -14
View File
@@ -509,7 +509,7 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
}
/**
* Render the call preview lines for the single spawned/resumed agent. The
* Render the call preview lines for the single spawned agent. The
* args stream in token by token, so every field access is defensive.
*/
function renderTaskCallLines(args: Partial<TaskParams> | undefined, theme: Theme): string[] {
@@ -517,9 +517,8 @@ function renderTaskCallLines(args: Partial<TaskParams> | undefined, theme: Theme
const bullet = theme.fg("dim", "•");
const lines: string[] = [];
const resume = typeof args.resume === "string" ? args.resume.trim() : "";
const rawId = typeof args.id === "string" ? args.id.trim() : "";
const idLabel = resume ? formatTaskId(resume) : rawId ? formatTaskId(rawId) : "";
const idLabel = rawId ? formatTaskId(rawId) : "";
const desc = typeof args.description === "string" ? args.description.trim() : "";
if (idLabel || desc) {
let line = `${bullet} ${theme.fg("accent", theme.bold(idLabel || "agent"))}`;
@@ -571,9 +570,7 @@ export function renderCall(
theme: Theme,
): Component {
const showIsolated = "isolated" in args && args.isolated === true;
const resume = typeof args.resume === "string" && args.resume.trim() ? args.resume.trim() : undefined;
const headerDescription = resume ? `resume ${formatTaskId(resume)}` : args.agent;
const header = renderStatusLine({ icon: "pending", title: "Task", description: headerDescription }, theme);
const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme);
const assignmentSection = createAssignmentSectionRenderer(args, theme);
return framedBlock(theme, width => {
const sections: Array<{ label?: string; lines: readonly string[]; separator?: boolean }> = [];
@@ -1111,20 +1108,19 @@ export function renderResult(
): Component {
const fallbackText = result.content.find(c => c.type === "text")?.text ?? "";
const details = result.details;
const resumeLabel =
typeof args?.resume === "string" && args.resume.trim() ? `resume ${formatTaskId(args.resume.trim())}` : undefined;
const agentLabel = args?.agent?.trim() || undefined;
const assignmentSection = createAssignmentSectionRenderer(args, theme);
if (!details) {
const text = result.content.find(c => c.type === "text")?.text || "";
const errored = result.isError === true;
const header = errored
? renderStatusLine({ icon: "error", title: "Task", description: resumeLabel ?? args?.agent }, theme)
? renderStatusLine({ icon: "error", title: "Task", description: agentLabel }, theme)
: renderStatusLine(
{
iconOverride: theme.styledSymbol("status.done", "accent"),
title: "Task",
description: resumeLabel ?? args?.agent,
description: agentLabel,
},
theme,
);
@@ -1147,11 +1143,10 @@ export function renderResult(
const isError = aborted || failed;
const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0);
const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
// Surface the dispatched agent type (e.g. `Reviewer`) or the resumed agent
// id alongside the count so the header reads `Task 1 agent: Reviewer`.
const agentName = resumeLabel ?? args?.agent?.trim();
// Surface the dispatched agent type (e.g. `Reviewer`) alongside the count
// so the header reads `Task 1 agent: Reviewer`.
const countLabel = agentCount > 0 ? `${agentCount} ${agentCount === 1 ? "agent" : "agents"}` : undefined;
const metaLabel = countLabel ? (agentName ? `${countLabel}: ${agentName}` : countLabel) : agentName;
const metaLabel = countLabel ? (agentLabel ? `${countLabel}: ${agentLabel}` : countLabel) : agentLabel;
const header = renderStatusLine(
{
icon: icon === "success" ? undefined : icon,
+3 -6
View File
@@ -68,11 +68,10 @@ export interface SubagentLifecyclePayload {
const createTaskSchema = (options: { isolationEnabled: boolean; customSchemaEnabled: boolean }) => {
let schema = z.object({
agent: z.string().optional().describe("agent type; omit when resume is set"),
agent: z.string().describe("agent type to spawn"),
id: z.string().max(48).optional().describe("stable agent id; default generated"),
description: z.string().optional().describe("ui label, not seen by subagent"),
assignment: z.string().describe("the work; self-contained instructions"),
resume: z.string().optional().describe("existing agent id: revive and continue instead of spawning"),
});
if (options.customSchemaEnabled) {
@@ -115,7 +114,7 @@ export function getTaskSchema(options: { isolationEnabled: boolean; simpleMode:
}
export interface TaskParams {
/** Agent type; required unless `resume` is set. */
/** Agent type; required. */
agent?: string;
/** Stable agent id; default = generated AdjectiveNoun. */
id?: string;
@@ -125,10 +124,8 @@ export interface TaskParams {
assignment?: string;
/** JTD schema for the expected yield shape; unchanged semantics. */
schema?: string;
/** Run in an isolated worktree; isolated agents are NOT resumable. */
/** Run in an isolated worktree; isolated agents are torn down at completion. */
isolated?: boolean;
/** Existing agent id: revive + follow-up instead of spawn. */
resume?: string;
}
/** A code review finding reported by the reviewer agent */
+2 -2
View File
@@ -42,7 +42,7 @@ import { resolveEvalBackends } from "./eval-backends";
import { FindTool } from "./find";
import { GithubTool } from "./gh";
import { InspectImageTool } from "./inspect-image";
import { IrcTool } from "./irc";
import { IrcTool, isIrcEnabled } from "./irc";
import { JobTool } from "./job";
import { MemoryEditTool } from "./memory-edit";
import { MemoryRecallTool } from "./memory-recall";
@@ -537,7 +537,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (name === "search_tool_bm25") return discoveryActive;
if (name === "browser") return session.settings.get("browser.enabled");
if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
if (name === "irc") return session.settings.get("irc.enabled");
if (name === "irc") return isIrcEnabled(session.settings, session.taskDepth ?? 0);
if (name === "retain" || name === "recall" || name === "reflect") {
return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
}
+15 -1
View File
@@ -13,6 +13,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
import { type Component, Text } from "@oh-my-pi/pi-tui";
import { formatAge, formatDuration, prompt } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import type { Settings } from "../config/settings";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { IrcBus, type IrcDeliveryReceipt, type IrcMessage } from "../irc/bus";
import type { Theme } from "../modes/theme/theme";
@@ -31,6 +32,19 @@ import {
} from "./render-utils";
const DEFAULT_IRC_TIMEOUT_MS = 120_000;
/**
* IRC availability: there must be someone to chat with. True for every
* subagent (it always has a parent, and possibly siblings) and for any
* session that can still spawn subagents through the task tool. Only a
* top-level session with task spawning unavailable has no peers — no irc.
*/
export function isIrcEnabled(settings: Settings, taskDepth: number): boolean {
if (taskDepth > 0) return true;
const maxDepth = settings.get("task.maxRecursionDepth") ?? 2;
return maxDepth < 0 || taskDepth < maxDepth;
}
const ircSchema = z.object({
op: z.enum(["send", "wait", "inbox", "list"]).describe("irc operation"),
to: z.string().optional().describe('send: recipient agent id or "all"'),
@@ -84,7 +98,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
}
static createIf(session: ToolSession): IrcTool | null {
if (!session.settings.get("irc.enabled")) return null;
if (!isIrcEnabled(session.settings, session.taskDepth ?? 0)) return null;
if (!session.agentRegistry || !session.getAgentId) return null;
return new IrcTool(session);
}
@@ -57,7 +57,7 @@ describe("job renderer task-result preview", () => {
meta: { lineCount: 3, charSize: "120 B" },
mergeSummary: "",
});
const deliveryText = `${summary}\n\nSpawnProbe is now idle — task(resume:"SpawnProbe")`;
const deliveryText = `${summary}\n\nSpawnProbe is now idle — message it via \`irc\` to follow up; transcript at history://SpawnProbe`;
const output = renderLines(deliveryText);
expect(output).toContain("Probe finished: spawned worker, ping ok.");
@@ -91,18 +91,6 @@ describe("task renderer: streaming call preview", () => {
expect(lines[0]).toContain("isolated");
});
it("labels resume calls with the resumed agent id", () => {
const args: TaskParams = {
resume: "AuthLoader",
assignment: "Also check the refresh-token path.",
};
const out = render(args);
const lines = out.split("\n");
expect(lines[0]).toContain("resume AuthLoader");
expect(out).toContain("Also check the refresh-token path.");
});
// Once the tool produces a result, the container suppresses the call entirely
// via `mergeCallAndResult` and `renderResult` draws the agent. As a safety
// net, `renderCall` also drops its preview when a result snapshot is present,
@@ -5,7 +5,7 @@ import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
// Contract (rework-contracts.md §3): the task tool spawns ONE agent per call.
// `tasks[]` and `context` are gone; `resume` continues an existing agent.
// `tasks[]` and `context` are gone; follow-ups go through `irc` messaging.
describe("task schema (single-spawn)", () => {
it("accepts {agent, assignment}", () => {
@@ -13,9 +13,9 @@ describe("task schema (single-spawn)", () => {
expect(parsed.success).toBe(true);
});
it("accepts {resume, assignment}", () => {
const parsed = taskSchema.safeParse({ resume: "AuthLoader", assignment: "Also check refresh tokens." });
expect(parsed.success).toBe(true);
it("requires agent", () => {
const parsed = taskSchema.safeParse({ assignment: "Map the auth module." });
expect(parsed.success).toBe(false);
});
it("requires assignment", () => {
@@ -39,7 +39,7 @@ describe("task schema (single-spawn)", () => {
});
});
describe("task spawn/resume validation", () => {
describe("task spawn validation", () => {
afterEach(() => {
vi.restoreAllMocks();
});
@@ -61,21 +61,11 @@ describe("task spawn/resume validation", () => {
return result.content.find(part => part.type === "text")?.text ?? "";
}
it("rejects resume + agent together", async () => {
const text = await executeText({ agent: "explore", resume: "AuthLoader", assignment: "..." });
expect(text).toContain("not both");
});
it("rejects neither resume nor agent", async () => {
it("rejects a missing agent", async () => {
const text = await executeText({ assignment: "..." });
expect(text).toContain("Missing `agent`");
});
it("rejects resume + isolated", async () => {
const text = await executeText({ resume: "AuthLoader", isolated: true, assignment: "..." });
expect(text).toContain("not resumable");
});
it("rejects a missing assignment", async () => {
const text = await executeText({ agent: "explore" });
expect(text).toContain("Missing `assignment`");
@@ -1,31 +1,26 @@
/**
* Contracts: task tool spawn/resume routing (rework-contracts.md §3).
* Contracts: task tool spawn routing (rework-contracts.md §3).
*
* 1. With an AsyncJobManager wired, `execute` returns immediately (agent id +
* job id) while the job body is still gated; job completion delivers a
* result carrying the `task(resume:"<id>")` / `history://<id>` hint.
* 2. Resume routes through `AgentLifecycleManager.ensureLive` and hands the
* live session to `resumeSubprocess`; an ensureLive rejection surfaces as a
* ToolError naming `history://<id>`.
* 3. The session-scoped spawn semaphore (task.maxConcurrency) serializes job
* result carrying the irc follow-up / `history://<id>` hint.
* 2. The session-scoped spawn semaphore (task.maxConcurrency) serializes job
* bodies: with concurrency 1 the second body does not start until the
* first releases.
*
* Param validation (agent XOR resume, resume+isolated, missing assignment) is
* covered by test/task/task-schema.test.ts.
* Param validation (missing agent / missing assignment) is covered by
* test/task/task-schema.test.ts.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { TaskTool } from "@oh-my-pi/pi-coding-agent/task";
import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery";
import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor";
import type { AgentDefinition, SingleResult, TaskParams } from "@oh-my-pi/pi-coding-agent/task/types";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
const taskAgent: AgentDefinition = {
name: "task",
@@ -75,10 +70,7 @@ interface Deferred {
}
function deferred(): Deferred {
let resolve!: () => void;
const promise = new Promise<void>(res => {
resolve = res;
});
const { promise, resolve } = Promise.withResolvers<void>();
return { promise, resolve };
}
@@ -90,7 +82,7 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 2000): Promise<vo
}
}
describe("task spawn/resume routing", () => {
describe("task spawn routing", () => {
const managers: AsyncJobManager[] = [];
function createManager(): AsyncJobManager {
@@ -113,7 +105,7 @@ describe("task spawn/resume routing", () => {
AgentRegistry.resetGlobalForTests();
});
it("returns immediately on spawn and delivers the resume hint when the job completes", async () => {
it("returns immediately on spawn and delivers the follow-up hint when the job completes", async () => {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({
agents: [taskAgent],
projectAgentsDir: null,
@@ -148,87 +140,12 @@ describe("task spawn/resume routing", () => {
await job!.promise;
expect(job!.status).toBe("completed");
expect(job!.resultText).toContain('task(resume:"Spawnling")');
expect(job!.resultText).toContain("Spawnling is now idle");
expect(job!.resultText).toContain("message it via `irc` to follow up");
expect(job!.resultText).toContain("history://Spawnling");
expect(runSpy).toHaveBeenCalledTimes(1);
});
it("rejects an async resume of an unregistered agent without registering a job", async () => {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({
agents: [taskAgent],
projectAgentsDir: null,
});
const manager = createManager();
const tool = await TaskTool.create(createSession({ manager }));
const error = await tool
.execute("tc-resume-unknown", { resume: "Nobody", assignment: "Follow up." } as TaskParams)
.then(
() => null,
err => err as Error,
);
expect(error).toBeInstanceOf(ToolError);
expect(error?.message).toContain('Unknown agent "Nobody"');
expect(error?.message).toContain("history://Nobody");
expect(manager.getAllJobs()).toHaveLength(0);
});
it("resume routes through AgentLifecycleManager.ensureLive and hands the live session to resumeSubprocess", async () => {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [], projectAgentsDir: null });
const fakeSession = { messages: [] } as unknown as AgentSession;
AgentRegistry.global().register({
id: "Reso",
displayName: "task",
kind: "sub",
session: fakeSession,
status: "idle",
});
const ensureLiveSpy = vi.spyOn(AgentLifecycleManager.global(), "ensureLive").mockResolvedValue(fakeSession);
const resumeSpy = vi
.spyOn(executorModule, "resumeSubprocess")
.mockResolvedValue(makeResult("Reso", { output: "Follow-up done." }));
// No job manager => sync fallback, so the resume pipeline runs inline.
const tool = await TaskTool.create(createSession({}));
const result = await tool.execute("tc-resume", {
resume: "Reso",
assignment: "Also check refresh tokens.",
} as TaskParams);
expect(ensureLiveSpy).toHaveBeenCalledTimes(1);
expect(ensureLiveSpy).toHaveBeenCalledWith("Reso");
expect(resumeSpy).toHaveBeenCalledTimes(1);
const resumeOptions = resumeSpy.mock.calls[0]![0];
expect(resumeOptions.session).toBe(fakeSession);
expect(resumeOptions.id).toBe("Reso");
expect(resumeOptions.assignment).toBe("Also check refresh tokens.");
const text = getFirstText(result);
expect(text).toContain("Reso");
expect(text).toContain("completed");
expect(result.details?.results).toHaveLength(1);
expect(result.details?.results[0]?.exitCode).toBe(0);
});
it("surfaces an ensureLive rejection as a ToolError naming history://", async () => {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [], projectAgentsDir: null });
vi.spyOn(AgentLifecycleManager.global(), "ensureLive").mockRejectedValue(new Error("session file corrupt"));
const tool = await TaskTool.create(createSession({}));
const error = await tool
.execute("tc-resume-dead", { resume: "Ghost", assignment: "Wake up." } as TaskParams)
.then(
() => null,
err => err as Error,
);
expect(error).toBeInstanceOf(ToolError);
expect(error?.message).toContain('Cannot resume "Ghost"');
expect(error?.message).toContain("session file corrupt");
expect(error?.message).toContain("history://Ghost");
});
it("bounds concurrent job bodies with the session spawn semaphore", async () => {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({
agents: [taskAgent],
@@ -253,10 +170,11 @@ describe("task spawn/resume routing", () => {
const firstJob = manager.getJob(first.details!.async!.jobId)!;
const secondJob = manager.getJob(second.details!.async!.jobId)!;
// First job body reaches the executor; second stays parked at the semaphore.
// First job body reaches the executor; second stays parked at the
// semaphore — still flagged queued because markRunning never ran.
await pollUntil(() => started.length >= 1);
await Bun.sleep(25);
expect(started).toHaveLength(1);
expect(started).toEqual(["First"]);
expect(secondJob.queued).toBe(true);
// Releasing the first body lets the second one start.
gates.get(started[0]!)!.resolve();
+34 -2
View File
@@ -319,7 +319,7 @@ describe("IRC", () => {
});
describe("IrcTool", () => {
it("createIf returns null when irc is disabled", () => {
it("createIf returns null for a top-level session that cannot spawn tasks", () => {
const session: ToolSession = {
cwd: "/tmp",
hasUI: false,
@@ -329,10 +329,42 @@ describe("IRC", () => {
agentRegistry: registry,
getAgentId: () => "0-Main",
};
session.settings.set("irc.enabled", false);
// Depth 0 with spawning gated off: no peers exist or can be created.
session.settings.set("task.maxRecursionDepth", 0);
expect(IrcTool.createIf(session)).toBeNull();
});
it("createIf enables irc while the task tool is available", () => {
const session: ToolSession = {
cwd: "/tmp",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
agentRegistry: registry,
getAgentId: () => "0-Main",
};
// Default task.maxRecursionDepth (2) at depth 0: task can spawn, and a
// finished subagent must stay reachable.
expect(IrcTool.createIf(session)).toBeInstanceOf(IrcTool);
});
it("createIf enables irc for a subagent even at the recursion-depth cap", () => {
const session: ToolSession = {
cwd: "/tmp",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
agentRegistry: registry,
getAgentId: () => "0-Leaf",
taskDepth: 2,
};
// A leaf subagent cannot spawn, but its parent (and siblings) exist.
session.settings.set("task.maxRecursionDepth", 2);
expect(IrcTool.createIf(session)).toBeInstanceOf(IrcTool);
});
it("createIf returns null without registry/agentId", () => {
const session: ToolSession = {
cwd: "/tmp",