@codemation/core-nodes 0.8.0 → 0.9.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,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "@codemation/core-nodes",
4
- "packageVersion": "0.8.0",
4
+ "packageVersion": "0.9.0",
5
5
  "description": "",
6
6
  "kind": "nodes",
7
7
  "nodes": [
@@ -15,7 +15,7 @@
15
15
  "outputPorts": [
16
16
  "main"
17
17
  ],
18
- "sourcePath": "src\\nodes\\collections\\collectionDeleteNode.types.ts"
18
+ "sourcePath": "src/nodes/collections/collectionDeleteNode.types.ts"
19
19
  },
20
20
  {
21
21
  "name": "Collection: Find One",
@@ -27,7 +27,7 @@
27
27
  "outputPorts": [
28
28
  "main"
29
29
  ],
30
- "sourcePath": "src\\nodes\\collections\\collectionFindOneNode.types.ts"
30
+ "sourcePath": "src/nodes/collections/collectionFindOneNode.types.ts"
31
31
  },
32
32
  {
33
33
  "name": "Collection: Get",
@@ -39,7 +39,7 @@
39
39
  "outputPorts": [
40
40
  "main"
41
41
  ],
42
- "sourcePath": "src\\nodes\\collections\\collectionGetNode.types.ts"
42
+ "sourcePath": "src/nodes/collections/collectionGetNode.types.ts"
43
43
  },
44
44
  {
45
45
  "name": "Collection: Insert",
@@ -51,7 +51,7 @@
51
51
  "outputPorts": [
52
52
  "main"
53
53
  ],
54
- "sourcePath": "src\\nodes\\collections\\collectionInsertNode.types.ts"
54
+ "sourcePath": "src/nodes/collections/collectionInsertNode.types.ts"
55
55
  },
56
56
  {
57
57
  "name": "Collection: List",
@@ -63,7 +63,7 @@
63
63
  "outputPorts": [
64
64
  "main"
65
65
  ],
66
- "sourcePath": "src\\nodes\\collections\\collectionListNode.types.ts"
66
+ "sourcePath": "src/nodes/collections/collectionListNode.types.ts"
67
67
  },
68
68
  {
69
69
  "name": "Collection: Update",
@@ -75,7 +75,7 @@
75
75
  "outputPorts": [
76
76
  "main"
77
77
  ],
78
- "sourcePath": "src\\nodes\\collections\\collectionUpdateNode.types.ts"
78
+ "sourcePath": "src/nodes/collections/collectionUpdateNode.types.ts"
79
79
  }
80
80
  ],
81
81
  "credentials": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemation/core-nodes",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -32,11 +32,11 @@
32
32
  "@ai-sdk/provider": "^3.0.8",
33
33
  "ai": "^6.0.168",
34
34
  "croner": "^10.0.1",
35
- "lucide-react": "^0.577.0",
36
- "@codemation/core": "0.11.0"
35
+ "@codemation/core": "0.12.0"
37
36
  },
38
37
  "devDependencies": {
39
38
  "@types/node": "^25.3.5",
39
+ "lucide-react": "^0.577.0",
40
40
  "eslint": "^10.0.3",
41
41
  "reflect-metadata": "^0.2.2",
42
42
  "tsdown": "^0.15.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
  }
package/src/index.ts CHANGED
@@ -43,3 +43,4 @@ 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";
@@ -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
  }
