@codemation/core-nodes 0.8.1 → 0.10.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.
@@ -1,10 +1,22 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "@codemation/core-nodes",
4
- "packageVersion": "0.8.1",
4
+ "packageVersion": "0.10.0",
5
5
  "description": "",
6
6
  "kind": "nodes",
7
7
  "nodes": [
8
+ {
9
+ "name": "Codemation Document Scanner",
10
+ "kind": "node",
11
+ "description": "",
12
+ "inputPorts": [
13
+ "main"
14
+ ],
15
+ "outputPorts": [
16
+ "main"
17
+ ],
18
+ "sourcePath": "src/nodes/codemationDocumentScannerNode.ts"
19
+ },
8
20
  {
9
21
  "name": "Collection: Delete",
10
22
  "kind": "node",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemation/core-nodes",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -32,7 +32,7 @@
32
32
  "@ai-sdk/provider": "^3.0.8",
33
33
  "ai": "^6.0.168",
34
34
  "croner": "^10.0.1",
35
- "@codemation/core": "0.11.1"
35
+ "@codemation/core": "0.13.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^25.3.5",
@@ -43,5 +43,5 @@ export class CodemationChatModelConfig implements ChatModelConfig {
43
43
  this.presentation = presentationIn ?? { icon: "lucide:bot", label: name };
44
44
  }
45
45
 
46
- // No getCredentialRequirements() — authentication is implicit via workspace pairing secret (D2).
46
+ // No getCredentialRequirements() — authentication is implicit via workspace pairing secret.
47
47
  }
@@ -1,10 +1,10 @@
1
- import { createHmac, createHash, randomBytes } from "node:crypto";
2
1
  import type { ChatLanguageModel, ChatModelFactory, NodeExecutionContext } from "@codemation/core";
3
2
  import { chatModel } from "@codemation/core";
4
3
 
5
4
  import { createOpenAI } from "@ai-sdk/openai";
6
5
 
7
6
  import type { CodemationChatModelConfig } from "./CodemationChatModelConfig";
7
+ import { managedHmacFetchFactory } from "./ManagedHmacSignerFactory.types";
8
8
 
9
9
  @chatModel({ packageName: "@codemation/core-nodes" })
10
10
  export class CodemationChatModelFactory implements ChatModelFactory<CodemationChatModelConfig> {
@@ -26,7 +26,7 @@ export class CodemationChatModelFactory implements ChatModelFactory<CodemationCh
26
26
  throw new Error("Codemation managed AI not available in this environment (workspace pairing is not configured).");
27
27
  }
28
28
 
29
- const hmacFetch = this.buildHmacSignedFetch(workspaceId, pairingSecret);
29
+ const hmacFetch = managedHmacFetchFactory(workspaceId, pairingSecret);
30
30
  // apiKey is required by the AI SDK but unused — authentication is handled by the HMAC-signed fetch wrapper.
31
31
  const provider = createOpenAI({ baseURL: `${gatewayUrl}/v1`, apiKey: "codemation-managed", fetch: hmacFetch });
32
32
  const languageModel = provider.chat(args.config.model);
@@ -41,63 +41,4 @@ export class CodemationChatModelFactory implements ChatModelFactory<CodemationCh
41
41
  },
42
42
  });
43
43
  }
