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:
+4
-4
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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") ?? "");
|
||||
}
|
||||
|
||||
@@ -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`");
|
||||
|
||||
+14
-96
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user