@kindgi/agents 0.1.3 → 0.1.4-rc.1

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 (119) hide show
  1. package/dist/blocks.d.ts +103 -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/define.d.ts +17 -2
  6. package/dist/define.d.ts.map +1 -1
  7. package/dist/define.js +73 -6
  8. package/dist/define.js.map +1 -1
  9. package/dist/handlers/compose-result.d.ts.map +1 -1
  10. package/dist/handlers/compose-result.js +3 -0
  11. package/dist/handlers/compose-result.js.map +1 -1
  12. package/dist/handlers/context.d.ts +10 -0
  13. package/dist/handlers/context.d.ts.map +1 -1
  14. package/dist/handlers/dispatch-tools.d.ts.map +1 -1
  15. package/dist/handlers/dispatch-tools.js +51 -1
  16. package/dist/handlers/dispatch-tools.js.map +1 -1
  17. package/dist/handlers/errors.d.ts +12 -1
  18. package/dist/handlers/errors.d.ts.map +1 -1
  19. package/dist/handlers/errors.js.map +1 -1
  20. package/dist/handlers/evaluate-guardrails.d.ts.map +1 -1
  21. package/dist/handlers/evaluate-guardrails.js +2 -0
  22. package/dist/handlers/evaluate-guardrails.js.map +1 -1
  23. package/dist/handlers/model-call.d.ts.map +1 -1
  24. package/dist/handlers/model-call.js +11 -0
  25. package/dist/handlers/model-call.js.map +1 -1
  26. package/dist/handlers/public-types.d.ts +21 -1
  27. package/dist/handlers/public-types.d.ts.map +1 -1
  28. package/dist/handlers/rehydrate.d.ts.map +1 -1
  29. package/dist/handlers/rehydrate.js +6 -1
  30. package/dist/handlers/rehydrate.js.map +1 -1
  31. package/dist/handlers/render-prompt.d.ts.map +1 -1
  32. package/dist/handlers/render-prompt.js +4 -1
  33. package/dist/handlers/render-prompt.js.map +1 -1
  34. package/dist/handlers/replay.d.ts +143 -0
  35. package/dist/handlers/replay.d.ts.map +1 -0
  36. package/dist/handlers/replay.js +177 -0
  37. package/dist/handlers/replay.js.map +1 -0
  38. package/dist/handlers/resolve-blocks.d.ts +32 -0
  39. package/dist/handlers/resolve-blocks.d.ts.map +1 -0
  40. package/dist/handlers/resolve-blocks.js +129 -0
  41. package/dist/handlers/resolve-blocks.js.map +1 -0
  42. package/dist/handlers/resolve-tools.d.ts +6 -2
  43. package/dist/handlers/resolve-tools.d.ts.map +1 -1
  44. package/dist/handlers/resolve-tools.js +59 -27
  45. package/dist/handlers/resolve-tools.js.map +1 -1
  46. package/dist/handlers/result-shape.d.ts +7 -0
  47. package/dist/handlers/result-shape.d.ts.map +1 -1
  48. package/dist/handlers/result-shape.js.map +1 -1
  49. package/dist/handlers/run-retrievals.d.ts.map +1 -1
  50. package/dist/handlers/run-retrievals.js +34 -17
  51. package/dist/handlers/run-retrievals.js.map +1 -1
  52. package/dist/handlers/run-snapshot.d.ts.map +1 -1
  53. package/dist/handlers/run-snapshot.js +1 -0
  54. package/dist/handlers/run-snapshot.js.map +1 -1
  55. package/dist/handlers/setup.d.ts +2 -0
  56. package/dist/handlers/setup.d.ts.map +1 -1
  57. package/dist/handlers/setup.js +14 -2
  58. package/dist/handlers/setup.js.map +1 -1
  59. package/dist/handlers/turn-environment.d.ts +13 -2
  60. package/dist/handlers/turn-environment.d.ts.map +1 -1
  61. package/dist/handlers/turn-environment.js +12 -3
  62. package/dist/handlers/turn-environment.js.map +1 -1
  63. package/dist/index.d.ts +8 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +4 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/invoke.d.ts.map +1 -1
  68. package/dist/invoke.js +3 -0
  69. package/dist/invoke.js.map +1 -1
  70. package/dist/pins.d.ts +54 -0
  71. package/dist/pins.d.ts.map +1 -0
  72. package/dist/pins.js +51 -0
  73. package/dist/pins.js.map +1 -0
  74. package/dist/prompt.d.ts +12 -2
  75. package/dist/prompt.d.ts.map +1 -1
  76. package/dist/prompt.js +42 -5
  77. package/dist/prompt.js.map +1 -1
  78. package/dist/run-snapshot-binding.d.ts +5 -0
  79. package/dist/run-snapshot-binding.d.ts.map +1 -1
  80. package/dist/schema.d.ts +17 -0
  81. package/dist/schema.d.ts.map +1 -1
  82. package/dist/schema.js +5 -0
  83. package/dist/schema.js.map +1 -1
  84. package/dist/streaming.d.ts +5 -0
  85. package/dist/streaming.d.ts.map +1 -1
  86. package/dist/streaming.js.map +1 -1
  87. package/dist/types.d.ts +50 -1
  88. package/dist/types.d.ts.map +1 -1
  89. package/migrations/0004_stormy_moondragon.sql +1 -0
  90. package/migrations/meta/0004_snapshot.json +327 -0
  91. package/migrations/meta/_journal.json +7 -0
  92. package/package.json +15 -15
  93. package/src/blocks.ts +233 -0
  94. package/src/define.ts +84 -7
  95. package/src/handlers/compose-result.ts +3 -0
  96. package/src/handlers/context.ts +10 -0
  97. package/src/handlers/dispatch-tools.ts +54 -1
  98. package/src/handlers/errors.ts +13 -0
  99. package/src/handlers/evaluate-guardrails.ts +2 -0
  100. package/src/handlers/model-call.ts +14 -0
  101. package/src/handlers/public-types.ts +22 -1
  102. package/src/handlers/rehydrate.ts +21 -6
  103. package/src/handlers/render-prompt.ts +13 -7
  104. package/src/handlers/replay.ts +314 -0
  105. package/src/handlers/resolve-blocks.ts +188 -0
  106. package/src/handlers/resolve-tools.ts +76 -28
  107. package/src/handlers/result-shape.ts +8 -0
  108. package/src/handlers/run-retrievals.ts +44 -23
  109. package/src/handlers/run-snapshot.ts +1 -0
  110. package/src/handlers/setup.ts +13 -2
  111. package/src/handlers/turn-environment.ts +28 -3
  112. package/src/index.ts +37 -0
  113. package/src/invoke.ts +3 -0
  114. package/src/pins.ts +98 -0
  115. package/src/prompt.ts +53 -5
  116. package/src/run-snapshot-binding.ts +6 -0
  117. package/src/schema.ts +5 -0
  118. package/src/streaming.ts +5 -0
  119. package/src/types.ts +53 -1
