@kindgi/agents 0.1.2 → 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 (92) 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 +18 -42
  13. package/dist/handlers/dispatch-tools.js.map +1 -1
  14. package/dist/handlers/evaluate-guardrails.d.ts.map +1 -1
  15. package/dist/handlers/evaluate-guardrails.js +34 -1
  16. package/dist/handlers/evaluate-guardrails.js.map +1 -1
  17. package/dist/handlers/gate-decision.d.ts +62 -0
  18. package/dist/handlers/gate-decision.d.ts.map +1 -0
  19. package/dist/handlers/gate-decision.js +55 -0
  20. package/dist/handlers/gate-decision.js.map +1 -0
  21. package/dist/handlers/model-call.d.ts +10 -0
  22. package/dist/handlers/model-call.d.ts.map +1 -1
  23. package/dist/handlers/model-call.js +109 -34
  24. package/dist/handlers/model-call.js.map +1 -1
  25. package/dist/handlers/persist-provenance.d.ts.map +1 -1
  26. package/dist/handlers/persist-provenance.js +3 -1
  27. package/dist/handlers/persist-provenance.js.map +1 -1
  28. package/dist/handlers/persist-user-message.d.ts.map +1 -1
  29. package/dist/handlers/persist-user-message.js +5 -16
  30. package/dist/handlers/persist-user-message.js.map +1 -1
  31. package/dist/handlers/public-types.d.ts +16 -2
  32. package/dist/handlers/public-types.d.ts.map +1 -1
  33. package/dist/handlers/rehydrate.d.ts.map +1 -1
  34. package/dist/handlers/rehydrate.js +85 -0
  35. package/dist/handlers/rehydrate.js.map +1 -1
  36. package/dist/handlers/run-retrievals.d.ts.map +1 -1
  37. package/dist/handlers/run-retrievals.js +2 -19
  38. package/dist/handlers/run-retrievals.js.map +1 -1
  39. package/dist/handlers/setup.d.ts.map +1 -1
  40. package/dist/handlers/setup.js +15 -10
  41. package/dist/handlers/setup.js.map +1 -1
  42. package/dist/handlers/tool-hitl.d.ts +5 -9
  43. package/dist/handlers/tool-hitl.d.ts.map +1 -1
  44. package/dist/handlers/tool-hitl.js +5 -0
  45. package/dist/handlers/tool-hitl.js.map +1 -1
  46. package/dist/handlers/turn-provenance.d.ts +67 -0
  47. package/dist/handlers/turn-provenance.d.ts.map +1 -0
  48. package/dist/handlers/turn-provenance.js +160 -0
  49. package/dist/handlers/turn-provenance.js.map +1 -0
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/invoke.d.ts +14 -1
  55. package/dist/invoke.d.ts.map +1 -1
  56. package/dist/invoke.js +34 -19
  57. package/dist/invoke.js.map +1 -1
  58. package/dist/provenance-emit.d.ts +4 -2
  59. package/dist/provenance-emit.d.ts.map +1 -1
  60. package/dist/provenance-emit.js +4 -2
  61. package/dist/provenance-emit.js.map +1 -1
  62. package/dist/schema.d.ts +17 -0
  63. package/dist/schema.d.ts.map +1 -1
  64. package/dist/schema.js +11 -0
  65. package/dist/schema.js.map +1 -1
  66. package/dist/types.d.ts +15 -1
  67. package/dist/types.d.ts.map +1 -1
  68. package/migrations/0003_hesitant_captain_cross.sql +2 -0
  69. package/migrations/meta/0003_snapshot.json +321 -0
  70. package/migrations/meta/_journal.json +7 -0
  71. package/package.json +15 -15
  72. package/src/conversation-binding.ts +9 -1
  73. package/src/define.ts +20 -0
  74. package/src/guardrails-gate.ts +17 -3
  75. package/src/handlers/context.ts +14 -0
  76. package/src/handlers/dispatch-tools.ts +21 -43
  77. package/src/handlers/evaluate-guardrails.ts +39 -1
  78. package/src/handlers/gate-decision.ts +101 -0
  79. package/src/handlers/model-call.ts +149 -33
  80. package/src/handlers/persist-provenance.ts +3 -1
  81. package/src/handlers/persist-user-message.ts +3 -16
  82. package/src/handlers/public-types.ts +16 -2
  83. package/src/handlers/rehydrate.ts +120 -3
  84. package/src/handlers/run-retrievals.ts +2 -19
  85. package/src/handlers/setup.ts +17 -20
  86. package/src/handlers/tool-hitl.ts +6 -10
  87. package/src/handlers/turn-provenance.ts +230 -0
  88. package/src/index.ts +7 -0
  89. package/src/invoke.ts +46 -20
  90. package/src/provenance-emit.ts +4 -1
  91. package/src/schema.ts +11 -0
  92. package/src/types.ts +15 -1
