@kindgi/agents 0.1.2 → 0.1.4-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/blocks.d.ts +101 -0
  2. package/dist/blocks.d.ts.map +1 -0
  3. package/dist/blocks.js +149 -0
  4. package/dist/blocks.js.map +1 -0
  5. package/dist/conversation-binding.d.ts +9 -1
  6. package/dist/conversation-binding.d.ts.map +1 -1
  7. package/dist/define.d.ts +17 -2
  8. package/dist/define.d.ts.map +1 -1
  9. package/dist/define.js +93 -6
  10. package/dist/define.js.map +1 -1
  11. package/dist/guardrails-gate.d.ts +7 -4
  12. package/dist/guardrails-gate.d.ts.map +1 -1
  13. package/dist/guardrails-gate.js +5 -2
  14. package/dist/guardrails-gate.js.map +1 -1
  15. package/dist/handlers/compose-result.d.ts.map +1 -1
  16. package/dist/handlers/compose-result.js +3 -0
  17. package/dist/handlers/compose-result.js.map +1 -1
  18. package/dist/handlers/context.d.ts +24 -0
  19. package/dist/handlers/context.d.ts.map +1 -1
  20. package/dist/handlers/dispatch-tools.d.ts.map +1 -1
  21. package/dist/handlers/dispatch-tools.js +69 -43
  22. package/dist/handlers/dispatch-tools.js.map +1 -1
  23. package/dist/handlers/errors.d.ts +12 -1
  24. package/dist/handlers/errors.d.ts.map +1 -1
  25. package/dist/handlers/errors.js.map +1 -1
  26. package/dist/handlers/evaluate-guardrails.d.ts.map +1 -1
  27. package/dist/handlers/evaluate-guardrails.js +36 -1
  28. package/dist/handlers/evaluate-guardrails.js.map +1 -1
  29. package/dist/handlers/gate-decision.d.ts +62 -0
  30. package/dist/handlers/gate-decision.d.ts.map +1 -0
  31. package/dist/handlers/gate-decision.js +55 -0
  32. package/dist/handlers/gate-decision.js.map +1 -0
  33. package/dist/handlers/model-call.d.ts +10 -0
  34. package/dist/handlers/model-call.d.ts.map +1 -1
  35. package/dist/handlers/model-call.js +120 -34
  36. package/dist/handlers/model-call.js.map +1 -1
  37. package/dist/handlers/persist-provenance.d.ts.map +1 -1
  38. package/dist/handlers/persist-provenance.js +3 -1
  39. package/dist/handlers/persist-provenance.js.map +1 -1
  40. package/dist/handlers/persist-user-message.d.ts.map +1 -1
  41. package/dist/handlers/persist-user-message.js +5 -16
  42. package/dist/handlers/persist-user-message.js.map +1 -1
  43. package/dist/handlers/public-types.d.ts +37 -3
  44. package/dist/handlers/public-types.d.ts.map +1 -1
  45. package/dist/handlers/rehydrate.d.ts.map +1 -1
  46. package/dist/handlers/rehydrate.js +91 -1
  47. package/dist/handlers/rehydrate.js.map +1 -1
  48. package/dist/handlers/render-prompt.d.ts.map +1 -1
  49. package/dist/handlers/render-prompt.js +4 -1
  50. package/dist/handlers/render-prompt.js.map +1 -1
  51. package/dist/handlers/replay.d.ts +143 -0
  52. package/dist/handlers/replay.d.ts.map +1 -0
  53. package/dist/handlers/replay.js +177 -0
  54. package/dist/handlers/replay.js.map +1 -0
  55. package/dist/handlers/resolve-blocks.d.ts +32 -0
  56. package/dist/handlers/resolve-blocks.d.ts.map +1 -0
  57. package/dist/handlers/resolve-blocks.js +129 -0
  58. package/dist/handlers/resolve-blocks.js.map +1 -0
  59. package/dist/handlers/resolve-tools.d.ts +6 -2
  60. package/dist/handlers/resolve-tools.d.ts.map +1 -1
  61. package/dist/handlers/resolve-tools.js +59 -27
  62. package/dist/handlers/resolve-tools.js.map +1 -1
  63. package/dist/handlers/result-shape.d.ts +7 -0
  64. package/dist/handlers/result-shape.d.ts.map +1 -1
  65. package/dist/handlers/result-shape.js.map +1 -1
  66. package/dist/handlers/run-retrievals.d.ts.map +1 -1
  67. package/dist/handlers/run-retrievals.js +35 -35
  68. package/dist/handlers/run-retrievals.js.map +1 -1
  69. package/dist/handlers/run-snapshot.d.ts.map +1 -1
  70. package/dist/handlers/run-snapshot.js +1 -0
  71. package/dist/handlers/run-snapshot.js.map +1 -1
  72. package/dist/handlers/setup.d.ts +2 -0
  73. package/dist/handlers/setup.d.ts.map +1 -1
  74. package/dist/handlers/setup.js +29 -12
  75. package/dist/handlers/setup.js.map +1 -1
  76. package/dist/handlers/tool-hitl.d.ts +5 -9
  77. package/dist/handlers/tool-hitl.d.ts.map +1 -1
  78. package/dist/handlers/tool-hitl.js +5 -0
  79. package/dist/handlers/tool-hitl.js.map +1 -1
  80. package/dist/handlers/turn-environment.d.ts +13 -2
  81. package/dist/handlers/turn-environment.d.ts.map +1 -1
  82. package/dist/handlers/turn-environment.js +12 -3
  83. package/dist/handlers/turn-environment.js.map +1 -1
  84. package/dist/handlers/turn-provenance.d.ts +67 -0
  85. package/dist/handlers/turn-provenance.d.ts.map +1 -0
  86. package/dist/handlers/turn-provenance.js +160 -0
  87. package/dist/handlers/turn-provenance.js.map +1 -0
  88. package/dist/index.d.ts +10 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +5 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/invoke.d.ts +14 -1
  93. package/dist/invoke.d.ts.map +1 -1
  94. package/dist/invoke.js +37 -19
  95. package/dist/invoke.js.map +1 -1
  96. package/dist/pins.d.ts +54 -0
  97. package/dist/pins.d.ts.map +1 -0
  98. package/dist/pins.js +51 -0
  99. package/dist/pins.js.map +1 -0
  100. package/dist/prompt.d.ts +12 -2
  101. package/dist/prompt.d.ts.map +1 -1
  102. package/dist/prompt.js +41 -4
  103. package/dist/prompt.js.map +1 -1
  104. package/dist/provenance-emit.d.ts +4 -2
  105. package/dist/provenance-emit.d.ts.map +1 -1
  106. package/dist/provenance-emit.js +4 -2
  107. package/dist/provenance-emit.js.map +1 -1
  108. package/dist/run-snapshot-binding.d.ts +5 -0
  109. package/dist/run-snapshot-binding.d.ts.map +1 -1
  110. package/dist/schema.d.ts +34 -0
  111. package/dist/schema.d.ts.map +1 -1
  112. package/dist/schema.js +16 -0
  113. package/dist/schema.js.map +1 -1
  114. package/dist/streaming.d.ts +5 -0
  115. package/dist/streaming.d.ts.map +1 -1
  116. package/dist/streaming.js.map +1 -1
  117. package/dist/types.d.ts +65 -2
  118. package/dist/types.d.ts.map +1 -1
  119. package/migrations/0003_hesitant_captain_cross.sql +2 -0
  120. package/migrations/0004_stormy_moondragon.sql +1 -0
  121. package/migrations/meta/0003_snapshot.json +321 -0
  122. package/migrations/meta/0004_snapshot.json +327 -0
  123. package/migrations/meta/_journal.json +14 -0
  124. package/package.json +15 -15
  125. package/src/blocks.ts +231 -0
  126. package/src/conversation-binding.ts +9 -1
  127. package/src/define.ts +104 -7
  128. package/src/guardrails-gate.ts +17 -3
  129. package/src/handlers/compose-result.ts +3 -0
  130. package/src/handlers/context.ts +24 -0
  131. package/src/handlers/dispatch-tools.ts +75 -44
  132. package/src/handlers/errors.ts +13 -0
  133. package/src/handlers/evaluate-guardrails.ts +41 -1
  134. package/src/handlers/gate-decision.ts +101 -0
  135. package/src/handlers/model-call.ts +163 -33
  136. package/src/handlers/persist-provenance.ts +3 -1
  137. package/src/handlers/persist-user-message.ts +3 -16
  138. package/src/handlers/public-types.ts +38 -3
  139. package/src/handlers/rehydrate.ts +141 -9
  140. package/src/handlers/render-prompt.ts +13 -7
  141. package/src/handlers/replay.ts +314 -0
  142. package/src/handlers/resolve-blocks.ts +188 -0
  143. package/src/handlers/resolve-tools.ts +76 -28
  144. package/src/handlers/result-shape.ts +8 -0
  145. package/src/handlers/run-retrievals.ts +45 -41
  146. package/src/handlers/run-snapshot.ts +1 -0
  147. package/src/handlers/setup.ts +30 -22
  148. package/src/handlers/tool-hitl.ts +6 -10
  149. package/src/handlers/turn-environment.ts +28 -3
  150. package/src/handlers/turn-provenance.ts +230 -0
  151. package/src/index.ts +44 -0
  152. package/src/invoke.ts +49 -20
  153. package/src/pins.ts +98 -0
  154. package/src/prompt.ts +52 -4
  155. package/src/provenance-emit.ts +4 -1
  156. package/src/run-snapshot-binding.ts +6 -0
  157. package/src/schema.ts +16 -0
  158. package/src/streaming.ts +5 -0
  159. package/src/types.ts +68 -2