44
-
45
- /**
46
- * Creates an HMAC-signed fetch wrapper for use with AI SDK's createOpenAI.
47
- * Each call signs the request body with the workspace pairing secret so the
48
- * LLM broker can authenticate the workspace without a user-managed API key.
49
- *
50
- * Mirrors HmacRequestSigner from @codemation/host/pairing without importing
51
- * that package (which would create a circular dependency since @codemation/host
52
- * depends on @codemation/core-nodes).
53
- */
54
- private buildHmacSignedFetch(workspaceId: string, pairingSecret: string): typeof fetch {
55
- return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
56
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
57
- const method = init?.method ?? "POST";
58
-
59
- // Normalise body to a string for signing. Chat completions are always JSON strings
60
- // but the fetch spec allows other BodyInit types — handle those defensively.
61
- let bodyString = "";
62
- if (init?.body !== undefined && init.body !== null) {
63
- if (typeof init.body === "string") {
64
- bodyString = init.body;
65
- } else {
66
- bodyString = await new Response(init.body).text();
67
- }
68
- }
69
-
70
- const authHeader = this.buildHmacAuthHeader(workspaceId, pairingSecret, method, url, bodyString);
71
-
72
- const headers = new Headers(init?.headers as Record<string, string> | undefined);
73
- headers.set("Authorization", authHeader);
74
-
75
- // Use the same (possibly normalised) body string that was signed.
76
- const effectiveBody = bodyString || init?.body;
77
- return fetch(input, { ...init, body: effectiveBody, headers });
78
- };
79
- }
80
-
81
- /**
82
- * Produces a Codemation-Hmac v1 Authorization header value.
83
- * The algorithm must match HmacVerifier.computeSignature() in the control-plane.
84
- */
85
- private buildHmacAuthHeader(
86
- workspaceId: string,
87
- pairingSecret: string,
88
- method: string,
89
- url: string,
90
- body: string,
91
- ): string {
92
- const ts = Math.floor(Date.now() / 1000);
93
- const nonce = randomBytes(16).toString("base64");
94
- const parsed = new URL(url);
95
- const path = (parsed.pathname + parsed.search).toLowerCase();
96
- const bodyHash = createHash("sha256").update(body, "utf8").digest("hex");
97
- const baseString = [method.toUpperCase(), path, ts, nonce, bodyHash].join("\n");
98
- // eslint-disable-next-line codemation/no-buffer-everything -- pairing secret is a fixed 32-byte value, never large
99
- const secretBytes = Buffer.from(pairingSecret, "base64");
100
- const sig = createHmac("sha256", secretBytes).update(baseString, "utf8").digest("base64");
101
- return `Codemation-Hmac v=1,workspaceId=${workspaceId},ts=${ts},nonce=${nonce},sig=${sig}`;
102
- }
103
44
  }
