@codemation/core-nodes 0.8.1 → 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.
- package/CHANGELOG.md +11 -0
- package/dist/index.cjs +301 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +904 -692
- package/dist/index.d.ts +904 -692
- package/dist/index.js +302 -9
- package/dist/index.js.map +1 -1
- package/dist/metadata.json +1 -1
- package/package.json +2 -2
- package/src/chatModels/CodemationChatModelConfig.ts +1 -1
- package/src/index.ts +1 -0
- package/src/nodes/AIAgentNode.ts +159 -7
- package/src/nodes/AgentLoopCheckpoint.types.ts +23 -0
- package/src/nodes/AgentToolExecutionCoordinator.ts +91 -2
- package/src/nodes/InboxApprovalNode.types.ts +87 -0
- package/src/nodes/aiAgentSupport.types.ts +5 -0
package/dist/metadata.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codemation/core-nodes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.
|
|
35
|
+
"@codemation/core": "0.12.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
|
|
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";
|
package/src/nodes/AIAgentNode.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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<{
|