@@ -0,0 +1,188 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { pickVersion } from '@kindgi/tools';
5
+
6
+ import {
7
+ type BlockDefinition,
8
+ MODEL_SETTINGS_SCHEMA,
9
+ type ModelSettings,
10
+ type PromptBlockContent,
11
+ settingsSchemaIssues,
12
+ } from '../blocks.js';
13
+
14
+ import type { TurnContext } from './context.js';
15
+ import { throwAgentTurnFailure } from './errors.js';
16
+
17
+ /** The block versions a turn runs, by kind then id, as `setup` journals them. */
18
+ export interface PinnedBlockVersions {
19
+ readonly prompts: Readonly<Record<string, string>>;
20
+ readonly settings: Readonly<Record<string, string>>;
21
+ }
22
+
23
+ /** The data blocks a turn runs with, loaded at its version. */
24
+ export interface TurnBlocks {
25
+ /** The prompt block the instructions come from. */
26
+ readonly prompt?: {
27
+ readonly id: string;
28
+ readonly version: string;
29
+ readonly content: PromptBlockContent;
30
+ };
31
+ /** Each settings block's values, by block id (`ToolContext.settings`, `settings` in templates). */
32
+ readonly settings: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
33
+ /** The model-settings block's values, for the turn's model calls. */
34
+ readonly modelSettings?: ModelSettings;
35
+ readonly versions: PinnedBlockVersions;
36
+ }
37
+
38
+ interface Ref {
39
+ readonly kind: 'prompts' | 'settings';
40
+ readonly id: string;
41
+ readonly range: string;
42
+ readonly role: 'prompt' | 'settings' | 'model-settings';
43
+ }
44
+
45
+ /**
46
+ * Load the data blocks the agent references, each at one exact version:
47
+ * the resumed turn's own (`pinned`, from its `setup`), else the agent
48
+ * version's pin (`agent.pins`), else its range resolved now by
49
+ * `pickVersion` (an agent version published before pins). A pinned
50
+ * version that's unregistered still loads; one that's gone, a range
51
+ * nothing satisfies, or a runtime with no blocks fails the turn
52
+ * (`block-unresolvable`). `undefined` when the agent references none.
53
+ */
54
+ export async function resolveTurnBlocks(
55
+ ctx: TurnContext,
56
+ pinned?: PinnedBlockVersions,
57
+ ): Promise<TurnBlocks | undefined> {
58
+ const agent = ctx.input.agent;
59
+ const refs = blockRefs(ctx);
60
+ if (refs.length === 0) return undefined;
61
+ const reader = ctx.bindings.blockReader;
62
+ if (reader === undefined) {
63
+ fail(
64
+ refs[0] as Ref,
65
+ `agent "${agent.id}" references data blocks, but this runtime serves none`,
66
+ );
67
+ }
68
+
69
+ const versions = {
70
+ prompts: {} as Record<string, string>,
71
+ settings: {} as Record<string, string>,
72
+ };
73
+ const loaded = new Map<Ref, BlockDefinition>();
74
+ for (const ref of refs) {
75
+ const version =
76
+ pinned?.[ref.kind][ref.id] ??
77
+ agent.pins?.[ref.kind][ref.id] ??
78
+ (await resolveRange(ctx, ref));
79
+ const block = await reader.getVersion({
80
+ tenantId: ctx.input.tenantId,
81
+ blockId: ref.id,
82
+ version,
83
+ });
84
+ if (block === null) {
85
+ fail(ref, `${describe(ref)} runs version ${version}, which isn't published`);
86
+ }
87
+ if (block.kind !== (ref.role === 'prompt' ? 'prompt' : 'settings')) {
88
+ fail(ref, `${describe(ref)} is a ${block.kind} block`);
89
+ }
90
+ versions[ref.kind][ref.id] = version;
91
+ loaded.set(ref, block);
92
+ }
93
+ return turnBlocks(refs, loaded, versions);
94
+ }
95
+
96
+ function blockRefs(ctx: TurnContext): Ref[] {
97
+ const agent = ctx.input.agent;
98
+ const refs: Ref[] = [];
99
+ if (typeof agent.instructions === 'object') {
100
+ refs.push({
101
+ kind: 'prompts',
102
+ id: agent.instructions.prompt,
103
+ range: agent.instructions.version,
104
+ role: 'prompt',
105
+ });
106
+ }
107
+ for (const s of agent.settings ?? []) {
108
+ refs.push({ kind: 'settings', id: s.id, range: s.version, role: 'settings' });
109
+ }
110
+ if (agent.modelSettings !== undefined) {
111
+ refs.push({
112
+ kind: 'settings',
113
+ id: agent.modelSettings.id,
114
+ range: agent.modelSettings.version,
115
+ role: 'model-settings',
116
+ });
117
+ }
118
+ return refs;
119
+ }
120
+
121
+ /** An unpinned reference's range, resolved over the block's active versions. */
122
+ async function resolveRange(ctx: TurnContext, ref: Ref): Promise<string> {
123
+ const available =
124
+ (await ctx.bindings.blockReader?.activeVersions({
125
+ tenantId: ctx.input.tenantId,
126
+ blockId: ref.id,
127
+ })) ?? [];
128
+ const pick = pickVersion(available, ref.range);
129
+ if (pick.kind === 'ok') return pick.version;
130
+ return fail(
131
+ ref,
132
+ available.length === 0
133
+ ? `${describe(ref)} has no published version`
134
+ : `${describe(ref)} has no published version in "${ref.range}" (published: ${available.join(', ')})`,
135
+ );
136
+ }
137
+
138
+ function turnBlocks(
139
+ refs: readonly Ref[],
140
+ loaded: ReadonlyMap<Ref, BlockDefinition>,
141
+ versions: PinnedBlockVersions,
142
+ ): TurnBlocks {
143
+ const settings: Record<string, Readonly<Record<string, unknown>>> = {};
144
+ let prompt: TurnBlocks['prompt'];
145
+ let modelSettings: ModelSettings | undefined;
146
+ for (const ref of refs) {
147
+ const block = loaded.get(ref) as BlockDefinition;
148
+ if (block.kind === 'prompt') {
149
+ prompt = { id: block.id, version: block.version, content: block.content };
150
+ } else if (ref.role === 'model-settings') {
151
+ const issues = settingsSchemaIssues(block.content.values, MODEL_SETTINGS_SCHEMA);
152
+ if (issues.length > 0) {
153
+ fail(
154
+ ref,
155
+ `${describe(ref)} version ${block.version} isn't model settings: ${issues.map((i) => `${i.path} ${i.message}`).join('; ')}`,
156
+ );
157
+ }
158
+ modelSettings = block.content.values as ModelSettings;
159
+ } else {
160
+ settings[block.id] = block.content.values;
161
+ }
162
+ }
163
+ return {
164
+ ...(prompt !== undefined && { prompt }),
165
+ settings,
166
+ ...(modelSettings !== undefined && { modelSettings }),
167
+ versions,
168
+ };
169
+ }
170
+
171
+ function describe(ref: Ref): string {
172
+ const what =
173
+ ref.role === 'prompt'
174
+ ? 'prompt'
175
+ : ref.role === 'model-settings'
176
+ ? 'model-settings'
177
+ : 'settings';
178
+ return `${what} block "${ref.id}"`;
179
+ }
180
+
181
+ function fail(ref: Ref, message: string): never {
182
+ return throwAgentTurnFailure({
183
+ code: 'block-unresolvable',
184
+ message,
185
+ blockId: ref.id,
186
+ requestedRange: ref.range,
187
+ });
188
+ }
@@ -2,9 +2,10 @@
2
2
  // Copyright (C) 2026 Kindgi Inc.