@@ -0,0 +1,88 @@
1
+ import { createHmac, createHash, randomBytes } from "node:crypto";
2
+
3
+ export interface ManagedHmacSignerOptions {
4
+ /**
5
+ * When true (the default), the signer hashes the request body and includes
6
+ * the hash in the HMAC base string. Use for small JSON payloads (LLM chat).
7
+ *
8
+ * When false (doc-scanner / LD11 mode), the signer computes the signature
9
+ * over sha256("") regardless of the actual body bytes, and forwards those
10
+ * bytes to the upstream fetch untouched. The HMAC binds the workspace
11
+ * identity, not the file content — enabling streaming without buffering.
12
+ */
13
+ signBody?: boolean;
14
+ /** Override wall-clock seconds for deterministic testing. */
15
+ now?: () => number;
16
+ /** Override nonce generation for deterministic testing. */
17
+ nonce?: () => string;
18
+ }
19
+
20
+ /**
21
+ * Creates an HMAC-signing fetch wrapper that authenticates requests to
22
+ * Codemation managed services (LLM broker, doc-scanner) with the
23
+ * Codemation-Hmac v=1 scheme.
24
+ *
25
+ * Mirrors HmacRequestSigner from @codemation/host/pairing without importing
26
+ * that package (which would create a circular dependency since @codemation/host
27
+ * depends on @codemation/core-nodes).
28
+ *
29
+ * @param workspaceId - Workspace identifier injected by the CP provisioner.
30
+ * @param pairingSecret - Base64-encoded 32-byte HMAC key injected by the provisioner.
31
+ * @param options - Optional behaviour flags and test seams.
32
+ */
33
+ export function managedHmacFetchFactory(
34
+ workspaceId: string,
35
+ pairingSecret: string,
36
+ options?: ManagedHmacSignerOptions,
37
+ ): typeof fetch {
38
+ // LLM chat (the existing caller) signs its small JSON body → signBody defaults true.
39
+ // The doc-scanner node passes signBody:false (LD11): the HMAC binds the WORKSPACE,
40
+ // not the file, so we never read/normalise the (possibly large, binary) body —
41
+ // it is forwarded untouched and the signature covers an empty body hash.
42
+ const signBody = options?.signBody ?? true;
43
+
44
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
45
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
46
+ const method = init?.method ?? "POST";
47
+
48
+ let bodyForSigning = "";
49
+ if (signBody && init?.body !== undefined && init.body !== null) {
50
+ bodyForSigning = typeof init.body === "string" ? init.body : await new Response(init.body).text();
51
+ }
52
+
53
+ const authHeader = buildHmacAuthHeader(workspaceId, pairingSecret, method, url, bodyForSigning, options);
54
+
55
+ const headers = new Headers(init?.headers as Record<string, string> | undefined);
56
+ headers.set("Authorization", authHeader);
57
+
58
+ // When signing the body, forward the (possibly normalised) string.
59
+ // When not signing (signBody:false), forward the ORIGINAL body untouched
60
+ // so binary/streamed payloads are never buffered or re-encoded.
61
+ const outgoingBody = signBody ? bodyForSigning || init?.body : init?.body;
62
+ return fetch(input, { ...init, body: outgoingBody, headers });
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Produces a Codemation-Hmac v=1 Authorization header value.
68
+ * Algorithm must match HmacVerifier.computeSignature() in the control-plane.
69
+ */
70
+ function buildHmacAuthHeader(
71
+ workspaceId: string,
72
+ pairingSecret: string,
73
+ method: string,
74
+ url: string,
75
+ body: string,
76
+ overrides?: Pick<ManagedHmacSignerOptions, "now" | "nonce">,
77
+ ): string {
78
+ const ts = overrides?.now ? overrides.now() : Math.floor(Date.now() / 1000);
79
+ const nonce = overrides?.nonce ? overrides.nonce() : randomBytes(16).toString("base64");
80
+ const parsed = new URL(url);
81
+ const path = (parsed.pathname + parsed.search).toLowerCase();
82
+ const bodyHash = createHash("sha256").update(body, "utf8").digest("hex");
83
+ const baseString = [method.toUpperCase(), path, ts, nonce, bodyHash].join("\n");
84
+ // eslint-disable-next-line codemation/no-buffer-everything -- pairing secret is a fixed 32-byte value, never large
85
+ const secretBytes = Buffer.from(pairingSecret, "base64");
86
+ const sig = createHmac("sha256", secretBytes).update(baseString, "utf8").digest("base64");
87
+ return `Codemation-Hmac v=1,workspaceId=${workspaceId},ts=${ts},nonce=${nonce},sig=${sig}`;
88
+ }
package/src/index.ts CHANGED
@@ -43,3 +43,5 @@ export * from "./nodes/ConnectionCredentialNodeConfig";
43
43
  export * from "./nodes/ConnectionCredentialNodeConfigFactory";
44
44
  export * from "./nodes/ConnectionCredentialExecutionContextFactory";
45
45
  export * from "./nodes/collections";
46
+ export * from "./nodes/InboxApprovalNode.types";
47
+ export * from "./nodes/codemationDocumentScannerNode";
@@ -46,7 +46,7 @@ import { Output, generateText, jsonSchema } from "ai";
46
46
  type AnyGenerateTextResult = GenerateTextResult<ToolSet, any>;
47
47
  import { z } from "zod";
48
48
 
49
- import type { AgentMcpIntegration, AgentMcpToolMap } from "@codemation/core";
49
+ import type { AgentMcpIntegration, AgentMcpToolMap, ResumeContext } from "@codemation/core";
50
50
  import type { AIAgent } from "./AIAgentConfig";
51
51
  import { AIAgentExecutionHelpersFactory } from "./AIAgentExecutionHelpersFactory";
52
52
  import { AgentToolExecutionCoordinator } from "./AgentToolExecutionCoordinator";
@@ -65,6 +65,10 @@ import {
65
65
  type PlannedToolCall,
66
66
  type ResolvedTool,
67
67
  } from "./aiAgentSupport.types";
68
+ import type { AgentLoopCheckpoint } from "./AgentLoopCheckpoint.types";
69
+
70
+ const HITL_SOLO_CONSTRAINT_SENTENCE =
71
+ "This tool requires human approval and may take time. Call it alone — do not invoke other tools in the same turn. Your turn will be paused until a decision is made.";
68
72
 
69
73
  type ResolvedGuardrails = Required<Pick<AgentGuardrailConfig, "maxTurns" | "onTurnLimitReached">> &
70
74
  Pick<AgentGuardrailConfig, "modelInvocationOptions">;
@@ -130,12 +134,120 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
130
134
  }
131
135
 
132
136
  async execute(args: RunnableNodeExecuteArgs<AIAgent<any, any>>): Promise<unknown> {
133
- const prepared = await this.getOrPrepareExecution(args.ctx);
137
+ const { ctx } = args;
138
+
139
+ // HITL resume branch (story 10): the engine re-activates us after a human decision.
140
+ if (ctx.resumeContext) {
141
+ return this.executeResumed(args, ctx.resumeContext);
142
+ }
143
+
144
+ const prepared = await this.getOrPrepareExecution(ctx);
134
145
  const itemWithMappedJson = { ...args.item, json: args.input };
135
146
  const resultItem = await this.runAgentForItem(prepared, itemWithMappedJson, args.itemIndex, args.items);
136
147
  return resultItem.json;
137
148
  }
