@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.
- package/CHANGELOG.md +49 -0
- package/dist/index.cjs +441 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +945 -694
- package/dist/index.d.ts +945 -694
- package/dist/index.js +441 -57
- package/dist/index.js.map +1 -1
- package/dist/metadata.json +13 -1
- package/package.json +2 -2
- package/src/chatModels/CodemationChatModelConfig.ts +1 -1
- package/src/chatModels/CodemationChatModelFactory.ts +2 -61
- package/src/chatModels/ManagedHmacSignerFactory.types.ts +88 -0
- package/src/index.ts +2 -0
- package/src/nodes/AIAgentNode.ts +173 -11
- 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/src/nodes/codemationDocumentScannerNode.ts +131 -0
package/dist/metadata.json
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"packageName": "@codemation/core-nodes",
|
|
4
|
-
"packageVersion": "0.
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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";
|
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
|
}
|
|
@@ -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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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],
|