@@ -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
  };
@@ -2,13 +2,13 @@
2
2
  // Copyright (C) 2026 Kindgi Inc.
3
3
 
4
4
  import type { Principal } from '@kindgi/authz';
5
- import type { ProviderRegistry, TenantPolicy } from '@kindgi/capabilities';
5
+ import type { ProviderRegistry, TenantPolicy, UsageSink } from '@kindgi/capabilities';
6
6
  import type { EmbeddingProviderRegistry } from '@kindgi/embedding';
7
7
  import type { MemoryQueryBinding } from '@kindgi/memory';
8
8
  import type { PolicyRegistry } from '@kindgi/policy-contract';
9
9
  import type { ParentRunRef, RunBinding } from '@kindgi/runtime';
10
10
  import type { ToolRegistry, ToolSecretRef } from '@kindgi/tools';
11
- import type { ProjectId, ProvenanceId, RunId, TenantId, Timestamp } from '@kindgi/types';
11
+ import type { OrgId, ProjectId, ProvenanceId, RunId, TenantId, Timestamp } from '@kindgi/types';
12
12
 
13
13
  import type { ConversationBinding } from '../conversation-binding.js';
14
14
  import type { GuardrailsBindings } from '../guardrails-gate.js';
@@ -32,6 +32,12 @@ export interface InvokeAgentInput {
32
32
  * at the caller layer.
33
33
  */
34
34
  readonly projectId: ProjectId;
35
+ /**
36
+ * The project's org, when it belongs to one: the runtime resolves it
37
+ * from the project, never from input. The turn's tools get it as
38
+ * `ToolContext.orgId`, its guardrails as `trace.orgId`.
39
+ */
40
+ readonly orgId?: OrgId;
35
41
  readonly agent: Agent;
36
42
  readonly conversationId: ConversationId;
37
43
  readonly userMessage: string;
@@ -153,6 +159,12 @@ export interface InvokeAgentBindings extends GuardrailsBindings {
153
159
  readonly onEvent?: OnTurnEvent;
154
160
  readonly provenance?: ProvenanceBindings;
155
161
  readonly hitl?: HitlBindings;
162
+ /**
163
+ * Where the turn records each model call (the runtime's cost ledger):
164
+ * its usage, provider, model, run, agent and step, before the step
165
+ * that made it goes on. Absent: calls aren't recorded.
166
+ */
167
+ readonly usage?: UsageSink;
156
168
  /**
157
169
  * Declarative HTTP tools: optional secret resolver populated
158
170
  * from the deployment's tenant-scoped `SecretBinding`. When present,
@@ -171,6 +183,8 @@ export interface HitlBindings {
171
183
 
172
184
  export interface HitlEnqueueInput {
173
185
  readonly tenantId: TenantId;
186
+ /** The turn's project: approval lists filter by it. */
187
+ readonly projectId?: ProjectId;
174
188
  readonly subjectKind: string;
175
189
  readonly subjectRef: Readonly<Record<string, unknown>>;
176
190
  readonly requiredRole?: 'standard' | 'senior' | 'admin';
@@ -27,26 +27,45 @@
27
27
  * A turn parked inside `setup` (the session gate) re-runs `setup`, and
28
28
  * nothing here applies.
29
29
  *
30
- * Not restored: provenance nodes recorded before the park. The resumed
31
- * turn's provenance covers what happens from the resume on.
30
+ * - the provenance nodes of the steps that ran before the park are
31
+ * added again from what they left (`turn-provenance.ts`), so a
32
+ * resumed turn's DAG is whole: its user message, its retrievals, each
33
+ * model call, and the tool calls of each completed step. The step the
34
+ * turn parked in adds its own when it runs again;
35
+ * - the tool-call approvals decided so far come from the journal (the
36
+ * waitpoint each gate recorded, when the call parked, the decision
37
+ * and when it came), so each call's provenance shows the approval it
38
+ * waited on and who decided it.
32
39
  */
33
40
 
34
41
  import type { LoopContext } from '@kindgi/handler';
35
- import { type JournalEntry, bodyStepKey } from '@kindgi/runtime';
42
+ import { type JournalEntry, type ValueRecordedPayload, bodyStepKey } from '@kindgi/runtime';
43
+ import type { Timestamp } from '@kindgi/types';
36
44
 
37
45
  import type { ConversationMessage, RetrievedFact } from '../types.js';
38
46
  import type { TurnContext } from './context.js';
39
47
  import { throwAgentTurnFailure } from './errors.js';
48
+ import { readGateDecision } from './gate-decision.js';
49
+ import { TOOL_GATE_RECORD_PREFIX } from './tool-hitl.js';
40
50
  import {
41
51
  loadTurnConversation,
42
52
  resolveTurnEnvironment,
43
53
  resolveTurnHitlPolicy,
44
54
  } from './turn-environment.js';
55
+ import {
56
+ addInputNode,
57
+ addModelCallNode,
58
+ addRetrievalNodes,
59
+ addStepToolNodes,
60
+ } from './turn-provenance.js';
61
+ import type { ToolApproval } from './turn-provenance.js';
45
62
 
46
63
  interface StepRecord {
47
64
  readonly nodeId: string;
48
65
  readonly output: unknown;
49
66
  readonly inLoop: boolean;
67
+ /** When the step completed. */
68
+ readonly at: Timestamp;
50
69
  }
51
70
 
52
71
  /** Each step's last completion — a step at one loop iteration counts once. */
@@ -67,6 +86,7 @@ function completedSteps(journal: readonly JournalEntry[]): readonly StepRecord[]
67
86
  nodeId: e.nodeId as unknown as string,
68
87
  output: payload.output,
69
88
  inLoop: payload.loopContext !== undefined,
89
+ at: e.timestamp,
70
90
  });
