@gea-ai/agent-sdk 0.1.261009-alpha.1 → 0.1.261010-alpha.0

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.
@@ -1225,7 +1225,9 @@ export declare function createWorkerRunCancelTool({ host, }: {
1225
1225
  status: "abort_requested" | "not_running";
1226
1226
  }, NoInfer<import("@ai-sdk/provider-utils").Context>>;
1227
1227
  });
1228
- export declare function readWorkerAgentResponse(response: Response): Promise<{
1228
+ export declare function readWorkerAgentResponse(response: Response, options?: {
1229
+ persistedSessionWaiting?: () => Promise<boolean>;
1230
+ }): Promise<{
1229
1231
  status: "completed" | "suspended";
1230
1232
  text: string;
1231
1233
  }>;
@@ -70,6 +70,23 @@ export function createWorkerAgentTool(input) {
70
70
  error: "child_busy",
71
71
  message: "This child Session has unfinished work. Wait for its Run before continuing the Session.",
72
72
  };
73
+ if (response.status === 403) {
74
+ const rejection = z
75
+ .object({
76
+ error: z
77
+ .string()
78
+ .max(80)
79
+ .regex(/^[A-Za-z0-9_.-]+$/),
80
+ })
81
+ .safeParse(value);
82
+ if (rejection.success) {
83
+ console.error("Child run rejected", {
84
+ status: 403,
85
+ error: rejection.data.error,
86
+ });
87
+ throw new Error(`Agent invocation was rejected (403 ${rejection.data.error}).`);
88
+ }
89
+ }
73
90
  }
74
91
  throw new Error(`Agent invocation was rejected (${response.status}).`);
75
92
  }
@@ -99,7 +116,15 @@ export function createWorkerRunCancelTool({ host, }) {
99
116
  }
100
117
  class AgentChildRunResponseError extends TaggedError("AgentChildRunResponseError")() {
101
118
  }