3
3
 
4
4
  import type { ModelToolDefinition } from '@kindgi/capabilities';
5
- import type { Tool, ToolRegistry } from '@kindgi/tools';
5
+ import type { Tool, ToolError, ToolRegistry, ToolResolution } from '@kindgi/tools';
6
+ import type { Result, ToolId } from '@kindgi/types';
6
7
 
7
- import type { Agent } from '../types.js';
8
+ import type { Agent, ToolRef } from '../types.js';
8
9
 
9
10
  import type { TurnContext } from './context.js';
10
11
  import { throwAgentTurnFailure } from './errors.js';
@@ -12,11 +13,16 @@ import { throwAgentTurnFailure } from './errors.js';
12
13
  /**
13
14
  * Resolve the agent's tool references against one tenant's registry
14
15
  * (`ToolRegistry.forTenant`). Throws the turn failure for an unknown
15
- * tool or an unsatisfiable version range.
16
+ * tool or an unsatisfiable version range. A tool in `pinned` (a resumed
17
+ * turn's, from its `setup`) resolves to exactly that version, and so,
18
+ * otherwise, does one in the agent version's own pins (`agent.pins`, set
19
+ * when it was published): one that's gone fails the turn rather than
20
+ * running another. Without either, the tool's range resolves.
16
21
  */