71
91
  }
72
92
  return [...byStep.values()];
@@ -129,9 +149,106 @@ export async function rehydrateTurnContext(
129
149
  ctx.usage.totalCostUsd += out.iterationUsage?.costUsd ?? 0;
130
150
  if (out.provider !== undefined) ctx.lastProvider = out.provider;
131
151
  }
152
+ ctx.toolApprovals = toolApprovalsOf(journal);
153
+ rebuildProvenance(ctx, steps, retrievals?.retrieved);
132
154
  return true;
133
155
  }
134
156
 
157
+ /**
158
+ * The tool-call approvals the journal shows decided, by invocation id: the
159
+ * waitpoint each call's gate recorded (`dispatch-tools`), when the call
160
+ * parked on it (`wait.suspended`, the first), and the decision that
161
+ * resolved it (`wait.resumed`).
162
+ */
163
+ function toolApprovalsOf(journal: readonly JournalEntry[]): ReadonlyMap<string, ToolApproval> {
164
+ const callOf = new Map<string, string>();
165
+ const parkedAt = new Map<string, Timestamp>();
166
+ const resumed = new Map<string, { readonly at: Timestamp; readonly value: unknown }>();
167
+ for (const e of journal) {
168
+ const gate = toolGateOf(e);
169
+ if (gate !== undefined) callOf.set(gate.waitTokenId, gate.invocationId);
170
+ const p = (e.payload ?? {}) as { readonly tokenId?: unknown; readonly value?: unknown };
171
+ if (typeof p.tokenId !== 'string') continue;
172
+ if (e.kind === 'wait.suspended' && !parkedAt.has(p.tokenId)) {
173
+ parkedAt.set(p.tokenId, e.timestamp);
174
+ }
175
+ if (e.kind === 'wait.resumed') resumed.set(p.tokenId, { at: e.timestamp, value: p.value });
176
+ }
177
+ const approvals = new Map<string, ToolApproval>();
178
+ for (const [waitTokenId, decided] of resumed) {
179
+ const invocationId = callOf.get(waitTokenId);
180
+ if (invocationId === undefined) continue;
181
+ approvals.set(invocationId, {
182
+ waitTokenId,
183
+ parkedAt: parkedAt.get(waitTokenId) ?? decided.at,
184
+ decidedAt: decided.at,
185
+ decision: readGateDecision(decided.value),
186
+ });
187
+ }
188
+ return approvals;
189
+ }
190
+
191
+ /** The waitpoint a tool call's gate recorded (`dispatch-tools`), if `e` is that record. */
192
+ function toolGateOf(
193
+ e: JournalEntry,
194
+ ): { readonly waitTokenId: string; readonly invocationId: string } | undefined {
195
+ if (e.kind !== 'value.recorded') return undefined;
196
+ const p = (e.payload ?? {}) as Partial<ValueRecordedPayload>;
197
+ if (typeof p.key !== 'string' || !p.key.startsWith(TOOL_GATE_RECORD_PREFIX)) return undefined;
198
+ const waitTokenId = (p.value as { readonly waitTokenId?: unknown } | undefined)?.waitTokenId;
199
+ return typeof waitTokenId === 'string'
200
+ ? { waitTokenId, invocationId: p.key.slice(TOOL_GATE_RECORD_PREFIX.length) }
201
+ : undefined;
202
+ }
203
+
204
+ /** A completed model-call step's output, as far as provenance reads it. */
205
+ interface ModelCallStepOutput {
206
+ readonly step?: number;
207
+ readonly callId?: string;
208
+ readonly finishReason?: string;
209
+ readonly provider?: { readonly id: string; readonly model: string };
210
+ }
211
+
212
+ /**
213
+ * The provenance of the steps that ran before the park, in the order
214
+ * they ran: the user message, the retrievals, then each model call with
215
+ * the tool calls its completed step stored.
216
+ */
217
+ function rebuildProvenance(
218
+ ctx: TurnContext,
219
+ steps: readonly StepRecord[],
220
+ retrieved: readonly RetrievedFact[] | undefined,
221
+ ): void {
222
+ const input = ctx.userMessage;
223
+ if (ctx.provenance === undefined || input === undefined) return;
224
+ addInputNode(ctx.provenance, input);
225
+ if (retrieved !== undefined) addRetrievalNodes(ctx.provenance, retrieved, input);
226
+
227
+ const storedByStep = new Map<number, readonly ConversationMessage[]>();
228
+ for (const s of steps.filter((s) => s.nodeId === 'dispatch-tools' && s.inLoop)) {
229
+ const out = s.output as {
230
+ readonly step?: number;
231
+ readonly iterationAppended?: readonly ConversationMessage[];
232
+ };
233
+ if (out.step !== undefined) storedByStep.set(out.step, out.iterationAppended ?? []);
234
+ }
235
+ const calls = steps
236
+ .filter((s) => s.nodeId === 'model-call' && s.inLoop)
237
+ .map((s) => ({ at: s.at, out: s.output as ModelCallStepOutput }))
238
+ .sort((a, b) => (a.out.step ?? 0) - (b.out.step ?? 0));
239
+ for (const { at, out } of calls) {
240
+ const { step, callId, provider, finishReason } = out;
241
+ if (step === undefined || callId === undefined || provider === undefined) continue;
242
+ addModelCallNode(
243
+ ctx.provenance,
244
+ { step, callId, provider, finishReason: finishReason ?? 'stop', at },
245
+ input.sequence,
246
+ [...(ctx.toolResultIds ?? [])],
247
+ );
248
+ addStepToolNodes(ctx, step, storedByStep.get(step) ?? []);
249
+ }
250
+ }
251
+
135
252
  /**
136
253
  * The messages no completed step accounts for: those the step the turn
137
254
  * parked in stored before it parked.
@@ -8,6 +8,7 @@ import { emitTurnEvent } from '../streaming.js';
8
8
 
9
9
  import type { TurnContext } from './context.js';
10
10
  import { throwAgentTurnFailure } from './errors.js';
11
+ import { addRetrievalNodes } from './turn-provenance.js';
11
12
 
12
13
  /**
13
14
  * Execute the agent's declared retrieval intents. Populates
@@ -49,25 +50,7 @@ export function buildRunRetrievalsHandler(ctx: TurnContext): NodeHandler {
49
50
  });
50
51
 
51
52
  if (ctx.provenance !== undefined && ctx.userMessage !== undefined) {
52
- for (const r of retrieved.value) {
53
- ctx.provenance.addNode({
54
- id: `retrieval:${r.fact.id}`,
55
- kind: 'retrieval',
56
- timestamp: ctx.userMessage.createdAt,
57
- ...(r.fact.contentHash !== undefined && { contentHash: r.fact.contentHash }),
58
- attributes: {
59
- factId: r.fact.id,
60
- factType: r.fact.type,
61
- intentScope: r.intent.scope,
62
- ...(r.score !== undefined && { score: r.score }),
63
- },
64
- });
65
- ctx.provenance.addEdge({
66
- from: `retrieval:${r.fact.id}`,
67
- to: `input:${ctx.userMessage.sequence}`,
68
- kind: 'influenced-by',
69
- });
70
- }
53
+ addRetrievalNodes(ctx.provenance, retrieved.value, ctx.userMessage);
71
54
  }
72
55
 
73
56
  // The facts go in the journal: a resumed turn restores them from it
@@ -11,12 +11,14 @@ import { emitTurnEvent } from '../streaming.js';
11
11
 
12
12
  import type { TurnContext } from './context.js';
13
13
  import { throwAgentTurnFailure } from './errors.js';
14
+ import { SESSION_GATE_SUBJECT, readGateDecision } from './gate-decision.js';
14
15
  import { writeRunSnapshot } from './run-snapshot.js';
15
16
  import {
16
17
  loadTurnConversation,
17
18
  resolveTurnEnvironment,
18
19
  resolveTurnHitlPolicy,
19
20
  } from './turn-environment.js';
21
+ import { decisionOf } from './turn-provenance.js';
20
22
 
21
23
  /**
22
24
  * Deterministic waitpoint token — must produce the same value on every
@@ -51,16 +53,6 @@ interface SessionGateRecord {
51
53
  readonly timeoutMs: number;
52
54
  }
53
55
 
54
- /**
55
- * Shape the setup handler expects when a session-gate waitpoint
56
- * resolves. Approvals-complete route materializes this from the
57
- * reviewer's decision (see packages/api/src/routes/approvals.ts).
58
- */
59
- interface SessionGateDecision {
60
- readonly decided: 'approve' | 'reject';
61
- readonly rationale?: string;
62
- }
63
-
64
56
  /**
65
57
  * The first node in the flow. Runs every precondition check in one
66
58
  * gate so the run either commits to a full turn or fails fast before
@@ -156,7 +148,8 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
156
148
  try {
157
149
  await ctx.bindings.hitl.enqueue({
158
150
  tenantId: ctx.input.tenantId,
159
- subjectKind: 'agent-turn:session-hitl-gate',
151
+ projectId: ctx.input.projectId,
152
+ subjectKind: SESSION_GATE_SUBJECT,
160
153
  subjectRef: {
161
154
  conversationId: ctx.input.conversationId,
162
155
  agentId: ctx.input.agent.id,
@@ -200,9 +193,11 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
200
193
  }
201
194
 
202
195
  try {
203
- const decision = await kctx.waitForToken<SessionGateDecision>(waitTokenId, {
204
- timeoutMs,
205
- });
196
+ // Fails closed: only an explicit approve lets the turn go on
197
+ // (`readGateDecision`).
198
+ const decision = readGateDecision(
199
+ await kctx.waitForToken<unknown>(waitTokenId, { timeoutMs }),
200
+ );
206
201
  // Post-resume: emit the resume node + resumed-from edge. Only
207
202
  // runs on the REPLAY path — the first execution throws
208
203
  // SuspensionSignal inside waitForToken and never gets here.
@@ -211,11 +206,14 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
211
206
  id: resumeNodeId,
212
207
  kind: 'resume',
213
208
  timestamp: new Date().toISOString() as never,
214
- actor: `agent:${ctx.input.agent.id as unknown as string}`,
209
+ // Whoever decided; a decision recorded before it named them, the agent.
210
+ actor: decision.decidedBy ?? `agent:${ctx.input.agent.id as unknown as string}`,
215
211
  attributes: {
216
212
  gate: 'session-hitl',
217
- decision: decision.decided,
218
- ...(decision.rationale !== undefined && { rationale: decision.rationale }),
213
+ decision: decisionOf(decision),
214
+ ...(!decision.approved &&
215
+ decision.rationale !== undefined && { rationale: decision.rationale }),
216
+ ...(decision.approvalId !== undefined && { approvalId: decision.approvalId }),
219
217
  },
220
218
  });
221
219
  ctx.provenance.addEdge({
@@ -225,7 +223,7 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
225
223
  });
226
224
  }
227
225
 
228
- if (decision.decided === 'reject') {
226
+ if (!decision.approved) {
229
227
  throwAgentTurnFailure({
230
228
  code: 'hitl-rejected',
231
229
  message: `Reviewer rejected the session-HITL gate${
@@ -234,8 +232,7 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
234
232
  ...(decision.rationale !== undefined && { rationale: decision.rationale }),
235
233
  } as never);
236
234
  }
237
- // decision.decided === 'approve' → fall through, turn proceeds
238
- // normally through the rest of setup.
235
+ // Approved: the turn proceeds through the rest of setup.
239
236
  } catch (cause) {
240
237
  if (cause instanceof WaitpointCancelledError) {
241
238
  // Emit resume node with cancelled attribute so the DAG still
@@ -83,6 +83,12 @@ function canonicalStringify(value: unknown): string {
83
83
  return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalStringify(v)}`).join(',')}}`;
84
84
  }
85
85
 
86
+ /**
87
+ * The `NodeContext.record` key of a tool call's gate, before its call id:
88
+ * the journal names the waitpoint a call parked on under this key.
89
+ */
90
+ export const TOOL_GATE_RECORD_PREFIX = 'tool-hitl-gate:';
91
+
86
92
  /**
87
93
  * Deterministic waitpoint token for a tool-call gate. Includes both
88
94
  * the model-generated call id (unique per iteration) AND the args hash
@@ -100,16 +106,6 @@ export function computeToolCallWaitToken(input: {
100
106
  .slice(0, 40);
101
107
  }
102
108
 
103
- /**
104
- * Shape the tool-level waitpoint resolves to when the reviewer decides.
105
- * Same shape as session-gate decisions — the approvals-complete route
106
- * materializes it identically.
107
- */
108
- export interface ToolHitlDecision {
109
- readonly decided: 'approve' | 'reject';
110
- readonly rationale?: string;
111
- }
112
-
113
109
  /**
114
110
  * In-conversation cache of decisions for `ask_on_first_use`. Persisted
115
111
  * on `agent_conversations.metadata.hitlToolDecisions` — a flat map of