@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.
Files changed (96) hide show
  1. package/dist/conversation-binding.d.ts +9 -1
  2. package/dist/conversation-binding.d.ts.map +1 -1
  3. package/dist/define.js +20 -0
  4. package/dist/define.js.map +1 -1
  5. package/dist/guardrails-gate.d.ts +7 -4
  6. package/dist/guardrails-gate.d.ts.map +1 -1
  7. package/dist/guardrails-gate.js +5 -2
  8. package/dist/guardrails-gate.js.map +1 -1
  9. package/dist/handlers/context.d.ts +14 -0
  10. package/dist/handlers/context.d.ts.map +1 -1
  11. package/dist/handlers/dispatch-tools.d.ts.map +1 -1
  12. package/dist/handlers/dispatch-tools.js +57 -60
  13. package/dist/handlers/dispatch-tools.js.map +1 -1
  14. package/dist/handlers/errors.d.ts +11 -1
  15. package/dist/handlers/errors.d.ts.map +1 -1
  16. package/dist/handlers/errors.js.map +1 -1
  17. package/dist/handlers/evaluate-guardrails.d.ts.map +1 -1
  18. package/dist/handlers/evaluate-guardrails.js +34 -1
  19. package/dist/handlers/evaluate-guardrails.js.map +1 -1
  20. package/dist/handlers/gate-decision.d.ts +62 -0
  21. package/dist/handlers/gate-decision.d.ts.map +1 -0
  22. package/dist/handlers/gate-decision.js +55 -0
  23. package/dist/handlers/gate-decision.js.map +1 -0
  24. package/dist/handlers/model-call.d.ts +10 -0
  25. package/dist/handlers/model-call.d.ts.map +1 -1
  26. package/dist/handlers/model-call.js +109 -34
  27. package/dist/handlers/model-call.js.map +1 -1
  28. package/dist/handlers/persist-provenance.d.ts.map +1 -1
  29. package/dist/handlers/persist-provenance.js +3 -1
  30. package/dist/handlers/persist-provenance.js.map +1 -1
  31. package/dist/handlers/persist-user-message.d.ts.map +1 -1
  32. package/dist/handlers/persist-user-message.js +5 -16
  33. package/dist/handlers/persist-user-message.js.map +1 -1
  34. package/dist/handlers/public-types.d.ts +16 -2
  35. package/dist/handlers/public-types.d.ts.map +1 -1
  36. package/dist/handlers/rehydrate.d.ts.map +1 -1
  37. package/dist/handlers/rehydrate.js +85 -0
  38. package/dist/handlers/rehydrate.js.map +1 -1
  39. package/dist/handlers/run-retrievals.d.ts.map +1 -1
  40. package/dist/handlers/run-retrievals.js +2 -19
  41. package/dist/handlers/run-retrievals.js.map +1 -1
  42. package/dist/handlers/setup.d.ts.map +1 -1
  43. package/dist/handlers/setup.js +44 -24
  44. package/dist/handlers/setup.js.map +1 -1
  45. package/dist/handlers/tool-hitl.d.ts +5 -9
  46. package/dist/handlers/tool-hitl.d.ts.map +1 -1
  47. package/dist/handlers/tool-hitl.js +5 -0
  48. package/dist/handlers/tool-hitl.js.map +1 -1
  49. package/dist/handlers/turn-provenance.d.ts +67 -0
  50. package/dist/handlers/turn-provenance.d.ts.map +1 -0
  51. package/dist/handlers/turn-provenance.js +160 -0
  52. package/dist/handlers/turn-provenance.js.map +1 -0
  53. package/dist/index.d.ts +3 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/invoke.d.ts +15 -2
  58. package/dist/invoke.d.ts.map +1 -1
  59. package/dist/invoke.js +38 -22
  60. package/dist/invoke.js.map +1 -1
  61. package/dist/provenance-emit.d.ts +4 -2
  62. package/dist/provenance-emit.d.ts.map +1 -1
  63. package/dist/provenance-emit.js +4 -2
  64. package/dist/provenance-emit.js.map +1 -1
  65. package/dist/schema.d.ts +17 -0
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +11 -0
  68. package/dist/schema.js.map +1 -1
  69. package/dist/types.d.ts +15 -1
  70. package/dist/types.d.ts.map +1 -1
  71. package/migrations/0003_hesitant_captain_cross.sql +2 -0
  72. package/migrations/meta/0003_snapshot.json +321 -0
  73. package/migrations/meta/_journal.json +7 -0
  74. package/package.json +19 -18
  75. package/src/conversation-binding.ts +9 -1
  76. package/src/define.ts +20 -0
  77. package/src/guardrails-gate.ts +17 -3
  78. package/src/handlers/context.ts +14 -0
  79. package/src/handlers/dispatch-tools.ts +76 -61
  80. package/src/handlers/errors.ts +13 -1
  81. package/src/handlers/evaluate-guardrails.ts +39 -1
  82. package/src/handlers/gate-decision.ts +101 -0
  83. package/src/handlers/model-call.ts +149 -33
  84. package/src/handlers/persist-provenance.ts +3 -1
  85. package/src/handlers/persist-user-message.ts +3 -16
  86. package/src/handlers/public-types.ts +16 -2
  87. package/src/handlers/rehydrate.ts +120 -3
  88. package/src/handlers/run-retrievals.ts +2 -19
  89. package/src/handlers/setup.ts +59 -33
  90. package/src/handlers/tool-hitl.ts +6 -10
  91. package/src/handlers/turn-provenance.ts +230 -0
  92. package/src/index.ts +8 -0
  93. package/src/invoke.ts +53 -25
  94. package/src/provenance-emit.ts +4 -1
  95. package/src/schema.ts +11 -0
  96. 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 { type ToolHitlDecision, computeToolCallWaitToken, hashToolArgs } from './tool-hitl.js';
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. Cross-turn `ask_on_first_use` caching
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 effectiveHitl = ctx.hitlPolicy;
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 (resolvedHitl.mode !== 'never_ask') {
204
- const argsHash = hashToolArgs(call.arguments);
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
- subjectKind: 'tool-call:pending',
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: resolvedHitl.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
- const decision = await kctx.waitForToken<ToolHitlDecision>(waitTokenId, {
242
- timeoutMs,
243
- });
244
- if (decision.decided === 'reject') {
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
@@ -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 { NodeHandler } from '@kindgi/handler';
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 type { ModelCallInput, ModelMessage } from '@kindgi/capabilities';
5
- import type { NodeHandler } from '@kindgi/handler';
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
- if (ctx.turnAbort.signal.aborted) {
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
- const modelCallNodeId = `model-call:${ctx.usage.steps}`;
104
- ctx.provenance.addNode({
105
- id: modelCallNodeId,
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
- promptTokens: callResult.usage.promptTokens,
112
- completionTokens: callResult.usage.completionTokens,
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
- ctx.provenance.addEdge({
118
- from: modelCallNodeId,
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
  };