17
22
  export function resolveTurnTools(
18
23
  registry: ToolRegistry,
19
24
  agent: Agent,
25
+ pinned?: Readonly<Record<string, string>>,
20
26
  ): NonNullable<TurnContext['tools']> {
21
27
  const definitions: ModelToolDefinition[] = [];
22
28
  const byName = new Map<
@@ -29,32 +35,16 @@ export function resolveTurnTools(
29
35
  // (npm-compatible semver, backed by `maxSatisfying`); capture `resolvedVersion` in
30
36
  // the byName map so `dispatch-tools` can emit it in provenance +
31
37
  // telemetry — a replay can then pin against the same version.
32
- const resolved = registry.resolve(ref.id as never, ref.version);
38
+ const turnPin = pinned?.[ref.id];
39
+ const pin = turnPin ?? agent.pins?.tools[ref.id];
40
+ // A pin names its exact version, which a retired (unregistered) one
41
+ // still serves; a range picks among the active versions only.
42
+ const resolved =
43
+ pin !== undefined
44
+ ? exactVersion(registry, ref.id, pin)
45
+ : registry.resolve(ref.id as never, ref.version);
33
46
  if (resolved.kind === 'err') {
34
- const err = resolved.error;
35
- if (err.code === 'tool-not-found') {
36
- throwAgentTurnFailure({
37
- code: 'unresolved-tool',
38
- message: `Tool "${ref.id}" declared by agent "${agent.id}" is not registered`,
39
- toolId: ref.id,
40
- });
41
- }
42
- if (err.code === 'invalid-version-range' || err.code === 'tool-version-unresolvable') {
43
- throwAgentTurnFailure({
44
- code: 'tool-version-unresolvable',
45
- message: err.message,
46
- toolId: ref.id,
47
- requestedRange: ref.version,
48
- ...(err.code === 'tool-version-unresolvable' && {
49
- availableVersions: err.availableVersions,
50
- }),
51
- });
52
- }
53
- throwAgentTurnFailure({
54
- code: 'unresolved-tool',
55
- message: err.message,
56
- toolId: ref.id,
57
- });
47
+ throwUnresolved(agent, ref, resolved.error, pin, turnPin !== undefined ? 'turn' : 'agent');
58
48
  }
59
49
  const { tool, resolvedVersion } = resolved.value;
60
50
  definitions.push({
@@ -66,3 +56,61 @@ export function resolveTurnTools(
66
56
  }
67
57
  return { definitions, byName };
68
58
  }
59
+
60
+ /** A pinned tool version, retired or not, as a resolution. */
61
+ function exactVersion(
62
+ registry: ToolRegistry,
63
+ id: string,
64
+ version: string,
65
+ ): Result<ToolResolution, ToolError> {
66
+ const found = registry.getVersion(id as ToolId, version);
67
+ return found.kind === 'ok'
68
+ ? { kind: 'ok', value: { tool: found.value, resolvedVersion: version } }
69
+ : found;
70
+ }
71
+
72
+ /**
73
+ * Fail the turn for a tool that didn't resolve. A pinned version that's
74
+ * gone names whose pin it was: the turn's own (it started with that
75
+ * version) or the agent version's (it was published with it).
76
+ */
77
+ function throwUnresolved(
78
+ agent: Agent,
79
+ ref: ToolRef,
80
+ err: ToolError,
81
+ pin: string | undefined,
82
+ pinnedBy: 'turn' | 'agent',
83
+ ): never {
84
+ const available = err.code === 'tool-version-unresolvable' && {
85
+ availableVersions: err.availableVersions,
86
+ };
87
+ if (pin !== undefined) {
88
+ throwAgentTurnFailure({
89
+ code: 'tool-version-unresolvable',
90
+ message:
91
+ pinnedBy === 'turn'
92
+ ? `Tool "${ref.id}": this turn started with version ${pin}, which is no longer registered; it doesn't run another version mid-turn. ${err.message}`
93
+ : `Tool "${ref.id}": agent "${agent.id}" version ${agent.version} runs version ${pin} (its pins), which isn't registered; it doesn't run another version. ${err.message}`,
94
+ toolId: ref.id,
95
+ requestedRange: ref.version,
96
+ ...available,
97
+ });
98
+ }
99
+ if (err.code === 'tool-not-found') {
100
+ throwAgentTurnFailure({
101
+ code: 'unresolved-tool',
102
+ message: `Tool "${ref.id}" declared by agent "${agent.id}" is not registered`,
103
+ toolId: ref.id,
104
+ });
105
+ }
106
+ if (err.code === 'invalid-version-range' || err.code === 'tool-version-unresolvable') {
107
+ throwAgentTurnFailure({
108
+ code: 'tool-version-unresolvable',
109
+ message: err.message,
110
+ toolId: ref.id,
111
+ requestedRange: ref.version,
112
+ ...available,
113
+ });
114
+ }
115
+ throwAgentTurnFailure({ code: 'unresolved-tool', message: err.message, toolId: ref.id });
116
+ }
@@ -7,6 +7,8 @@ import type { RunId } from '@kindgi/types';
7
7
 
8
8
  import type { ConversationId, ConversationMessage, RetrievedFact } from '../types.js';
9
9
 
10
+ import type { ReplayTurnReport } from './replay.js';
11
+
10
12
  /**
11
13
  * Public shape returned by `invokeAgent` on success. Extracted from
12
14
  * `invoke.ts` into this module so handler code can import the type
@@ -70,6 +72,12 @@ export interface AgentTurnResult {
70
72
  * `response.content`.
71
73
  */
72
74
  readonly status?: 'completed' | 'suspended';
75
+ /**
76
+ * A replay turn's report (`InvokeAgentInput.replay`): each tool call and
77
+ * whether it ran, used the past run's result, or was refused (what the
78
+ * turn would have done), and how the session approval gate went.
79
+ */
80
+ readonly replay?: ReplayTurnReport;
73
81
  }
74
82
 
75
83
  /**
@@ -1,13 +1,15 @@
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 { NodeContext, NodeHandler } from '@kindgi/handler';
5
5
 
6
6
  import { runRetrievals } from '../retrieval.js';
7
7
  import { emitTurnEvent } from '../streaming.js';
8
+ import type { RetrievedFact } from '../types.js';
8
9
 
9
10
  import type { TurnContext } from './context.js';
10
11
  import { throwAgentTurnFailure } from './errors.js';
12
+ import { addRetrievalNodes } from './turn-provenance.js';
11
13
 
12
14
  /**
13
15
  * Execute the agent's declared retrieval intents. Populates
@@ -16,7 +18,7 @@ import { throwAgentTurnFailure } from './errors.js';
16
18
  * is wired.
17
19
  */
18
20
  export function buildRunRetrievalsHandler(ctx: TurnContext): NodeHandler {
19
- return async () => {
21
+ return async (_input, kctx) => {
20
22
  if (ctx.conversation === undefined || ctx.userMessage === undefined) {
21
23
  throwAgentTurnFailure({
22
24
  code: 'model-invocation-failed',
@@ -24,54 +26,56 @@ export function buildRunRetrievalsHandler(ctx: TurnContext): NodeHandler {
24
26
  cause: null,
25
27
  });
26
28
  }
27
- const retrieved = await runRetrievals(
28
- ctx.input.agent,
29
- ctx.conversation,
30
- ctx.input.conversationId,
31
- ctx.input.userMessage,
32
- {
33
- memory: ctx.bindings.memoryBinding,
34
- ...(ctx.bindings.embeddingRegistry !== undefined && {
35
- embeddingRegistry: ctx.bindings.embeddingRegistry,
36
- }),
37
- ...(ctx.bindings.embeddingModel !== undefined && {
38
- embeddingModel: ctx.bindings.embeddingModel,
39
- }),
40
- },
41
- );
42
- if (retrieved.kind === 'err') throwAgentTurnFailure(retrieved.error);
43
- ctx.retrieved = retrieved.value;
29
+ const facts = (await recordedRetrievals(ctx, kctx)) ?? (await retrieveLive(ctx));
30
+ ctx.retrieved = facts;
44
31
 
45
32
  await emitTurnEvent(ctx.bindings.onEvent, {
46
33
  kind: 'retrieval.completed',
47
- count: retrieved.value.length,
48
- factIds: retrieved.value.map((r) => r.fact.id),
34
+ count: facts.length,
35
+ factIds: facts.map((r) => r.fact.id),
49
36
  });
50
37
 
51
38
  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
- }
39
+ addRetrievalNodes(ctx.provenance, facts, ctx.userMessage);
71
40
  }
72
41
 
73
42
  // The facts go in the journal: a resumed turn restores them from it
74
43
  // (`rehydrateTurnContext`) rather than retrieving again.
75
- return { count: retrieved.value.length, retrieved: retrieved.value };
44
+ return { count: facts.length, retrieved: facts };
76
45
  };
77
46
  }
47
+
48
+ /** A replay's retrievals: what the past run retrieved, when the replay binding has it. */
49
+ async function recordedRetrievals(
50
+ ctx: TurnContext,
51
+ kctx: NodeContext,
52
+ ): Promise<readonly RetrievedFact[] | undefined> {
53
+ const replay = ctx.input.replay;
54
+ if (replay === undefined || ctx.bindings.replay?.retrievals === undefined) return undefined;
55
+ return ctx.bindings.replay.retrievals({
56
+ tenantId: ctx.input.tenantId,
57
+ runId: kctx.runId,
58
+ replay,
59
+ });
60
+ }
61
+
62
+ async function retrieveLive(ctx: TurnContext): Promise<readonly RetrievedFact[]> {
63
+ if (ctx.conversation === undefined) return [];
64
+ const retrieved = await runRetrievals(
65
+ ctx.input.agent,
66
+ ctx.conversation,
67
+ ctx.input.conversationId,
68
+ ctx.input.userMessage,
69
+ {
70
+ memory: ctx.bindings.memoryBinding,
71
+ ...(ctx.bindings.embeddingRegistry !== undefined && {
72
+ embeddingRegistry: ctx.bindings.embeddingRegistry,
73
+ }),
74
+ ...(ctx.bindings.embeddingModel !== undefined && {
75
+ embeddingModel: ctx.bindings.embeddingModel,
76
+ }),
77
+ },
78
+ );
79
+ if (retrieved.kind === 'err') throwAgentTurnFailure(retrieved.error);
80
+ return retrieved.value;
81
+ }
@@ -40,5 +40,6 @@ export async function writeRunSnapshot(ctx: TurnContext, kctx: NodeContext): Pro
40
40
  ...(ctx.input.dryRun === true && { dryRun: true }),
41
41
  ...(ctx.input.principal !== undefined && { principal: ctx.input.principal }),
42
42
  ...(ctx.input.authz !== undefined && { authz: ctx.input.authz }),
43
+ ...(ctx.input.replay !== undefined && { replay: ctx.input.replay }),
43
44
  });
