@morlay/dsh-subagent 0.0.6 → 0.0.7

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Provider-side vocabulary for OUT-OF-PROCESS subagent backends — the pieces
3
+ * that enforce this seam's own contracts around a child in another process:
4
+ * the no-capabilities advertisement, timing-bound validation, child
5
+ * working-directory validation, the never-reject result settlement, and the standard
6
+ * run-handle publication. Backends compose these with their own wire drivers;
7
+ * the process machinery itself (spawn, env scrub, managed-range teardown)
8
+ * belongs to the `dsh-subprocess` seam.
9
+ *
10
+ * @module @deepseek-ai/dsh-subagent/out-of-process
11
+ */
12
+
13
+ import { accessSync, constants, statSync } from "node:fs";
14
+ import { isAbsolute } from "node:path";
15
+ import type { ContentBlock } from "@deepseek-ai/dsh-llm";
16
+ import type {
17
+ SubagentCapabilities,
18
+ SubagentResult,
19
+ SubagentRun,
20
+ SubagentStopReason,
21
+ } from "./types.ts";
22
+
23
+ /** Maximum UTF-8 size of {@link SubagentResult.diagnostic}. */
24
+ const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096;
25
+
26
+ const DIAGNOSTIC_TRUNCATION_SUFFIX = "\n[diagnostic truncated]";
27
+ const utf8Encoder = new TextEncoder();
28
+ const utf8Decoder = new TextDecoder();
29
+
30
+ /**
31
+ * Limit provider-authored failure detail without splitting a UTF-8 sequence.
32
+ * @param diagnostic - safe diagnostic text produced by the provider.
33
+ * @returns the original text, or a visibly truncated value within the limit.
34
+ */
35
+ function limitSubagentDiagnostic(diagnostic: string): string {
36
+ const bytes = utf8Encoder.encode(diagnostic);
37
+ if (bytes.byteLength <= MAX_SUBAGENT_DIAGNOSTIC_BYTES) return diagnostic;
38
+
39
+ const suffixBytes = utf8Encoder.encode(DIAGNOSTIC_TRUNCATION_SUFFIX).byteLength;
40
+ let prefixBytes = MAX_SUBAGENT_DIAGNOSTIC_BYTES - suffixBytes;
41
+ while (((bytes[prefixBytes] as number) & 0b1100_0000) === 0b1000_0000) {
42
+ prefixBytes -= 1;
43
+ }
44
+ return utf8Decoder.decode(bytes.subarray(0, prefixBytes)) + DIAGNOSTIC_TRUNCATION_SUFFIX;
45
+ }
46
+
47
+ /** Enforce the byte limit on a provider-returned diagnostic. */
48
+ function normalizeSubagentDiagnostic(result: SubagentResult): SubagentResult {
49
+ return result.diagnostic === undefined
50
+ ? result
51
+ : { ...result, diagnostic: limitSubagentDiagnostic(result.diagnostic) };
52
+ }
53
+
54
+ /**
55
+ * The capability advertisement of an out-of-process backend: NONE. A child in
56
+ * another process cannot honor parent-enforced start features
57
+ * (`agentOptions`/`agentPreset`/`outputSchema`/`maxDepth`), so the service rejects a
58
+ * request needing any of them before `start` runs — never accepted-then-ignored.
59
+ */
60
+ export const NO_START_CAPABILITIES: SubagentCapabilities = Object.freeze({
61
+ agentOptions: false,
62
+ agentPreset: false,
63
+ outputSchema: false,
64
+ depthLimit: false,
65
+ });
66
+
67
+ /**
68
+ * Assert a configured timing bound is a positive finite number (it bounds a
69
+ * teardown or shutdown wait; zero, negative, or NaN would skip or wedge it).
70
+ * @param prefix - the consuming plugin's diagnostic prefix (e.g. `subagent-acp`).
71
+ * @param name - the config field name, for the diagnostic.
72
+ * @param value - the configured value.
73
+ */
74
+ export function assertPositiveFinite(prefix: string, name: string, value: number): void {
75
+ if (!Number.isFinite(value) || value <= 0) {
76
+ throw new Error(`${prefix}: ${name} must be a positive finite number`);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Whether `path` names an existing directory the harness can ENTER. The
82
+ * search-permission probe matters: `statSync().isDirectory()` is true for a
83
+ * mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
84
+ */
85
+ function isEnterableDirectory(path: string): boolean {
86
+ try {
87
+ if (!statSync(path).isDirectory()) return false;
88
+ accessSync(path, constants.X_OK);
89
+ return true;
90
+ } catch {
91
+ // statSync/accessSync throw only filesystem access errors here
92
+ // (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
93
+ // serve as the child's cwd.
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Assert `cwd` can actually host the child: absolute (it doubles as the
100
+ * child's workspace identity, and a relative path would be re-anchored to the
101
+ * server process's launch directory) and an existing directory (fail here,
102
+ * before the process boundary, instead of as an ambiguous spawn ENOENT).
103
+ * @param prefix - the consuming plugin's diagnostic prefix.
104
+ * @param label - which source supplied the value, for the diagnostic.
105
+ * @param cwd - the candidate working directory.
106
+ * @returns `cwd`, validated.
107
+ */
108
+ export function assertUsableCwd(prefix: string, label: string, cwd: string): string {
109
+ if (!isAbsolute(cwd)) {
110
+ throw new Error(`${prefix}: ${label} must be an absolute path: ${cwd}`);
111
+ }
112
+ if (!isEnterableDirectory(cwd)) {
113
+ throw new Error(`${prefix}: ${label} is not an accessible directory: ${cwd}`);
114
+ }
115
+ return cwd;
116
+ }
117
+
118
+ /** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
119
+ function toError(value: unknown): Error {
120
+ // The rejecting surfaces (wire clients, spawn failures) only throw
121
+ // `Error`s; the `String(value)` arm is a defensive fallback for a non-Error
122
+ // throw the typed surfaces cannot produce.
123
+ /* v8 ignore next */
124
+ return value instanceof Error ? value : new Error(String(value));
125
+ }
126
+
127
+ /** Inputs to {@link settleRunResult}. */
128
+ export interface RunResultSettlement {
129
+ /** The turn attempt (typically racing local cancellation); returns the terminal result. */
130
+ attempt: () => Promise<SubagentResult>;
131
+ /** Snapshot the provider exposes when cancellation or failure wins settlement. */
132
+ collectOutput: () => readonly ContentBlock[];
133
+ /** Snapshot safe provider-authored detail when a failure wins settlement. */
134
+ collectDiagnostic?: (() => string | undefined) | undefined;
135
+ /** Whether local cancellation settled before the attempt's outcome is observed. */
136
+ cancelled: () => boolean;
137
+ /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */
138
+ onError?: ((error: Error, stopReason: SubagentStopReason) => void) | undefined;
139
+ /** The request's cancellation signal (the listener is removed at settlement). */
140
+ signal: AbortSignal;
141
+ /** The abort listener registered on {@link signal} at start. */
142
+ onAbort: () => void;
143
+ }
144
+
145
+ /**
146
+ * Settle an out-of-process run result under the seam contract: `result` never
147
+ * rejects after publication. A normally completed or rejected attempt resolves
148
+ * as `aborted` when cancellation already settled locally; another rejection is
149
+ * flattened to `stopReason: 'error'` through the contained diagnostic sink.
150
+ * Provider-returned diagnostics use the same byte limit. The abort listener is
151
+ * removed on every path.
152
+ * @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring.
153
+ * @returns the terminal result (never a rejection).
154
+ */
155
+ export async function settleRunResult(parts: RunResultSettlement): Promise<SubagentResult> {
156
+ try {
157
+ const result = await parts.attempt();
158
+ return parts.cancelled()
159
+ ? { output: parts.collectOutput(), stopReason: "aborted" }
160
+ : normalizeSubagentDiagnostic(result);
161
+ } catch (error: unknown) {
162
+ // Cover a rejection already queued when cancellation arrives.
163
+ if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: "aborted" };
164
+ // Flatten post-publication transport failures while preserving diagnostics.
165
+ try {
166
+ parts.onError?.(toError(error), "error");
167
+ } catch {
168
+ // The diagnostic sink cannot reject the run result.
169
+ }
170
+ const collected = parts.collectDiagnostic?.();
171
+ const diagnostic = collected === undefined ? undefined : limitSubagentDiagnostic(collected);
172
+ return {
173
+ output: parts.collectOutput(),
174
+ ...(diagnostic === undefined ? {} : { diagnostic }),
175
+ stopReason: "error",
176
+ };
177
+ } finally {
178
+ parts.signal.removeEventListener("abort", parts.onAbort);
179
+ }
180
+ }
181
+
182
+ /** Inputs to {@link subprocessRunHandle}. */
183
+ export interface SubprocessRunHandleParts {
184
+ /** The parent-scoped run id. */
185
+ id: SubagentRun["id"];
186
+ /** The flattened, never-rejecting result (the seam contract). */
187
+ result: Promise<SubagentResult>;
188
+ /** The request's cancellation signal (the listener is removed on dispose). */
189
+ signal: AbortSignal;
190
+ /** The abort listener registered on {@link signal} at start. */
191
+ onAbort: () => void;
192
+ /** Settle local cancellation so {@link result} resolves without the child. */
193
+ requestCancel: () => void;
194
+ /** Tear the child process down to quiescence (backend-owned ladder). */
195
+ teardown: () => Promise<void>;
196
+ }
197
+
198
+ /**
199
+ * Publish the seam run handle for an out-of-process child. `dispose()` is
200
+ * idempotent (one memoized teardown): it removes the abort listener, settles
201
+ * local cancellation — there is no assumption the child cooperates — and then
202
+ * awaits the backend's teardown to actual exit.
203
+ * @param parts - the run identity, result, cancellation wiring, and teardown.
204
+ * @returns the external run handle.
205
+ */
206
+ export function subprocessRunHandle(parts: SubprocessRunHandleParts): SubagentRun {
207
+ let disposal: Promise<void> | undefined;
208
+ return {
209
+ id: parts.id,
210
+ result: parts.result,
211
+ dispose(): Promise<void> {
212
+ if (disposal !== undefined) return disposal;
213
+ parts.signal.removeEventListener("abort", parts.onAbort);
214
+ parts.requestCancel();
215
+ disposal = parts.teardown();
216
+ return disposal;
217
+ },
218
+ };
219
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Pure client-safe subagent projection vocabulary.
3
+ *
4
+ * @module @deepseek-ai/dsh-subagent/projection-types
5
+ */
6
+
7
+ import type { SessionId, SessionSeq } from "@deepseek-ai/dsh-session/types";
8
+
9
+ /** One current direct-child discovery row materialized from parent facts. */
10
+ export type SubagentCatalogEntry = {
11
+ readonly id: SessionId;
12
+ readonly createdAt: number;
13
+ } & (
14
+ | { readonly mode: "one-shot"; readonly label?: string }
15
+ | { readonly mode: "external"; readonly label?: string }
16
+ | { readonly mode: "continuable"; readonly label: string }
17
+ | { readonly mode: "unknown"; readonly label?: string }
18
+ );
19
+
20
+ /** Durable active-turn timing for one descriptor-backed child session. */
21
+ export interface SubagentTimingProjection {
22
+ /** Milliseconds accumulated across completed turns after the child's own descriptor. */
23
+ settledMs: number;
24
+ /** Same-cut bounds of the currently open turn, when one has not reached `turn/end`. */
25
+ active?: {
26
+ /** Start of the open turn. */
27
+ since: number;
28
+ /** Latest event time folded into this projection cut. */
29
+ through: number;
30
+ };
31
+ /**
32
+ * Whether the latest closed turn after the child's own descriptor completed
33
+ * normally; absent while a turn is open or before one closes.
34
+ */
35
+ lastTurnCompleted?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Durable identity of one descriptor-backed subagent session: lifecycle mode
40
+ * plus creation label, folded last-wins from `subagent/descriptor` events.
41
+ * Label strength follows the descriptor schema: a continuable child always
42
+ * carries one, a one-shot child may omit it.
43
+ */
44
+ export type SubagentIdentityProjection =
45
+ | {
46
+ /** A terminal one-shot child. */
47
+ mode: "one-shot";
48
+ /** Optional durable creation label from the child's descriptor. */
49
+ label?: string;
50
+ /**
51
+ * Seq of the `subagent/descriptor` event this identity was folded from.
52
+ * `session.isOwnSeq(seq)` proves the identity comes from the child's
53
+ * OWN log suffix — where a descriptor is immutable once appended — and
54
+ * not from a fork seed's replayed ancestor descriptor.
55
+ */
56
+ seq: SessionSeq;
57
+ }
58
+ | {
59
+ /** A resumable conversation. */
60
+ mode: "continuable";
61
+ /** Durable creation label from the child's descriptor. */
62
+ label: string;
63
+ /** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */
64
+ seq: SessionSeq;
65
+ };
66
+
67
+ declare module "@deepseek-ai/dsh-session-projection/types" {
68
+ interface SessionProjectionMap {
69
+ /** Direct children in parent catalog event order, excluding fork-inherited facts. */
70
+ subagentCatalog: SubagentCatalogEntry[];
71
+ /** Active-turn duration and latest closed-turn completion for a descriptor-backed subagent session. */
72
+ subagentTiming: SubagentTimingProjection;
73
+ /**
74
+ * Identity of a descriptor-backed subagent session. `null` ⟺ no valid
75
+ * descriptor (missing, malformed, or unrecognized-version — deliberately
76
+ * undistinguished). The sentinel is deliberately serializable: a
77
+ * value pushed over JSON transports must survive `JSON.stringify`
78
+ * losslessly, where an `undefined` field would be dropped and a stale
79
+ * identity would survive on the receiving side. The entry itself stays
80
+ * non-optional.
81
+ */
82
+ subagent: SubagentIdentityProjection | null;
83
+ }
84
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Pure session projections for subagent identity (mode/label) and active-turn
3
+ * duration.
4
+ *
5
+ * @module @deepseek-ai/dsh-subagent/projection
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import { SessionSeq } from "@deepseek-ai/dsh-session";
10
+ import type { ProjectionDefinition } from "@deepseek-ai/dsh-session-projection";
11
+ import type { SessionEvent } from "@deepseek-ai/dsh-session";
12
+ import { foldSubagentDescriptor } from "./descriptor.ts";
13
+ import type { SubagentDescriptorData } from "./descriptor.ts";
14
+ import type { SubagentIdentityProjection, SubagentTimingProjection } from "./projection-types.ts";
15
+
16
+ /** Fold state for a subagent's latest timing snapshot. */
17
+ export interface TimingState {
18
+ /** Milliseconds accumulated across completed post-descriptor turns. */
19
+ settledMs: number;
20
+ /** Current open interval kept paired inside the fold. */
21
+ active?: { since: number; through: number } | undefined;
22
+ /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */
23
+ pendingTurnStart?: number | undefined;
24
+ /** Whether the fold has crossed a descriptor in this logical log. */
25
+ descriptorSeen: boolean;
26
+ /** Whether the latest closed post-descriptor turn completed normally; absent while a turn is open or before one closes. */
27
+ lastTurnCompleted?: boolean | undefined;
28
+ }
29
+
30
+ const activeIntervalSchema = z
31
+ .object({
32
+ since: z.number().int().nonnegative(),
33
+ through: z.number().int().nonnegative(),
34
+ })
35
+ .strict();
36
+
37
+ const projectionSchema: z.ZodType<SubagentTimingProjection> = z
38
+ .object({
39
+ settledMs: z.number().int().nonnegative(),
40
+ active: activeIntervalSchema.optional(),
41
+ lastTurnCompleted: z.boolean().optional(),
42
+ })
43
+ .strict()
44
+ .transform(({ settledMs, active, lastTurnCompleted }) => ({
45
+ settledMs,
46
+ ...(active === undefined ? {} : { active }),
47
+ ...(lastTurnCompleted === undefined ? {} : { lastTurnCompleted }),
48
+ }));
49
+
50
+ const timingStateSchema: z.ZodType<TimingState> = z
51
+ .object({
52
+ settledMs: z.number().int().nonnegative(),
53
+ active: activeIntervalSchema.optional(),
54
+ pendingTurnStart: z.number().int().nonnegative().optional(),
55
+ descriptorSeen: z.boolean(),
56
+ lastTurnCompleted: z.boolean().optional(),
57
+ })
58
+ .strict();
59
+
60
+ declare module "@deepseek-ai/dsh-session-projection/types" {
61
+ interface SessionProjectionStateMap {
62
+ subagentTiming: TimingState;
63
+ subagent: IdentityState;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Fold turn boundaries around the child's own durable descriptor.
69
+ *
70
+ * A fork seed may contain an ancestor descriptor and completed turns. Every
71
+ * descriptor therefore resets the accumulated state; the healthy catalog
72
+ * admits only a child with exactly one descriptor in its own suffix, making
73
+ * the final reset the child's authoritative timing origin.
74
+ */
75
+ export const subagentTimingProjectionDefinition = {
76
+ key: "subagentTiming",
77
+ stateSchema: timingStateSchema,
78
+ init: () => ({ descriptorSeen: false, settledMs: 0 }),
79
+ apply: (state, event) => {
80
+ if (event.type === "turn/start") {
81
+ const { lastTurnCompleted: _closed, ...openState } = state;
82
+ return state.descriptorSeen
83
+ ? { ...openState, active: { since: event.time, through: event.time } }
84
+ : { ...openState, pendingTurnStart: event.time };
85
+ }
86
+ if (event.type === "subagent/descriptor") {
87
+ const activeSince = state.active?.since ?? state.pendingTurnStart;
88
+ return {
89
+ descriptorSeen: true,
90
+ settledMs: 0,
91
+ ...(activeSince === undefined
92
+ ? {}
93
+ : { active: { since: activeSince, through: event.time } }),
94
+ };
95
+ }
96
+ if (event.type === "turn/end") {
97
+ if (!state.descriptorSeen) {
98
+ if (state.pendingTurnStart === undefined) return state;
99
+ const { pendingTurnStart: _closed, ...next } = state;
100
+ return next;
101
+ }
102
+ if (state.active === undefined) return state;
103
+ const { active, ...rest } = state;
104
+ return {
105
+ ...rest,
106
+ settledMs: state.settledMs + Math.max(0, event.time - active.since),
107
+ lastTurnCompleted: event.data.reason.kind === "completed",
108
+ };
109
+ }
110
+ if (state.active === undefined) return state;
111
+ return { ...state, active: { ...state.active, through: event.time } };
112
+ },
113
+ wire: {
114
+ viewSchema: projectionSchema,
115
+ view: (state) => ({
116
+ settledMs: state.settledMs,
117
+ ...(state.active === undefined ? {} : { active: state.active }),
118
+ ...(state.lastTurnCompleted === undefined
119
+ ? {}
120
+ : { lastTurnCompleted: state.lastTurnCompleted }),
121
+ }),
122
+ },
123
+ stateVersion: 3,
124
+ } satisfies ProjectionDefinition<"subagentTiming", TimingState>;
125
+
126
+ interface IdentityState {
127
+ /** Identity from the last valid descriptor; absent before one, and after an invalid one. */
128
+ identity?: SubagentIdentityProjection | undefined;
129
+ }
130
+
131
+ // The cast bridges only the optional-label arm: Zod's optional output
132
+ // includes explicit `undefined`, which exactOptionalPropertyTypes excludes
133
+ // from the public interface. The no-value state itself is the serializable
134
+ // `null` arm — never `undefined` — so every registry read and push frame
135
+ // survives JSON.stringify losslessly.
136
+ const identityValueSchema = z.discriminatedUnion("mode", [
137
+ z
138
+ .object({
139
+ mode: z.literal("one-shot"),
140
+ label: z.string().optional(),
141
+ seq: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq),
142
+ })
143
+ .strict(),
144
+ z
145
+ .object({
146
+ mode: z.literal("continuable"),
147
+ label: z.string(),
148
+ seq: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq),
149
+ })
150
+ .strict(),
151
+ ]) as unknown as z.ZodType<SubagentIdentityProjection>;
152
+
153
+ const identitySchema = identityValueSchema.nullable();
154
+
155
+ const identityStateSchema: z.ZodType<IdentityState> = z
156
+ .object({
157
+ identity: identityValueSchema.optional(),
158
+ })
159
+ .strict();
160
+
161
+ /** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */
162
+ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined {
163
+ let descriptor: SubagentDescriptorData | undefined;
164
+ try {
165
+ descriptor = foldSubagentDescriptor([event]);
166
+ } catch {
167
+ // Only a malformed current-version payload throws in descriptor parsing;
168
+ // a projection fold must never throw, so damage folds to no value.
169
+ descriptor = undefined;
170
+ }
171
+ if (descriptor === undefined) return undefined;
172
+ return descriptor.mode === "one-shot"
173
+ ? {
174
+ mode: "one-shot",
175
+ ...(descriptor.label !== undefined ? { label: descriptor.label } : {}),
176
+ seq: event.seq,
177
+ }
178
+ : { mode: "continuable", label: descriptor.label, seq: event.seq };
179
+ }
180
+
181
+ /**
182
+ * Fold the durable mode/label identity from `subagent/descriptor` events,
183
+ * last-wins: a fork seed may replay an ancestor's descriptor, and the child's
184
+ * own descriptor must override it — the same reset discipline as
185
+ * {@link subagentTimingProjectionDefinition}. A malformed or unknown-version
186
+ * payload resets to the `null` sentinel instead of throwing, so a fork of a
187
+ * healthy ancestor never inherits an identity its own descriptor failed to
188
+ * establish — and the reset survives every JSON push frame, so a consumer
189
+ * holding the earlier identity replaces it instead of keeping it stale;
190
+ * `null` ⟺ no valid descriptor, with the causes deliberately undistinguished.
191
+ */
192
+ export const subagentIdentityProjectionDefinition = {
193
+ key: "subagent",
194
+ stateSchema: identityStateSchema,
195
+ init: () => ({}),
196
+ apply: (state, event) => {
197
+ if (event.type !== "subagent/descriptor") return state;
198
+ const identity = descriptorIdentity(event);
199
+ return identity === undefined ? {} : { identity };
200
+ },
201
+ wire: { viewSchema: identitySchema, view: (state) => state.identity ?? null },
202
+ // Bumped when the identity gained its `seq` field: an older checkpoint row
203
+ // would replay into a value the schema rejects, so it must refold instead.
204
+ stateVersion: 2,
205
+ } satisfies ProjectionDefinition<"subagent", IdentityState>;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Child-scoped structured-output tool, prompt instruction, terminal guard, and authoritative
3
+ * result capture for in-process subagents. Each child registers its real schema on its own
4
+ * scope, so concurrent runs do not interact and disposal leaves no global residue. The prompt
5
+ * contribution is ordinary reconstructed request state.
6
+ *
7
+ * Capture commits only after the authoritative `tools/result` succeeds; PTC mode capture also
8
+ * waits for the enclosing `run_code` result. The terminal result marker and monotonic tool
9
+ * guard prevent later calls from reopening a completed structured run.
10
+ * @module @deepseek-ai/dsh-subagent/structured
11
+ */
12
+
13
+ import type { Context } from "@deepseek-ai/cordis";
14
+ import type { ToolSchema } from "@deepseek-ai/dsh-llm";
15
+ import type { ToolExecution, ToolRunContext } from "@deepseek-ai/dsh-tools";
16
+ import {
17
+ ToolArgsError,
18
+ validateJsonSchemaValue,
19
+ type ObjectJsonSchema,
20
+ } from "@deepseek-ai/dsh-tools";
21
+
22
+ /** The model-facing tool name a structured child must call to finish. */
23
+ export const STRUCTURED_OUTPUT_TOOL = "structured_output";
24
+
25
+ /**
26
+ * The instruction registered as the child's trailing scoped prompt section:
27
+ * the demand travels with the
28
+ * tool, as ordinary prompt state of exactly one agent.
29
+ */
30
+ export const STRUCTURED_OUTPUT_INSTRUCTION =
31
+ "When you have your final answer, you MUST report it by calling the " +
32
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` +
33
+ "Do not finish with a plain text answer: only the tool call counts as your result.";
34
+
35
+ /** One structured run's live handle: read the captured value once the child settles. */
36
+ export interface StructuredAttachment {
37
+ /**
38
+ * Input stays closed from capture staging through its final result, including
39
+ * an enclosing PTC execution. A failed result discards its capture and reopens input.
40
+ * @returns whether the activation can accept another message.
41
+ */
42
+ acceptsInput(): boolean;
43
+ /**
44
+ * The captured value, once the child called the tool with valid arguments
45
+ * and the authoritative final tool result accepted that call.
46
+ * @returns the committed value, or undefined while none was accepted.
47
+ */
48
+ captured(): { value: unknown } | undefined;
49
+ }
50
+
51
+ /**
52
+ * Attach the scoped capture tool, instruction, and enforcement to a child during
53
+ * its creation window. Child disposal removes every registration.
54
+ * @param childCtx - the child agent's scope context (`setup`'s argument).
55
+ * @param schema - the trusted, already-asserted schema subset to enforce (see
56
+ * `assertObjectJsonSchema` in dsh-tools).
57
+ * @param canComplete - whether accepted input and owned child work have finished.
58
+ * @returns the attachment handle (read `captured()` after the child settles).
59
+ */
60
+ export function attachStructuredRuntime(
61
+ childCtx: Context,
62
+ schema: ObjectJsonSchema,
63
+ canComplete: () => boolean,
64
+ ): StructuredAttachment {
65
+ /**
66
+ * Validated values staged by the capture tool body, awaiting THEIR OWN
67
+ * authoritative `tools/result` notification. The execution object's identity
68
+ * uniquely identifies a trip through the pipeline: adapter call ids may
69
+ * repeat across steps, but another execution can never reach this Map
70
+ * entry. This is distinct from the opaque `ToolExecutionToken` used to
71
+ * correlate nested transports. The final notification always deletes its own
72
+ * stage, whether the result succeeded or failed.
73
+ */
74
+ const staged = new Map<ToolExecution, { value: unknown }>();
75
+ /** Successful nested capture waiting for its enclosing transport to commit. */
76
+ let pending: { parent: ToolExecution["token"]; value: unknown } | undefined;
77
+ let captured: { value: unknown } | undefined;
78
+
79
+ const schemaEntry: ToolSchema = {
80
+ name: STRUCTURED_OUTPUT_TOOL,
81
+ description:
82
+ "Report your final structured result. Call this exactly once, when your answer is complete; " +
83
+ "the arguments must match this tool's parameter schema exactly.",
84
+ parameters: { ...schema },
85
+ };
86
+
87
+ childCtx.tools.register({
88
+ ...schemaEntry,
89
+ output: {
90
+ schema: {
91
+ type: "object",
92
+ properties: { recorded: { type: "boolean", const: true } },
93
+ required: ["recorded"],
94
+ additionalProperties: false,
95
+ },
96
+ render: () => [{ type: "text", text: "Structured output recorded." }],
97
+ },
98
+ execute(args: unknown, exec: ToolRunContext): Promise<{ recorded: true }> {
99
+ const violations = validateJsonSchemaValue(schema, args);
100
+ // ToolArgsError → isError result with INVALID_ARGS: the model retries
101
+ // within the same turn, exactly like a schema-validated defineTool call.
102
+ if (violations.length > 0) throw new ToolArgsError(violations);
103
+ if (!canComplete())
104
+ throw new Error(
105
+ "Wait for all delegated child tasks to finish and process pending messages before submitting structured output.",
106
+ );
107
+ // Two-phase commit, keyed by THIS execution: later transformable
108
+ // waterfalls may still turn the success into an error. ToolRuntime has
109
+ // already frozen model-bound arguments at the actual input boundary.
110
+ staged.set(exec, { value: args });
111
+ exec.concludeTurn();
112
+ return Promise.resolve({ recorded: true });
113
+ },
114
+ });
115
+
116
+ childCtx.systemPrompt.section({
117
+ name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
118
+ order: childCtx.systemPrompt.getSectionOrder("STRUCTURED_OUTPUT"),
119
+ text: STRUCTURED_OUTPUT_INSTRUCTION,
120
+ });
121
+
122
+ // Terminal WITHIN the step. Guards run after the whole pre-execute
123
+ // waterfall and compose monotonically (deny or abstain, never allow), so a
124
+ // later prepended listener cannot resurrect dispatch. Calls that precede
125
+ // capture in the same response remain untouched.
126
+ childCtx.tools.guard((exec) =>
127
+ captured === undefined && pending === undefined
128
+ ? undefined
129
+ : `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
130
+ );
131
+
132
+ // The capture COMMIT observes the immutable, authoritative result after the
133
+ // complete pipeline and outer error normalization. This notification cannot
134
+ // transform the outcome, so there is no wrapper outside the commit verdict.
135
+ childCtx.on("tools/result", function (this: unknown, exec, result) {
136
+ if (exec.name === STRUCTURED_OUTPUT_TOOL) {
137
+ const entry = staged.get(exec);
138
+ if (entry === undefined) return;
139
+ staged.delete(exec);
140
+ if (result.isError) return;
141
+ if (exec.parent === undefined) {
142
+ /* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */
143
+ if (captured === undefined) captured = { value: entry.value };
144
+ } else {
145
+ /* v8 ignore else -- PTC mode serializes sub-dispatches, so the guard blocks every later supported call */
146
+ if (captured === undefined && pending === undefined) {
147
+ pending = { parent: exec.parent, value: entry.value };
148
+ }
149
+ }
150
+ return;
151
+ }
152
+ if (pending?.parent !== exec.token) return;
153
+ const entry = pending;
154
+ pending = undefined;
155
+ if (result.isError) return;
156
+ /* v8 ignore else -- PTC mode serializes outer executions, so the guard blocks every later supported call */
157
+ if (captured === undefined) captured = { value: entry.value };
158
+ });
159
+
160
+ return {
161
+ captured: () => captured,
162
+ acceptsInput: () => staged.size === 0 && pending === undefined && captured === undefined,
163
+ };
164
+ }