@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
@@ -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
@@ -34,14 +36,21 @@ function computeSessionGateWaitToken(input: {
34
36
  .slice(0, 40);
35
37
  }
36
38
 
39
+ /** The `record` key of the session gate's decision. */
40
+ const SESSION_GATE_RECORD = 'session-hitl-gate';
41
+
37
42
  /**
38
- * Shape the setup handler expects when a session-gate waitpoint
39
- * resolves. Approvals-complete route materializes this from the
40
- * reviewer's decision (see packages/api/src/routes/approvals.ts).
43
+ * The session gate's decision, as `setup` records it the first time
44
+ * the gate asks for a review: the wait it parks on, and the review it asks
45
+ * for. A turn resumed after the park reads it back instead of deciding again.
41
46
  */
42
- interface SessionGateDecision {
43
- readonly decided: 'approve' | 'reject';
44
- readonly rationale?: string;
47
+ interface SessionGateRecord {
48
+ readonly waitTokenId: string;
49
+ readonly turnCount: number;
50
+ readonly threshold: number;
51
+ readonly reason: string;
52
+ readonly requiredRole: 'standard' | 'senior' | 'admin';
53
+ readonly timeoutMs: number;
45
54
  }
46
55
 
47
56
  /**
@@ -111,33 +120,46 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
111
120
  // `hitl` policy — before the gate that may park on them.
112
121
  const effectiveHitl = await resolveTurnHitlPolicy(ctx);
113
122
  ctx.hitlPolicy = effectiveHitl;
114
- const sessionGate = evaluateSessionGate(
115
- { ...(effectiveHitl.turn !== undefined && { afterTurns: effectiveHitl.turn.afterTurns }) },
116
- conversation.turnCount,
117
- );
118
- if (sessionGate.kind === 'hitl-required') {
119
- const waitTokenId = computeSessionGateWaitToken({
120
- runId: kctx.runId as unknown as string,
123
+ // Decided once: a turn that parked here resumes on the gate it parked
124
+ // on, whatever the policy or the conversation's turn count say by then.
125
+ const gate = await kctx.record(SESSION_GATE_RECORD, (): SessionGateRecord | undefined => {
126
+ const evaluated = evaluateSessionGate(
127
+ { ...(effectiveHitl.turn !== undefined && { afterTurns: effectiveHitl.turn.afterTurns }) },
128
+ conversation.turnCount,
129
+ );
130
+ if (evaluated.kind !== 'hitl-required') return undefined;
131
+ return {
132
+ waitTokenId: computeSessionGateWaitToken({
133
+ runId: kctx.runId as unknown as string,
134
+ turnCount: conversation.turnCount,
135
+ }),
121
136
  turnCount: conversation.turnCount,
122
- });
123
- const timeoutMs = effectiveHitl.timeoutMs;
137
+ threshold: evaluated.threshold,
138
+ reason: evaluated.reason,
139
+ requiredRole: effectiveHitl.defaultReviewerRole,
140
+ timeoutMs: effectiveHitl.timeoutMs,
141
+ };
142
+ });
143
+ if (gate !== undefined) {
144
+ const { waitTokenId, timeoutMs } = gate;
124
145
  const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
125
146
 
126
147
  if (ctx.bindings.hitl?.enqueue !== undefined) {
127
148
  try {
128
149
  await ctx.bindings.hitl.enqueue({
129
150
  tenantId: ctx.input.tenantId,
130
- subjectKind: 'agent-turn:session-hitl-gate',
151
+ projectId: ctx.input.projectId,
152
+ subjectKind: SESSION_GATE_SUBJECT,
131
153
  subjectRef: {
132
154
  conversationId: ctx.input.conversationId,
133
155
  agentId: ctx.input.agent.id,
134
156
  agentVersion: ctx.input.agent.version,
135
- turnCount: conversation.turnCount,
136
- threshold: sessionGate.threshold,
157
+ turnCount: gate.turnCount,
158
+ threshold: gate.threshold,
137
159
  },
138
- requiredRole: effectiveHitl.defaultReviewerRole,
160
+ requiredRole: gate.requiredRole,
139
161
  title: `HITL review required for ${ctx.input.agent.name}`,
140
- description: sessionGate.reason,
162
+ description: gate.reason,
141
163
  waitTokenId,
142
164
  provenanceRef: { runId: kctx.runId },
143
165
  expiresAt: expiresAt as never,
@@ -153,8 +175,8 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
153
175
  // execution (park), persist-provenance never runs so this node is
154
176
  // lost — that's fine. On the FINAL replay after resume, this node
155
177
  // AND the `resume` node below both emit and get persisted.
156
- const waitNodeId = `session-hitl-gate-wait:${conversation.turnCount}`;
157
- const resumeNodeId = `session-hitl-gate-resume:${conversation.turnCount}`;
178
+ const waitNodeId = `session-hitl-gate-wait:${gate.turnCount}`;
179
+ const resumeNodeId = `session-hitl-gate-resume:${gate.turnCount}`;
158
180
  if (ctx.provenance !== undefined) {
159
181
  ctx.provenance.addNode({
160
182
  id: waitNodeId,
@@ -163,17 +185,19 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
163
185
  actor: `agent:${ctx.input.agent.id as unknown as string}`,
164
186
  attributes: {
165
187
  gate: 'session-hitl',
166
- turnCount: conversation.turnCount,
167
- threshold: sessionGate.threshold,
188
+ turnCount: gate.turnCount,
189
+ threshold: gate.threshold,
168
190
  waitTokenId,
169
191
  },
170
192
  });
171
193
  }
172
194
 
173
195
  try {
174
- const decision = await kctx.waitForToken<SessionGateDecision>(waitTokenId, {
175
- timeoutMs,
176
- });
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
+ );
177
201
  // Post-resume: emit the resume node + resumed-from edge. Only
178
202
  // runs on the REPLAY path — the first execution throws
179
203
  // SuspensionSignal inside waitForToken and never gets here.
@@ -182,11 +206,14 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
182
206
  id: resumeNodeId,
183
207
  kind: 'resume',
184
208
  timestamp: new Date().toISOString() as never,
185
- 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}`,
186
211
  attributes: {
187
212
  gate: 'session-hitl',
188
- decision: decision.decided,
189
- ...(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 }),
190
217
  },
191
218
  });
192
219
  ctx.provenance.addEdge({
@@ -196,7 +223,7 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
196
223
  });
197
224
  }
198
225
 
199
- if (decision.decided === 'reject') {
226
+ if (!decision.approved) {
200
227
  throwAgentTurnFailure({
201
228
  code: 'hitl-rejected',
202
229
  message: `Reviewer rejected the session-HITL gate${
@@ -205,8 +232,7 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
205
232
  ...(decision.rationale !== undefined && { rationale: decision.rationale }),
206
233
  } as never);
207
234
  }
208
- // decision.decided === 'approve' → fall through, turn proceeds
209
- // normally through the rest of setup.
235
+ // Approved: the turn proceeds through the rest of setup.
210
236
  } catch (cause) {
211
237
  if (cause instanceof WaitpointCancelledError) {
212
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