From 6ac73655c8bc703af851fef323f9e5609afacd49 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 10 Jun 2026 23:00:03 +0200 Subject: [PATCH] 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:)`. --- docs/tools/irc.md | 8 +- docs/tools/job.md | 2 +- docs/tools/task.md | 71 ++++---- packages/coding-agent/CHANGELOG.md | 9 +- .../src/cli/gallery-fixtures/agentic.ts | 58 ------- .../src/cli/gallery-fixtures/types.ts | 2 +- .../src/config/settings-schema.ts | 10 -- .../src/modes/components/agent-hub.ts | 7 +- .../coding-agent/src/prompts/tools/task.md | 12 +- packages/coding-agent/src/task/executor.ts | 163 +----------------- packages/coding-agent/src/task/index.ts | 143 +++------------ packages/coding-agent/src/task/render.ts | 23 +-- packages/coding-agent/src/task/types.ts | 9 +- packages/coding-agent/src/tools/index.ts | 4 +- packages/coding-agent/src/tools/irc.ts | 16 +- .../test/job-renderer-preview.test.ts | 2 +- .../test/task/render-call.test.ts | 12 -- .../test/task/task-schema.test.ts | 22 +-- ...task-resume.test.ts => task-spawn.test.ts} | 110 ++---------- packages/coding-agent/test/tools/irc.test.ts | 36 +++- 20 files changed, 159 insertions(+), 560 deletions(-) rename packages/coding-agent/test/task/{task-resume.test.ts => task-spawn.test.ts} (58%) diff --git a/docs/tools/irc.md b/docs/tools/irc.md index bf4e79a03..927f251be 100644 --- a/docs/tools/irc.md +++ b/docs/tools/irc.md @@ -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://`). diff --git a/docs/tools/job.md b/docs/tools/job.md index 5a50c7be0..e42d68e2c 100644 --- a/docs/tools/job.md +++ b/docs/tools/job.md @@ -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`. diff --git a/docs/tools/task.md b/docs/tools/task.md index 383d9c595..95d91cbaa 100644 --- a/docs/tools/task.md +++ b/docs/tools/task.md @@ -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 `` (job ``). The result will be delivered when it yields. ... `` (or `Resumed agent ...`), plus a coordination hint (`irc` DM when enabled, otherwise `job`). +- `content`: `` Spawned agent `` (job ``). 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: [], 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: `` is now idle — task(resume:"") to continue it, transcript at history:// `` (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: `` is now idle — message it via `irc` to follow up; transcript at history:// `` (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://` 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 `.md`; `agent://` resolves to that file. Resumes overwrite it per assignment. +- Every subagent with an artifacts dir writes `.md`; `agent://` resolves to that file. - If the output file is JSON, `agent:///` and `agent://?q=` perform JSON extraction. - Each subagent gets `.jsonl` session history when the parent persists artifacts; `history://` renders it as a concise transcript (works for live and parked agents). - Isolated patch mode writes `.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://`); 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: ""` — fresh subagent with a new (or caller-provided) id. - - `resume: ""` — follow-up assignment in an existing session; revives a parked agent first. Transcript accretes; `agent://` 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://` 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://`. -- `ensureLive(...)` failures (agent parked without a reviver — e.g. an isolated run — or torn down) surface as `` Cannot resume "": ... `` `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://` 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://` 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://` 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. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3d6b5f682..ef5ef53c4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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: "")` 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://` 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 `
` 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 diff --git a/packages/coding-agent/src/cli/gallery-fixtures/agentic.ts b/packages/coding-agent/src/cli/gallery-fixtures/agentic.ts index df66493a9..a4b47b769 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/agentic.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/agentic.ts @@ -142,64 +142,6 @@ export const agenticFixtures: Record = { }, }, - // 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. diff --git a/packages/coding-agent/src/cli/gallery-fixtures/types.ts b/packages/coding-agent/src/cli/gallery-fixtures/types.ts index cdf935e16..a17b9d53f 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/types.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/types.ts @@ -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; /** diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 3b20c0387..216044fef 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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, diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index ebff0ae7f..9dedb5f14 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -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(); } diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index d567568c2..ca780ac87 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -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}} -- Finished agents stay alive: `idle` first, then `parked` after a TTL — both remain addressable and revivable. -- `resume: ""` 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://` is the agent's transcript; `agent://` its latest output artifact. -- `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}} @@ -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}} diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index dda69eeef..583d2d4e8 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -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 * `.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 { const { monitor, done, index, id, agent, task, assignment, signal, modelOverride } = args; @@ -1659,7 +1660,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise void; - eventBus?: EventBus; - settings?: Settings; - /** Where the `.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://` output - * artifact; the transcript accretes in the session JSONL. - */ -export async function resumeSubprocess(options: ResumeExecutorOptions): Promise { - 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, - }); -} diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 7f7c9698a..64b74b8b5 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -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 { const params = args as Partial; 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 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 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 { + 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, preAllocatedId?: string, ): Promise> { - 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, - ): Promise> { - 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:// 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, diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 556f59438..9f361ffaf 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -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 | undefined, theme: Theme): string[] { @@ -517,9 +517,8 @@ function renderTaskCallLines(args: Partial | 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, diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index 01b84a1e5..c571cad08 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -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 */ diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index c70a022bf..938139835 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -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") ?? ""); } diff --git a/packages/coding-agent/src/tools/irc.ts b/packages/coding-agent/src/tools/irc.ts index 49d594aa7..2b4770ab3 100644 --- a/packages/coding-agent/src/tools/irc.ts +++ b/packages/coding-agent/src/tools/irc.ts @@ -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 { } 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); } diff --git a/packages/coding-agent/test/job-renderer-preview.test.ts b/packages/coding-agent/test/job-renderer-preview.test.ts index 86890768e..0ad5f1882 100644 --- a/packages/coding-agent/test/job-renderer-preview.test.ts +++ b/packages/coding-agent/test/job-renderer-preview.test.ts @@ -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."); diff --git a/packages/coding-agent/test/task/render-call.test.ts b/packages/coding-agent/test/task/render-call.test.ts index f666239de..5a11f822c 100644 --- a/packages/coding-agent/test/task/render-call.test.ts +++ b/packages/coding-agent/test/task/render-call.test.ts @@ -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, diff --git a/packages/coding-agent/test/task/task-schema.test.ts b/packages/coding-agent/test/task/task-schema.test.ts index 0e3f6ecb7..9d181a947 100644 --- a/packages/coding-agent/test/task/task-schema.test.ts +++ b/packages/coding-agent/test/task/task-schema.test.ts @@ -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`"); diff --git a/packages/coding-agent/test/task/task-resume.test.ts b/packages/coding-agent/test/task/task-spawn.test.ts similarity index 58% rename from packages/coding-agent/test/task/task-resume.test.ts rename to packages/coding-agent/test/task/task-spawn.test.ts index 3db8d3273..0a18bf1cb 100644 --- a/packages/coding-agent/test/task/task-resume.test.ts +++ b/packages/coding-agent/test/task/task-spawn.test.ts @@ -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:"")` / `history://` hint. - * 2. Resume routes through `AgentLifecycleManager.ensureLive` and hands the - * live session to `resumeSubprocess`; an ensureLive rejection surfaces as a - * ToolError naming `history://`. - * 3. The session-scoped spawn semaphore (task.maxConcurrency) serializes job + * result carrying the irc follow-up / `history://` 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(res => { - resolve = res; - }); + const { promise, resolve } = Promise.withResolvers(); return { promise, resolve }; } @@ -90,7 +82,7 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 2000): Promise { +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(); diff --git a/packages/coding-agent/test/tools/irc.test.ts b/packages/coding-agent/test/tools/irc.test.ts index 2b88dcfa4..b405ebb61 100644 --- a/packages/coding-agent/test/tools/irc.test.ts +++ b/packages/coding-agent/test/tools/irc.test.ts @@ -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",