138
149
 
150
+ /**
151
+ * Resume path: re-enters the agent loop after a HITL suspension.
152
+ * Reconstructs the conversation from the checkpoint, injects the human decision
153
+ * as a tool_result, and continues the loop from where it suspended.
154
+ */
155
+ private async executeResumed(
156
+ args: RunnableNodeExecuteArgs<AIAgent<any, any>>,
157
+ resumeContext: ResumeContext,
158
+ ): Promise<unknown> {
159
+ const { ctx } = args;
160
+ const taskMetadata = resumeContext.task.metadata ?? {};
161
+ const checkpoint = taskMetadata["agentCheckpoint"] as AgentLoopCheckpoint | undefined;
162
+ const onRejected = (taskMetadata["onRejected"] as "halt" | "return" | undefined) ?? "return";
163
+
164
+ if (!checkpoint) {
165
+ // Not an agent-HITL resume (e.g., a direct HITL node, not wrapped in agent). Fall through.
166
+ const prepared = await this.getOrPrepareExecution(ctx);
167
+ const itemWithMappedJson = { ...args.item, json: args.input };
168
+ const resultItem = await this.runAgentForItem(prepared, itemWithMappedJson, args.itemIndex, args.items);
169
+ return resultItem.json;
170
+ }
171
+
172
+ // If rejected with halt policy, the engine has already halted; return gracefully.
173
+ if (resumeContext.decision.kind === "decided" && resumeContext.decision.value === null && onRejected === "halt") {
174
+ return undefined;
175
+ }
176
+
177
+ const decision = this.normalizeDecision(resumeContext);
178
+
179
+ if (decision.status === "rejected" && onRejected === "halt") {
180
+ // Engine halts the run. Return nothing — the run is dead.
181
+ return undefined;
182
+ }
183
+
184
+ const prepared = await this.getOrPrepareExecution(ctx);
185
+ const item = args.item;
186
+ const itemInputsByPort = AgentItemPortMap.fromItem(item);
187
+ const itemScopedTools = this.createItemScopedTools(prepared.resolvedTools, ctx, item, args.itemIndex, args.items);
188
+
189
+ // Reconstruct conversation: checkpoint.conversation already includes the assistant message
190
+ // with the pending tool_use. Append the tool_result for the decision.
191
+ const toolResultEntry: ExecutedToolCall = {
192
+ toolName: checkpoint.pendingToolCallId,
193
+ toolCallId: checkpoint.pendingToolCallId,
194
+ result: decision,
195
+ serialized: JSON.stringify(decision),
196
+ };
197
+ const conversation: ModelMessage[] = [
198
+ ...checkpoint.conversation,
199
+ AgentMessageFactory.createToolResultsMessage([toolResultEntry]),
200
+ ];
201
+
202
+ const loopResult = await this.runTurnLoopUntilFinalAnswer({
203
+ prepared,
204
+ itemInputsByPort,
205
+ itemScopedTools,
206
+ conversation,
207
+ resumedTurnCount: checkpoint.turnCount,
208
+ resumedToolCallCount: checkpoint.toolCallCount,
209
+ });
210
+ await ctx.telemetry.recordMetric({ name: CodemationTelemetryMetricNames.agentTurns, value: loopResult.turnCount });
211
+ await ctx.telemetry.recordMetric({
212
+ name: CodemationTelemetryMetricNames.agentToolCalls,
213
+ value: loopResult.toolCallCount,
214
+ });
215
+ const outputJson = await this.resolveFinalOutputJson(
216
+ prepared,
217
+ itemInputsByPort,
218
+ conversation,
219
+ loopResult.finalText,
220
+ itemScopedTools.length > 0,
221
+ );
222
+ return this.buildOutputItem(item, outputJson).json;
223
+ }
224
+
225
+ /**
226
+ * Normalizes a {@link ResumeContext} decision into a flat JSON-serializable shape
227
+ * suitable for injection as a tool_result content.
228
+ */
229
+ private normalizeDecision(resumeContext: ResumeContext): Record<string, unknown> {
230
+ const { decision } = resumeContext;
231
+ if (decision.kind === "decided") {
232
+ const value = decision.value as Record<string, unknown> | null | undefined;
233
+ // Convention: the decision schema for an approval tool has { approved: boolean, note?: string }.
234
+ // The status is "approved" when approved === true, otherwise "rejected".
235
+ const isApproved =
236
+ typeof value === "object" && value !== null && "approved" in value ? Boolean(value["approved"]) : true;
237
+ return {
238
+ status: isApproved ? "approved" : "rejected",
239
+ value: decision.value,
240
+ actor: decision.actor,
241
+ decidedAt: decision.decidedAt.toISOString(),
242
+ };
243
+ }
244
+ if (decision.kind === "timed_out") {
245
+ return { status: "timed_out", at: decision.at.toISOString() };
246
+ }
247
+ // auto_accepted
248
+ return { status: "auto_accepted", at: decision.at.toISOString() };
249
+ }
250
+
139
251
  private async getOrPrepareExecution(ctx: NodeExecutionContext<AIAgent<any, any>>): Promise<PreparedAgentExecution> {
140
252
  let pending = this.preparedByExecutionContext.get(ctx);
141
253
  if (!pending) {
@@ -272,12 +384,16 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
272
384
  itemInputsByPort: NodeInputsByPort;
273
385
  itemScopedTools: ReadonlyArray<ItemScopedToolBinding>;
274
386
  conversation: ModelMessage[];
387
+ /** When resuming from HITL suspension, the turn count at the point of suspension. */
388
+ resumedTurnCount?: number;
389
+ /** When resuming from HITL suspension, the tool-call count at the point of suspension. */
390
+ resumedToolCallCount?: number;
275
391
  }): Promise<Readonly<{ finalText: string; turnCount: number; toolCallCount: number }>> {
276
392
  const { prepared, itemInputsByPort, itemScopedTools, conversation } = args;
277
393
  const { ctx, guardrails, toolLoadingStrategy } = prepared;
278
394
 
279
395
  let finalText = "";
280
- let toolCallCount = 0;
396
+ let toolCallCount = args.resumedToolCallCount ?? 0;
281
397
  let turnCount = 0;
282
398
  const repairAttemptsByToolName = new Map<string, number>();
283
399
  /** Tool IDs surfaced by find_tools across all prior turns in this item run. */
@@ -335,11 +451,20 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
335
451
  const plannedToolCalls = this.planToolCalls(itemScopedTools, coordinatorCalls, ctx.nodeId);
336
452
  toolCallCount += plannedToolCalls.length;
337
453
  await this.markQueuedTools(plannedToolCalls, ctx);
454
+ // Snapshot conversation with the assistant message appended — this is the checkpoint
455
+ // conversation the agent coordinator stores if a HITL tool suspends the run.
456
+ const assistantMsg =
457
+ result.assistantMessage ?? AgentMessageFactory.createAssistantWithToolCalls(result.text, result.toolCalls);
458
+ const conversationWithAssistant: ModelMessage[] = [...conversation, assistantMsg];
338
459
  const executed = await this.toolExecutionCoordinator.execute({
339
460
  plannedToolCalls,
340
461
  ctx,
341
462
  agentName: this.getAgentDisplayName(ctx),
342
463
  repairAttemptsByToolName,
464
+ conversationSnapshot: conversationWithAssistant,
465
+ turnCount,
466
+ toolCallCount,
467
+ modelId: this.resolveChatModelName(ctx.config.chatModel),
343
468
  });
344
469
  coordinatorExecutedCalls.push(...executed);
345
470
  }