44
45
  }
@@ -11,12 +11,15 @@ 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';
15
+ import { followReplaySessionGate } from './replay.js';
14
16
  import { writeRunSnapshot } from './run-snapshot.js';
15
17
  import {
16
18
  loadTurnConversation,
17
19
  resolveTurnEnvironment,
18
20
  resolveTurnHitlPolicy,
19
21
  } from './turn-environment.js';
22
+ import { decisionOf } from './turn-provenance.js';
20
23
 
21
24
  /**
22
25
  * Deterministic waitpoint token — must produce the same value on every
@@ -35,7 +38,7 @@ function computeSessionGateWaitToken(input: {
35
38
  }
36
39
 
37
40
  /** The `record` key of the session gate's decision. */
38
- const SESSION_GATE_RECORD = 'session-hitl-gate';
41
+ export const SESSION_GATE_RECORD = 'session-hitl-gate';
39
42
 
40
43
  /**
41
44
  * The session gate's decision, as `setup` records it the first time
@@ -51,16 +54,6 @@ interface SessionGateRecord {
51
54
  readonly timeoutMs: number;
52
55
  }
53
56
 
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
57
  /**
65
58
  * The first node in the flow. Runs every precondition check in one
66
59
  * gate so the run either commits to a full turn or fails fast before
@@ -148,7 +141,11 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
148
141
  timeoutMs: effectiveHitl.timeoutMs,
149
142
  };
150
143
  });
151
- if (gate !== undefined) {
144
+ if (gate !== undefined && ctx.input.replay !== undefined) {
145
+ // A replay follows the past run's decision at this gate, or skips it
146
+ // when none was recorded; it never waits for a reviewer.
147
+ await followReplaySessionGate(ctx, kctx);
148
+ } else if (gate !== undefined) {
152
149
  const { waitTokenId, timeoutMs } = gate;
153
150
  const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
154
151
 
@@ -156,7 +153,8 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
156
153
  try {
157
154
  await ctx.bindings.hitl.enqueue({
158
155
  tenantId: ctx.input.tenantId,
159
- subjectKind: 'agent-turn:session-hitl-gate',
156
+ projectId: ctx.input.projectId,
157
+ subjectKind: SESSION_GATE_SUBJECT,
160
158
  subjectRef: {
161
159
  conversationId: ctx.input.conversationId,
162
160
  agentId: ctx.input.agent.id,
@@ -200,9 +198,11 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
200
198
  }
201
199
 
202
200
  try {
203
- const decision = await kctx.waitForToken<SessionGateDecision>(waitTokenId, {
204
- timeoutMs,
205
- });
201
+ // Fails closed: only an explicit approve lets the turn go on
202
+ // (`readGateDecision`).
203
+ const decision = readGateDecision(
204
+ await kctx.waitForToken<unknown>(waitTokenId, { timeoutMs }),
205
+ );
206
206
  // Post-resume: emit the resume node + resumed-from edge. Only
207
207
  // runs on the REPLAY path — the first execution throws
208
208
  // SuspensionSignal inside waitForToken and never gets here.
@@ -211,11 +211,14 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
211
211
  id: resumeNodeId,
212
212
  kind: 'resume',
213
213
  timestamp: new Date().toISOString() as never,
214
- actor: `agent:${ctx.input.agent.id as unknown as string}`,
214
+ // Whoever decided; a decision recorded before it named them, the agent.
215
+ actor: decision.decidedBy ?? `agent:${ctx.input.agent.id as unknown as string}`,
215
216
  attributes: {
216
217
  gate: 'session-hitl',
217
- decision: decision.decided,
218
- ...(decision.rationale !== undefined && { rationale: decision.rationale }),
218
+ decision: decisionOf(decision),
219
+ ...(!decision.approved &&
220
+ decision.rationale !== undefined && { rationale: decision.rationale }),
221
+ ...(decision.approvalId !== undefined && { approvalId: decision.approvalId }),
219
222
  },
220
223
  });
221
224
  ctx.provenance.addEdge({
@@ -225,7 +228,7 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
225
228
  });
226
229
  }
227
230
 
228
- if (decision.decided === 'reject') {
231
+ if (!decision.approved) {
229
232
  throwAgentTurnFailure({
230
233
  code: 'hitl-rejected',
231
234
  message: `Reviewer rejected the session-HITL gate${
@@ -234,8 +237,7 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
234
237
  ...(decision.rationale !== undefined && { rationale: decision.rationale }),
235
238
  } as never);
236
239
  }
237
- // decision.decided === 'approve' → fall through, turn proceeds
238
- // normally through the rest of setup.
240
+ // Approved: the turn proceeds through the rest of setup.
239
241
  } catch (cause) {
240
242
  if (cause instanceof WaitpointCancelledError) {
241
243
  // Emit resume node with cancelled attribute so the DAG still
@@ -293,6 +295,12 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
293
295
  providerId: environment.providerId,
294
296
  providerModel: environment.model,
295
297
  toolCount: environment.toolCount,
298
+ // The version each tool resolved to: a resumed turn runs these.
299
+ toolVersions: environment.toolVersions,
300
+ // And each data block's.
301
+ ...(environment.blockVersions !== undefined && {
302
+ blockVersions: environment.blockVersions,
303
+ }),
296
304
  };
297
305
  };
298
306
  }
@@ -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