@zvada/agent-server 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/docs/harnesses.md +7 -0
- package/docs/rfds/0002-native-subagents.md +197 -0
- package/package.json +1 -1
- package/src/core/agents/base.ts +1 -0
- package/src/core/agents/claude-code/adapter.ts +10 -3
- package/src/core/agents/claude-code/claude-agent.ts +2 -0
- package/src/core/agents/claude-code/options.ts +3 -0
- package/src/core/agents/claude-code/session-manager.ts +1 -0
- package/src/core/agents/codex-app-server/adapter.ts +32 -0
- package/src/core/agents/codex-app-server/client.ts +54 -1
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +123 -8
- package/src/core/agents/codex-app-server/subagents.ts +201 -0
- package/src/core/agents/codex-items.ts +27 -0
- package/src/core/agents/session-agent.ts +3 -0
- package/src/core/runtime/agent-runtime.ts +1 -0
- package/src/protocol/config.ts +2 -0
- package/src/protocol/guards.ts +1 -1
- package/src/protocol/harness.ts +2 -0
- package/src/protocol/parts.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.13
|
|
4
|
+
|
|
5
|
+
- Add opt-in `subagentMode: "turn"` for Claude Code and Codex app-server.
|
|
6
|
+
Native workers stay within the managed execution's lifetime and existing
|
|
7
|
+
permission broker. Unsupported harnesses reject this mode.
|
|
8
|
+
- Preserve modern Claude Agent metadata and nested text, normalize Codex V1
|
|
9
|
+
task tools, and expose V2 native activity as a typed `SubagentPart`.
|
|
10
|
+
- Require native cleanup before a managed Codex execution releases ownership,
|
|
11
|
+
including cancellation and failed startup. Unconfirmed cleanup prevents
|
|
12
|
+
session reuse. Managed Codex successors resume in a fresh native process.
|
|
13
|
+
Default interactive harness behavior remains unchanged.
|
|
14
|
+
|
|
15
|
+
## 0.3.12
|
|
16
|
+
|
|
17
|
+
- Report a Claude turn whose API call failed as an error. Claude Code ends it
|
|
18
|
+
as a `success` result flagged `is_error` (a rejected key, an exhausted quota,
|
|
19
|
+
an overloaded provider), with the failure as its text; it was reported as a
|
|
20
|
+
completed `end_turn` whose answer quoted the failure. The failure's text now
|
|
21
|
+
classifies the error (a 401 is `auth`).
|
|
22
|
+
|
|
3
23
|
## 0.3.11
|
|
4
24
|
|
|
5
25
|
- Report a Claude turn stopped while a tool runs as `cancelled`. Claude Code
|
package/docs/harnesses.md
CHANGED
|
@@ -120,6 +120,13 @@ provide the cache breakdown without an additional native usage report.
|
|
|
120
120
|
|
|
121
121
|
## Known limitations (roadmap)
|
|
122
122
|
|
|
123
|
+
- **Native subagents:** opt into `RunConfig.subagentMode: "turn"` for Claude
|
|
124
|
+
Code or Codex app-server. The parent collects results; native workers settle
|
|
125
|
+
before the engine releases the turn. Claude forwards nested worker text.
|
|
126
|
+
Codex emits task tools and typed `subagent` activity; it does not forward full
|
|
127
|
+
child transcripts. Managed Codex turns close the native process after cleanup
|
|
128
|
+
and resume saved context on the next turn. Default behavior is unchanged.
|
|
129
|
+
See [RFD 0002](rfds/0002-native-subagents.md) for the rehearsal and qualification.
|
|
123
130
|
- **MCP servers** are supported by Claude and Codex app-server. Codex SDK
|
|
124
131
|
passthrough remains unsupported. See [MCP configuration](consuming.md#configure-mcp-between-turns)
|
|
125
132
|
for Codex home ownership, supported transports, and live-update behavior.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# RFD 0002 — Native delegation within a managed turn
|
|
2
|
+
|
|
3
|
+
**Status:** implemented; native Claude and Codex qualification passed on 2026-09-29.
|
|
4
|
+
**Date:** 2026-09-29
|
|
5
|
+
**Scope:** Claude Code and Codex app-server in agent-server and AGNT.
|
|
6
|
+
**Baseline:** agent-server `7248c43`; AGNT `39df4fa9`.
|
|
7
|
+
|
|
8
|
+
## Recommendation
|
|
9
|
+
|
|
10
|
+
Support native delegation inside the existing logical engine turn. The parent
|
|
11
|
+
assigns work, collects results and produces the final answer. The engine retains
|
|
12
|
+
ownership until its native work finishes or is stopped. AGNT keeps using its
|
|
13
|
+
existing turn-scoped credentials, permission bridge, persistence and cancellation.
|
|
14
|
+
|
|
15
|
+
This is a smaller extension of [DESIGN D10](../../../../DESIGN.md#d10--built-in-turns-own-their-logical-session-through-cleanup).
|
|
16
|
+
It does not require a second scheduler, child credential leases, a worker
|
|
17
|
+
database, or a stream that remains active between managed turns. Independently
|
|
18
|
+
running workers across later turns are outside this first contract.
|
|
19
|
+
|
|
20
|
+
## What already works — observed
|
|
21
|
+
|
|
22
|
+
Deus Machine already configures this engine with `forwardSubagentText: true`
|
|
23
|
+
and renders nested subagent output. At `origin/main` `4e957a26c`, see
|
|
24
|
+
`apps/agent-server/agents/core/engine.ts`,
|
|
25
|
+
`apps/web/src/features/session/ui/blocks/SubagentGroupBlock.tsx` and
|
|
26
|
+
`apps/backend/test/unit/services/event-persistence-integration.test.ts`.
|
|
27
|
+
The last file tests nesting a worker message under its spawning tool. This is
|
|
28
|
+
source evidence, not a new live execution.
|
|
29
|
+
|
|
30
|
+
The engine already carries `ToolPart.subagent` and `parentToolCallId`; its
|
|
31
|
+
`test/server/flows.test.ts` covers nested output. AGNT already round-trips these
|
|
32
|
+
fields. Its two focused message suites passed **37 tests** during this rehearsal.
|
|
33
|
+
|
|
34
|
+
[SessionAgent](../../src/core/agents/session-agent.ts) already reserves a session
|
|
35
|
+
through `executeTurn` cleanup. AGNT's controller waits for its predecessor to
|
|
36
|
+
drain before preparing a new credential and releases credentials in its existing
|
|
37
|
+
`finally`. These are the owners to reuse.
|
|
38
|
+
|
|
39
|
+
AGNT does not globally disable native delegation. Its explicit `toolAllowlist`
|
|
40
|
+
rejects `Agent` and `Task`; QApp also omits them. AGNT's Claude SDK options omit
|
|
41
|
+
Deus Machine's forwarding flag. Saying that the whole stack lacked subagents
|
|
42
|
+
was too broad.
|
|
43
|
+
|
|
44
|
+
## Pass 1 — the smallest apparent patch
|
|
45
|
+
|
|
46
|
+
1. Enable Claude child-text forwarding in AGNT. Recognize modern `Agent` as
|
|
47
|
+
well as legacy `Task` in the Claude adapter.
|
|
48
|
+
2. Normalize Codex's existing native delegation output.
|
|
49
|
+
3. Permit delegation under AGNT's scoped tool policy.
|
|
50
|
+
|
|
51
|
+
Steps 1–3 alone are insufficient. The following counterexamples determine the
|
|
52
|
+
additional work; they do not justify a general background-worker platform.
|
|
53
|
+
|
|
54
|
+
## Pass 2 — counterexamples and smallest corrections
|
|
55
|
+
|
|
56
|
+
| Trigger and current path | Wrong result | Smallest correction and proof |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| Claude returns its parent `result` while background work remains; `ClaudeGeneratorSession` closes the tap. | Late child output is lost or reaches the next tap after the host changes policy. | In the managed mode, enforce native foreground execution and qualify cleanup on the pinned SDK. Test cancellation plus a successor with changed policy. |
|
|
59
|
+
| Codex emits root `turn/completed`; `executeTurn` ends its queue and removes listeners. | Child activity disappears; credentials can be released too early. | Track native descendants for this execution, separate parent completion from execution settlement, and retain the existing session reservation through cleanup. |
|
|
60
|
+
| Merely delay Codex's queue end, leaving `turnEnded = true`. | `forceCancel` returns early, so cancellation can hang or falsely settle. | Make cancellation depend on execution settlement, target active owned native turns, and observe completion. Test cancel after the parent response. |
|
|
61
|
+
| A Codex child requests approval. `bindRequests` currently declines every foreign thread id. | Owned children cannot use the existing permission broker. | Admit only descendants proven by native ownership; route them through the current turn's handler. Test an owned child and an unrelated thread. |
|
|
62
|
+
| Treat `client.close()` or a stop request as proof of teardown. | Current `close()` marks itself closed before the OS process exits. | Await actual native completion or bounded subprocess teardown; qualify descendant tools too. Never report a clean stop from the local boolean alone. |
|
|
63
|
+
| Set foreground behavior globally for all engine consumers. | Existing interactive Deus Machine behavior changes unintentionally. | Use one explicit managed-turn option, include it in native session configuration, and preserve the default for existing consumers. |
|
|
64
|
+
| Wait for workers after the parent has already submitted its answer. | The final answer may never have used their results. | Require the parent to collect results. If it returns with outstanding work, stop that work and report incomplete execution; do not silently run another model turn. |
|
|
65
|
+
|
|
66
|
+
The earlier synthetic probes reproduced omitted adapter events and late-tap
|
|
67
|
+
routing, not a live permission escape. Artifacts remain under
|
|
68
|
+
`.context/subagents-research/` and QApp's `.context/delegation-audit/`.
|
|
69
|
+
|
|
70
|
+
## Pass 3 — revised implementation
|
|
71
|
+
|
|
72
|
+
### Engine option and lifetime
|
|
73
|
+
|
|
74
|
+
The opt-in `RunConfig.subagentMode: "turn"` preserves existing native behavior
|
|
75
|
+
when omitted. Unsupported
|
|
76
|
+
harnesses reject the request. Thread it through
|
|
77
|
+
`src/protocol/config.ts`, `src/core/agents/base.ts`,
|
|
78
|
+
`src/core/runtime/agent-runtime.ts` and native session configuration.
|
|
79
|
+
A warm process must not silently retain an incompatible mode.
|
|
80
|
+
|
|
81
|
+
The normal path remains one run: native workers execute, the parent receives
|
|
82
|
+
their results, and the engine finishes after native cleanup. Multiple native
|
|
83
|
+
workers may run concurrently where the pinned harness supports it; qualify that
|
|
84
|
+
behavior rather than promising it from an SDK option.
|
|
85
|
+
|
|
86
|
+
The adapter needs an execution-local record of owned native work, not a durable
|
|
87
|
+
child-session service. Track starts, terminal outcomes and active native turn
|
|
88
|
+
identifiers, including descendants. A spawn acknowledgement is not completion.
|
|
89
|
+
On early parent exit or cancellation, stop remaining owned work before releasing
|
|
90
|
+
ownership. Failure to establish cleanup must remain a failed/unconfirmed
|
|
91
|
+
execution and prevent unsafe session reuse. Do not add automatic retries or
|
|
92
|
+
background parent wake-ups.
|
|
93
|
+
|
|
94
|
+
### Claude
|
|
95
|
+
|
|
96
|
+
Use the native foreground control in managed mode and forward child text.
|
|
97
|
+
The current [Claude documentation](https://code.claude.com/docs/en/sub-agents#run-subagents-in-foreground-or-background)
|
|
98
|
+
documents `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1`; verify the behavior against
|
|
99
|
+
the pinned SDK **0.3.220**, including explicit background requests and
|
|
100
|
+
cancellation. Reuse the existing message nesting.
|
|
101
|
+
|
|
102
|
+
Changes belong in `claude-code/options.ts`, `claude-agent.ts`,
|
|
103
|
+
`generator-session.ts` as required by that qualification, and the
|
|
104
|
+
`subagentFromInput` branch in `claude-code/adapter.ts`. Do not build a separate
|
|
105
|
+
Claude scheduler. If the native foreground control does not contain the pinned
|
|
106
|
+
runtime, keep the managed capability disabled until that specific gap is fixed.
|
|
107
|
+
|
|
108
|
+
### Codex
|
|
109
|
+
|
|
110
|
+
The pinned CLI **0.153.4** emits V1 `collabAgentToolCall` and V2
|
|
111
|
+
`subAgentActivity`. Both currently disappear in the adapters. V2 activity may
|
|
112
|
+
arrive after its originating parent response. The
|
|
113
|
+
[pinned app-server contract](https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/app-server/README.md)
|
|
114
|
+
supports `turn/interrupt` for parent-owned V2 children too: use each child's
|
|
115
|
+
active native turn, then observe its terminal outcome. Interrupting an already
|
|
116
|
+
completed root is insufficient. Native background terminals have a separate
|
|
117
|
+
cleanup operation; include child-owned terminals in cancellation qualification.
|
|
118
|
+
|
|
119
|
+
Implement ownership, notification selection and drain behavior in
|
|
120
|
+
`codex-app-server/codex-app-server-agent.ts`; add awaited process-exit fallback
|
|
121
|
+
in `client.ts`. Use native parent links/read APIs to settle notification-order
|
|
122
|
+
races, never permissive thread matching. Child approval handling stays on the
|
|
123
|
+
existing broker.
|
|
124
|
+
|
|
125
|
+
Normalize V1 calls in `codex-app-server/adapter.ts` and `codex-items.ts`.
|
|
126
|
+
Preserve real native ids, results and task metadata. V2 is an activity, not a
|
|
127
|
+
public tool invocation: add the small typed activity representation it needs to
|
|
128
|
+
the existing Part/adapter/reducer path rather than inventing a tool call.
|
|
129
|
+
Carry its native activity id, child thread id and observed kind. This is a
|
|
130
|
+
persisted message part, not a new independently managed session. Full child
|
|
131
|
+
transcript parity is not a prerequisite; unavailable transcripts stay explicit.
|
|
132
|
+
|
|
133
|
+
### AGNT and review consumers
|
|
134
|
+
|
|
135
|
+
AGNT enables the qualified managed mode in `AgentEngine.prepareTurn` and adds
|
|
136
|
+
`forwardSubagentText`. Its existing controller and credential cleanup remain
|
|
137
|
+
the owners. Extend existing part persistence only for the activity shape the
|
|
138
|
+
engine actually emits; no new worker table is needed.
|
|
139
|
+
|
|
140
|
+
Relax `Agent`/`Task` rejection only when child tools demonstrably obey the same
|
|
141
|
+
Claude allowlist, including bypass mode. Gate this admission on an updated
|
|
142
|
+
sidecar version. Codex's existing lack of custom `toolAllowlist` enforcement
|
|
143
|
+
remains a separate limitation; this work must not claim to solve it.
|
|
144
|
+
|
|
145
|
+
The parent decides what to test through skills, gives each worker the necessary
|
|
146
|
+
context, waits for results and submits one report. Device workers share no
|
|
147
|
+
simulator concurrently unless separate devices exist. QApp's evidence still
|
|
148
|
+
comes through its trusted capture path. No review roles belong in the engine.
|
|
149
|
+
|
|
150
|
+
## Checks that establish the feature
|
|
151
|
+
|
|
152
|
+
- Adapter fixtures cover modern Claude `Agent`, legacy `Task`, Codex V1 and
|
|
153
|
+
V2, interleaved workers, native ids and honest outcomes.
|
|
154
|
+
- Runtime tests cover early parent completion, child failure, cancellation while
|
|
155
|
+
waiting, owned/unrelated approvals, actual teardown, and a successor with
|
|
156
|
+
different credentials/MCP/policy. No old child reaches that successor.
|
|
157
|
+
- Existing reducer, replay and AGNT JSON-part round trips retain nesting and
|
|
158
|
+
activity without a separate lifecycle database.
|
|
159
|
+
- Live pinned Claude and Codex runs each delegate two tasks and incorporate both
|
|
160
|
+
results. Repeat through AGNT's real proxy/MCP path, including a denied child
|
|
161
|
+
tool for Claude. Prove stop behavior and absence of continued child execution.
|
|
162
|
+
- Existing consumers without the managed option keep their native behavior.
|
|
163
|
+
A QApp review demonstrates worker evidence reaching the final report and
|
|
164
|
+
visible partial coverage on failure.
|
|
165
|
+
|
|
166
|
+
## Implementation qualification
|
|
167
|
+
|
|
168
|
+
The deterministic engine suite passes (907 tests). The opt-in live suite
|
|
169
|
+
`test/core/live/subagents.live.test.ts` passes four real-model cases: Claude and
|
|
170
|
+
Codex each delegate to two workers and incorporate both unique file values;
|
|
171
|
+
each cancels a worker running a real shell command, confirms that PID stopped,
|
|
172
|
+
and successfully runs a successor. Proof is retained in `.context/proof/subagents`.
|
|
173
|
+
No simulator was used in these engine tests.
|
|
174
|
+
|
|
175
|
+
Live testing found that Claude can return `error_during_execution` with
|
|
176
|
+
`stop_reason: tool_use` on an acknowledged cancellation. The adapter now uses
|
|
177
|
+
the confirmed interrupt marker to distinguish that from an execution failure.
|
|
178
|
+
|
|
179
|
+
Codex can register a background terminal AFTER its thread becomes idle. An
|
|
180
|
+
interrupt response and empty terminal list therefore cannot prove cleanup.
|
|
181
|
+
Managed Codex turns close the app-server through stdin EOF after reconciliation,
|
|
182
|
+
which invokes native thread/tool shutdown. Root SIGTERM alone left a real child
|
|
183
|
+
command running on macOS. Forced exits or native cleanup warnings are unconfirmed
|
|
184
|
+
and quarantine the logical session. A managed successor resumes persisted context
|
|
185
|
+
in a fresh process; the default interactive mode retains its warm process.
|
|
186
|
+
|
|
187
|
+
This contract covers native managed work. It does not turn arbitrary shell
|
|
188
|
+
commands into an OS containment boundary; sandbox custody remains with the host.
|
|
189
|
+
AGNT and QApp integration qualification is recorded in their own design documents.
|
|
190
|
+
|
|
191
|
+
Repeated cancellation also consults persistent unconfirmed native cleanup after the wrapper turn has ended. The idle response of another harness must not erase that result in the host. AGNT cloud qualification additionally verified real child PID cleanup and a successful successor for both harnesses.
|
|
192
|
+
|
|
193
|
+
Releasing a quarantined Codex session also refuses unconfirmed cleanup. A host
|
|
194
|
+
must propagate that release failure before resetting the session or switching
|
|
195
|
+
harnesses; otherwise a successor could bypass the same-harness admission guard.
|
|
196
|
+
Deterministic regressions cover failure after normal parent completion, repeated
|
|
197
|
+
release attempts, and successful release after confirmed cleanup.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvada/agent-server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/core/agents/base.ts
CHANGED
|
@@ -97,6 +97,8 @@ type ClaudeMessage =
|
|
|
97
97
|
turn_cost_usd?: number | null;
|
|
98
98
|
stop_reason?: string | null;
|
|
99
99
|
is_error?: boolean;
|
|
100
|
+
/** On an `is_error` success, the API failure's text. */
|
|
101
|
+
result?: string;
|
|
100
102
|
};
|
|
101
103
|
|
|
102
104
|
type BlockKind = "text" | "reasoning" | "tool";
|
|
@@ -194,7 +196,7 @@ function toolResultContent(content: unknown): ToolResultContent[] | undefined {
|
|
|
194
196
|
/**
|
|
195
197
|
* SubagentMetadata from a spawning tool's input, shared by the streamed and
|
|
196
198
|
* non-streaming paths. `subagent_type` in the input identifies a spawn on any
|
|
197
|
-
* tool name;
|
|
199
|
+
* tool name; Claude's `Agent` (formerly `Task`) also counts before its
|
|
198
200
|
* input parses into anything useful (presence of `subagent` is what promotes
|
|
199
201
|
* the part to `kind: "task"` — see protocol §3.5).
|
|
200
202
|
*/
|
|
@@ -205,7 +207,7 @@ function subagentFromInput(
|
|
|
205
207
|
const str = (key: string): string | undefined =>
|
|
206
208
|
typeof input[key] === "string" ? (input[key] as string) : undefined;
|
|
207
209
|
const type = str("subagent_type");
|
|
208
|
-
if (type === undefined && toolName !== "Task") return undefined;
|
|
210
|
+
if (type === undefined && toolName !== "Task" && toolName !== "Agent") return undefined;
|
|
209
211
|
const description = str("description");
|
|
210
212
|
const model = str("model");
|
|
211
213
|
return {
|
|
@@ -505,7 +507,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
505
507
|
}
|
|
506
508
|
|
|
507
509
|
private captureResult(msg: Extract<ClaudeMessage, { type: "result" }>): AdapterEvent[] {
|
|
508
|
-
this.succeeded = msg.subtype === "success";
|
|
510
|
+
this.succeeded = msg.subtype === "success" && !msg.is_error;
|
|
509
511
|
if (msg.usage) {
|
|
510
512
|
const creation = msg.usage.cache_creation;
|
|
511
513
|
this.usage = {
|
|
@@ -538,6 +540,11 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
538
540
|
} else {
|
|
539
541
|
this.error = `Claude turn ended: ${msg.subtype}`;
|
|
540
542
|
}
|
|
543
|
+
} else if (msg.is_error) {
|
|
544
|
+
// A failed API call (a rejected key, an exhausted quota, an overloaded
|
|
545
|
+
// provider) ends the turn as a "success" whose text is the error. The
|
|
546
|
+
// text names the failure, so the runtime classifies it (401 -> auth).
|
|
547
|
+
this.error = msg.result || "Claude turn ended with an API error";
|
|
541
548
|
}
|
|
542
549
|
// Final authoritative gauge: the result knows the context-window size.
|
|
543
550
|
if (!msg.usage) return [];
|
|
@@ -25,6 +25,7 @@ const CAPABILITIES: AgentCapabilities = {
|
|
|
25
25
|
images: true,
|
|
26
26
|
mcpServers: true,
|
|
27
27
|
permissionRequests: true,
|
|
28
|
+
turnScopedSubagents: true,
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
/** Convert our normalized input into Claude's message content. */
|
|
@@ -68,6 +69,7 @@ function sessionConfigFrom(
|
|
|
68
69
|
thinkingLevel: options.thinkingLevel,
|
|
69
70
|
permissionMode: options.permissionMode,
|
|
70
71
|
maxTurns: options.maxTurns,
|
|
72
|
+
subagentMode: options.subagentMode,
|
|
71
73
|
systemPromptAppend: options.systemPromptAppend,
|
|
72
74
|
resumeSessionId: options.resumeSessionId,
|
|
73
75
|
resumeSessionAt: options.resumeSessionAt,
|
|
@@ -44,6 +44,7 @@ export interface ClaudeSessionConfig {
|
|
|
44
44
|
thinkingLevel?: ThinkingLevel;
|
|
45
45
|
permissionMode?: PermissionMode;
|
|
46
46
|
maxTurns?: number;
|
|
47
|
+
subagentMode?: "turn";
|
|
47
48
|
systemPromptAppend?: string;
|
|
48
49
|
resumeSessionId?: string;
|
|
49
50
|
resumeSessionAt?: string;
|
|
@@ -95,6 +96,7 @@ function buildEnv(config: ClaudeSessionConfig): Record<string, string | undefine
|
|
|
95
96
|
...process.env,
|
|
96
97
|
...(config.apiKey ? { ANTHROPIC_API_KEY: config.apiKey } : {}),
|
|
97
98
|
...config.env,
|
|
99
|
+
...(config.subagentMode === "turn" && { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" }),
|
|
98
100
|
};
|
|
99
101
|
}
|
|
100
102
|
|
|
@@ -156,6 +158,7 @@ export function buildClaudeOptions(
|
|
|
156
158
|
// this function returns, so those can never be clobbered (and the
|
|
157
159
|
// ClaudeSdkOptionOverrides type excludes them anyway).
|
|
158
160
|
if (overrides) Object.assign(options, overrides);
|
|
161
|
+
if (config.subagentMode === "turn") options.forwardSubagentText = true;
|
|
159
162
|
|
|
160
163
|
// Fable-class models withhold thinking plaintext by default: blocks stream
|
|
161
164
|
// structure + token estimates only, so every reasoning part arrives with
|
|
@@ -42,6 +42,7 @@ export function claudeSessionNeedsRestart(
|
|
|
42
42
|
(prev.maxTurns ?? 100) !== (next.maxTurns ?? 100) ||
|
|
43
43
|
(prev.idleTimeoutMs ?? 5 * 60_000) !== (next.idleTimeoutMs ?? 5 * 60_000) ||
|
|
44
44
|
Boolean(prev.disableTools) !== Boolean(next.disableTools) ||
|
|
45
|
+
prev.subagentMode !== next.subagentMode ||
|
|
45
46
|
configFingerprint(prev.env) !== configFingerprint(next.env)
|
|
46
47
|
// mcpServers deliberately absent: MCP changes hot-swap on the live
|
|
47
48
|
// session (`setMcpServers`) instead of restarting the subprocess.
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ReasoningPart,
|
|
5
5
|
type StopReason,
|
|
6
6
|
type StreamContext,
|
|
7
|
+
type SubagentPart,
|
|
7
8
|
type TextPart,
|
|
8
9
|
type TokenUsage,
|
|
9
10
|
type ToolPart,
|
|
@@ -11,6 +12,8 @@ import {
|
|
|
11
12
|
createReasoningPart,
|
|
12
13
|
createTextPart,
|
|
13
14
|
createToolPart,
|
|
15
|
+
generateUUIDv7,
|
|
16
|
+
setToolMeta,
|
|
14
17
|
} from "../../../protocol/index.ts";
|
|
15
18
|
import {
|
|
16
19
|
type CodexToolDescriptor,
|
|
@@ -31,6 +34,9 @@ import type { AdapterEvent, EventTransformer, TransformResult } from "../types.t
|
|
|
31
34
|
interface CodexItem extends CodexToolItem {
|
|
32
35
|
type: string;
|
|
33
36
|
text?: string;
|
|
37
|
+
agentThreadId?: string;
|
|
38
|
+
agentPath?: string;
|
|
39
|
+
kind?: SubagentPart["activity"];
|
|
34
40
|
}
|
|
35
41
|
interface Notification {
|
|
36
42
|
method: string;
|
|
@@ -119,6 +125,8 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
|
|
|
119
125
|
|
|
120
126
|
private openItem(item: CodexItem): AdapterEvent[] {
|
|
121
127
|
switch (item.type) {
|
|
128
|
+
case "subAgentActivity":
|
|
129
|
+
return this.activity(item);
|
|
122
130
|
case "agentMessage": {
|
|
123
131
|
const part = createTextPart(this.ctx, item.text ?? "", true);
|
|
124
132
|
this.partByItem.set(item.id, part);
|
|
@@ -142,6 +150,8 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
|
|
|
142
150
|
private completeItem(item: CodexItem): AdapterEvent[] {
|
|
143
151
|
const part = this.partByItem.get(item.id);
|
|
144
152
|
switch (item.type) {
|
|
153
|
+
case "subAgentActivity":
|
|
154
|
+
return this.activity(item);
|
|
145
155
|
case "agentMessage":
|
|
146
156
|
case "reasoning": {
|
|
147
157
|
const p = (part ??
|
|
@@ -161,12 +171,34 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
|
|
|
161
171
|
const d = codexToolDescriptor(item.type);
|
|
162
172
|
if (!d) return [];
|
|
163
173
|
const tool = this.ensureTool(item, d);
|
|
174
|
+
setToolMeta(tool, d.meta(item));
|
|
164
175
|
completeToolPart(tool, d.result(item));
|
|
165
176
|
return [{ kind: "part-update", part: tool }];
|
|
166
177
|
}
|
|
167
178
|
}
|
|
168
179
|
}
|
|
169
180
|
|
|
181
|
+
private activity(item: CodexItem): AdapterEvent[] {
|
|
182
|
+
if (
|
|
183
|
+
!item.agentThreadId ||
|
|
184
|
+
!item.kind ||
|
|
185
|
+
!["started", "interacted", "interrupted", "completed"].includes(item.kind)
|
|
186
|
+
)
|
|
187
|
+
return [];
|
|
188
|
+
const existing = this.partByItem.get(item.id);
|
|
189
|
+
const part: SubagentPart = {
|
|
190
|
+
...this.ctx,
|
|
191
|
+
id: existing?.id ?? generateUUIDv7(),
|
|
192
|
+
type: "subagent",
|
|
193
|
+
nativeActivityId: item.id,
|
|
194
|
+
nativeThreadId: item.agentThreadId,
|
|
195
|
+
...(item.agentPath && { path: item.agentPath }),
|
|
196
|
+
activity: item.kind,
|
|
197
|
+
};
|
|
198
|
+
this.partByItem.set(item.id, part);
|
|
199
|
+
return [{ kind: existing ? "part-update" : "part-open", part }];
|
|
200
|
+
}
|
|
201
|
+
|
|
170
202
|
private textDelta(
|
|
171
203
|
itemId: string | undefined,
|
|
172
204
|
delta: string | undefined,
|
|
@@ -51,6 +51,7 @@ export interface CodexAppServerClientOptions {
|
|
|
51
51
|
*/
|
|
52
52
|
export class CodexAppServerClient {
|
|
53
53
|
private proc?: ChildProcess;
|
|
54
|
+
private shutdown?: Promise<boolean>;
|
|
54
55
|
private nextId = 1;
|
|
55
56
|
private buffer = "";
|
|
56
57
|
private readonly decoder = new StringDecoder("utf8");
|
|
@@ -62,6 +63,7 @@ export class CodexAppServerClient {
|
|
|
62
63
|
/** Bounded rolling tail of the subprocess's stderr — folded into the exit
|
|
63
64
|
* error so failures carry the CLI's own explanation. */
|
|
64
65
|
private stderrTail = "";
|
|
66
|
+
private nativeShutdownFailed = false;
|
|
65
67
|
/** Chunk-boundary-safe UTF-8 decoding for the stderr tail; reset per spawn. */
|
|
66
68
|
private stderrDecoder = new StringDecoder("utf8");
|
|
67
69
|
|
|
@@ -122,10 +124,13 @@ export class CodexAppServerClient {
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
close(): void {
|
|
127
|
+
if (this.shutdown) return;
|
|
125
128
|
const proc = this.proc;
|
|
126
129
|
const wasOpen = !this.exited;
|
|
127
130
|
this.proc = undefined;
|
|
128
131
|
this.exited = true;
|
|
132
|
+
// Forced process exit cannot attest that native child commands stopped.
|
|
133
|
+
this.shutdown = Promise.resolve(!proc);
|
|
129
134
|
for (const [id, p] of this.pending) {
|
|
130
135
|
if (p.timer) clearTimeout(p.timer);
|
|
131
136
|
p.reject(new Error(`codex app-server closed before '${p.method}'`));
|
|
@@ -135,6 +140,46 @@ export class CodexAppServerClient {
|
|
|
135
140
|
if (wasOpen) this.notifyClosed();
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
/** EOF invokes native thread/tool cleanup. Killing the server alone proves nothing about workers. */
|
|
144
|
+
closeAndWait(timeoutMs = 2_000): Promise<boolean> {
|
|
145
|
+
if (this.shutdown) return this.shutdown;
|
|
146
|
+
const proc = this.proc;
|
|
147
|
+
if (!proc) return Promise.resolve(true);
|
|
148
|
+
if (this.exited || proc.exitCode !== null || proc.signalCode !== null) {
|
|
149
|
+
this.shutdown = Promise.resolve(false);
|
|
150
|
+
return this.shutdown;
|
|
151
|
+
}
|
|
152
|
+
this.exited = true;
|
|
153
|
+
this.shutdown = new Promise<boolean>((resolve) => {
|
|
154
|
+
let forced = false;
|
|
155
|
+
const finish = (confirmed: boolean) => {
|
|
156
|
+
clearTimeout(killTimer);
|
|
157
|
+
clearTimeout(deadline);
|
|
158
|
+
proc.off("close", exited);
|
|
159
|
+
this.proc = undefined;
|
|
160
|
+
resolve(confirmed);
|
|
161
|
+
};
|
|
162
|
+
const exited = (code: number | null, signal: NodeJS.Signals | null) =>
|
|
163
|
+
finish(!forced && !this.nativeShutdownFailed && code === 0 && signal === null);
|
|
164
|
+
proc.once("close", exited);
|
|
165
|
+
const killTimer = setTimeout(() => {
|
|
166
|
+
forced = true;
|
|
167
|
+
proc.kill("SIGKILL");
|
|
168
|
+
}, timeoutMs);
|
|
169
|
+
const deadline = setTimeout(() => finish(false), timeoutMs * 2);
|
|
170
|
+
// In stdio mode SIGTERM bypasses Codex's async shutdown. EOF drains
|
|
171
|
+
// connection tasks and awaits shutdown_threads before a clean exit.
|
|
172
|
+
proc.stdin?.end();
|
|
173
|
+
});
|
|
174
|
+
for (const [id, p] of this.pending) {
|
|
175
|
+
if (p.timer) clearTimeout(p.timer);
|
|
176
|
+
p.reject(new Error(`codex app-server closed before '${p.method}'`));
|
|
177
|
+
this.pending.delete(id);
|
|
178
|
+
}
|
|
179
|
+
this.notifyClosed();
|
|
180
|
+
return this.shutdown;
|
|
181
|
+
}
|
|
182
|
+
|
|
138
183
|
private notifyClosed(error?: Error): void {
|
|
139
184
|
for (const handler of this.closeHandlers) handler(error);
|
|
140
185
|
this.closeHandlers.clear();
|
|
@@ -143,6 +188,7 @@ export class CodexAppServerClient {
|
|
|
143
188
|
private start(): void {
|
|
144
189
|
if (this.proc && !this.exited) return;
|
|
145
190
|
this.exited = false;
|
|
191
|
+
this.shutdown = undefined;
|
|
146
192
|
const spawnProcess = this.opts.spawnProcess ?? nodeSpawn;
|
|
147
193
|
const proc = spawnProcess(this.opts.codexPath ?? "codex", ["app-server"], {
|
|
148
194
|
cwd: this.opts.cwd,
|
|
@@ -151,6 +197,7 @@ export class CodexAppServerClient {
|
|
|
151
197
|
});
|
|
152
198
|
this.proc = proc;
|
|
153
199
|
this.stderrTail = "";
|
|
200
|
+
this.nativeShutdownFailed = false;
|
|
154
201
|
this.stderrDecoder = new StringDecoder("utf8");
|
|
155
202
|
proc.stdout?.on("data", (chunk: Buffer) => this.onStdout(chunk));
|
|
156
203
|
proc.stdin?.on("error", (err) => this.onExit(err));
|
|
@@ -161,7 +208,13 @@ export class CodexAppServerClient {
|
|
|
161
208
|
// from a crash for the integrator staring at the turn error. Decoded
|
|
162
209
|
// via StringDecoder so a UTF-8 sequence split across chunks survives.
|
|
163
210
|
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
164
|
-
|
|
211
|
+
const text = this.stderrTail + this.stderrDecoder.write(chunk);
|
|
212
|
+
// The pinned native server logs these cleanup failures but still exits 0.
|
|
213
|
+
if (
|
|
214
|
+
/failed to submit Shutdown|timed out waiting for .*shut down|shutdown timed out/.test(text)
|
|
215
|
+
)
|
|
216
|
+
this.nativeShutdownFailed = true;
|
|
217
|
+
this.stderrTail = text.slice(-STDERR_TAIL_MAX);
|
|
165
218
|
});
|
|
166
219
|
proc.on("error", (err) => this.onExit(err));
|
|
167
220
|
// Build the exit error on CLOSE, not exit: `exit` can fire before the
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
codexMcpServers,
|
|
31
31
|
writeCodexMcpServers,
|
|
32
32
|
} from "./mcp.ts";
|
|
33
|
+
import { CodexTurnSubagents } from "./subagents.ts";
|
|
33
34
|
|
|
34
35
|
const CAPABILITIES: AgentCapabilities = {
|
|
35
36
|
multiTurn: true,
|
|
@@ -40,6 +41,7 @@ const CAPABILITIES: AgentCapabilities = {
|
|
|
40
41
|
images: true,
|
|
41
42
|
mcpServers: true,
|
|
42
43
|
permissionRequests: true,
|
|
44
|
+
turnScopedSubagents: true,
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
export interface AppServerSession {
|
|
@@ -49,6 +51,7 @@ export interface AppServerSession {
|
|
|
49
51
|
approvalPolicy: string;
|
|
50
52
|
sandbox: string;
|
|
51
53
|
systemPromptAppend?: string;
|
|
54
|
+
subagentMode?: "turn";
|
|
52
55
|
envFingerprint: string;
|
|
53
56
|
/** Native model connections retain the identity used at their first handshake. */
|
|
54
57
|
authFingerprint?: string;
|
|
@@ -63,6 +66,7 @@ interface AppServerTurn {
|
|
|
63
66
|
auth?: CodexAppServerAuth;
|
|
64
67
|
permissionHandler?: PermissionRequestHandler;
|
|
65
68
|
signal: AbortSignal;
|
|
69
|
+
subagents?: CodexTurnSubagents;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
export function appServerSessionCompatible(
|
|
@@ -76,6 +80,7 @@ export function appServerSessionCompatible(
|
|
|
76
80
|
existing.approvalPolicy === approvalPolicyFor(options) &&
|
|
77
81
|
existing.sandbox === mapSandbox(options.permissionMode) &&
|
|
78
82
|
(existing.systemPromptAppend ?? "") === (options.systemPromptAppend ?? "") &&
|
|
83
|
+
existing.subagentMode === options.subagentMode &&
|
|
79
84
|
existing.envFingerprint === configFingerprint(options.env) &&
|
|
80
85
|
(!options.resumeSessionId || options.resumeSessionId === existing.threadId),
|
|
81
86
|
);
|
|
@@ -231,6 +236,7 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
231
236
|
readonly capabilities = CAPABILITIES;
|
|
232
237
|
private readonly sessions = new SessionStore<AppServerSession>();
|
|
233
238
|
private readonly invalidatedClients = new WeakSet<CodexAppServerClient>();
|
|
239
|
+
private readonly unsettledSessions = new Map<string, CodexAppServerClient>();
|
|
234
240
|
/** Home ownership outlives idle process eviction and failed preparation. */
|
|
235
241
|
private readonly mcpHomes = new Map<string, string>();
|
|
236
242
|
/** A cancel acknowledgement belongs to one invocation, even after it drains. */
|
|
@@ -288,7 +294,9 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
288
294
|
(await this.agentOptions.resolveCliPath?.()) ?? process.env.CODEX_CLI_PATH ?? "codex",
|
|
289
295
|
cwd: options.cwd,
|
|
290
296
|
env: options.env ? ({ ...process.env, ...options.env } as NodeJS.ProcessEnv) : process.env,
|
|
291
|
-
...(turn.auth?.type === "chatgptAuthTokens"
|
|
297
|
+
...((turn.auth?.type === "chatgptAuthTokens" || options.subagentMode === "turn") && {
|
|
298
|
+
experimentalApi: true,
|
|
299
|
+
}),
|
|
292
300
|
};
|
|
293
301
|
signal.throwIfAborted();
|
|
294
302
|
const client =
|
|
@@ -305,6 +313,7 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
305
313
|
approvalPolicy,
|
|
306
314
|
sandbox,
|
|
307
315
|
systemPromptAppend: options.systemPromptAppend,
|
|
316
|
+
subagentMode: options.subagentMode,
|
|
308
317
|
envFingerprint: configFingerprint(options.env),
|
|
309
318
|
authFingerprint: identity,
|
|
310
319
|
turn,
|
|
@@ -327,6 +336,15 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
327
336
|
if (options.model) startParams.model = options.model;
|
|
328
337
|
if (options.systemPromptAppend)
|
|
329
338
|
startParams.developerInstructions = options.systemPromptAppend;
|
|
339
|
+
if (options.subagentMode === "turn") {
|
|
340
|
+
startParams.config = { "features.multi_agent": true };
|
|
341
|
+
startParams.developerInstructions = [
|
|
342
|
+
options.systemPromptAppend,
|
|
343
|
+
"Collect the results of every subagent before ending your turn. Stop any worker you no longer need. Delegated work cannot continue after this task completes.",
|
|
344
|
+
]
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.join("\n\n");
|
|
347
|
+
}
|
|
330
348
|
|
|
331
349
|
const method = resumeThreadId ? "thread/resume" : "thread/start";
|
|
332
350
|
const result = (await client
|
|
@@ -466,7 +484,14 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
466
484
|
const requestThread = params.threadId ?? params.conversationId;
|
|
467
485
|
const v2 = method.startsWith("item/");
|
|
468
486
|
const decline = v2 ? { decision: "decline" } : { decision: "denied" };
|
|
469
|
-
if (requestThread && requestThread !== session.threadId)
|
|
487
|
+
if (requestThread && requestThread !== session.threadId) {
|
|
488
|
+
if (
|
|
489
|
+
typeof requestThread !== "string" ||
|
|
490
|
+
!turn?.subagents ||
|
|
491
|
+
!(await turn.subagents.owns(requestThread).catch(() => false))
|
|
492
|
+
)
|
|
493
|
+
return decline;
|
|
494
|
+
}
|
|
470
495
|
const handler = turn?.permissionHandler;
|
|
471
496
|
if (!handler || turn?.signal.aborted) return decline;
|
|
472
497
|
const decision = await handler(approvalToolCall(method, params));
|
|
@@ -511,8 +536,16 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
511
536
|
const unsubscribe: Array<() => void> = [];
|
|
512
537
|
let session: AppServerSession | undefined;
|
|
513
538
|
let turnEnded = false;
|
|
539
|
+
let parentEnded = false;
|
|
540
|
+
let managedCompletion: Promise<boolean> | undefined;
|
|
514
541
|
try {
|
|
515
542
|
controller.signal.throwIfAborted();
|
|
543
|
+
const unsettled = this.unsettledSessions.get(options.sessionId);
|
|
544
|
+
if (unsettled) {
|
|
545
|
+
if (!(await unsettled.closeAndWait()))
|
|
546
|
+
throw new Error("Previous Codex execution has not stopped");
|
|
547
|
+
this.unsettledSessions.delete(options.sessionId);
|
|
548
|
+
}
|
|
516
549
|
const requestedMcp = codexMcpServers(snapshotMcpServers(options.mcpServers));
|
|
517
550
|
const mcpServers = this.managedMcpHome(options, Object.keys(requestedMcp).length > 0)
|
|
518
551
|
? requestedMcp
|
|
@@ -527,7 +560,17 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
527
560
|
});
|
|
528
561
|
const threadId = session.threadId;
|
|
529
562
|
const client = session.client;
|
|
530
|
-
|
|
563
|
+
if (options.subagentMode === "turn") {
|
|
564
|
+
turn.subagents = new CodexTurnSubagents(
|
|
565
|
+
client,
|
|
566
|
+
threadId,
|
|
567
|
+
this.agentOptions.cancelGraceMs ?? 2_000,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
const abortPreparation = () => {
|
|
571
|
+
if (turn.subagents) void client.closeAndWait();
|
|
572
|
+
else client.close();
|
|
573
|
+
};
|
|
531
574
|
controller.signal.addEventListener("abort", abortPreparation, { once: true });
|
|
532
575
|
try {
|
|
533
576
|
controller.signal.throwIfAborted();
|
|
@@ -545,6 +588,47 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
545
588
|
}
|
|
546
589
|
|
|
547
590
|
let turnId: string | undefined;
|
|
591
|
+
const finishManaged = (): Promise<boolean> => {
|
|
592
|
+
if (managedCompletion) return managedCompletion;
|
|
593
|
+
managedCompletion = (async () => {
|
|
594
|
+
let confirmed = true;
|
|
595
|
+
let failure: unknown;
|
|
596
|
+
const graceMs = this.agentOptions.cancelGraceMs ?? 2_000;
|
|
597
|
+
const deadline = setTimeout(() => {
|
|
598
|
+
void client.closeAndWait(graceMs);
|
|
599
|
+
}, graceMs * 5);
|
|
600
|
+
try {
|
|
601
|
+
if (!parentEnded) {
|
|
602
|
+
// A startup interrupt only acknowledges submission, not native settlement.
|
|
603
|
+
if (!turnId) throw new Error("Codex cancelled before turn admission was observed");
|
|
604
|
+
await client.request("turn/interrupt", { threadId, turnId }, graceMs);
|
|
605
|
+
}
|
|
606
|
+
const unfinished = await turn.subagents!.settle();
|
|
607
|
+
// Native terminal registration can land after turn/completed and
|
|
608
|
+
// after an empty terminal inventory. EOF awaits native thread/tool
|
|
609
|
+
// shutdown; an idle snapshot alone cannot release a managed turn.
|
|
610
|
+
confirmed = await client.closeAndWait(graceMs);
|
|
611
|
+
if (!confirmed) throw new Error("Codex native cleanup was not confirmed");
|
|
612
|
+
if (unfinished && !controller.signal.aborted) {
|
|
613
|
+
failure = new Error(
|
|
614
|
+
"Parent finished with outstanding subagent work; workers were stopped and the result is incomplete",
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
} catch (error) {
|
|
618
|
+
failure = error;
|
|
619
|
+
confirmed = await client.closeAndWait(graceMs);
|
|
620
|
+
if (!confirmed) this.unsettledSessions.set(options.sessionId, client);
|
|
621
|
+
} finally {
|
|
622
|
+
clearTimeout(deadline);
|
|
623
|
+
}
|
|
624
|
+
turnEnded = true;
|
|
625
|
+
if (controller.signal.aborted) queue.fail(new Error("turn aborted"));
|
|
626
|
+
else if (failure) queue.fail(failure);
|
|
627
|
+
else queue.end();
|
|
628
|
+
return confirmed;
|
|
629
|
+
})();
|
|
630
|
+
return managedCompletion;
|
|
631
|
+
};
|
|
548
632
|
const forceCancel = () => {
|
|
549
633
|
if (turnEnded) return;
|
|
550
634
|
queue.fail(new Error("turn aborted"));
|
|
@@ -553,6 +637,10 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
553
637
|
const onAbort = () => {
|
|
554
638
|
clearStall();
|
|
555
639
|
if (interruptState.acknowledgement) return;
|
|
640
|
+
if (turn.subagents) {
|
|
641
|
+
interruptState.acknowledgement = finishManaged();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
556
644
|
if (!turnId) {
|
|
557
645
|
interruptState.acknowledgement = Promise.resolve(false);
|
|
558
646
|
forceCancel();
|
|
@@ -574,6 +662,7 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
574
662
|
|
|
575
663
|
unsubscribe.push(
|
|
576
664
|
client.onNotification((n) => {
|
|
665
|
+
turn.subagents?.observe(n);
|
|
577
666
|
if (n.params.threadId && n.params.threadId !== threadId) return;
|
|
578
667
|
if (n.method === "turn/started") {
|
|
579
668
|
const turn = n.params.turn as { id?: string } | undefined;
|
|
@@ -581,6 +670,12 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
581
670
|
turnId = turn.id;
|
|
582
671
|
}
|
|
583
672
|
}
|
|
673
|
+
if (appServerNotificationEndsTurn(n) && turn.subagents) {
|
|
674
|
+
parentEnded = n.method === "turn/completed";
|
|
675
|
+
queue.push(n);
|
|
676
|
+
void finishManaged();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
584
679
|
if (appServerNotificationEndsTurn(n)) turnEnded = true;
|
|
585
680
|
if (turnEnded && controller.signal.aborted) {
|
|
586
681
|
queue.fail(new Error("turn aborted"));
|
|
@@ -602,13 +697,14 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
602
697
|
);
|
|
603
698
|
// If the subprocess dies mid-turn, fail the turn instead of hanging.
|
|
604
699
|
unsubscribe.push(
|
|
605
|
-
client.onClose((err) =>
|
|
700
|
+
client.onClose((err) => {
|
|
701
|
+
if (managedCompletion) return;
|
|
606
702
|
queue.fail(
|
|
607
703
|
controller.signal.aborted
|
|
608
704
|
? new Error("turn aborted")
|
|
609
705
|
: (err ?? new Error("codex app-server exited mid-turn")),
|
|
610
|
-
)
|
|
611
|
-
),
|
|
706
|
+
);
|
|
707
|
+
}),
|
|
612
708
|
);
|
|
613
709
|
|
|
614
710
|
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
@@ -628,7 +724,8 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
628
724
|
}
|
|
629
725
|
client.request("turn/start", turnParams).catch((err: unknown) => {
|
|
630
726
|
if (controller.signal.aborted) {
|
|
631
|
-
|
|
727
|
+
if (turn.subagents) void finishManaged();
|
|
728
|
+
else forceCancel();
|
|
632
729
|
return;
|
|
633
730
|
}
|
|
634
731
|
queue.push({ method: "error", params: { message: String(err) } });
|
|
@@ -641,6 +738,11 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
641
738
|
} catch (error) {
|
|
642
739
|
throw controller.signal.aborted ? new Error("turn aborted") : error;
|
|
643
740
|
} finally {
|
|
741
|
+
if (managedCompletion) await managedCompletion;
|
|
742
|
+
// Every exit owns cleanup, including turn/start failure and iterator.return().
|
|
743
|
+
if (options.subagentMode === "turn" && session && !(await session.client.closeAndWait())) {
|
|
744
|
+
this.unsettledSessions.set(options.sessionId, session.client);
|
|
745
|
+
}
|
|
644
746
|
turnEnded = true;
|
|
645
747
|
clearStall();
|
|
646
748
|
if (cancelTimer) clearTimeout(cancelTimer);
|
|
@@ -664,7 +766,13 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
664
766
|
// set on a later microtask, so reading afterwards would race that drain.
|
|
665
767
|
const controllers = [...(this.inflight.get(sessionId) ?? [])];
|
|
666
768
|
const base = await super.cancel(sessionId);
|
|
667
|
-
if (!base.hadTurn)
|
|
769
|
+
if (!base.hadTurn) {
|
|
770
|
+
const unsettled = this.unsettledSessions.get(sessionId);
|
|
771
|
+
if (!unsettled) return base;
|
|
772
|
+
const confirmed = await unsettled.closeAndWait();
|
|
773
|
+
if (confirmed) this.unsettledSessions.delete(sessionId);
|
|
774
|
+
return { confirmed, hadTurn: false };
|
|
775
|
+
}
|
|
668
776
|
const acknowledgements = await Promise.all(
|
|
669
777
|
controllers.map(
|
|
670
778
|
(controller) => this.turnInterruptState.get(controller)?.acknowledgement ?? false,
|
|
@@ -691,6 +799,13 @@ export class CodexAppServerAgent extends SessionAgent {
|
|
|
691
799
|
}
|
|
692
800
|
|
|
693
801
|
override async release(sessionId: string): Promise<void> {
|
|
802
|
+
// A host may release this harness before switching providers. Do not let
|
|
803
|
+
// that path discard ownership while managed child cleanup is unconfirmed.
|
|
804
|
+
const managed =
|
|
805
|
+
this.unsettledSessions.has(sessionId) ||
|
|
806
|
+
this.sessions.get(sessionId)?.subagentMode === "turn";
|
|
807
|
+
if (managed && !(await this.cancel(sessionId)).confirmed)
|
|
808
|
+
throw new Error("Codex native cleanup was not confirmed; cannot release the session");
|
|
694
809
|
await super.release(sessionId);
|
|
695
810
|
this.sessions.close(sessionId);
|
|
696
811
|
for (const [home, owner] of this.mcpHomes) {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { CodexAppServerClient, CodexNotification } from "./client.ts";
|
|
2
|
+
|
|
3
|
+
interface NativeThread {
|
|
4
|
+
id: string;
|
|
5
|
+
parentThreadId?: string | null;
|
|
6
|
+
status?: { type: string };
|
|
7
|
+
source?: { subagent?: { thread_spawn?: { parent_thread_id?: string } } } | string;
|
|
8
|
+
turns?: Array<{ id: string; status: string }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parentOf(thread: NativeThread): string | undefined {
|
|
12
|
+
return (
|
|
13
|
+
thread.parentThreadId ??
|
|
14
|
+
(typeof thread.source === "object"
|
|
15
|
+
? thread.source.subagent?.thread_spawn?.parent_thread_id
|
|
16
|
+
: undefined)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Native ownership and cleanup for ONE execution; scheduling stays in Codex. */
|
|
21
|
+
export class CodexTurnSubagents {
|
|
22
|
+
private readonly parents = new Map<string, string>();
|
|
23
|
+
private readonly candidates = new Set<string>();
|
|
24
|
+
private readonly active = new Set<string>();
|
|
25
|
+
private activeAtParentEnd = new Set<string>();
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly client: CodexAppServerClient,
|
|
29
|
+
private readonly root: string,
|
|
30
|
+
private readonly timeoutMs: number,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
observe(notification: CodexNotification): void {
|
|
34
|
+
const id = notification.params.threadId;
|
|
35
|
+
if (id && id !== this.root) {
|
|
36
|
+
if (notification.method === "turn/started") this.active.add(id);
|
|
37
|
+
if (notification.method === "turn/completed") this.active.delete(id);
|
|
38
|
+
if (notification.method === "thread/status/changed") {
|
|
39
|
+
const status = notification.params.status as { type?: string } | undefined;
|
|
40
|
+
if (status?.type === "active") this.active.add(id);
|
|
41
|
+
else if (status?.type) this.active.delete(id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (id === this.root && notification.method === "turn/completed")
|
|
45
|
+
this.activeAtParentEnd = new Set(this.active);
|
|
46
|
+
if (notification.method === "thread/started") {
|
|
47
|
+
const thread = notification.params.thread as NativeThread | undefined;
|
|
48
|
+
const parent = thread && parentOf(thread);
|
|
49
|
+
if (thread?.id && parent) {
|
|
50
|
+
this.parents.set(thread.id, parent);
|
|
51
|
+
this.candidates.add(thread.id);
|
|
52
|
+
if (thread.status?.type === "active") this.active.add(thread.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (notification.params.threadId !== this.root) return;
|
|
56
|
+
const item = notification.params.item as
|
|
57
|
+
| {
|
|
58
|
+
type?: string;
|
|
59
|
+
agentThreadId?: string;
|
|
60
|
+
receiverThreadIds?: string[];
|
|
61
|
+
agentsStates?: Record<string, { status: string }>;
|
|
62
|
+
}
|
|
63
|
+
| undefined;
|
|
64
|
+
if (item?.type === "subAgentActivity" && item.agentThreadId)
|
|
65
|
+
this.candidates.add(item.agentThreadId);
|
|
66
|
+
if (item?.type === "collabAgentToolCall") {
|
|
67
|
+
for (const id of item.receiverThreadIds ?? []) this.candidates.add(id);
|
|
68
|
+
for (const [id, state] of Object.entries(item.agentsStates ?? {})) {
|
|
69
|
+
if (state.status === "running" || state.status === "pendingInit") this.active.add(id);
|
|
70
|
+
else this.active.delete(id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async read(id: string): Promise<NativeThread> {
|
|
76
|
+
const result = (await this.client.request(
|
|
77
|
+
"thread/read",
|
|
78
|
+
{ threadId: id, includeTurns: true },
|
|
79
|
+
this.timeoutMs,
|
|
80
|
+
)) as { thread?: NativeThread };
|
|
81
|
+
if (result.thread?.id !== id)
|
|
82
|
+
throw new Error("Codex did not confirm the requested child thread");
|
|
83
|
+
const parent = parentOf(result.thread);
|
|
84
|
+
if (parent) this.parents.set(id, parent);
|
|
85
|
+
return result.thread;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async owns(id: string): Promise<boolean> {
|
|
89
|
+
const visited = new Set<string>();
|
|
90
|
+
let current = id;
|
|
91
|
+
while (current !== this.root) {
|
|
92
|
+
if (visited.has(current) || visited.size >= 32) return false;
|
|
93
|
+
visited.add(current);
|
|
94
|
+
const parent = this.parents.get(current) ?? parentOf(await this.read(current));
|
|
95
|
+
if (!parent) return false;
|
|
96
|
+
current = parent;
|
|
97
|
+
}
|
|
98
|
+
if (id !== this.root) this.candidates.add(id);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async descendants(): Promise<NativeThread[]> {
|
|
103
|
+
// The native inventory closes missed/start-notification races, including nested workers.
|
|
104
|
+
let cursor: string | undefined;
|
|
105
|
+
const cursors = new Set<string>();
|
|
106
|
+
do {
|
|
107
|
+
const page = (await this.client.request(
|
|
108
|
+
"thread/loaded/list",
|
|
109
|
+
{
|
|
110
|
+
limit: 100,
|
|
111
|
+
...(cursor && { cursor }),
|
|
112
|
+
},
|
|
113
|
+
this.timeoutMs,
|
|
114
|
+
)) as { data?: string[]; nextCursor?: string | null };
|
|
115
|
+
if (!Array.isArray(page.data)) throw new Error("Codex did not return its child inventory");
|
|
116
|
+
for (const id of page.data) this.candidates.add(id);
|
|
117
|
+
cursor = page.nextCursor ?? undefined;
|
|
118
|
+
if (cursor && cursors.has(cursor)) throw new Error("Codex repeated a child inventory cursor");
|
|
119
|
+
if (cursor) cursors.add(cursor);
|
|
120
|
+
if (this.candidates.size > 512)
|
|
121
|
+
throw new Error("Codex child inventory exceeds the managed turn limit");
|
|
122
|
+
} while (cursor);
|
|
123
|
+
const threads: NativeThread[] = [];
|
|
124
|
+
for (const id of this.candidates) {
|
|
125
|
+
if (id !== this.root && (await this.owns(id))) threads.push(await this.read(id));
|
|
126
|
+
}
|
|
127
|
+
return threads;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Root must have stopped first. Returns whether it left unfinished delegated work. */
|
|
131
|
+
async settle(): Promise<boolean> {
|
|
132
|
+
let unfinished = false;
|
|
133
|
+
for (const id of this.activeAtParentEnd) {
|
|
134
|
+
if (await this.owns(id)) unfinished = true;
|
|
135
|
+
}
|
|
136
|
+
let idleInventory: string | undefined;
|
|
137
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
138
|
+
let stoppedWork = false;
|
|
139
|
+
const descendants = await this.descendants();
|
|
140
|
+
for (const thread of descendants) {
|
|
141
|
+
if (!thread.status) throw new Error("Codex child liveness is unknown");
|
|
142
|
+
if (thread.status.type === "active") {
|
|
143
|
+
unfinished = true;
|
|
144
|
+
stoppedWork = true;
|
|
145
|
+
const active = thread.turns
|
|
146
|
+
?.slice()
|
|
147
|
+
.reverse()
|
|
148
|
+
.find((turn) => turn.status === "inProgress");
|
|
149
|
+
// Empty IDs are native startup interrupts, acknowledged on submission.
|
|
150
|
+
// The read below and final EOF cleanup still have to establish settlement.
|
|
151
|
+
// A worker can finish naturally between the inventory and the interrupt.
|
|
152
|
+
await this.client
|
|
153
|
+
.request(
|
|
154
|
+
"turn/interrupt",
|
|
155
|
+
{ threadId: thread.id, turnId: active?.id ?? "" },
|
|
156
|
+
this.timeoutMs,
|
|
157
|
+
)
|
|
158
|
+
.catch(async (error) => {
|
|
159
|
+
if ((await this.read(thread.id)).status?.type !== "idle") throw error;
|
|
160
|
+
});
|
|
161
|
+
const stopped = await this.read(thread.id);
|
|
162
|
+
if (!stopped.status || stopped.status.type === "active")
|
|
163
|
+
throw new Error("Codex child interrupt was not confirmed");
|
|
164
|
+
}
|
|
165
|
+
if (thread.status.type === "notLoaded") continue;
|
|
166
|
+
const terminals = (await this.client.request(
|
|
167
|
+
"thread/backgroundTerminals/list",
|
|
168
|
+
{ threadId: thread.id },
|
|
169
|
+
this.timeoutMs,
|
|
170
|
+
)) as { data?: unknown[] };
|
|
171
|
+
if (!Array.isArray(terminals.data))
|
|
172
|
+
throw new Error("Codex child terminal liveness is unknown");
|
|
173
|
+
if (terminals.data.length) {
|
|
174
|
+
unfinished = true;
|
|
175
|
+
stoppedWork = true;
|
|
176
|
+
await this.client.request(
|
|
177
|
+
"thread/backgroundTerminals/clean",
|
|
178
|
+
{ threadId: thread.id },
|
|
179
|
+
this.timeoutMs,
|
|
180
|
+
);
|
|
181
|
+
const remaining = (await this.client.request(
|
|
182
|
+
"thread/backgroundTerminals/list",
|
|
183
|
+
{ threadId: thread.id },
|
|
184
|
+
this.timeoutMs,
|
|
185
|
+
)) as { data?: unknown[] };
|
|
186
|
+
if (!Array.isArray(remaining.data) || remaining.data.length)
|
|
187
|
+
throw new Error("Codex child terminals did not stop");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Interrupting a worker may race its final spawn. Reconcile the native
|
|
191
|
+
// inventory after stops, while the root can no longer create new work.
|
|
192
|
+
const inventory = descendants
|
|
193
|
+
.map((thread) => thread.id)
|
|
194
|
+
.sort()
|
|
195
|
+
.join("\n");
|
|
196
|
+
if (!stoppedWork && inventory === idleInventory) return unfinished;
|
|
197
|
+
idleInventory = stoppedWork ? undefined : inventory;
|
|
198
|
+
}
|
|
199
|
+
throw new Error("Codex descendants did not settle within the managed cleanup bound");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -23,6 +23,10 @@ export interface CodexToolItem {
|
|
|
23
23
|
result?: unknown;
|
|
24
24
|
error?: { message?: string } | string;
|
|
25
25
|
query?: string;
|
|
26
|
+
receiverThreadIds?: string[];
|
|
27
|
+
prompt?: string | null;
|
|
28
|
+
model?: string | null;
|
|
29
|
+
agentsStates?: Record<string, { status: string; message?: string | null }>;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface CodexToolDescriptor {
|
|
@@ -60,6 +64,27 @@ function stringifyMcpResult(result: unknown): string {
|
|
|
60
64
|
const failed = (item: CodexToolItem) => item.status === "failed";
|
|
61
65
|
|
|
62
66
|
export const CODEX_TOOL_ITEMS: Record<string, CodexToolDescriptor> = {
|
|
67
|
+
collabAgentToolCall: {
|
|
68
|
+
toolName: (item) => item.tool ?? "collabAgentToolCall",
|
|
69
|
+
input: (item) => ({
|
|
70
|
+
...(item.prompt != null && { prompt: item.prompt }),
|
|
71
|
+
...(item.model != null && { model: item.model }),
|
|
72
|
+
receiverThreadIds: item.receiverThreadIds ?? [],
|
|
73
|
+
}),
|
|
74
|
+
meta: (item) => ({
|
|
75
|
+
kind: "task",
|
|
76
|
+
...(item.tool === "spawnAgent" && {
|
|
77
|
+
subagent: {
|
|
78
|
+
...(item.model && { model: item.model }),
|
|
79
|
+
...(item.receiverThreadIds?.[0] && { agentId: item.receiverThreadIds[0] }),
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
}),
|
|
83
|
+
result: (item) => ({
|
|
84
|
+
output: JSON.stringify(item.agentsStates ?? {}),
|
|
85
|
+
isError: failed(item) || item.status === "interrupted",
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
63
88
|
commandExecution: {
|
|
64
89
|
toolName: () => "shell",
|
|
65
90
|
input: (item) => ({ command: item.command ?? "" }),
|
|
@@ -104,6 +129,8 @@ export const CODEX_TOOL_ITEMS: Record<string, CodexToolDescriptor> = {
|
|
|
104
129
|
/** Map either protocol's item-type spelling onto a descriptor. */
|
|
105
130
|
export function codexToolDescriptor(itemType: string): CodexToolDescriptor | undefined {
|
|
106
131
|
switch (itemType) {
|
|
132
|
+
case "collabAgentToolCall":
|
|
133
|
+
return CODEX_TOOL_ITEMS.collabAgentToolCall;
|
|
107
134
|
case "command_execution":
|
|
108
135
|
case "commandExecution":
|
|
109
136
|
return CODEX_TOOL_ITEMS.commandExecution;
|
|
@@ -21,6 +21,9 @@ export abstract class SessionAgent extends BaseAgent {
|
|
|
21
21
|
): AsyncIterableIterator<RawAgentEvent> {
|
|
22
22
|
const { sessionId } = options;
|
|
23
23
|
if (this.terminated) throw new Error("Agent has been terminated");
|
|
24
|
+
if (options.subagentMode && !this.capabilities.turnScopedSubagents) {
|
|
25
|
+
throw new Error(`${this.harness} does not support turn-scoped subagents`);
|
|
26
|
+
}
|
|
24
27
|
if (this.activeSessions.has(sessionId)) throw new TurnActiveError(sessionId);
|
|
25
28
|
this.activeSessions.add(sessionId);
|
|
26
29
|
const controller = this.trackTurn(sessionId, options.signal);
|
|
@@ -396,6 +396,7 @@ export class AgentRuntime {
|
|
|
396
396
|
thinkingLevel: config.thinkingLevel,
|
|
397
397
|
permissionMode: config.permissionMode,
|
|
398
398
|
maxTurns: config.maxTurns,
|
|
399
|
+
subagentMode: config.subagentMode,
|
|
399
400
|
systemPromptAppend: config.systemPromptAppend,
|
|
400
401
|
resumeSessionId: config.resumeSessionId,
|
|
401
402
|
resumeSessionAt: config.resumeSessionAt,
|
package/src/protocol/config.ts
CHANGED
|
@@ -57,6 +57,8 @@ export const RunConfigSchema = z.object({
|
|
|
57
57
|
additionalDirectories: z.array(z.string()).optional(),
|
|
58
58
|
systemPromptAppend: z.string().optional(),
|
|
59
59
|
maxTurns: z.number().int().positive().optional(),
|
|
60
|
+
/** Native workers must settle before this managed execution releases its context. */
|
|
61
|
+
subagentMode: z.literal("turn").optional(),
|
|
60
62
|
permissionMode: PermissionModeSchema.optional(),
|
|
61
63
|
/**
|
|
62
64
|
* Native session/thread id to resume (from a prior `session.created`). When
|
package/src/protocol/guards.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { DecodedLifecycleEvent, UnknownEvent } from "./lifecycle.ts";
|
|
15
15
|
import type { Part, UnknownPart } from "./parts.ts";
|
|
16
16
|
|
|
17
|
-
export const PART_TYPES = ["text", "reasoning", "tool", "image", "file"] as const;
|
|
17
|
+
export const PART_TYPES = ["text", "reasoning", "tool", "subagent", "image", "file"] as const;
|
|
18
18
|
|
|
19
19
|
export function isKnownPartType(type: unknown): type is (typeof PART_TYPES)[number] {
|
|
20
20
|
return typeof type === "string" && (PART_TYPES as readonly string[]).includes(type);
|
package/src/protocol/harness.ts
CHANGED
|
@@ -46,5 +46,7 @@ export const AgentCapabilitiesSchema = z.object({
|
|
|
46
46
|
mcpServers: z.boolean(),
|
|
47
47
|
/** Can raise interactive `permission.requested` round-trips mid-turn. */
|
|
48
48
|
permissionRequests: z.boolean(),
|
|
49
|
+
/** Supports native delegation contained within the current execution. */
|
|
50
|
+
turnScopedSubagents: z.boolean().optional(),
|
|
49
51
|
});
|
|
50
52
|
export type AgentCapabilities = z.infer<typeof AgentCapabilitiesSchema>;
|
package/src/protocol/parts.ts
CHANGED
|
@@ -81,6 +81,16 @@ export const ToolPartSchema = PartBase.extend({
|
|
|
81
81
|
});
|
|
82
82
|
export type ToolPart = z.infer<typeof ToolPartSchema>;
|
|
83
83
|
|
|
84
|
+
/** Native worker activity, including harnesses without a public spawning tool call. */
|
|
85
|
+
export const SubagentPartSchema = PartBase.extend({
|
|
86
|
+
type: z.literal("subagent"),
|
|
87
|
+
nativeActivityId: z.string(),
|
|
88
|
+
nativeThreadId: z.string(),
|
|
89
|
+
path: z.string().optional(),
|
|
90
|
+
activity: z.enum(["started", "interacted", "interrupted", "completed"]),
|
|
91
|
+
});
|
|
92
|
+
export type SubagentPart = z.infer<typeof SubagentPartSchema>;
|
|
93
|
+
|
|
84
94
|
/**
|
|
85
95
|
* Exactly one of `data` (base64) | `url` — ENFORCED, not merely documented: a
|
|
86
96
|
* payload-less blob part renders as a broken image, and one carrying both
|
|
@@ -112,6 +122,7 @@ export const PartSchema = z.discriminatedUnion("type", [
|
|
|
112
122
|
TextPartSchema,
|
|
113
123
|
ReasoningPartSchema,
|
|
114
124
|
ToolPartSchema,
|
|
125
|
+
SubagentPartSchema,
|
|
115
126
|
ImagePartSchema,
|
|
116
127
|
FilePartSchema,
|
|
117
128
|
]);
|