@@ -0,0 +1,314 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Replay turns: an eval run re-running a past run (`InvokeAgentInput.replay`)
6
+ * on an agent version, without doing anything the past run didn't already
7
+ * do. Each tool call is decided by the deployment's `ReplayBinding`:
8
+ *
9
+ * - `live`: the tool runs. Only a tool declared read-only (see
10
+ * `isReadOnlyTool`) with no approval to wait for can; a `live`
11
+ * decision for any other is refused here, whatever the binding says;
12
+ * - `recorded`: the past run's result for the same call is used;
13
+ * - `refused`: the tool doesn't run, and the model gets the given result.
14
+ *
15
+ * A turn marked as a replay with no binding refuses every call. Each
16
+ * decision is journaled (`NodeContext.record`), so a resumed turn keeps
17
+ * the decisions it made, and the turn's result lists them
18
+ * (`AgentTurnResult.replay`).
19
+ */
20
+
21
+ import type { NodeContext } from '@kindgi/handler';
22
+ import type { RunReplayRef } from '@kindgi/runtime';
23
+ import type { JournalEntry, ValueRecordedPayload } from '@kindgi/runtime';
24
+ import type { Tool } from '@kindgi/tools';
25
+ import type { RunId, TenantId } from '@kindgi/types';
26
+
27
+ import type { RetrievedFact } from '../types.js';
28
+
29
+ import type { TurnContext } from './context.js';
30
+ import { throwAgentTurnFailure } from './errors.js';
31
+
32
+ /** The replay turn a `ReplayBinding` is asked about. */
33
+ export interface ReplayTurnRef {
34
+ readonly tenantId: TenantId;
35
+ /** The replay turn's own run. */
36
+ readonly runId: RunId;
37
+ readonly replay: RunReplayRef;
38
+ }
39
+
40
+ export interface ReplayToolInput extends ReplayTurnRef {
41
+ readonly tool: {
42
+ readonly id: string;
43
+ readonly version: string;
44
+ /** As the tool declares it; absent means it changes things. */
45
+ readonly mutating?: boolean;
46
+ readonly effects?: readonly string[];
47
+ };
48
+ readonly arguments: unknown;
49
+ /** The model's id for the call. */
50
+ readonly callId: string;
51
+ /** `true` when the tool's approval rules would ask for a review before it runs. */
52
+ readonly gated: boolean;
53
+ }
54
+
55
+ export type ReplayToolDecision =
56
+ | { readonly kind: 'live' }
57
+ | { readonly kind: 'recorded'; readonly result: unknown }
58
+ | { readonly kind: 'refused'; readonly result: unknown; readonly reason: string };
59
+
60
+ /** An approval decision the past run recorded. */
61
+ export interface ReplayApproval {
62
+ readonly approved: boolean;
63
+ readonly rationale?: string;
64
+ }
65
+
66
+ /**
67
+ * How a deployment replays turns. Consulted only for a turn marked as a
68
+ * replay (`InvokeAgentInput.replay`).
69
+ */
70
+ export interface ReplayBinding {
71
+ /** Decide one tool call. */
72
+ decideTool(input: ReplayToolInput): Promise<ReplayToolDecision>;
73
+ /**
74
+ * What the turn's retrievals return: the past run's, or `undefined` to
75
+ * retrieve live. Absent: retrieve live.
76
+ */
77
+ retrievals?(input: ReplayTurnRef): Promise<readonly RetrievedFact[] | undefined>;
78
+ /**
79
+ * The past run's decision at the session approval gate, when the replay
80
+ * reaches that gate: the replay follows it. `undefined` (or absent): the
81
+ * gate is skipped, and the result says so.
82
+ */
83
+ sessionApproval?(input: ReplayTurnRef): Promise<ReplayApproval | undefined>;
84
+ }
85
+
86
+ /** One tool call of a replay turn, and what happened to it. */
87
+ export interface ReplayToolTrace {
88
+ readonly step: number;
89
+ readonly callId: string;
90
+ readonly toolId: string;
91
+ readonly toolVersion: string;
92
+ readonly arguments: unknown;
93
+ readonly source: 'live' | 'recorded' | 'refused';
94
+ /** Why it was refused. A refused call is what the turn would have done. */
95
+ readonly reason?: string;
96
+ }
97
+
98
+ /** What a replay turn did differently from a live one (`AgentTurnResult.replay`). */
99
+ export interface ReplayTurnReport extends RunReplayRef {
100
+ readonly tools: readonly ReplayToolTrace[];
101
+ /**
102
+ * The session approval gate, when the replay reached it: `followed` the
103
+ * past run's recorded decision, or `skipped` (none was recorded).
104
+ */
105
+ readonly approval?: 'followed' | 'skipped';
106
+ }
107
+
108
+ /** The `record` key of a replay's tool decision, per call. */
109
+ export const REPLAY_TOOL_RECORD_PREFIX = 'replay-tool:';
110
+ /** The `record` key of a replay's session approval. */
111
+ export const REPLAY_APPROVAL_RECORD = 'replay-session-approval';
112
+
113
+ /** Effects a read-only tool can't declare. */
114
+ const CHANGING_EFFECTS: ReadonlySet<string> = new Set([
115
+ 'writes',
116
+ 'deletes',
117
+ 'spawns-run',
118
+ 'emits-event',
119
+ 'external-side-effect',
120
+ ]);
121
+
122
+ /** A tool that declares it changes nothing: `mutating: false`, and no changing effect. */
123
+ export function isReadOnlyTool(tool: {
124
+ readonly mutating?: boolean;
125
+ readonly effects?: readonly { readonly kind: string }[] | readonly string[];
126
+ }): boolean {
127
+ if (tool.mutating !== false) return false;
128
+ return !(tool.effects ?? []).some((e) =>
129
+ CHANGING_EFFECTS.has(typeof e === 'string' ? e : e.kind),
130
+ );
131
+ }
132
+
133
+ /** What the model gets for a call a replay refuses because nothing decided it. */
134
+ const NO_BINDING_REASON = 'replay: no replay binding is wired, so no tool runs';
135
+ const CHANGES_REASON = 'replay: this call changes things and has no recorded result';
136
+ const GATED_REASON = 'replay: this call needs an approval and has no recorded result';
137
+
138
+ function refusedResult(reason: string): Readonly<Record<string, unknown>> {
139
+ return { status: 'not-executed', reason };
140
+ }
141
+
142
+ /** The journaled decision of one call: its trace entry, and the result the model got. */
143
+ interface RecordedDecision extends ReplayToolTrace {
144
+ readonly result?: unknown;
145
+ }
146
+
147
+ /**
148
+ * Decide one tool call of a replay turn, once: the binding's decision,
149
+ * held to `isReadOnlyTool`, journaled, and added to the turn's trace.
150
+ */
151
+ export async function decideReplayTool(
152
+ ctx: TurnContext,
153
+ kctx: NodeContext,
154
+ call: {
155
+ readonly step: number;
156
+ readonly callId: string;
157
+ readonly tool: Tool;
158
+ readonly version: string;
159
+ readonly arguments: unknown;
160
+ readonly gated: boolean;
161
+ },
162
+ ): Promise<ReplayToolDecision> {
163
+ const replay = ctx.input.replay;
164
+ if (replay === undefined) return { kind: 'live' };
165
+ const base = {
166
+ step: call.step,
167
+ callId: call.callId,
168
+ toolId: call.tool.id as unknown as string,
169
+ toolVersion: call.version,
170
+ arguments: call.arguments,
171
+ };
172
+ const refuse = (reason: string): RecordedDecision => ({
173
+ ...base,
174
+ source: 'refused',
175
+ reason,
176
+ result: refusedResult(reason),
177
+ });
178
+ const decided = await kctx.record(
179
+ `${REPLAY_TOOL_RECORD_PREFIX}${call.callId}`,
180
+ async (): Promise<RecordedDecision> => {
181
+ const binding = ctx.bindings.replay;
182
+ if (binding === undefined) return refuse(NO_BINDING_REASON);
183
+ const decision = await binding.decideTool({
184
+ tenantId: ctx.input.tenantId,
185
+ runId: kctx.runId,
186
+ replay,
187
+ tool: {
188
+ id: base.toolId,
189
+ version: call.version,
190
+ ...(call.tool.mutating !== undefined && { mutating: call.tool.mutating }),
191
+ ...(call.tool.effects !== undefined && {
192
+ effects: call.tool.effects.map((e) => e.kind),
193
+ }),
194
+ },
195
+ arguments: call.arguments,
196
+ callId: call.callId,
197
+ gated: call.gated,
198
+ });
199
+ if (decision.kind === 'recorded') {
200
+ return { ...base, source: 'recorded', result: decision.result };
201
+ }
202
+ if (decision.kind === 'refused') {
203
+ return { ...base, source: 'refused', reason: decision.reason, result: decision.result };
204
+ }
205
+ // `live` runs only a read-only tool, and never waits for an approval.
206
+ if (!isReadOnlyTool(call.tool)) return refuse(CHANGES_REASON);
207
+ if (call.gated) return refuse(GATED_REASON);
208
+ return { ...base, source: 'live' };
209
+ },
210
+ );
211
+ addReplayTrace(ctx, traceOf(decided));
212
+ if (decided.source === 'live') return { kind: 'live' };
213
+ if (decided.source === 'recorded') return { kind: 'recorded', result: decided.result };
214
+ return { kind: 'refused', result: decided.result, reason: decided.reason ?? CHANGES_REASON };
215
+ }
216
+
217
+ /** A journaled decision's trace entry (without the result). */
218
+ function traceOf(decided: RecordedDecision): ReplayToolTrace {
219
+ const { result: _result, ...trace } = decided;
220
+ return trace;
221
+ }
222
+
223
+ function addReplayTrace(ctx: TurnContext, entry: ReplayToolTrace): void {
224
+ const trace = ctx.replayTrace ?? [];
225
+ ctx.replayTrace = trace;
226
+ // A step run again after a resume decides its calls again (from the journal).
227
+ if (trace.some((t) => t.callId === entry.callId)) return;
228
+ trace.push(entry);
229
+ }
230
+
231
+ /** The session approval a replay follows, decided once: the past run's, or none. */
232
+ export async function replaySessionApproval(
233
+ ctx: TurnContext,
234
+ kctx: NodeContext,
235
+ ): Promise<ReplayApproval | undefined> {
236
+ const replay = ctx.input.replay;
237
+ if (replay === undefined) return undefined;
238
+ const recorded = await kctx.record(
239
+ REPLAY_APPROVAL_RECORD,
240
+ async (): Promise<{ readonly approval: ReplayApproval | null }> => {
241
+ const approval = await ctx.bindings.replay?.sessionApproval?.({
242
+ tenantId: ctx.input.tenantId,
243
+ runId: kctx.runId,
244
+ replay,
245
+ });
246
+ return { approval: approval ?? null };
247
+ },
248
+ );
249
+ ctx.replayApproval = recorded.approval === null ? 'skipped' : 'followed';
250
+ return recorded.approval ?? undefined;
251
+ }
252
+
253
+ /**
254
+ * A replay at the session approval gate: it goes on when the past run's
255
+ * reviewer approved (or none was recorded), and fails as the past run did
256
+ * (`hitl-rejected`) when they rejected.
257
+ */
258
+ export async function followReplaySessionGate(ctx: TurnContext, kctx: NodeContext): Promise<void> {
259
+ const approval = await replaySessionApproval(ctx, kctx);
260
+ if (approval === undefined || approval.approved) return;
261
+ throwAgentTurnFailure({
262
+ code: 'hitl-rejected',
263
+ message: `The replayed run's reviewer rejected the session-HITL gate${
264
+ approval.rationale !== undefined ? `: ${approval.rationale}` : ''
265
+ }`,
266
+ ...(approval.rationale !== undefined && { rationale: approval.rationale }),
267
+ } as never);
268
+ }
269
+
270
+ /**
271
+ * A resumed replay turn's trace and approval, from the journal: every call
272
+ * decided before the park, and the session approval if `setup` reached it.
273
+ * The step the turn parked in decides its calls again, from the journal.
274
+ */
275
+ export function rehydrateReplay(ctx: TurnContext, journal: readonly JournalEntry[]): void {
276
+ if (ctx.input.replay === undefined) return;
277
+ for (const e of journal) {
278
+ if (e.kind !== 'value.recorded') continue;
279
+ const p = (e.payload ?? {}) as Partial<ValueRecordedPayload>;
280
+ if (p.key === REPLAY_APPROVAL_RECORD) {
281
+ const approval = (p.value as { readonly approval?: unknown } | undefined)?.approval;
282
+ ctx.replayApproval = approval == null ? 'skipped' : 'followed';
283
+ } else if (p.key?.startsWith(REPLAY_TOOL_RECORD_PREFIX) === true) {
284
+ restoreDecision(ctx, p.value);
285
+ }
286
+ }
287
+ }
288
+
289
+ function restoreDecision(ctx: TurnContext, value: unknown): void {
290
+ const decided = value as RecordedDecision | undefined;
291
+ if (decided?.source !== undefined && decided.callId !== undefined) {
292
+ addReplayTrace(ctx, traceOf(decided));
293
+ }
294
+ }
295
+
296
+ /** The turn's replay report, for its result. */
297
+ export function replayReport(ctx: TurnContext): ReplayTurnReport | undefined {
298
+ const replay = ctx.input.replay;
299
+ if (replay === undefined) return undefined;
300
+ return {
301
+ of: replay.of,
302
+ evalRunId: replay.evalRunId,
303
+ tools: [...(ctx.replayTrace ?? [])].sort((a, b) => a.step - b.step),
304
+ ...(ctx.replayApproval !== undefined && { approval: ctx.replayApproval }),
305
+ };
306
+ }
307
+
308
+ /** A replay's tag on its usage records (`ModelUsageRecord.replay`). */
309
+ export function replayTag(replay: RunReplayRef): {
310
+ readonly of: string;
311
+ readonly evalRunId: string;
312
+ } {
313
+ return { of: replay.of as unknown as string, evalRunId: replay.evalRunId };
314
+ }
@@ -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
  /**