@@ -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],
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import { defineHumanApprovalNode } from "@codemation/core";
3
+ import type { Item, JsonValue } from "@codemation/core";
4
+ import { InboxChannelResolverToken } from "@codemation/core";
5
+
6
+ /**
7
+ * A subject field (title / body) for an inbox approval. Either a static string
8
+ * or a contextual callback that builds the string from the item using ordinary
9
+ * JavaScript template literals — e.g. `({ item }) => `Approve ${item.json.vendor}``.
10
+ * Code-first: no template DSL, just functions.
11
+ */
12
+ type InboxSubjectField = string | ((args: { item: Item }) => string);
13
+
14
+ function resolveSubjectField(field: InboxSubjectField, item: Item): string {
15
+ return typeof field === "function" ? field({ item }) : field;
16
+ }
17
+
18
+ /**
19
+ * Auto-detecting inbox approval node.
20
+ *
21
+ * Uses `ctx.resolve(InboxChannelResolverToken)` to pick the right inbox channel
22
+ * at runtime:
23
+ * - In managed mode (PairingConfig present): routes to the control-plane inbox.
24
+ * - Otherwise: routes to the local inbox.
25
+ *
26
+ * Authors use this node directly; no extra wiring needed per deployment mode.
27
+ */
28
+ export const inboxApproval = defineHumanApprovalNode({
29
+ key: "inbox.approval",
30
+ title: "Inbox Approval",
31
+ description: "Suspend and wait for a human reviewer to approve or reject.",
32
+ icon: "lucide:inbox",
33
+ channel: "inbox",
34
+
35
+ configSchema: z.object({
36
+ title: z.custom<InboxSubjectField>((v) => typeof v === "string" || typeof v === "function"),
37
+ body: z.custom<InboxSubjectField>((v) => typeof v === "string" || typeof v === "function"),
38
+ priority: z.enum(["low", "normal", "high"]).default("normal"),
39
+ timeout: z.string().default("24h"),
40
+ onTimeout: z.enum(["halt", "auto-accept"]).default("halt"),
41
+ }),
42
+ decisionSchema: z.object({
43
+ approved: z.boolean(),
44
+ note: z.string().optional(),
45
+ }),
46
+ defaultTimeout: "24h",
47
+ defaultOnTimeout: "halt",
48
+
49
+ async deliver({ task, config, item }, ctx) {
50
+ const resolver = ctx.resolve(InboxChannelResolverToken);
51
+ if (!resolver) {
52
+ throw new Error("inboxApproval: no InboxChannelResolver registered. Ensure the host DI container is wired.");
53
+ }
54
+ const { channel, workspaceId } = resolver.resolve();
55
+ const subject = {
56
+ title: resolveSubjectField(config.title, item),
57
+ summary: resolveSubjectField(config.body, item),
58
+ attributes: { workflowId: ctx.workflowId, item: item.json as JsonValue },
59
+ };
60
+ const delivery = await channel.deliver({
61
+ task,
62
+ subject,
63
+ priority: config.priority,
64
+ item,
65
+ workspaceId,
66
+ });
67
+ ctx.telemetry.addSpanEvent({
68
+ name: "hitl.task.delivered",
69
+ attributes: { taskId: task.taskId, channel: channel.kind },
70
+ });
71
+ return delivery;
72
+ },
73
+
74
+ async onDecision({ decision, actor, delivery }, ctx) {
75
+ const resolver = ctx.resolve(InboxChannelResolverToken);
76
+ if (!resolver) return;
77
+ const { channel } = resolver.resolve();
78
+ await channel.updateOnDecision?.({ delivery, decision, actor });
79
+ },
80
+
81
+ async onTimeout({ delivery, policy }, ctx) {
82
+ const resolver = ctx.resolve(InboxChannelResolverToken);
83
+ if (!resolver) return;
84
+ const { channel } = resolver.resolve();
85
+ await channel.updateOnTimeout?.({ delivery, policy });
86
+ },
87
+ });
@@ -32,11 +32,16 @@ export type ResolvedTool = Readonly<{
32
32
  * span and the planned tool-call's `invocationId`. Node-backed sub-agent tools use these hooks
33
33
  * via {@link ChildExecutionScopeFactory} to re-root their runtime ctx under the tool-call boundary
34
34
  * (fresh activationId, telemetry parented at the tool-call span, `parentInvocationId` set).
35
+ *
36
+ * `humanApproval` is present only when the tool was created via `defineHumanApprovalNode`
37
+ * (via its marker) — detected during `resolveTools` in `AIAgentNode`.
35
38
  */
36
39
  export type ItemScopedToolBinding = Readonly<{
37
40
  config: ToolConfig;
38
41
  inputSchema: ZodSchemaAny;
39
42
  execute(input: unknown, hooks?: ItemScopedToolCallHooks): Promise<unknown>;
43
+ /** Present when this binding is backed by a HITL-approval node (story 10). */
44
+ humanApproval?: Readonly<{ onRejected: "halt" | "return" }>;
40
45
  }>;
41
46
 
42
47
  export type ItemScopedToolCallHooks = Readonly<{