102
- export async function readWorkerAgentResponse(response) {
119
+ const childDiagnosticIdentifierPattern = /^[A-Za-z0-9_.-]+$/;
120
+ function childDiagnosticIdentifier(value) {
121
+ return typeof value === "string" &&
122
+ value.length <= 80 &&
123
+ childDiagnosticIdentifierPattern.test(value)
124
+ ? value
125
+ : undefined;
126
+ }
127
+ export async function readWorkerAgentResponse(response, options) {
103
128
  if (response.status === 409 &&
104
129
  response.headers.get("x-gea-session-execution") === "already-started") {
105
130
  await response.body?.cancel();
@@ -117,6 +142,9 @@ export async function readWorkerAgentResponse(response) {
117
142
  let completed = false;
118
143
  let suspended = false;
119
144
  let failure;
145
+ let legacyToolCalls = false;
146
+ let otherUnacceptableFinish = false;
147
+ let sawStreamFailure = false;
120
148
  const reader = parseJsonEventStream({
121
149
  stream: response.body,
122
150
  schema: uiMessageChunkSchema,
@@ -133,26 +161,46 @@ export async function readWorkerAgentResponse(response) {
133
161
  const chunk = event.value;
134
162
  if (chunk.type === "text-delta")
135
163
  text += chunk.delta;
136
- if (chunk.type === "error")
164
+ if (chunk.type === "error") {
165
+ sawStreamFailure = true;
137
166
  failure ??= new AgentChildRunResponseError({
138
167
  code: "child_stream_error",
139
168
  message: chunk.errorText,
140
169
  });
141
- if (chunk.type === "abort")
170
+ }
171
+ if (chunk.type === "abort") {
172
+ sawStreamFailure = true;
142
173
  failure ??= new AgentChildRunResponseError({
143
174
  code: "child_stream_aborted",
144
175
  message: "Child Agent was cancelled.",
145
176
  });
177
+ }
146
178
  if (chunk.type === "finish") {
147
179
  suspended = z
148
180
  .object({ sessionStatus: z.literal("waiting") })
149
181
  .safeParse(chunk.messageMetadata).success;
150
- if (chunk.finishReason !== "stop" &&
151
- !(suspended && chunk.finishReason === "tool-calls"))
182
+ const unacceptable = chunk.finishReason !== "stop" &&
183
+ !(suspended && chunk.finishReason === "tool-calls");
184
+ if (unacceptable) {
185
+ if (chunk.finishReason === "tool-calls" && !suspended)
186
+ legacyToolCalls = true;
187
+ else
188
+ otherUnacceptableFinish = true;
189
+ const parsedSession = z
190
+ .object({ sessionStatus: z.unknown() })
191
+ .safeParse(chunk.messageMetadata);
192
+ const rawSessionStatus = parsedSession.success
193
+ ? parsedSession.data.sessionStatus
194
+ : undefined;
195
+ const sessionStatus = childDiagnosticIdentifier(rawSessionStatus);
152
196
  failure ??= new AgentChildRunResponseError({
153
197
  code: "child_finish_reason",
154
198
  message: `Child Agent did not complete (${chunk.finishReason ?? "unknown"}).`,
199
+ finishReason: childDiagnosticIdentifier(chunk.finishReason) ?? "unknown",
200
+ hasSessionStatus: rawSessionStatus !== undefined,
201
+ ...(sessionStatus ? { sessionStatus } : {}),
155
202
  });
203
+ }
156
204
  completed = true;
157
205
  }
158
206
  }
@@ -161,6 +209,18 @@ export async function readWorkerAgentResponse(response) {
161
209
  await reader.cancel();
162
210
  reader.releaseLock();
163
211
  }
212
+ if (options?.persistedSessionWaiting &&
213
+ legacyToolCalls &&
214
+ !otherUnacceptableFinish &&
215
+ !sawStreamFailure &&
216
+ failure instanceof AgentChildRunResponseError &&
217
+ failure.code === "child_finish_reason") {
218
+ const waiting = await Result.tryPromise(() => options.persistedSessionWaiting());
219
+ if (waiting.isOk() && waiting.value) {
220
+ failure = undefined;
221
+ suspended = true;
222
+ }
223
+ }
164
224
  if (failure)
165
225
  throw failure;
166
226
  if (!completed)
@@ -40,7 +40,7 @@ export function extractChannelReply(messageValue, responseMessageId) {
40
40
  }
41
41
  /** Channel owns delivery; ordinary Agents API owns admission, execution and history. */
42
42
  export async function executeChannelAgentRun(input) {
43
- const fetchApi = async (path, body) => {
43
+ const fetchApi = async (path, body, headers) => {
44
44
  input.signal.throwIfAborted();
45
45
  const response = await input.binding.fetch(`/api/v1${path}`, {
46
46
  method: body === undefined ? "GET" : "POST",
@@ -48,7 +48,7 @@ export async function executeChannelAgentRun(input) {
48
48
  ...(body === undefined
49
49
  ? {}
50
50
  : {
51
- headers: { "content-type": "application/json" },
51
+ headers: { "content-type": "application/json", ...headers },
52
52
  body: JSON.stringify(body),
53
53
  }),
54
54
  });
@@ -67,7 +67,7 @@ export async function executeChannelAgentRun(input) {
67
67
  id: input.runId,
68
68
  input: input.message,
69
69
  stream: false,
70
- })).body?.cancel();
70
+ }, { "idempotency-key": input.runId })).body?.cancel();
71
71
  let stream = true;
72
72
  for (;;) {
73
73
  const current = runSchema.parse(await (await fetchApi(`/runs/${input.runId}`)).json());
@@ -62,13 +62,42 @@ export async function submitRemoteRun(fetch, input, options = {}) {
62
62
  }))).id;
63
63
  signal.throwIfAborted();
64
64
  submitting = true;
65
- // No automatic replay: a lost response does not prove Run admission failed.
66
- const response = await fetch(`/sessions/${sessionId}/runs`, {
67
- method: "POST",
68
- headers: { "content-type": "application/json" },
69
- signal,
70
- body: JSON.stringify({ input: message, stream: false }),
65
+ // One key per logical submission. Retries reuse it so a lost 202 cannot
66
+ // start a second Run. A later call to submitRemoteRun is a new request.
67
+ const idempotencyKey = crypto.randomUUID();
68
+ const runBody = JSON.stringify({
69
+ id: idempotencyKey,
70
+ input: message,
71
+ stream: false,
71
72
  });
73
+ const runHeaders = {
74
+ "content-type": "application/json",
75
+ "idempotency-key": idempotencyKey,
76
+ };
77
+ let response;
78
+ for (let attempt = 0; attempt < 3; attempt++) {
79
+ signal.throwIfAborted();
80
+ try {
81
+ response = await fetch(`/sessions/${sessionId}/runs`, {
82
+ method: "POST",
83
+ headers: runHeaders,
84
+ signal,
85
+ body: runBody,
86
+ });
87
+ }
88
+ catch (error) {
89
+ if (signal.aborted || attempt === 2)
90
+ throw error;
91
+ continue;
92
+ }
93
+ const retryable = [502, 503, 504].includes(response.status);
94
+ const admitted = response.headers.has("x-gea-agent-run-id");
95
+ if (!retryable || admitted || attempt === 2)
96
+ break;
97
+ await response.body?.cancel();
98
+ }
99
+ if (!response)
100
+ throw new Error("Remote Run was not submitted.");
72
101
  rejected = [400, 401, 403, 404, 405, 409, 413, 415, 422, 429].includes(response.status);
73
102
  const run = agentHttpRunSchema.parse(await responseJson(response));
74
103
  if (run.session_id !== sessionId)
@@ -11,17 +11,21 @@ export type AgentChildRunSession = (operation: string, body: unknown, traceConte
11
11
  export declare class AgentRunHost {
12
12
  private readonly host;
13
13
  private readonly executions;
14
+ private readonly executionDone;
14
15
  private readonly work;
15
16
  private closed;
16
17
  private readonly shutdown;
17
18
  constructor(host: {
18
19
  session: AgentChildRunSession;
20
+ /** Authorize against the root Session and return its retained child binding. */
21
+ resolveSession?(sessionId: string): Promise<Response>;
19
22
  remoteFetch?(destination: RemoteRunDestination, path: string, init?: RequestInit): Promise<Response>;
20
23
  transferRemoteInputs?(destination: RemoteRunDestination, files: RemoteInputFiles, scope: StudioArtifactScope | undefined, signal: AbortSignal): Promise<RemoteFileMapping>;
21
24
  register(run: AgentChildRun): Promise<string>;
22
25
  invoke(run: AgentChildRun, signal: AbortSignal, continuation?: TraceContinuation, inboxExecution?: AgentSessionInboxExecution): Promise<{
23
26
  response: Response;
24
27
  dispose?: () => void;
28
+ persistedSessionWaiting?: () => Promise<boolean>;
25
29
  }>;
26
30
  cancelChild(run: AgentChildRun): Promise<boolean | void>;
27
31
  onError(error: unknown, identity?: {
@@ -1,7 +1,7 @@
1
1
  import { submitRemoteRun, observeRemoteRun, cancelRemoteRun, } from "./agent-remote-run.js";
2
2
  import { z } from "zod";
3
3
  import { Result } from "better-result";
4
- import { agentChildRunSchema, getAgentCallFileIds, agentChildRunListResultSchema, agentChildRunStartResultSchema, } from "@gea-ai/contract/agent-worker-tools";
4
+ import { agentChildRunSchema, agentChildSessionSchema, getAgentCallFileIds, agentChildRunListResultSchema, agentChildRunStartResultSchema, } from "@gea-ai/contract/agent-worker-tools";
5
5
  import { readWorkerAgentResponse } from "./agent-call.js";
6
6
  export { readWorkerAgentResponse } from "./agent-call.js";
7
7
  export { streamManagedAgentSessionRun } from "./agent-session-stream.js";
@@ -9,6 +9,7 @@ export { streamManagedAgentSessionRun } from "./agent-session-stream.js";
9
9
  export class AgentRunHost {
10
10
  host;
11
11
  executions = new Map();
12
+ executionDone = new Map();
12
13
  work = new Set();
13
14
  closed = false;
14
15
  shutdown = new AbortController();
@@ -31,24 +32,94 @@ export class AgentRunHost {
31
32
  return this.host.session(operation, body, traceContext);
32
33
  }
33
34
  async start(input) {
34
- if (input.sessionId) {
35
+ let authorizedSession;
36
+ if (input.sessionId && this.host.resolveSession) {
37
+ const response = await this.host.resolveSession(input.sessionId);
38
+ if (!response.ok)
39
+ return response;
40
+ authorizedSession = z
41
+ .object({ session: agentChildSessionSchema })
42
+ .parse(await response.json()).session;
43
+ if (authorizedSession.sessionId !== input.sessionId ||
44
+ (input.target !== undefined &&
45
+ input.target !==
46
+ (authorizedSession.target ?? authorizedSession.agentKey)))
47
+ return Response.json({ error: "unknown_child_session" }, { status: 403 });
48
+ input = {
49
+ ...input,
50
+ agentKey: authorizedSession.agentKey,
51
+ target: authorizedSession.target ?? authorizedSession.agentKey,
52
+ destination: authorizedSession.destination,
53
+ };
54
+ // Existing parent ledgers already carry this immutable binding. Keep their
55
+ // original start payload so deployed strict-schema bundles still resume.
56
+ const listed = await this.host.session("list", {});
57
+ if (!listed.ok)
58
+ return listed;
59
+ const page = agentChildRunListResultSchema.parse(await listed.json());
60
+ if (page.runs.some((run) => run.sessionId === input.sessionId)) {
61
+ authorizedSession = undefined;
62
+ }
63
+ else if (page.subagentConcurrencyVersion) {
64
+ const local = await this.host.session("session", {
65
+ sessionId: input.sessionId,
66
+ });
67
+ if (local.ok) {
68
+ z.object({
69
+ session: agentChildSessionSchema.omit({ sessionId: true }),
70
+ }).parse(await local.json());
71
+ authorizedSession = undefined;
72
+ }
73
+ else if (local.status !== 403)
74
+ return local;
75
+ }
76
+ }
77
+ else if (input.sessionId) {
35
78
  const response = await this.host.session("list", {});
36
79
  if (!response.ok)
37
80
  return response;
38
81
  const page = agentChildRunListResultSchema.parse(await response.json());
39
- let destination = page.runs.find((run) => run.sessionId === input.sessionId)?.destination;
82
+ const listed = page.runs.find((run) => run.sessionId === input.sessionId);
83
+ let destination = listed?.destination;
84
+ let recordedTarget;
40
85
  if (page.subagentConcurrencyVersion) {
41
86
  const relation = await this.host.session("session", {
42
87
  sessionId: input.sessionId,
43
88
  });
89
+ // This parent ledger does not own the Session. Do not invent a target.
44
90
  if (!relation.ok)
45
91
  return relation;
46
- destination = z
92
+ const session = z
47
93
  .object({
48
- session: agentChildRunSchema.pick({ destination: true }).strip(),
94
+ session: agentChildRunSchema
95
+ .pick({ target: true, agentKey: true, destination: true })
96
+ .strip(),
49
97
  })
50
- .parse(await relation.json()).session.destination;
98
+ .parse(await relation.json()).session;
99
+ destination = session.destination;
100
+ recordedTarget = session.target ?? session.agentKey;
51
101
  }
102
+ else if (!listed) {
103
+ return Response.json({ error: "unknown_child_session" }, { status: 403 });
104
+ }
105
+ else {
106
+ recordedTarget = listed.target ?? listed.agentKey;
107
+ }
108
+ // v0.56.2 bundles admit resume only when
109
+ // (stored target ?? agentKey) === (request target ?? request agentKey).
110
+ // Dispatch sends the parent agentKey and may omit target. Copy the
111
+ // ledger target after ownership is confirmed. Legacy remote admission
112
+ // compares the stored target on this host and does not use that copy.
113
+ const callsBundle = destination?.type !== "remote" || !!page.subagentConcurrencyVersion;
114
+ if (callsBundle &&
115
+ recordedTarget !== undefined &&
116
+ input.target !== undefined &&
117
+ input.target !== recordedTarget)
118
+ return Response.json({ error: "unknown_child_session" }, { status: 403 });
119
+ if (callsBundle &&
120
+ input.target === undefined &&
121
+ recordedTarget !== undefined)
122
+ input = { ...input, target: recordedTarget };
52
123
  if (destination?.type === "remote")
53
124
  return this.startRemote({ ...input, destination });
54
125
  input = { ...input, destination: destination ?? input.destination };
@@ -56,7 +127,10 @@ export class AgentRunHost {
56
127
  if (this.closed)
57
128
  return new Response("Run dispatcher is stopping", { status: 503 });
58
129
  const { artifactScope: _artifactScope, signal: _signal, ...sessionInput } = input;
59
- const response = await this.host.session("start", sessionInput);
130
+ const response = await this.host.session("start", {
131
+ ...sessionInput,
132
+ ...(authorizedSession ? { authorizedSession } : {}),
133
+ });
60
134
  if (!response.ok)
61
135
  return response;
62
136
  const { run, scheduled } = agentChildRunStartResultSchema.parse(await response.json());
@@ -96,10 +170,12 @@ export class AgentRunHost {
96
170
  if (input.sessionId) {
97
171
  const previous = runs.find((run) => run.sessionId === input.sessionId);
98
172
  if (!previous ||
99
- previous.target !== input.target ||
173
+ (input.target !== undefined && input.target !== previous.target) ||
100
174
  JSON.stringify(previous.destination) !==
101
175
  JSON.stringify(input.destination))
102
176
  return new Response("Unknown child Session", { status: 403 });
177
+ if (input.target === undefined && previous.target !== undefined)
178
+ sessionInput.target = previous.target;
103
179
  if (runs.some((run) => run.sessionId === input.sessionId && !run.outcome))
104
180
  return new Response("Child Session is busy", { status: 409 });
105
181
  }
@@ -185,7 +261,11 @@ export class AgentRunHost {
185
261
  runId: run.id,
186
262
  sessionId: run.sessionId,
187
263
  });
188
- this.track(this.execute(run, cancellation, inboxExecution), {
264
+ const done = this.execute(run, cancellation, inboxExecution).finally(() => {
265
+ this.executionDone.delete(run.id);
266
+ });
267
+ this.executionDone.set(run.id, done);
268
+ this.track(done, {
189
269
  runId: run.id,
190
270
  sessionId: run.sessionId,
191
271
  });
@@ -268,7 +348,9 @@ export class AgentRunHost {
268
348
  await this.host.register(run);
269
349
  const invocation = await this.host.invoke(run, cancellation.signal, inboxExecution?.tracing ?? { parent: run.traceContext, links: [] }, inboxExecution);
270
350
  try {
271
- await readWorkerAgentResponse(invocation.response);
351
+ await readWorkerAgentResponse(invocation.response, {
352
+ persistedSessionWaiting: invocation.persistedSessionWaiting,
353
+ });
272
354
  }
273
355
  finally {
274
356
  invocation.dispose?.();
@@ -308,8 +390,10 @@ export class AgentRunHost {
308
390
  }
309
391
  else {
310
392
  this.dispatch(run);
311
- if ((await this.host.cancelChild(run)) !== false)
393
+ if ((await this.host.cancelChild(run)) !== false) {
312
394
  this.executions.get(runId)?.abort();
395
+ await this.executionDone.get(runId);
396
+ }
313
397
  }
314
398
  }
315
399
  return Response.json({
@@ -3,7 +3,7 @@ import { agentApplicationAssistantMessageSchema } from "@gea-ai/contract/agent-a
3
3
  import { readTraceContext } from "./trace-context.js";
4
4
  import { traceContextSchema } from "@gea-ai/contract/traces";
5
5
  import { DEFAULT_MAX_CONCURRENT_SUBAGENTS, maxConcurrentSubagentsSchema, } from "@gea-ai/contract/agent-packages";
6
- import { AGENT_SUBAGENT_CONCURRENCY_HEADER, agentChildRunSchema, agentChildRunStartInputSchema, agentChildRunDestinationSchema, agentChildRunOutcomeSchema, agentChildRunListInputSchema, } from "@gea-ai/contract/agent-worker-tools";
6
+ import { AGENT_SUBAGENT_CONCURRENCY_HEADER, agentChildRunSchema, agentChildSessionSchema, agentChildRunStartInputSchema, agentChildRunDestinationSchema, agentChildRunOutcomeSchema, agentChildRunListInputSchema, } from "@gea-ai/contract/agent-worker-tools";
7
7
  import { notifyAgentSessionChildRun, scheduleAgentSessionChildRunChanges, queueAgentSessionChildRun, acceptAgentSessionChildRun, inheritedAgentSessionMetadata, } from "./agent-session-inbox.js";
8
8
  import { getChildRun, getChildSession, listChildRuns, putChildRun, readChildRunIndex, readChildRuns, saveChildRunIndex, } from "./agent-session-child-store.js";
9
9
  const runIdSchema = z.strictObject({ runId: z.uuid() });
@@ -12,6 +12,8 @@ const startSchema = agentChildRunStartInputSchema.extend({
12
12
  agentKey: z.string().min(1).max(80),
13
13
  destination: agentChildRunDestinationSchema.optional(),
14
14
  traceContext: traceContextSchema.optional(),
15
+ // Only the controlled Host transport can supply a root-authorized binding.
16
+ authorizedSession: agentChildSessionSchema.optional(),
15
17
  });
16
18
  const reservationSchema = startSchema
17
19
  .pick({
@@ -96,14 +98,38 @@ export async function handleAgentSessionChildren(request, storage) {
96
98
  const runs = await readChildRuns(tx);
97
99
  if (!index && runs.length >= 128)
98
100
  return Response.json({ error: "child_run_limit" }, { status: 409 });
99
- const previous = input.sessionId
100
- ? await getChildSession(tx, input.sessionId)
101
+ if (input.authorizedSession &&
102
+ (remote ||
103
+ input.authorizedSession.sessionId !== input.sessionId ||
104
+ input.authorizedSession.destination.type !== "worker"))
105
+ return Response.json({ error: "unknown_child_session" }, { status: 403 });
106
+ const previous = input.authorizedSession ??
107
+ (input.sessionId
108
+ ? await getChildSession(tx, input.sessionId)
109
+ : undefined);
110
+ // Hosted callers authorize the root Session. The local ledger remains
111
+ // the binding source for hosts without a Session directory.
112
+ if (input.sessionId && !previous)
113
+ return Response.json({ error: "unknown_child_session" }, { status: 403 });
114
+ const recordedTarget = previous
115
+ ? (previous.target ?? previous.agentKey)
101
116
  : undefined;
102
- if (input.sessionId &&
103
- (!previous ||
104
- (previous.target ?? previous.agentKey) !==
105
- (input.target ?? input.agentKey)))
117
+ if (previous &&
118
+ input.target !== undefined &&
119
+ input.target !== recordedTarget)
106
120
  return Response.json({ error: "unknown_child_session" }, { status: 403 });
121
+ const resumedTarget = input.target ?? previous?.target;
122
+ const { authorizedSession: _authorizedSession, ...startInput } = input;
123
+ const continued = {
124
+ ...startInput,
125
+ ...(resumedTarget === undefined ? {} : { target: resumedTarget }),
126
+ ...(previous
127
+ ? {
128
+ agentKey: previous.agentKey,
129
+ destination: previous.destination ?? input.destination,
130
+ }
131
+ : {}),
132
+ };
107
133
  if (remote &&
108
134
  previous &&
109
135
  JSON.stringify(previous.destination) !==
@@ -132,12 +158,12 @@ export async function handleAgentSessionChildren(request, storage) {
132
158
  id: reservationId,
133
159
  sessionId: input.sessionId,
134
160
  });
135
- await tx.put(`child-reservation:${reservationId}`, reservationSchema.parse(input));
161
+ await tx.put(`child-reservation:${reservationId}`, reservationSchema.parse(continued));
136
162
  await saveChildRunIndex(tx, index);
137
163
  return Response.json({ reservationId });
138
164
  }
139
165
  const run = agentChildRunSchema.parse({
140
- ...input,
166
+ ...continued,
141
167
  id: crypto.randomUUID(),
142
168
  sessionId: input.sessionId ?? crypto.randomUUID(),
143
169
  agentKey: previous?.agentKey ?? input.agentKey,
@@ -35,6 +35,8 @@ const stateSchema = z.strictObject({
35
35
  transport: agentSessionTransportSchema.nullable().default(null),
36
36
  started: z.boolean().default(false),
37
37
  stopped: z.boolean().default(false),
38
+ /** User cancel. Process recovery sets `stopped` with `interrupted` and must not set this. */
39
+ cancelRequested: z.boolean().default(false),
38
40
  resumeWait: agentRunWaitResultSchema.nullable().default(null),
39
41
  pendingStarts: z.array(z.uuid()).max(128).default([]),
40
42
  terminal: terminalSchema.nullable().default(null),
@@ -238,9 +240,13 @@ export async function queueAgentSessionChildRun(tx, run) {
238
240
  // External protocols retain their own execution and cancellation transport.
239
241
  if (!state?.transport)
240
242
  return false;
241
- // A remote receipt can arrive after its parent's cancellation or terminal commit.
242
- if (state.stopped ||
243
- (await tx.get(`inbox:outcome:${run.parentRunId}`)) !== undefined) {
243
+ // Explicit cancellation propagates to delayed receipts. Ordinary parent
244
+ // completion does not revoke an already admitted child dispatch.
245
+ const savedOutcome = await tx.get(`inbox:outcome:${run.parentRunId}`);
246
+ const parentCancelled = savedOutcome !== undefined &&
247
+ agentSessionDispatchSchema.shape.outcome.unwrap().parse(savedOutcome)
248
+ .status === "cancelled";
249
+ if (state.stopped || parentCancelled) {
244
250
  run.cancelRequested = true;
245
251
  await putChildRun(tx, run);
246
252
  }
@@ -423,7 +429,11 @@ export async function notifyAgentSessionChildRun(tx, run) {
423
429
  if (!state || !run.outcome)
424
430
  return;
425
431
  const runs = await readInboxChildRuns(tx, state);
426
- if (state.stopped) {
432
+ // Completion belongs to the originating Run. A later turn can explicitly
433
+ // wait for this receipt, but must not receive an unsolicited old notification.
434
+ const parentFinished = (await tx.get(`inbox:outcome:${run.parentRunId}`)) !== undefined;
435
+ if (state.stopped ||
436
+ (parentFinished && !state.wait?.runIds.includes(run.id))) {
427
437
  await save(tx, state, runs);
428
438
  return;
429
439
  }
@@ -482,6 +492,45 @@ async function finishInbox(tx, state, runs, input) {
482
492
  pendingStarts: state.pendingStarts.length,
483
493
  });
484
494
  }
495
+ // A durable user cancel wins a later transport failure or shutdown abort.
496
+ // Socket close can report `failed` before the worker observes the abort,
497
+ // and a request abort without cancelRequestedAt is only an interruption.
498
+ // Release can arrive after cancelRequestedAt is stored but before the inbox
499
+ // flag. An interrupted stop (process recovery or graceful shutdown) sets
500
+ // `stopped` without that flag and must stay failed or interrupted.
501
+ if (outcome?.status === "failed" ||
502
+ outcome?.status === "cancelled" ||
503
+ outcome?.status === "interrupted") {
504
+ const storedTurn = await tx.get("execution-v2:active-turn");
505
+ const turn = storedTurn === undefined
506
+ ? null
507
+ : agentSessionExecutionTurnSchema.parse(storedTurn);
508
+ const userCancel = state.cancelRequested ||
509
+ (turn?.runId === state.execution.turnId &&
510
+ Boolean(turn.cancelRequestedAt));
511
+ if (userCancel) {
512
+ if (outcome.status !== "cancelled")
513
+ outcome = { ...outcome, status: "cancelled" };
514
+ state.cancelRequested = true;
515
+ state.stopped = true;
516
+ }
517
+ }
518
+ // A later transport failure must not replace a cancel this turn already sealed.
519
+ if (outcome?.status === "failed" || outcome?.status === "interrupted") {
520
+ const turnId = state.execution.turnId;
521
+ const sealedCancelled = [state.terminal, ...state.terminalQueue].some((entry) => entry?.execution.turnId === turnId &&
522
+ entry.outcome.status === "cancelled");
523
+ const stored = sealedCancelled
524
+ ? undefined
525
+ : await tx.get(`inbox:outcome:${turnId}`);
526
+ const storedOutcome = stored === undefined
527
+ ? null
528
+ : agentSessionDispatchSchema.shape.outcome.unwrap().safeParse(stored);
529
+ if (sealedCancelled || storedOutcome?.data?.status === "cancelled") {
530
+ outcome = { ...outcome, status: "cancelled" };
531
+ state.stopped = true;
532
+ }
533
+ }
485
534
  if (inputWait && !outcome && !state.stopped) {
486
535
  state.inputWait = {
487
536
  turnId: state.execution.turnId,
@@ -599,6 +648,7 @@ export async function handleAgentSessionInboxRequest(request, storage) {
599
648
  transport: null,
600
649
  started: false,
601
650
  stopped: false,
651
+ cancelRequested: false,
602
652
  resumeWait: null,
603
653
  terminal: null,
604
654
  draining: null,
@@ -707,8 +757,10 @@ export async function handleAgentSessionInboxRequest(request, storage) {
707
757
  state.inputWait = null;
708
758
  state.inputNotification = null;
709
759
  state.resumeWait = null;
710
- if (!state.draining)
760
+ if (!state.draining) {
711
761
  state.stopped = false;
762
+ state.cancelRequested = false;
763
+ }
712
764
  state.started = path === "/v1/inbox/start";
713
765
  if (input.message)
714
766
  await tx.put(`inbox:message:${input.message.id}`, input.message);
@@ -775,8 +827,10 @@ export async function handleAgentSessionInboxRequest(request, storage) {
775
827
  if (new TextEncoder().encode(JSON.stringify(state) + JSON.stringify(message)).byteLength > 2_000_000)
776
828
  return Response.json({ error: "inbox_full" }, { status: 429 });
777
829
  state.messages.push(message);
778
- if (!state.draining)
830
+ if (!state.draining) {
779
831
  state.stopped = false;
832
+ state.cancelRequested = false;
833
+ }
780
834
  await tx.put(key, message);
781
835
  await save(tx, state, runs);
782
836
  return Response.json({ id: message.id, duplicate: false });
@@ -805,6 +859,7 @@ export async function handleAgentSessionInboxRequest(request, storage) {
805
859
  const joining = state.wait?.toolCallId === null;
806
860
  await consumeInputRequest(tx, state, runs, result);
807
861
  state.stopped = false;
862
+ state.cancelRequested = false;
808
863
  state.execution = {
809
864
  id: crypto.randomUUID(),
810
865
  metadata: state.metadata,
@@ -847,6 +902,8 @@ export async function handleAgentSessionInboxRequest(request, storage) {
847
902
  state.draining?.execution.turnId))
848
903
  return Response.json({ cancelled: false });
849
904
  state.stopped = true;
905
+ if (!input.interrupted)
906
+ state.cancelRequested = true;
850
907
  if (input.cancelPending)
851
908
  state.messages = [];
852
909
  if (state.wait?.toolCallId)
@@ -14,6 +14,7 @@ import { agentToolOutputOffloadOptionsSchema } from "@gea-ai/contract/agent-cont
14
14
  import { resolveAgentToolOutputOffload } from "@gea-ai/contract/agent-context";
15
15
  import { decodeAgentModelProxyValue, encodeAgentModelProxyValue, } from "@gea-ai/contract/agent-model-proxy";
16
16
  import { createGeaAI, modelDescriptorSchema } from "./gea-ai.js";
17
+ import { hostRejectionError } from "./host-rejection.js";
17
18
  import { storeToolModelOutput, resolveToolArtifacts, } from "./tool-model-output.js";
18
19
  import { mcpToolModelOutput } from "./mcp-tool-output.js";
19
20
  import { AgentMediaReadError, readAgentMedia } from "./agent-media-read.js";
@@ -2340,7 +2341,7 @@ function createHostBackedAgentTools(input) {
2340
2341
  async function requireHostJson(host, path, init) {
2341
2342
  const response = await host.fetch(`${HOST_PATH}${path}`, init);
2342
2343
  if (!response.ok)
2343
- throw new Error(`Agent host rejected ${path} (${response.status}).`);
2344
+ throw await hostRejectionError(path, response);
2344
2345
  return decodeWireValue(await response.json());
2345
2346
  }
2346
2347
  async function createGeaCoreConnection(host, publicModel, requestOptions, signal, maxOutputTokens) {
@@ -2762,12 +2763,38 @@ export async function runAgentWorkerRequest(input) {
2762
2763
  });
2763
2764
  if (input.inboxExecution) {
2764
2765
  const inboxStartedAt = performance.now();
2765
- const status = termination === "cancelled"
2766
+ let status = termination === "cancelled"
2766
2767
  ? "cancelled"
2767
- : termination === "failed" ||
2768
- results.some((result) => result.status === "rejected")
2769
- ? "failed"
2770
- : inboxOutcome.status;
2768
+ : termination === "interrupted"
2769
+ ? "interrupted"
2770
+ : termination === "failed" ||
2771
+ results.some((result) => result.status === "rejected")
2772
+ ? "failed"
2773
+ : inboxOutcome.status;
2774
+ // A request abort is interrupted until a durable user cancel is
2775
+ // visible. Parent cancel sets cancelRequestedAt before aborting
2776
+ // the fetch, so this recheck still seals that run as cancelled.
2777
+ if ((status === "failed" || status === "interrupted") &&
2778
+ input.sessionExecution) {
2779
+ try {
2780
+ const state = await requestSessionJson({
2781
+ chatId: sessionChatId,
2782
+ method: "GET",
2783
+ namespace: input.sessionNamespace,
2784
+ path: "/v2/state?view=metadata",
2785
+ schema: agentSessionExecutionStateSchema,
2786
+ });
2787
+ const active = state.activeTurn;
2788
+ if (active?.cancelRequestedAt &&
2789
+ active.runId === input.sessionExecution.runId &&
2790
+ active.attemptId === input.sessionExecution.attemptId)
2791
+ status = "cancelled";
2792
+ }
2793
+ catch {
2794
+ // A metadata miss must not skip release. finishInbox remaps a
2795
+ // failed or interrupted outcome when the same cancel is durable.
2796
+ }
2797
+ }
2771
2798
  invocationSpan?.setAttribute("gea.session.status", status);
2772
2799
  const inbox = {
2773
2800
  executionId: input.inboxExecution.id,
@@ -2900,12 +2927,16 @@ export async function runAgentWorkerRequest(input) {
2900
2927
  stopComputer = stop;
2901
2928
  }, (outcome) => {
2902
2929
  inboxOutcome = outcome;
2903
- if (outcome.status === "failed" || outcome.status === "cancelled")
2930
+ if (outcome.status === "failed" ||
2931
+ outcome.status === "cancelled" ||
2932
+ outcome.status === "interrupted")
2904
2933
  termination ??= outcome.status;
2905
2934
  }, invocationTiming);
2906
2935
  }
2907
2936
  catch (error) {
2908
- termination ??= signal.aborted ? "cancelled" : "failed";
2937
+ // Lease expiry and an explicit cancel assign termination before they
2938
+ // abort. A bare request abort is process shutdown, not a user cancel.
2939
+ termination ??= signal.aborted ? "interrupted" : "failed";
2909
2940
  cancellation.abort(error);
2910
2941
  try {
2911
2942
  await finish();
@@ -2938,7 +2969,7 @@ export async function runAgentWorkerRequest(input) {
2938
2969
  throw failure.reason;
2939
2970
  };
2940
2971
  const cancel = (reason) => {
2941
- termination ??= "cancelled";
2972
+ termination ??= "interrupted";
2942
2973
  return stop(reason);
2943
2974
  };
2944
2975
  const onAbort = () => waitUntil(cancel(signal.reason));
@@ -2966,7 +2997,7 @@ export async function runAgentWorkerRequest(input) {
2966
2997
  controller.close();
2967
2998
  }
2968
2999
  catch (error) {
2969
- termination ??= "failed";
3000
+ termination ??= signal.aborted ? "interrupted" : "failed";
2970
3001
  try {
2971
3002
  await stop(error);
2972
3003
  }
@@ -4120,7 +4151,7 @@ async function executeAgentWorkerRequest(input, waitUntil, ownComputer, ownSessi
4120
4151
  // A UI reader may cancel as soon as it receives an error chunk. Preserve
4121
4152
  // the execution's original failure before publishing that chunk.
4122
4153
  ownSessionOutcome({
4123
- status: input.request.signal.aborted ? "cancelled" : "failed",
4154
+ status: input.request.signal.aborted ? "interrupted" : "failed",
4124
4155
  text: executionErrorText(error),
4125
4156
  });
4126
4157
  recordExecutionFailure(error);
@@ -4276,7 +4307,7 @@ async function executeAgentWorkerRequest(input, waitUntil, ownComputer, ownSessi
4276
4307
  // journal. Throwing from onFinish would truncate those chunks and hide
4277
4308
  // both the error and final usage from clients.
4278
4309
  ownSessionOutcome({
4279
- status: input.request.signal.aborted ? "cancelled" : "failed",
4310
+ status: input.request.signal.aborted ? "interrupted" : "failed",
4280
4311
  text: executionErrorText(failure.error, responseMessage.parts
4281
4312
  .flatMap((part) => (part.type === "text" ? [part.text] : []))
4282
4313
  .join("")),
package/dist/gea-ai.js CHANGED
@@ -7,6 +7,7 @@ import { AGENT_MODEL_PROXY_PROTOCOL_VERSION, AGENT_MODEL_PROXY_TRANSPORT, agentC
7
7
  import { mergeLlmJson } from "@gea-ai/contract/llm-catalog";
8
8
  import { wrapLanguageModel } from "ai";
9
9
  import { z } from "zod";
10
+ import { hostRejectionError } from "./host-rejection.js";
10
11
  import { addCacheControlToMessages, addCacheControlToToolList, } from "./cache-control.js";
11
12
  const HOST_PATH = "/internal/agent-worker-host/v1";
12
13
  const jsonObjectSchema = z.record(z.string(), z.json());
@@ -117,7 +118,7 @@ export async function createGeaAI(host, publicModel, extraBody, client, requestB
117
118
  const path = `/model?model=${encodeURIComponent(publicModel)}&includeModelId=true&includeMaxOutputTokens=true&includeCatalogConfig=true`;
118
119
  const response = await host.fetch(`${HOST_PATH}${path}`);
119
120
  if (!response.ok)
120
- throw new Error(`Agent host rejected ${path} (${response.status}).`);
121
+ throw await hostRejectionError(path, response);
121
122
  const descriptor = modelDescriptorSchema.parse(await response.json());
122
123
  if ("transport" in descriptor) {
123
124
  return createHostedGeaLanguageModel({ client, host, publicModel });
@@ -0,0 +1,12 @@
1
+ type HostRejectionDetail = {
2
+ code?: string;
3
+ reason?: string;
4
+ };
5
+ export declare class AgentHostRejectionError extends Error {
6
+ readonly status: number;
7
+ readonly code: string | undefined;
8
+ readonly reason: string | undefined;
9
+ constructor(path: string, status: number, detail: HostRejectionDetail);
10
+ }
11
+ export declare function hostRejectionError(path: string, response: Response): Promise<AgentHostRejectionError>;
12
+ export {};
@@ -0,0 +1,78 @@
1
+ import { Result } from "better-result";
2
+ import { z } from "zod";
3
+ const HOST_REJECTION_DETAIL_LIMIT = 500;
4
+ const detailString = z.preprocess((value) => (typeof value === "string" ? value : undefined), z.string().optional());
5
+ const hostErrorFieldsSchema = z.object({
6
+ code: detailString,
7
+ message: detailString,
8
+ reason: detailString,
9
+ });
10
+ const hostErrorBodySchema = hostErrorFieldsSchema.extend({
11
+ error: z.unknown().optional(),
12
+ });
13
+ function clipHostDetail(value) {
14
+ const trimmed = value.trim();
15
+ if (!trimmed)
16
+ return undefined;
17
+ return trimmed.length > HOST_REJECTION_DETAIL_LIMIT
18
+ ? trimmed.slice(0, HOST_REJECTION_DETAIL_LIMIT)
19
+ : trimmed;
20
+ }
21
+ function readFields(value) {
22
+ const parsed = hostErrorFieldsSchema.safeParse(value);
23
+ if (!parsed.success)
24
+ return {};
25
+ return {
26
+ code: clipHostDetail(parsed.data.code ?? ""),
27
+ reason: clipHostDetail(parsed.data.reason ?? "") ??
28
+ clipHostDetail(parsed.data.message ?? ""),
29
+ };
30
+ }
31
+ function detailFromText(body) {
32
+ const trimmed = body.trim();
33
+ if (!trimmed)
34
+ return {};
35
+ const parsed = Result.try(() => JSON.parse(trimmed));
36
+ if (parsed.isErr()) {
37
+ const reason = clipHostDetail(trimmed);
38
+ return reason ? { reason } : {};
39
+ }
40
+ const bodyFields = hostErrorBodySchema.safeParse(parsed.value);
41
+ if (!bodyFields.success)
42
+ return {};
43
+ const nested = readFields(bodyFields.data.error);
44
+ const top = readFields(parsed.value);
45
+ return {
46
+ code: nested.code ?? top.code,
47
+ reason: nested.reason ?? top.reason,
48
+ };
49
+ }
50
+ async function readHostRejectionDetail(response) {
51
+ const body = await Result.tryPromise(() => response.text());
52
+ if (body.isErr())
53
+ return {};
54
+ return detailFromText(body.value);
55
+ }
56
+ function hostRejectionMessage(path, status, detail) {
57
+ const summary = detail.code && detail.reason
58
+ ? `${detail.code} - ${detail.reason}`
59
+ : (detail.code ?? detail.reason);
60
+ return summary
61
+ ? `Agent host rejected ${path} (${status}): ${summary}`
62
+ : `Agent host rejected ${path} (${status}).`;
63
+ }
64
+ export class AgentHostRejectionError extends Error {
65
+ status;
66
+ code;
67
+ reason;
68
+ constructor(path, status, detail) {
69
+ super(hostRejectionMessage(path, status, detail));
70
+ this.name = "AgentHostRejectionError";
71
+ this.status = status;
72
+ this.code = detail.code;
73
+ this.reason = detail.reason;
74
+ }
75
+ }
76
+ export async function hostRejectionError(path, response) {
77
+ return new AgentHostRejectionError(path, response.status, await readHostRejectionDetail(response));
78
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const agentSdkVersion = "0.1.261009-alpha.1";
1
+ export declare const agentSdkVersion = "0.1.261010-alpha.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-sdk-version.mjs from package.json.
2
- export const agentSdkVersion = "0.1.261009-alpha.1";
2
+ export const agentSdkVersion = "0.1.261010-alpha.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gea-ai/agent-sdk",
3
- "version": "0.1.261009-alpha.1",
3
+ "version": "0.1.261010-alpha.0",
4
4
  "private": false,
5
5
  "homepage": "https://musegea.com/developers",
6
6
  "license": "Apache-2.0",
@@ -119,7 +119,7 @@
119
119
  "@ai-sdk/otel": "1.0.119",
120
120
  "@ai-sdk/provider": "4.0.19",
121
121
  "@ai-sdk/provider-utils": "5.0.50",
122
- "@gea-ai/contract": "0.1.261009-alpha.1",
122
+ "@gea-ai/contract": "0.1.261010-alpha.0",
123
123
  "@opentelemetry/api": "1.9.1",
124
124
  "@opentelemetry/context-async-hooks": "2.10.0",
125
125
  "@opentelemetry/core": "2.10.0",