@kindgi/agents 0.1.1 → 0.1.3
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/dist/conversation-binding.d.ts +9 -1
- package/dist/conversation-binding.d.ts.map +1 -1
- package/dist/define.js +20 -0
- package/dist/define.js.map +1 -1
- package/dist/guardrails-gate.d.ts +7 -4
- package/dist/guardrails-gate.d.ts.map +1 -1
- package/dist/guardrails-gate.js +5 -2
- package/dist/guardrails-gate.js.map +1 -1
- package/dist/handlers/context.d.ts +14 -0
- package/dist/handlers/context.d.ts.map +1 -1
- package/dist/handlers/dispatch-tools.d.ts.map +1 -1
- package/dist/handlers/dispatch-tools.js +57 -60
- package/dist/handlers/dispatch-tools.js.map +1 -1
- package/dist/handlers/errors.d.ts +11 -1
- package/dist/handlers/errors.d.ts.map +1 -1
- package/dist/handlers/errors.js.map +1 -1
- package/dist/handlers/evaluate-guardrails.d.ts.map +1 -1
- package/dist/handlers/evaluate-guardrails.js +34 -1
- package/dist/handlers/evaluate-guardrails.js.map +1 -1
- package/dist/handlers/gate-decision.d.ts +62 -0
- package/dist/handlers/gate-decision.d.ts.map +1 -0
- package/dist/handlers/gate-decision.js +55 -0
- package/dist/handlers/gate-decision.js.map +1 -0
- package/dist/handlers/model-call.d.ts +10 -0
- package/dist/handlers/model-call.d.ts.map +1 -1
- package/dist/handlers/model-call.js +109 -34
- package/dist/handlers/model-call.js.map +1 -1
- package/dist/handlers/persist-provenance.d.ts.map +1 -1
- package/dist/handlers/persist-provenance.js +3 -1
- package/dist/handlers/persist-provenance.js.map +1 -1
- package/dist/handlers/persist-user-message.d.ts.map +1 -1
- package/dist/handlers/persist-user-message.js +5 -16
- package/dist/handlers/persist-user-message.js.map +1 -1
- package/dist/handlers/public-types.d.ts +16 -2
- package/dist/handlers/public-types.d.ts.map +1 -1
- package/dist/handlers/rehydrate.d.ts.map +1 -1
- package/dist/handlers/rehydrate.js +85 -0
- package/dist/handlers/rehydrate.js.map +1 -1
- package/dist/handlers/run-retrievals.d.ts.map +1 -1
- package/dist/handlers/run-retrievals.js +2 -19
- package/dist/handlers/run-retrievals.js.map +1 -1
- package/dist/handlers/setup.d.ts.map +1 -1
- package/dist/handlers/setup.js +44 -24
- package/dist/handlers/setup.js.map +1 -1
- package/dist/handlers/tool-hitl.d.ts +5 -9
- package/dist/handlers/tool-hitl.d.ts.map +1 -1
- package/dist/handlers/tool-hitl.js +5 -0
- package/dist/handlers/tool-hitl.js.map +1 -1
- package/dist/handlers/turn-provenance.d.ts +67 -0
- package/dist/handlers/turn-provenance.d.ts.map +1 -0
- package/dist/handlers/turn-provenance.js +160 -0
- package/dist/handlers/turn-provenance.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/invoke.d.ts +15 -2
- package/dist/invoke.d.ts.map +1 -1
- package/dist/invoke.js +38 -22
- package/dist/invoke.js.map +1 -1
- package/dist/provenance-emit.d.ts +4 -2
- package/dist/provenance-emit.d.ts.map +1 -1
- package/dist/provenance-emit.js +4 -2
- package/dist/provenance-emit.js.map +1 -1
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +11 -0
- package/dist/schema.js.map +1 -1
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/migrations/0003_hesitant_captain_cross.sql +2 -0
- package/migrations/meta/0003_snapshot.json +321 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +19 -18
- package/src/conversation-binding.ts +9 -1
- package/src/define.ts +20 -0
- package/src/guardrails-gate.ts +17 -3
- package/src/handlers/context.ts +14 -0
- package/src/handlers/dispatch-tools.ts +76 -61
- package/src/handlers/errors.ts +13 -1
- package/src/handlers/evaluate-guardrails.ts +39 -1
- package/src/handlers/gate-decision.ts +101 -0
- package/src/handlers/model-call.ts +149 -33
- package/src/handlers/persist-provenance.ts +3 -1
- package/src/handlers/persist-user-message.ts +3 -16
- package/src/handlers/public-types.ts +16 -2
- package/src/handlers/rehydrate.ts +120 -3
- package/src/handlers/run-retrievals.ts +2 -19
- package/src/handlers/setup.ts +59 -33
- package/src/handlers/tool-hitl.ts +6 -10
- package/src/handlers/turn-provenance.ts +230 -0
- package/src/index.ts +8 -0
- package/src/invoke.ts +53 -25
- package/src/provenance-emit.ts +4 -1
- package/src/schema.ts +11 -0
- package/src/types.ts +15 -1
|
@@ -19,13 +19,15 @@ import {
|
|
|
19
19
|
type UnresolvedToolError,
|
|
20
20
|
throwAgentTurnFailure,
|
|
21
21
|
} from './errors.js';
|
|
22
|
+
import { TOOL_CALL_GATE_SUBJECT, readGateDecision } from './gate-decision.js';
|
|
22
23
|
import {
|
|
23
24
|
effectiveToolErrorPolicy,
|
|
24
25
|
toolErrorKindOf,
|
|
25
26
|
toolErrorResult,
|
|
26
27
|
toolRetriesSoFar,
|
|
27
28
|
} from './tool-errors.js';
|
|
28
|
-
import {
|
|
29
|
+
import { TOOL_GATE_RECORD_PREFIX, computeToolCallWaitToken, hashToolArgs } from './tool-hitl.js';
|
|
30
|
+
import { addStepToolNodes } from './turn-provenance.js';
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Resolves the effective HITL mode + reviewer role for a specific
|
|
@@ -79,6 +81,55 @@ function agentToolHitl(
|
|
|
79
81
|
return { mode, requiredRole: defaultRole };
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* A tool call's gate, as the step records it the first time the gate asks
|
|
86
|
+
* for a review: the wait it parks on, and the review it asks for.
|
|
87
|
+
*/
|
|
88
|
+
interface ToolGateRecord {
|
|
89
|
+
readonly argsHash: string;
|
|
90
|
+
readonly waitTokenId: string;
|
|
91
|
+
readonly requiredRole: 'standard' | 'senior' | 'admin';
|
|
92
|
+
readonly timeoutMs: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether `call` waits for a review, decided once (`NodeContext.record`):
|
|
97
|
+
* a step resumed after the park reads the gate it parked on back, so a
|
|
98
|
+
* policy relaxed meanwhile can't skip the reviewer's answer. A call the
|
|
99
|
+
* gate lets through isn't recorded: it runs, and its stored result is
|
|
100
|
+
* reused after a park further down the list.
|
|
101
|
+
*/
|
|
102
|
+
async function decideToolGate(
|
|
103
|
+
ctx: TurnContext,
|
|
104
|
+
kctx: NodeContext,
|
|
105
|
+
call: ModelToolCall,
|
|
106
|
+
tool: Tool,
|
|
107
|
+
): Promise<ToolGateRecord | undefined> {
|
|
108
|
+
const effectiveHitl = ctx.hitlPolicy;
|
|
109
|
+
if (effectiveHitl === undefined) {
|
|
110
|
+
throwAgentTurnFailure({
|
|
111
|
+
code: 'model-invocation-failed',
|
|
112
|
+
message: 'dispatch-tools invoked before the turn resolved its approval rules',
|
|
113
|
+
cause: null,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return kctx.record(`${TOOL_GATE_RECORD_PREFIX}${call.id}`, (): ToolGateRecord | undefined => {
|
|
117
|
+
const resolved = resolveEffectiveToolHitl(effectiveHitl, tool);
|
|
118
|
+
if (resolved.mode === 'never_ask') return undefined;
|
|
119
|
+
const argsHash = hashToolArgs(call.arguments);
|
|
120
|
+
return {
|
|
121
|
+
argsHash,
|
|
122
|
+
waitTokenId: computeToolCallWaitToken({
|
|
123
|
+
runId: kctx.runId as unknown as string,
|
|
124
|
+
callId: call.id,
|
|
125
|
+
argsHash,
|
|
126
|
+
}),
|
|
127
|
+
requiredRole: resolved.requiredRole,
|
|
128
|
+
timeoutMs: effectiveHitl.timeoutMs,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
82
133
|
/**
|
|
83
134
|
* Loop-body node #2. If the model returned `finishReason='tool-use'`
|
|
84
135
|
* with a non-empty tool-call list, persist the assistant tool-call
|
|
@@ -188,33 +239,22 @@ export function buildDispatchToolsHandler(ctx: TurnContext): NodeHandler {
|
|
|
188
239
|
//
|
|
189
240
|
// Kernel replay: within one run, waitForToken with the same
|
|
190
241
|
// deterministic tokenId returns the resolved decision from the
|
|
191
|
-
// journal — no re-park.
|
|
242
|
+
// journal — no re-park. The gate itself is decided once
|
|
243
|
+
// (`decideToolGate`), so a policy changed during the park can't
|
|
244
|
+
// skip the reviewer's answer. Cross-turn `ask_on_first_use` caching
|
|
192
245
|
// (via conversation metadata) is not implemented.
|
|
193
|
-
const
|
|
194
|
-
if (effectiveHitl === undefined) {
|
|
195
|
-
throwAgentTurnFailure({
|
|
196
|
-
code: 'model-invocation-failed',
|
|
197
|
-
message: 'dispatch-tools invoked before the turn resolved its approval rules',
|
|
198
|
-
cause: null,
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
const resolvedHitl = resolveEffectiveToolHitl(effectiveHitl, tool);
|
|
246
|
+
const gate = await decideToolGate(ctx, kctx, call, tool);
|
|
202
247
|
let toolRejectionPayload: { readonly rationale?: string } | null = null;
|
|
203
|
-
if (
|
|
204
|
-
const argsHash =
|
|
205
|
-
const waitTokenId = computeToolCallWaitToken({
|
|
206
|
-
runId: kctx.runId as unknown as string,
|
|
207
|
-
callId: call.id,
|
|
208
|
-
argsHash,
|
|
209
|
-
});
|
|
210
|
-
const timeoutMs = effectiveHitl.timeoutMs;
|
|
248
|
+
if (gate !== undefined) {
|
|
249
|
+
const { argsHash, waitTokenId, timeoutMs } = gate;
|
|
211
250
|
const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
|
|
212
251
|
|
|
213
252
|
if (ctx.bindings.hitl?.enqueue !== undefined) {
|
|
214
253
|
try {
|
|
215
254
|
await ctx.bindings.hitl.enqueue({
|
|
216
255
|
tenantId: ctx.input.tenantId,
|
|
217
|
-
|
|
256
|
+
projectId: ctx.input.projectId,
|
|
257
|
+
subjectKind: TOOL_CALL_GATE_SUBJECT,
|
|
218
258
|
subjectRef: {
|
|
219
259
|
conversationId: ctx.input.conversationId,
|
|
220
260
|
agentId: ctx.input.agent.id,
|
|
@@ -225,7 +265,7 @@ export function buildDispatchToolsHandler(ctx: TurnContext): NodeHandler {
|
|
|
225
265
|
argsHash,
|
|
226
266
|
arguments: call.arguments as never,
|
|
227
267
|
},
|
|
228
|
-
requiredRole:
|
|
268
|
+
requiredRole: gate.requiredRole,
|
|
229
269
|
title: `HITL review: ${call.name}`,
|
|
230
270
|
description: `Tool call ${call.name} awaiting reviewer approval before dispatch.`,
|
|
231
271
|
waitTokenId,
|
|
@@ -238,14 +278,16 @@ export function buildDispatchToolsHandler(ctx: TurnContext): NodeHandler {
|
|
|
238
278
|
}
|
|
239
279
|
|
|
240
280
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
281
|
+
// Fails closed: only an explicit approve runs the tool. A reject,
|
|
282
|
+
// or an answer that isn't a decision at all, gives the model a
|
|
283
|
+
// rejected result instead.
|
|
284
|
+
const decision = readGateDecision(
|
|
285
|
+
await kctx.waitForToken<unknown>(waitTokenId, { timeoutMs }),
|
|
286
|
+
);
|
|
287
|
+
if (!decision.approved) {
|
|
245
288
|
toolRejectionPayload =
|
|
246
289
|
decision.rationale !== undefined ? { rationale: decision.rationale } : {};
|
|
247
290
|
}
|
|
248
|
-
// approve → fall through to dispatch
|
|
249
291
|
} catch (cause) {
|
|
250
292
|
if (cause instanceof WaitpointCancelledError) {
|
|
251
293
|
throwAgentTurnFailure({
|
|
@@ -335,43 +377,13 @@ export function buildDispatchToolsHandler(ctx: TurnContext): NodeHandler {
|
|
|
335
377
|
output: dispatched.value.persisted.content,
|
|
336
378
|
durationMs: Date.now() - toolStarted,
|
|
337
379
|
});
|
|
338
|
-
|
|
339
|
-
if (ctx.provenance !== undefined) {
|
|
340
|
-
const modelCallNodeId = `model-call:${partial.step}`;
|
|
341
|
-
const toolCallNodeId = `tool-call:${call.id}`;
|
|
342
|
-
const toolResultNodeId = `tool-result:${call.id}`;
|
|
343
|
-
ctx.provenance.addNode({
|
|
344
|
-
id: toolCallNodeId,
|
|
345
|
-
kind: 'tool-call',
|
|
346
|
-
timestamp: dispatched.value.persisted.createdAt,
|
|
347
|
-
attributes: {
|
|
348
|
-
toolId: call.name,
|
|
349
|
-
invocationId: call.id,
|
|
350
|
-
// Capture the exact version the
|
|
351
|
-
// registry picked at run start + the range the agent asked
|
|
352
|
-
// for, so a replay can pin against the same version.
|
|
353
|
-
toolVersion: resolvedVersion,
|
|
354
|
-
toolVersionRange: requestedRange,
|
|
355
|
-
},
|
|
356
|
-
});
|
|
357
|
-
ctx.provenance.addNode({
|
|
358
|
-
id: toolResultNodeId,
|
|
359
|
-
kind: 'tool-result',
|
|
360
|
-
timestamp: dispatched.value.persisted.createdAt,
|
|
361
|
-
});
|
|
362
|
-
ctx.provenance.addEdge({
|
|
363
|
-
from: toolCallNodeId,
|
|
364
|
-
to: modelCallNodeId,
|
|
365
|
-
kind: 'invoked',
|
|
366
|
-
});
|
|
367
|
-
ctx.provenance.addEdge({
|
|
368
|
-
from: toolResultNodeId,
|
|
369
|
-
to: toolCallNodeId,
|
|
370
|
-
kind: 'produced',
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
380
|
}
|
|
374
381
|
|
|
382
|
+
// Every call's nodes, once the step has all its results: the ones it
|
|
383
|
+
// ran, the rejected and failed ones, and those it took from before a
|
|
384
|
+
// park in this step.
|
|
385
|
+
addStepToolNodes(ctx, partial.step, iterationAppended);
|
|
386
|
+
|
|
375
387
|
const forwarded: Partial<AgentTurnIterationOutput> & {
|
|
376
388
|
readonly step: number;
|
|
377
389
|
readonly hasToolCalls: true;
|
|
@@ -536,6 +548,9 @@ async function dispatchOne(
|
|
|
536
548
|
const toolCtx: ToolContext = {
|
|
537
549
|
tenantId: ctx.input.tenantId,
|
|
538
550
|
runId,
|
|
551
|
+
// The run's own project and org, never the model's arguments.
|
|
552
|
+
projectId: ctx.input.projectId,
|
|
553
|
+
...(ctx.input.orgId !== undefined && { orgId: ctx.input.orgId }),
|
|
539
554
|
requestId: call.id,
|
|
540
555
|
abortSignal: ctx.turnAbort.signal,
|
|
541
556
|
// HTTP tools built via defineTool({spec: {kind: 'http'}}) resolve declared
|
package/src/handlers/errors.ts
CHANGED
|
@@ -25,7 +25,19 @@ export type InvokeAgentError =
|
|
|
25
25
|
| GuardrailViolationError
|
|
26
26
|
| UnresolvedGuardrailError
|
|
27
27
|
| OutputSchemaViolationError
|
|
28
|
-
| TenantPolicyUnavailableError
|
|
28
|
+
| TenantPolicyUnavailableError
|
|
29
|
+
| RunSnapshotError;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A turn being resumed can't be rebuilt from its snapshot:
|
|
33
|
+
* `run-snapshot-missing`, there is none (its write failed), so it can't
|
|
34
|
+
* be resumed; `run-snapshot-unreadable`, reading it failed (a passing
|
|
35
|
+
* storage error), so resuming again may work.
|
|
36
|
+
*/
|
|
37
|
+
export interface RunSnapshotError {
|
|
38
|
+
readonly code: 'run-snapshot-missing' | 'run-snapshot-unreadable';
|
|
39
|
+
readonly message: string;
|
|
40
|
+
}
|
|
29
41
|
|
|
30
42
|
/**
|
|
31
43
|
* A tenant policy the turn must apply couldn't be: its spec doesn't
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) 2026 Kindgi Inc.
|
|
3
3
|
|
|
4
|
-
import type {
|
|
4
|
+
import type { UsageSink } from '@kindgi/capabilities';
|
|
5
|
+
import type { EvaluationOutcome } from '@kindgi/guardrails';
|
|
6
|
+
import type { NodeContext, NodeHandler } from '@kindgi/handler';
|
|
5
7
|
import type { Timestamp } from '@kindgi/types';
|
|
6
8
|
|
|
7
9
|
import {
|
|
@@ -77,6 +79,7 @@ export function buildEvaluateGuardrailsHandler(ctx: TurnContext): NodeHandler {
|
|
|
77
79
|
runId: kctx.runId,
|
|
78
80
|
tenantId: ctx.input.tenantId,
|
|
79
81
|
projectId: ctx.input.projectId,
|
|
82
|
+
...(ctx.input.orgId !== undefined && { orgId: ctx.input.orgId }),
|
|
80
83
|
conversationId: ctx.input.conversationId,
|
|
81
84
|
turnNumber: (ctx.conversation?.turnCount ?? 0) + 1,
|
|
82
85
|
agent: ctx.input.agent,
|
|
@@ -93,7 +96,9 @@ export function buildEvaluateGuardrailsHandler(ctx: TurnContext): NodeHandler {
|
|
|
93
96
|
ctx.bindings,
|
|
94
97
|
ctx.tenantPolicy,
|
|
95
98
|
ctx.turnAbort.signal,
|
|
99
|
+
judgeUsageSink(ctx, kctx),
|
|
96
100
|
);
|
|
101
|
+
throwIfJudgeCallsUnrecorded(outcomes);
|
|
97
102
|
const categorized = categorizeOutcomes(outcomes);
|
|
98
103
|
|
|
99
104
|
const allViolations = [...categorized.blocking, ...categorized.warnings, ...categorized.other];
|
|
@@ -151,3 +156,36 @@ export function buildEvaluateGuardrailsHandler(ctx: TurnContext): NodeHandler {
|
|
|
151
156
|
};
|
|
152
157
|
};
|
|
153
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The turn's usage sink for its llm-judge calls, adding what only the
|
|
162
|
+
* turn knows: the step that made them and the agent's version.
|
|
163
|
+
*/
|
|
164
|
+
function judgeUsageSink(ctx: TurnContext, kctx: NodeContext): UsageSink | undefined {
|
|
165
|
+
const sink = ctx.bindings.usage;
|
|
166
|
+
if (sink === undefined) return undefined;
|
|
167
|
+
return {
|
|
168
|
+
record: (call) =>
|
|
169
|
+
sink.record({
|
|
170
|
+
nodeId: kctx.nodeId as unknown as string,
|
|
171
|
+
agentVersion: ctx.input.agent.version,
|
|
172
|
+
...call,
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A judge call that answered but couldn't be recorded fails the step, as
|
|
179
|
+
* the turn's own model calls do: no answered call is left unrecorded.
|
|
180
|
+
*/
|
|
181
|
+
function throwIfJudgeCallsUnrecorded(outcomes: readonly EvaluationOutcome[]): void {
|
|
182
|
+
for (const outcome of outcomes) {
|
|
183
|
+
if (outcome.kind === 'err' && outcome.error.code === 'judge-usage-unrecorded') {
|
|
184
|
+
throwAgentTurnFailure({
|
|
185
|
+
code: 'persistence-error',
|
|
186
|
+
message: outcome.error.message,
|
|
187
|
+
cause: outcome.error,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The reviewer's answer at an agent turn's approval gates: the tool-call
|
|
6
|
+
* gate (`dispatch-tools`) and the session gate (`setup`). A gate parks the
|
|
7
|
+
* turn on a waitpoint; the approvals route resumes it with the decision
|
|
8
|
+
* (`GateDecisionValue`): approve or reject, the reviewer's rationale, and
|
|
9
|
+
* who decided which approval.
|
|
10
|
+
*
|
|
11
|
+
* A gate fails closed. Only an explicit approve lets the tool run (or the
|
|
12
|
+
* session go on). Anything else blocks: a reject, and also a resume value
|
|
13
|
+
* that isn't a decision at all (a `value` that replaced it, a malformed
|
|
14
|
+
* payload), so no answer can be read as consent by omission.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The approval subject an agent's tool-call gate parks on. */
|
|
18
|
+
export const TOOL_CALL_GATE_SUBJECT = 'tool-call:pending';
|
|
19
|
+
|
|
20
|
+
/** The approval subject an agent's session gate (`afterTurns`) parks on. */
|
|
21
|
+
export const SESSION_GATE_SUBJECT = 'agent-turn:session-hitl-gate';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The approval subjects an agent turn parks on. Their resume value is the
|
|
25
|
+
* reviewer's decision and nothing else, so the approvals route takes no
|
|
26
|
+
* `value` for them.
|
|
27
|
+
*/
|
|
28
|
+
export const AGENT_GATE_SUBJECTS: ReadonlySet<string> = new Set([
|
|
29
|
+
TOOL_CALL_GATE_SUBJECT,
|
|
30
|
+
SESSION_GATE_SUBJECT,
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A gate's resume value: the reviewer's decision, as the approvals route
|
|
35
|
+
* (and the runtime, delivering a decision whose delivery was lost) completes
|
|
36
|
+
* the gate's waitpoint with it. The run's journal keeps it as it came.
|
|
37
|
+
*/
|
|
38
|
+
export interface GateDecisionValue {
|
|
39
|
+
readonly decided: 'approve' | 'reject';
|
|
40
|
+
readonly rationale?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Who decided, as an actor: `user:<userId>`, the reviewer's user. Absent
|
|
43
|
+
* from values written before it was recorded.
|
|
44
|
+
*/
|
|
45
|
+
readonly decidedBy?: string;
|
|
46
|
+
/** The approval decided. Absent from values written before it was recorded. */
|
|
47
|
+
readonly approvalId?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Who decided a gate, and which approval, when its value says. */
|
|
51
|
+
interface GateDecider {
|
|
52
|
+
readonly decidedBy?: string;
|
|
53
|
+
readonly approvalId?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a gate's resume value decides. */
|
|
57
|
+
export type GateDecision = GateDecider &
|
|
58
|
+
(
|
|
59
|
+
| { readonly approved: true }
|
|
60
|
+
| {
|
|
61
|
+
readonly approved: false;
|
|
62
|
+
/** `rejected`: the reviewer said no. `unreadable`: the answer wasn't a decision. */
|
|
63
|
+
readonly reason: 'rejected' | 'unreadable';
|
|
64
|
+
/** The reviewer's rationale, or why an unreadable answer blocks. */
|
|
65
|
+
readonly rationale: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** Why a gate blocks on an answer that isn't a decision. */
|
|
70
|
+
export const UNREADABLE_DECISION =
|
|
71
|
+
"the approval's answer wasn't a decision (approve or reject), so it counts as a rejection";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The decision in a gate's resume value. Fails closed: only
|
|
75
|
+
* `{ decided: 'approve' }` approves.
|
|
76
|
+
*/
|
|
77
|
+
export function readGateDecision(value: unknown): GateDecision {
|
|
78
|
+
if (typeof value !== 'object' || value === null) {
|
|
79
|
+
return { approved: false, reason: 'unreadable', rationale: UNREADABLE_DECISION };
|
|
80
|
+
}
|
|
81
|
+
const { decided, rationale, decidedBy, approvalId } = value as {
|
|
82
|
+
decided?: unknown;
|
|
83
|
+
rationale?: unknown;
|
|
84
|
+
decidedBy?: unknown;
|
|
85
|
+
approvalId?: unknown;
|
|
86
|
+
};
|
|
87
|
+
const decider: GateDecider = {
|
|
88
|
+
...(typeof decidedBy === 'string' && { decidedBy }),
|
|
89
|
+
...(typeof approvalId === 'string' && { approvalId }),
|
|
90
|
+
};
|
|
91
|
+
if (decided === 'approve') return { approved: true, ...decider };
|
|
92
|
+
if (decided === 'reject') {
|
|
93
|
+
return {
|
|
94
|
+
approved: false,
|
|
95
|
+
reason: 'rejected',
|
|
96
|
+
rationale: typeof rationale === 'string' ? rationale : undefined,
|
|
97
|
+
...decider,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { approved: false, reason: 'unreadable', rationale: UNREADABLE_DECISION };
|
|
101
|
+
}
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) 2026 Kindgi Inc.
|
|
3
3
|
|
|
4
|
-
import
|
|
5
|
-
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type ModelCallInput,
|
|
8
|
+
type ModelMessage,
|
|
9
|
+
type ModelProvider,
|
|
10
|
+
type ModelUsageRecord,
|
|
11
|
+
recordModelUsage,
|
|
12
|
+
} from '@kindgi/capabilities';
|
|
13
|
+
import { attemptsOf } from '@kindgi/capabilities/attempts';
|
|
14
|
+
import type { NodeContext, NodeHandler } from '@kindgi/handler';
|
|
15
|
+
import type { Timestamp } from '@kindgi/types';
|
|
6
16
|
|
|
7
17
|
import { emitTurnEvent } from '../streaming.js';
|
|
8
18
|
|
|
9
19
|
import type { AgentTurnIterationOutput, TurnContext } from './context.js';
|
|
10
20
|
import { throwAgentTurnFailure } from './errors.js';
|
|
21
|
+
import { addModelCallNode } from './turn-provenance.js';
|
|
11
22
|
|
|
12
23
|
/**
|
|
13
24
|
* Loop-body node #1. Invoke the model with the current
|
|
@@ -15,6 +26,16 @@ import { throwAgentTurnFailure } from './errors.js';
|
|
|
15
26
|
* `model.call.completed`, records provenance for the model-call node,
|
|
16
27
|
* and accumulates usage on `ctx.usage`.
|
|
17
28
|
*
|
|
29
|
+
* Every call is recorded in the usage sink (`InvokeAgentBindings.usage`,
|
|
30
|
+
* the runtime's cost ledger) before the step goes on, a call that threw
|
|
31
|
+
* included. A sink that fails is tried again (a record is idempotent by
|
|
32
|
+
* call id); one that still fails fails the step when the call succeeded,
|
|
33
|
+
* so no answered call is left unrecorded. A call that failed keeps its
|
|
34
|
+
* own failure, which says it couldn't be recorded too. The call's id goes into
|
|
35
|
+
* the step's output and its provenance node, which keeps the call's
|
|
36
|
+
* identity (provider, model) while its usage lives in the ledger. A dry
|
|
37
|
+
* run spends nothing and records nothing.
|
|
38
|
+
*
|
|
18
39
|
* Output is a partial `AgentTurnIterationOutput` — `dispatch-tools`
|
|
19
40
|
* receives it and finishes composing the iteration output.
|
|
20
41
|
*/
|
|
@@ -31,6 +52,8 @@ export function buildModelCallHandler(ctx: TurnContext): NodeHandler {
|
|
|
31
52
|
const nextMessages: readonly ModelMessage[] = shaped?.nextMessages ?? [];
|
|
32
53
|
|
|
33
54
|
ctx.usage.steps += 1;
|
|
55
|
+
const step = ctx.usage.steps;
|
|
56
|
+
const callId = randomUUID();
|
|
34
57
|
|
|
35
58
|
await emitTurnEvent(ctx.bindings.onEvent, {
|
|
36
59
|
kind: 'model.call.started',
|
|
@@ -63,25 +86,13 @@ export function buildModelCallHandler(ctx: TurnContext): NodeHandler {
|
|
|
63
86
|
abortSignal: ctx.turnAbort.signal,
|
|
64
87
|
};
|
|
65
88
|
|
|
89
|
+
const startedAt = Date.now();
|
|
66
90
|
try {
|
|
67
91
|
callResult = await ctx.provider.invoke(callInput);
|
|
68
92
|
} catch (cause) {
|
|
69
|
-
|
|
70
|
-
throwAgentTurnFailure({
|
|
71
|
-
code: 'agent-turn-aborted',
|
|
72
|
-
message: `Agent turn aborted: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
73
|
-
reason: ctx.abortReason ?? 'timeout',
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
// The provider's own words (a 401's "invalid x-api-key", a 429)
|
|
77
|
-
// are what the caller needs; they're in `cause` too, but callers
|
|
78
|
-
// show `message`.
|
|
79
|
-
throwAgentTurnFailure({
|
|
80
|
-
code: 'model-invocation-failed',
|
|
81
|
-
message: `Model call to ${ctx.provider.metadata.id} (${ctx.model.name}) failed: ${describeCause(cause)}`,
|
|
82
|
-
cause,
|
|
83
|
-
});
|
|
93
|
+
return await failCall(ctx, kctx, { callId, step, cause, startedAt });
|
|
84
94
|
}
|
|
95
|
+
await recordAnswer(ctx, kctx, callId, step, callResult);
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
ctx.usage.promptTokens += callResult.usage.promptTokens;
|
|
@@ -100,32 +111,27 @@ export function buildModelCallHandler(ctx: TurnContext): NodeHandler {
|
|
|
100
111
|
});
|
|
101
112
|
|
|
102
113
|
if (ctx.provenance !== undefined && ctx.userMessage !== undefined) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
kind: 'model-call',
|
|
107
|
-
timestamp: new Date().toISOString() as never,
|
|
108
|
-
modelVersion: `${callResult.provider.id}/${callResult.provider.model}`,
|
|
109
|
-
attributes: {
|
|
114
|
+
addModelCallNode(
|
|
115
|
+
ctx.provenance,
|
|
116
|
+
{
|
|
110
117
|
step: ctx.usage.steps,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
costUsd: callResult.costUsd,
|
|
118
|
+
callId,
|
|
119
|
+
provider: callResult.provider,
|
|
114
120
|
finishReason: callResult.finishReason,
|
|
121
|
+
at: new Date().toISOString() as Timestamp,
|
|
115
122
|
},
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
to: `input:${ctx.userMessage.sequence}`,
|
|
120
|
-
kind: 'caused-by',
|
|
121
|
-
});
|
|
123
|
+
ctx.userMessage.sequence,
|
|
124
|
+
ctx.toolResultIds ?? [],
|
|
125
|
+
);
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
// Partial iteration output — `dispatch-tools` finishes it.
|
|
125
129
|
const partial: Omit<AgentTurnIterationOutput, 'iterationAppended' | 'finishedTurn'> & {
|
|
126
130
|
readonly step: number;
|
|
131
|
+
readonly callId: string;
|
|
127
132
|
} = {
|
|
128
133
|
step: ctx.usage.steps,
|
|
134
|
+
callId,
|
|
129
135
|
finishReason: callResult.finishReason,
|
|
130
136
|
message: callResult.message,
|
|
131
137
|
iterationUsage: {
|
|
@@ -140,6 +146,116 @@ export function buildModelCallHandler(ctx: TurnContext): NodeHandler {
|
|
|
140
146
|
};
|
|
141
147
|
}
|
|
142
148
|
|
|
149
|
+
/**
|
|
150
|
+
* A call that answered: record it before the step goes on. A sink that
|
|
151
|
+
* still fails after its retries fails the step: an answered call isn't
|
|
152
|
+
* left unrecorded.
|
|
153
|
+
*/
|
|
154
|
+
async function recordAnswer(
|
|
155
|
+
ctx: TurnContext,
|
|
156
|
+
kctx: NodeContext,
|
|
157
|
+
callId: string,
|
|
158
|
+
step: number,
|
|
159
|
+
answer: Awaited<ReturnType<ModelProvider['invoke']>>,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const { message: _answer, ...result } = answer;
|
|
162
|
+
const unrecorded = await recordCall(ctx, kctx, {
|
|
163
|
+
callId,
|
|
164
|
+
step,
|
|
165
|
+
status: 'ok',
|
|
166
|
+
result,
|
|
167
|
+
durationMs: answer.durationMs,
|
|
168
|
+
});
|
|
169
|
+
if (unrecorded !== undefined) {
|
|
170
|
+
throwAgentTurnFailure({
|
|
171
|
+
code: 'persistence-error',
|
|
172
|
+
message: `The model call couldn't be recorded: ${describeCause(unrecorded)}`,
|
|
173
|
+
cause: unrecorded,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A call that threw: record it, then fail the step with the call's own
|
|
180
|
+
* failure (aborted, or the provider's words). A record that failed too
|
|
181
|
+
* is said after it.
|
|
182
|
+
*/
|
|
183
|
+
async function failCall(
|
|
184
|
+
ctx: TurnContext,
|
|
185
|
+
kctx: NodeContext,
|
|
186
|
+
failed: {
|
|
187
|
+
readonly callId: string;
|
|
188
|
+
readonly step: number;
|
|
189
|
+
readonly cause: unknown;
|
|
190
|
+
readonly startedAt: number;
|
|
191
|
+
},
|
|
192
|
+
): Promise<never> {
|
|
193
|
+
const { cause } = failed;
|
|
194
|
+
const attempts = attemptsOf(cause);
|
|
195
|
+
const unrecorded = await recordCall(ctx, kctx, {
|
|
196
|
+
callId: failed.callId,
|
|
197
|
+
step: failed.step,
|
|
198
|
+
status: 'failed',
|
|
199
|
+
error: { message: describeCause(cause), ...(attempts !== undefined && { attempts }) },
|
|
200
|
+
durationMs: Date.now() - failed.startedAt,
|
|
201
|
+
});
|
|
202
|
+
const andUnrecorded =
|
|
203
|
+
unrecorded === undefined
|
|
204
|
+
? ''
|
|
205
|
+
: ` (and the failed call couldn't be recorded: ${describeCause(unrecorded)})`;
|
|
206
|
+
if (ctx.turnAbort.signal.aborted) {
|
|
207
|
+
throwAgentTurnFailure({
|
|
208
|
+
code: 'agent-turn-aborted',
|
|
209
|
+
message: `Agent turn aborted: ${cause instanceof Error ? cause.message : String(cause)}${andUnrecorded}`,
|
|
210
|
+
reason: ctx.abortReason ?? 'timeout',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// The provider's own words (a 401's "invalid x-api-key", a 429) are
|
|
214
|
+
// what the caller needs; they're in `cause` too, but callers show
|
|
215
|
+
// `message`.
|
|
216
|
+
throwAgentTurnFailure({
|
|
217
|
+
code: 'model-invocation-failed',
|
|
218
|
+
message: `Model call to ${ctx.provider?.metadata.id} (${ctx.model?.name}) failed: ${describeCause(cause)}${andUnrecorded}`,
|
|
219
|
+
cause,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What the call came to, for `recordCall`. */
|
|
224
|
+
type CallOutcome = Pick<
|
|
225
|
+
ModelUsageRecord,
|
|
226
|
+
'callId' | 'step' | 'status' | 'result' | 'error' | 'durationMs'
|
|
227
|
+
>;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Record a model call in the usage sink, when the turn has one, trying
|
|
231
|
+
* a failing sink again. Resolves with the sink's last failure when it
|
|
232
|
+
* couldn't record; the caller decides what that means for the step.
|
|
233
|
+
*/
|
|
234
|
+
async function recordCall(
|
|
235
|
+
ctx: TurnContext,
|
|
236
|
+
kctx: NodeContext,
|
|
237
|
+
call: CallOutcome,
|
|
238
|
+
): Promise<unknown | undefined> {
|
|
239
|
+
const sink = ctx.bindings.usage;
|
|
240
|
+
if (sink === undefined || ctx.provider === undefined || ctx.model === undefined) return;
|
|
241
|
+
const { input } = ctx;
|
|
242
|
+
const recorded = await recordModelUsage(sink, {
|
|
243
|
+
...call,
|
|
244
|
+
tenantId: input.tenantId,
|
|
245
|
+
projectId: input.projectId,
|
|
246
|
+
runId: kctx.runId,
|
|
247
|
+
agentId: input.agent.id as unknown as string,
|
|
248
|
+
agentVersion: input.agent.version,
|
|
249
|
+
conversationId: input.conversationId as unknown as string,
|
|
250
|
+
nodeId: kctx.nodeId as unknown as string,
|
|
251
|
+
providerId: ctx.provider.metadata.id,
|
|
252
|
+
model: ctx.model.name,
|
|
253
|
+
...(ctx.provider.metadata.fallback === true && { fallback: true }),
|
|
254
|
+
occurredAt: new Date().toISOString(),
|
|
255
|
+
});
|
|
256
|
+
return recorded.kind === 'err' ? (recorded.error ?? new Error('no detail')) : undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
143
259
|
/** Longest cause text a failure message carries. */
|
|
144
260
|
const MAX_CAUSE_CHARS = 500;
|
|
145
261
|
|
|
@@ -28,7 +28,9 @@ export function buildPersistProvenanceHandler(ctx: TurnContext): NodeHandler {
|
|
|
28
28
|
// want).
|
|
29
29
|
return { persisted: false };
|
|
30
30
|
}
|
|
31
|
-
const persisted = await persistProvenance(ctx.provenance, ctx.provenanceBindings
|
|
31
|
+
const persisted = await persistProvenance(ctx.provenance, ctx.provenanceBindings, {
|
|
32
|
+
projectId: ctx.input.projectId,
|
|
33
|
+
});
|
|
32
34
|
if (persisted.kind === 'ok') {
|
|
33
35
|
ctx.persistedProvenance = persisted.value;
|
|
34
36
|
return { persisted: true };
|
|
@@ -8,6 +8,7 @@ import type { ConversationMessage } from '../types.js';
|
|
|
8
8
|
|
|
9
9
|
import type { TurnContext } from './context.js';
|
|
10
10
|
import { throwAgentTurnFailure } from './errors.js';
|
|
11
|
+
import { addInputNode } from './turn-provenance.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Persist the caller's user message BEFORE the model is called. A
|
|
@@ -33,14 +34,7 @@ export function buildPersistUserMessageHandler(ctx: TurnContext): NodeHandler {
|
|
|
33
34
|
ctx.userMessage = synthetic;
|
|
34
35
|
ctx.appended.push(synthetic);
|
|
35
36
|
|
|
36
|
-
if (ctx.provenance !== undefined)
|
|
37
|
-
ctx.provenance.addNode({
|
|
38
|
-
id: `input:${synthetic.sequence}`,
|
|
39
|
-
kind: 'input',
|
|
40
|
-
timestamp: synthetic.createdAt,
|
|
41
|
-
...(synthetic.actor !== undefined && { actor: synthetic.actor }),
|
|
42
|
-
});
|
|
43
|
-
}
|
|
37
|
+
if (ctx.provenance !== undefined) addInputNode(ctx.provenance, synthetic);
|
|
44
38
|
return { sequence: synthetic.sequence };
|
|
45
39
|
}
|
|
46
40
|
|
|
@@ -56,14 +50,7 @@ export function buildPersistUserMessageHandler(ctx: TurnContext): NodeHandler {
|
|
|
56
50
|
ctx.userMessage = persisted.value;
|
|
57
51
|
ctx.appended.push(persisted.value);
|
|
58
52
|
|
|
59
|
-
if (ctx.provenance !== undefined)
|
|
60
|
-
ctx.provenance.addNode({
|
|
61
|
-
id: `input:${persisted.value.sequence}`,
|
|
62
|
-
kind: 'input',
|
|
63
|
-
timestamp: persisted.value.createdAt,
|
|
64
|
-
...(persisted.value.actor !== undefined && { actor: persisted.value.actor }),
|
|
65
|
-
});
|
|
66
|
-
}
|
|
53
|
+
if (ctx.provenance !== undefined) addInputNode(ctx.provenance, persisted.value);
|
|
67
54
|
|
|
68
55
|
return { sequence: persisted.value.sequence };
|
|
69
56
|
};
|