@@ -448,7 +573,8 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
448
573
  connectionNodeId: ConnectionNodeIdFactory.toolConnectionNodeId(ctx.nodeId, entry.config.name),
449
574
  getCredentialRequirements: () => entry.config.getCredentialRequirements?.() ?? [],
450
575
  });
451
- return {
576
+ const hitlBehavior = this.resolveHumanApprovalBehavior(entry.config);
577
+ const binding: ItemScopedToolBinding = {
452
578
  config: entry.config,
453
579
  inputSchema: entry.runtime.inputSchema,
454
580
  execute: async (input, hooks): Promise<unknown> => {
@@ -463,10 +589,24 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
463
589
  hooks,
464
590
  });
465
591
  },
466
- } satisfies ItemScopedToolBinding;
592
+ ...(hitlBehavior !== undefined ? { humanApproval: hitlBehavior } : {}),
593
+ };
594
+ return binding;
467
595
  });
468
596
  }
469
597
 
598
+ /**
599
+ * Detects whether a tool config is backed by a `defineHumanApprovalNode` marker
600
+ * and returns the HITL behavior config, or `undefined` when not a HITL tool.
601
+ */
602
+ private resolveHumanApprovalBehavior(config: ToolConfig): Readonly<{ onRejected: "halt" | "return" }> | undefined {
603
+ if (!this.isNodeBackedToolConfig(config)) return undefined;
604
+ const nodeConfig = config.node as unknown as { humanApprovalToolBehavior?: { onRejected?: "halt" | "return" } };
605
+ const marker = nodeConfig.humanApprovalToolBehavior;
606
+ if (marker === undefined) return undefined;
607
+ return { onRejected: marker.onRejected ?? "return" };
608
+ }
609
+
470
610
  /**
471
611
  * Invoke a text turn using the merged tool set from item-scoped tools (coordinator-managed)
472
612
  * and strategy tools (find_tools + discovered MCP tools).
@@ -505,6 +645,8 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
505
645
  /**
506
646
  * Builds a ToolSet from resolved tools for strategy initialization.
507
647
  * The strategy uses this for its "always-included" node-backed tool descriptions.
648
+ * HITL tools (detected via the `humanApprovalToolBehavior` field set by `defineHumanApprovalNode`) get the solo-constraint sentence
649
+ * appended to their description.
508
650
  */
509
651
  private buildToolSetFromResolved(resolvedTools: ReadonlyArray<ResolvedTool>): ToolSet {
510
652
  if (resolvedTools.length === 0) return {};
@@ -514,8 +656,11 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
514
656
  schemaName: entry.config.name,
515
657
  requireObjectRoot: true,
516
658
  });
659
+ const baseDescription = entry.config.description ?? entry.runtime.defaultDescription;
660
+ const isHitl = this.resolveHumanApprovalBehavior(entry.config) !== undefined;
661
+ const description = isHitl ? `${baseDescription} ${HITL_SOLO_CONSTRAINT_SENTENCE}` : baseDescription;
517
662
  toolSet[entry.config.name] = {
518
- description: entry.config.description ?? entry.runtime.defaultDescription,
663
+ description,
519
664
  inputSchema: jsonSchema(schemaRecord as Parameters<typeof jsonSchema>[0]),
520
665
  };
521
666
  }
@@ -544,8 +689,15 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
544
689
  schemaName: entry.config.name,
545
690
  requireObjectRoot: true,
546
691
  });
692
+ const baseDescription = entry.config.description;
693
+ const description =
694
+ entry.humanApproval !== undefined && baseDescription !== undefined
695
+ ? `${baseDescription} ${HITL_SOLO_CONSTRAINT_SENTENCE}`
696
+ : entry.humanApproval !== undefined
697
+ ? HITL_SOLO_CONSTRAINT_SENTENCE
698
+ : baseDescription;
547
699
  toolSet[entry.config.name] = {
548
- description: entry.config.description,
700
+ description,
549
701
  inputSchema: jsonSchema(schemaRecord as Parameters<typeof jsonSchema>[0]),
550
702
  };
551
703
  }
@@ -715,10 +867,20 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
715
867
  });
716
868
  try {
717
869
  const callOptions = this.resolveCallOptions(model, guardrails.modelInvocationOptions);
718
- const outputSchema =
719
- structuredOptions?.strict && !this.isZodSchema(schema)
720
- ? Output.object({ schema: jsonSchema(schema as Parameters<typeof jsonSchema>[0]) as never })
721
- : Output.object({ schema: schema as ZodSchemaAny });
870
+ // Always feed the AI SDK a plain JSON Schema, never a raw Zod schema. A consumer
871
+ // workflow's outputSchema is created with the consumer's tsx-loaded Zod — a different
872
+ // runtime copy than the framework's Zod so handing that object to `Output.object`
873
+ // throws "schema is not a function". Convert via the schema's own instance method
874
+ // (dual-zod safe; see AIAgentExecutionHelpersFactory) before wrapping with jsonSchema().
875
+ const schemaRecord = this.isZodSchema(schema)
876
+ ? this.executionHelpers.createJsonSchemaRecord(schema, {
877
+ schemaName: structuredOptions?.schemaName ?? "structured_output",
878
+ requireObjectRoot: true,
879
+ })
880
+ : schema;
881
+ const outputSchema = Output.object({
882
+ schema: jsonSchema(schemaRecord as Parameters<typeof jsonSchema>[0]) as never,
883
+ });
722
884
  const result = await generateText({
723
885
  model: model.languageModel as LanguageModel,
724
886
  messages: [...messages],
@@ -0,0 +1,23 @@
1
+ import type { ModelMessage } from "ai";
2
+
3
+ /**
4
+ * Snapshot of the agent loop state at the moment of HITL suspension.
5
+ * Serialized as JSON and stored on `SuspensionRequest.request.metadata.agentCheckpoint`
6
+ * so the resumed node can reconstruct and continue the conversation.
7
+ *
8
+ * Defined here (story 10) and consumed in `AIAgentNode` resume branch.
9
+ */
10
+ export type AgentLoopCheckpoint = Readonly<{
11
+ /** Full conversation history up to and including the assistant message that emitted tool_use. */
12
+ conversation: ModelMessage[];
13
+ /** Turn count at the point of suspension (1-based, matches loop counter in runTurnLoopUntilFinalAnswer). */
14
+ turnCount: number;
15
+ /** Total tool-call count accumulated before suspension. */
16
+ toolCallCount: number;
17
+ /** The tool_use id that triggered suspension; matched against the tool_result on resume. */
18
+ pendingToolCallId: string;
19
+ /** Display name of the agent (for logging / telemetry continuity). */
20
+ agentName: string;
21
+ /** Model identifier carried for migration-safety redundancy. */
22
+ modelId: string;
23
+ }>;
@@ -1,5 +1,6 @@
1
1
  import type { JsonValue, NodeExecutionContext } from "@codemation/core";
2
- import { CodemationTelemetryAttributeNames, inject, injectable } from "@codemation/core";
2
+ import { CodemationTelemetryAttributeNames, SuspensionRequest, inject, injectable } from "@codemation/core";
3
+ import type { ModelMessage } from "ai";
3
4
 
4
5
  import type { AIAgent } from "./AIAgentConfig";
5
6
  import { AgentOutputFactory } from "./AgentOutputFactory";
@@ -9,6 +10,7 @@ import { AgentToolRepairExhaustedError } from "./AgentToolRepairExhaustedError";
9
10
  import { AgentToolRepairPolicy } from "./AgentToolRepairPolicy";
10
11
  import type { AgentToolRepairDecision, AgentToolValidationIssue } from "./AgentToolRepair.types";
11
12
  import type { ExecutedToolCall, PlannedToolCall } from "./aiAgentSupport.types";
13
+ import type { AgentLoopCheckpoint } from "./AgentLoopCheckpoint.types";
12
14
 
13
15
  @injectable()
14
16
  export class AgentToolExecutionCoordinator {
@@ -25,8 +27,38 @@ export class AgentToolExecutionCoordinator {
25
27
  ctx: NodeExecutionContext<AIAgent<any, any>>;
26
28
  agentName: string;
27
29
  repairAttemptsByToolName: Map<string, number>;
30
+ /** Conversation including the assistant message that emitted these tool_use blocks. Stored in checkpoint on HITL suspension. */
31
+ conversationSnapshot?: ReadonlyArray<ModelMessage>;
32
+ /** Turn count at the moment of this coordinator invocation. */
33
+ turnCount?: number;
34
+ /** Cumulative tool-call count up to and including this batch. */
35
+ toolCallCount?: number;
36
+ /** Model id for checkpoint migration safety. */
37
+ modelId?: string;
28
38
  }>,
29
39
  ): Promise<ReadonlyArray<ExecutedToolCall>> {
40
+ // Solo enforcement: if any HITL tool appears alongside other tools, return error results
41
+ // for all calls so the model self-corrects on the next turn.
42
+ const hitlCalls = args.plannedToolCalls.filter((c) => c.binding.humanApproval !== undefined);
43
+ if (hitlCalls.length > 0 && args.plannedToolCalls.length > 1) {
44
+ return args.plannedToolCalls.map((c) => ({
45
+ toolName: c.binding.config.name,
46
+ toolCallId: c.toolCall.id ?? c.binding.config.name,
47
+ result: {
48
+ error:
49
+ c.binding.humanApproval !== undefined
50
+ ? `HITL tool '${c.binding.config.name}' cannot be called alongside other tools in the same turn; call it alone.`
51
+ : `deferred: a HITL tool in the same turn blocked execution. Retry this tool alone in the next turn.`,
52
+ },
53
+ serialized: JSON.stringify({
54
+ error:
55
+ c.binding.humanApproval !== undefined
56
+ ? `HITL tool '${c.binding.config.name}' cannot be called alongside other tools in the same turn; call it alone.`
57
+ : `deferred: a HITL tool in the same turn blocked execution. Retry this tool alone in the next turn.`,
58
+ }),
59
+ }));
60
+ }
61
+
30
62
  const results = await Promise.allSettled(
31
63
  args.plannedToolCalls.map(
32
64
  async (plannedToolCall) => await this.executePlannedToolCall({ ...args, plannedToolCall }),
@@ -35,7 +67,10 @@ export class AgentToolExecutionCoordinator {
35
67
 
36
68
  const rejected = results.find((result) => result.status === "rejected");
37
69
  if (rejected?.status === "rejected") {
38
- throw rejected.reason instanceof Error ? rejected.reason : new Error(String(rejected.reason));
70
+ const reason = rejected.reason;
71
+ // Preserve SuspensionRequest (not an Error subclass) before falling back to Error wrapping.
72
+ if (reason instanceof SuspensionRequest) throw reason;
73
+ throw reason instanceof Error ? reason : new Error(String(reason));
39
74
  }
40
75
 
41
76
  return results
@@ -49,6 +84,10 @@ export class AgentToolExecutionCoordinator {
49
84
  ctx: NodeExecutionContext<AIAgent<any, any>>;
50
85
  agentName: string;
51
86
  repairAttemptsByToolName: Map<string, number>;
87
+ conversationSnapshot?: ReadonlyArray<ModelMessage>;
88
+ turnCount?: number;
89
+ toolCallCount?: number;
90
+ modelId?: string;
52
91
  }>,
53
92
  ): Promise<ExecutedToolCall> {
54
93
  const { plannedToolCall, ctx } = args;
@@ -134,6 +173,32 @@ export class AgentToolExecutionCoordinator {
134
173
  result,
135
174
  } satisfies ExecutedToolCall;
136
175
  } catch (error) {
176
+ // D1: Suspension catch — intercept before error classifier, augment with agent checkpoint.
177
+ if (error instanceof SuspensionRequest) {
178
+ const pendingToolCallId = plannedToolCall.toolCall.id ?? plannedToolCall.binding.config.name;
179
+ const checkpoint: AgentLoopCheckpoint = {
180
+ conversation: args.conversationSnapshot ? [...args.conversationSnapshot] : [],
181
+ turnCount: args.turnCount ?? 0,
182
+ toolCallCount: args.toolCallCount ?? 0,
183
+ pendingToolCallId,
184
+ agentName: args.agentName,
185
+ modelId: args.modelId ?? "",
186
+ };
187
+ const agentReasoning = this.extractLastAssistantText(args.conversationSnapshot ?? []);
188
+ const augmented = new SuspensionRequest({
189
+ ...error.request,
190
+ metadata: {
191
+ ...error.request.metadata,
192
+ agentCheckpoint: checkpoint as unknown as JsonValue,
193
+ pendingToolCallId: pendingToolCallId as JsonValue,
194
+ agentReasoning: agentReasoning as JsonValue,
195
+ onRejected: (plannedToolCall.binding.humanApproval?.onRejected ?? "return") as JsonValue,
196
+ },
197
+ });
198
+ await span.end({ status: "error", statusMessage: "suspended", endedAt: new Date() });
199
+ throw augmented;
200
+ }
201
+
137
202
  const classification = this.errorClassifier.classify({
138
203
  error,
139
204
  toolName: plannedToolCall.binding.config.name,
@@ -362,6 +427,30 @@ export class AgentToolExecutionCoordinator {
362
427
  return candidate.details;
363
428
  }
364
429
 
430
+ /**
431
+ * Extracts the text content from the last assistant message in the conversation snapshot.
432
+ * Used to populate `agentReasoning` in the HITL suspension metadata.
433
+ */
434
+ private extractLastAssistantText(conversation: ReadonlyArray<ModelMessage>): string {
435
+ for (let i = conversation.length - 1; i >= 0; i--) {
436
+ const msg = conversation[i];
437
+ if (msg?.role !== "assistant") continue;
438
+ const content = msg.content;
439
+ if (typeof content === "string") return content;
440
+ if (Array.isArray(content)) {
441
+ const textParts = content
442
+ .filter(
443
+ (part): part is { type: "text"; text: string } =>
444
+ typeof part === "object" && (part as { type?: unknown }).type === "text",
445
+ )
446
+ .map((part) => part.text);
447
+ if (textParts.length > 0) return textParts.join("");
448
+ }
449
+ break;
450
+ }
451
+ return "";
452
+ }
453
+
365
454
  private serializeIssue(issue: AgentToolValidationIssue): JsonValue {
366
455
  const result: Record<string, JsonValue> = {
367
456
  path: [...issue.path],