@kindgi/agents 0.0.0-bootstrap.0 → 0.1.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 (240) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +131 -2
  3. package/dist/agent-turn-flow.d.ts +57 -0
  4. package/dist/agent-turn-flow.d.ts.map +1 -0
  5. package/dist/agent-turn-flow.js +172 -0
  6. package/dist/agent-turn-flow.js.map +1 -0
  7. package/dist/conversation-binding.d.ts +111 -0
  8. package/dist/conversation-binding.d.ts.map +1 -0
  9. package/dist/conversation-binding.js +4 -0
  10. package/dist/conversation-binding.js.map +1 -0
  11. package/dist/define.d.ts +180 -0
  12. package/dist/define.d.ts.map +1 -0
  13. package/dist/define.js +361 -0
  14. package/dist/define.js.map +1 -0
  15. package/dist/errors.d.ts +62 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +4 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/guardrails-gate.d.ts +169 -0
  20. package/dist/guardrails-gate.d.ts.map +1 -0
  21. package/dist/guardrails-gate.js +202 -0
  22. package/dist/guardrails-gate.js.map +1 -0
  23. package/dist/handlers/budget-check.d.ts +22 -0
  24. package/dist/handlers/budget-check.d.ts.map +1 -0
  25. package/dist/handlers/budget-check.js +109 -0
  26. package/dist/handlers/budget-check.js.map +1 -0
  27. package/dist/handlers/build-initial-messages.d.ts +17 -0
  28. package/dist/handlers/build-initial-messages.d.ts.map +1 -0
  29. package/dist/handlers/build-initial-messages.js +86 -0
  30. package/dist/handlers/build-initial-messages.js.map +1 -0
  31. package/dist/handlers/compose-result.d.ts +10 -0
  32. package/dist/handlers/compose-result.d.ts.map +1 -0
  33. package/dist/handlers/compose-result.js +79 -0
  34. package/dist/handlers/compose-result.js.map +1 -0
  35. package/dist/handlers/constants.d.ts +8 -0
  36. package/dist/handlers/constants.d.ts.map +1 -0
  37. package/dist/handlers/constants.js +10 -0
  38. package/dist/handlers/constants.js.map +1 -0
  39. package/dist/handlers/context.d.ts +210 -0
  40. package/dist/handlers/context.d.ts.map +1 -0
  41. package/dist/handlers/context.js +4 -0
  42. package/dist/handlers/context.js.map +1 -0
  43. package/dist/handlers/dispatch-tools.d.ts +15 -0
  44. package/dist/handlers/dispatch-tools.d.ts.map +1 -0
  45. package/dist/handlers/dispatch-tools.js +511 -0
  46. package/dist/handlers/dispatch-tools.js.map +1 -0
  47. package/dist/handlers/errors.d.ts +133 -0
  48. package/dist/handlers/errors.d.ts.map +1 -0
  49. package/dist/handlers/errors.js +134 -0
  50. package/dist/handlers/errors.js.map +1 -0
  51. package/dist/handlers/evaluate-guardrails.d.ts +14 -0
  52. package/dist/handlers/evaluate-guardrails.d.ts.map +1 -0
  53. package/dist/handlers/evaluate-guardrails.js +128 -0
  54. package/dist/handlers/evaluate-guardrails.js.map +1 -0
  55. package/dist/handlers/final-iteration.d.ts +7 -0
  56. package/dist/handlers/final-iteration.d.ts.map +1 -0
  57. package/dist/handlers/final-iteration.js +26 -0
  58. package/dist/handlers/final-iteration.js.map +1 -0
  59. package/dist/handlers/index.d.ts +5 -0
  60. package/dist/handlers/index.d.ts.map +1 -0
  61. package/dist/handlers/index.js +41 -0
  62. package/dist/handlers/index.js.map +1 -0
  63. package/dist/handlers/model-call.d.ts +13 -0
  64. package/dist/handlers/model-call.d.ts.map +1 -0
  65. package/dist/handlers/model-call.js +136 -0
  66. package/dist/handlers/model-call.js.map +1 -0
  67. package/dist/handlers/persist-final-message.d.ts +14 -0
  68. package/dist/handlers/persist-final-message.d.ts.map +1 -0
  69. package/dist/handlers/persist-final-message.js +54 -0
  70. package/dist/handlers/persist-final-message.js.map +1 -0
  71. package/dist/handlers/persist-provenance.d.ts +12 -0
  72. package/dist/handlers/persist-provenance.d.ts.map +1 -0
  73. package/dist/handlers/persist-provenance.js +34 -0
  74. package/dist/handlers/persist-provenance.js.map +1 -0
  75. package/dist/handlers/persist-user-message.d.ts +15 -0
  76. package/dist/handlers/persist-user-message.d.ts.map +1 -0
  77. package/dist/handlers/persist-user-message.js +59 -0
  78. package/dist/handlers/persist-user-message.js.map +1 -0
  79. package/dist/handlers/public-types.d.ts +201 -0
  80. package/dist/handlers/public-types.d.ts.map +1 -0
  81. package/dist/handlers/public-types.js +4 -0
  82. package/dist/handlers/public-types.js.map +1 -0
  83. package/dist/handlers/rehydrate.d.ts +8 -0
  84. package/dist/handlers/rehydrate.d.ts.map +1 -0
  85. package/dist/handlers/rehydrate.js +94 -0
  86. package/dist/handlers/rehydrate.js.map +1 -0
  87. package/dist/handlers/render-prompt.d.ts +12 -0
  88. package/dist/handlers/render-prompt.d.ts.map +1 -0
  89. package/dist/handlers/render-prompt.js +41 -0
  90. package/dist/handlers/render-prompt.js.map +1 -0
  91. package/dist/handlers/resolve-tools.d.ts +10 -0
  92. package/dist/handlers/resolve-tools.d.ts.map +1 -0
  93. package/dist/handlers/resolve-tools.js +55 -0
  94. package/dist/handlers/resolve-tools.js.map +1 -0
  95. package/dist/handlers/result-shape.d.ts +94 -0
  96. package/dist/handlers/result-shape.d.ts.map +1 -0
  97. package/dist/handlers/result-shape.js +19 -0
  98. package/dist/handlers/result-shape.js.map +1 -0
  99. package/dist/handlers/run-retrievals.d.ts +10 -0
  100. package/dist/handlers/run-retrievals.d.ts.map +1 -0
  101. package/dist/handlers/run-retrievals.js +64 -0
  102. package/dist/handlers/run-retrievals.js.map +1 -0
  103. package/dist/handlers/run-snapshot.d.ts +4 -0
  104. package/dist/handlers/run-snapshot.d.ts.map +1 -0
  105. package/dist/handlers/run-snapshot.js +26 -0
  106. package/dist/handlers/run-snapshot.js.map +1 -0
  107. package/dist/handlers/setup.d.ts +25 -0
  108. package/dist/handlers/setup.d.ts.map +1 -0
  109. package/dist/handlers/setup.js +231 -0
  110. package/dist/handlers/setup.js.map +1 -0
  111. package/dist/handlers/structured-output.d.ts +38 -0
  112. package/dist/handlers/structured-output.d.ts.map +1 -0
  113. package/dist/handlers/structured-output.js +89 -0
  114. package/dist/handlers/structured-output.js.map +1 -0
  115. package/dist/handlers/tool-errors.d.ts +56 -0
  116. package/dist/handlers/tool-errors.d.ts.map +1 -0
  117. package/dist/handlers/tool-errors.js +73 -0
  118. package/dist/handlers/tool-errors.js.map +1 -0
  119. package/dist/handlers/tool-hitl.d.ts +45 -0
  120. package/dist/handlers/tool-hitl.d.ts.map +1 -0
  121. package/dist/handlers/tool-hitl.js +81 -0
  122. package/dist/handlers/tool-hitl.js.map +1 -0
  123. package/dist/handlers/turn-environment.d.ts +26 -0
  124. package/dist/handlers/turn-environment.d.ts.map +1 -0
  125. package/dist/handlers/turn-environment.js +154 -0
  126. package/dist/handlers/turn-environment.js.map +1 -0
  127. package/dist/hitl-policy.d.ts +45 -0
  128. package/dist/hitl-policy.d.ts.map +1 -0
  129. package/dist/hitl-policy.js +74 -0
  130. package/dist/hitl-policy.js.map +1 -0
  131. package/dist/index.d.ts +31 -0
  132. package/dist/index.d.ts.map +1 -0
  133. package/dist/index.js +19 -0
  134. package/dist/index.js.map +1 -0
  135. package/dist/invoke.d.ts +36 -0
  136. package/dist/invoke.d.ts.map +1 -0
  137. package/dist/invoke.js +228 -0
  138. package/dist/invoke.js.map +1 -0
  139. package/dist/migrations-dir.d.ts +11 -0
  140. package/dist/migrations-dir.d.ts.map +1 -0
  141. package/dist/migrations-dir.js +14 -0
  142. package/dist/migrations-dir.js.map +1 -0
  143. package/dist/project-run-result.d.ts +23 -0
  144. package/dist/project-run-result.d.ts.map +1 -0
  145. package/dist/project-run-result.js +116 -0
  146. package/dist/project-run-result.js.map +1 -0
  147. package/dist/prompt.d.ts +83 -0
  148. package/dist/prompt.d.ts.map +1 -0
  149. package/dist/prompt.js +119 -0
  150. package/dist/prompt.js.map +1 -0
  151. package/dist/provenance-emit.d.ts +44 -0
  152. package/dist/provenance-emit.d.ts.map +1 -0
  153. package/dist/provenance-emit.js +51 -0
  154. package/dist/provenance-emit.js.map +1 -0
  155. package/dist/registry.d.ts +38 -0
  156. package/dist/registry.d.ts.map +1 -0
  157. package/dist/registry.js +125 -0
  158. package/dist/registry.js.map +1 -0
  159. package/dist/retrieval.d.ts +47 -0
  160. package/dist/retrieval.d.ts.map +1 -0
  161. package/dist/retrieval.js +155 -0
  162. package/dist/retrieval.js.map +1 -0
  163. package/dist/run-snapshot-binding.d.ts +77 -0
  164. package/dist/run-snapshot-binding.d.ts.map +1 -0
  165. package/dist/run-snapshot-binding.js +4 -0
  166. package/dist/run-snapshot-binding.js.map +1 -0
  167. package/dist/schema.d.ts +497 -0
  168. package/dist/schema.d.ts.map +1 -0
  169. package/dist/schema.js +133 -0
  170. package/dist/schema.js.map +1 -0
  171. package/dist/streaming.d.ts +118 -0
  172. package/dist/streaming.d.ts.map +1 -0
  173. package/dist/streaming.js +17 -0
  174. package/dist/streaming.js.map +1 -0
  175. package/dist/tenant-policy.d.ts +16 -0
  176. package/dist/tenant-policy.d.ts.map +1 -0
  177. package/dist/tenant-policy.js +77 -0
  178. package/dist/tenant-policy.js.map +1 -0
  179. package/dist/types.d.ts +435 -0
  180. package/dist/types.d.ts.map +1 -0
  181. package/dist/types.js +4 -0
  182. package/dist/types.js.map +1 -0
  183. package/dist/versioning.d.ts +29 -0
  184. package/dist/versioning.d.ts.map +1 -0
  185. package/dist/versioning.js +58 -0
  186. package/dist/versioning.js.map +1 -0
  187. package/migrations/0000_sparkling_talkback.sql +18 -0
  188. package/migrations/0001_tired_warhawk.sql +16 -0
  189. package/migrations/0002_violet_ezekiel.sql +2 -0
  190. package/migrations/meta/0000_snapshot.json +172 -0
  191. package/migrations/meta/0001_snapshot.json +275 -0
  192. package/migrations/meta/0002_snapshot.json +287 -0
  193. package/migrations/meta/_journal.json +27 -0
  194. package/package.json +76 -4
  195. package/src/agent-turn-flow.ts +183 -0
  196. package/src/conversation-binding.ts +147 -0
  197. package/src/define.ts +572 -0
  198. package/src/errors.ts +80 -0
  199. package/src/guardrails-gate.ts +342 -0
  200. package/src/handlers/budget-check.ts +143 -0
  201. package/src/handlers/build-initial-messages.ts +103 -0
  202. package/src/handlers/compose-result.ts +90 -0
  203. package/src/handlers/constants.ts +10 -0
  204. package/src/handlers/context.ts +226 -0
  205. package/src/handlers/dispatch-tools.ts +633 -0
  206. package/src/handlers/errors.ts +282 -0
  207. package/src/handlers/evaluate-guardrails.ts +153 -0
  208. package/src/handlers/final-iteration.ts +30 -0
  209. package/src/handlers/index.ts +63 -0
  210. package/src/handlers/model-call.ts +151 -0
  211. package/src/handlers/persist-final-message.ts +67 -0
  212. package/src/handlers/persist-provenance.ts +39 -0
  213. package/src/handlers/persist-user-message.ts +70 -0
  214. package/src/handlers/public-types.ts +209 -0
  215. package/src/handlers/rehydrate.ts +161 -0
  216. package/src/handlers/render-prompt.ts +46 -0
  217. package/src/handlers/resolve-tools.ts +68 -0
  218. package/src/handlers/result-shape.ts +113 -0
  219. package/src/handlers/run-retrievals.ts +77 -0
  220. package/src/handlers/run-snapshot.ts +44 -0
  221. package/src/handlers/setup.ts +269 -0
  222. package/src/handlers/structured-output.ts +117 -0
  223. package/src/handlers/tool-errors.ts +122 -0
  224. package/src/handlers/tool-hitl.ts +126 -0
  225. package/src/handlers/turn-environment.ts +191 -0
  226. package/src/hitl-policy.ts +128 -0
  227. package/src/index.ts +154 -0
  228. package/src/invoke.ts +299 -0
  229. package/src/migrations-dir.ts +17 -0
  230. package/src/project-run-result.ts +131 -0
  231. package/src/prompt.ts +185 -0
  232. package/src/provenance-emit.ts +100 -0
  233. package/src/registry.ts +164 -0
  234. package/src/retrieval.ts +219 -0
  235. package/src/run-snapshot-binding.ts +87 -0
  236. package/src/schema.ts +154 -0
  237. package/src/streaming.ts +153 -0
  238. package/src/tenant-policy.ts +78 -0
  239. package/src/types.ts +453 -0
  240. package/src/versioning.ts +77 -0
@@ -0,0 +1,633 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { ModelMessage, ModelToolCall } from '@kindgi/capabilities';
5
+ import type { NodeContext, NodeHandler } from '@kindgi/handler';
6
+ import { WaitpointCancelledError } from '@kindgi/handler';
7
+ import { stricterToolHitlRule } from '@kindgi/policy-contract';
8
+ import { invokeTool } from '@kindgi/tools';
9
+ import type { Tool, ToolContext } from '@kindgi/tools';
10
+
11
+ import { emitTurnEvent } from '../streaming.js';
12
+ import type { ConversationMessage, ToolHitlMode, ToolHitlRule } from '../types.js';
13
+
14
+ import type { EffectiveHitlPolicy } from '../hitl-policy.js';
15
+
16
+ import type { AgentTurnIterationOutput, TurnContext } from './context.js';
17
+ import {
18
+ type ToolInvocationError,
19
+ type UnresolvedToolError,
20
+ throwAgentTurnFailure,
21
+ } from './errors.js';
22
+ import {
23
+ effectiveToolErrorPolicy,
24
+ toolErrorKindOf,
25
+ toolErrorResult,
26
+ toolRetriesSoFar,
27
+ } from './tool-errors.js';
28
+ import { type ToolHitlDecision, computeToolCallWaitToken, hashToolArgs } from './tool-hitl.js';
29
+
30
+ /**
31
+ * Resolves the effective HITL mode + reviewer role for a specific
32
+ * tool from the pre-resolved effective policy. Mirrors the standalone
33
+ * `resolveToolHitl(agent, tool)` in tool-hitl.ts but consumes the
34
+ * resolver's output.
35
+ */
36
+ function resolveEffectiveToolHitl(
37
+ effective: EffectiveHitlPolicy,
38
+ tool: Tool,
39
+ ): { readonly mode: ToolHitlMode; readonly requiredRole: 'standard' | 'senior' | 'admin' } {
40
+ const gate = agentToolHitl(effective, tool);
41
+ // The tenant's floor for this tool, when it names one: the stricter wins.
42
+ const floor = effective.toolFloors?.get(tool.id as unknown as string);
43
+ if (floor === undefined) return gate;
44
+ const stricter = stricterToolHitlRule(gate, floor);
45
+ return { mode: stricter.mode, requiredRole: stricter.requiredRole ?? gate.requiredRole };
46
+ }
47
+
48
+ /** The agent's own gate for `tool`. */
49
+ function agentToolHitl(
50
+ effective: EffectiveHitlPolicy,
51
+ tool: Tool,
52
+ ): { readonly mode: ToolHitlMode; readonly requiredRole: 'standard' | 'senior' | 'admin' } {
53
+ const toolsPolicy = effective.tools;
54
+ const defaultRole = effective.defaultReviewerRole;
55
+
56
+ // Opt-in: no `hitl.tools` block → never gate.
57
+ if (toolsPolicy === undefined) {
58
+ return { mode: 'never_ask', requiredRole: defaultRole };
59
+ }
60
+
61
+ const rawOverride = toolsPolicy.overrides.get(tool.id as unknown as string);
62
+ if (rawOverride !== undefined) {
63
+ const rule: ToolHitlRule =
64
+ typeof rawOverride === 'string'
65
+ ? { mode: rawOverride as ToolHitlMode }
66
+ : (rawOverride as ToolHitlRule);
67
+ return {
68
+ mode: rule.mode,
69
+ requiredRole: rule.requiredRole ?? defaultRole,
70
+ };
71
+ }
72
+
73
+ if (toolsPolicy.default !== undefined) {
74
+ return { mode: toolsPolicy.default, requiredRole: defaultRole };
75
+ }
76
+
77
+ // Per-tool default from Tool.mutating (opt-in scope only).
78
+ const mode: ToolHitlMode = tool.mutating === false ? 'never_ask' : 'ask_on_first_use';
79
+ return { mode, requiredRole: defaultRole };
80
+ }
81
+
82
+ /**
83
+ * Loop-body node #2. If the model returned `finishReason='tool-use'`
84
+ * with a non-empty tool-call list, persist the assistant tool-call
85
+ * message + dispatch every tool + persist every tool result. Update
86
+ * `nextMessages` so the next iteration's model call sees the tool
87
+ * outputs.
88
+ *
89
+ * When the model returned a terminal `finishReason` (or `tool-use`
90
+ * with no calls), pass through unchanged so
91
+ * `budget-check` marks the iteration finished.
92
+ */
93
+ export function buildDispatchToolsHandler(ctx: TurnContext): NodeHandler {
94
+ return async (input: unknown, kctx: NodeContext) => {
95
+ if (ctx.tools === undefined) {
96
+ throwAgentTurnFailure({
97
+ code: 'model-invocation-failed',
98
+ message: 'dispatch-tools invoked before setup completed',
99
+ cause: null,
100
+ });
101
+ }
102
+ const partial = input as {
103
+ readonly step: number;
104
+ readonly finishReason: AgentTurnIterationOutput['finishReason'];
105
+ readonly message: ModelMessage;
106
+ readonly iterationUsage: AgentTurnIterationOutput['iterationUsage'];
107
+ readonly provider: AgentTurnIterationOutput['provider'];
108
+ readonly nextMessages: readonly ModelMessage[];
109
+ };
110
+
111
+ const assistantMsg = partial.message;
112
+ const hasToolCalls =
113
+ partial.finishReason === 'tool-use' &&
114
+ assistantMsg.toolCalls !== undefined &&
115
+ assistantMsg.toolCalls.length > 0;
116
+
117
+ if (!hasToolCalls) {
118
+ // No tool calls this iteration — pass through. `budget-check`
119
+ // will finalize with `finishedTurn=true` and the loop exits.
120
+ const forwarded: Partial<AgentTurnIterationOutput> & {
121
+ readonly step: number;
122
+ readonly hasToolCalls: false;
123
+ } = {
124
+ step: partial.step,
125
+ finishReason: partial.finishReason,
126
+ message: assistantMsg,
127
+ iterationAppended: [],
128
+ iterationUsage: partial.iterationUsage,
129
+ provider: partial.provider,
130
+ nextMessages: partial.nextMessages,
131
+ hasToolCalls: false,
132
+ };
133
+ return forwarded;
134
+ }
135
+
136
+ const iterationAppended: ConversationMessage[] = [
137
+ await persistToolCallMessage(ctx, assistantMsg, partial.step),
138
+ ];
139
+ let nextMessages: ModelMessage[] = [...partial.nextMessages, assistantMsg];
140
+
141
+ for (const call of assistantMsg.toolCalls ?? []) {
142
+ // Resumed after an approval further down this list: a call that
143
+ // already ran keeps the result it stored.
144
+ const stored = takeStoredBeforePark(
145
+ ctx,
146
+ (m) => m.role === 'tool' && m.toolCall?.invocationId === call.id,
147
+ );
148
+ if (stored !== undefined) {
149
+ iterationAppended.push(stored);
150
+ nextMessages = [...nextMessages, toolMessageOf(stored, call.id)];
151
+ continue;
152
+ }
153
+ // Look up the resolved binding before emitting `tool.started` so
154
+ // the event can carry `toolVersion` + `toolVersionRange` alongside
155
+ // the id. Model-requested tools the
156
+ // agent didn't declare emit a bare `tool.failed` with no version.
157
+ const binding = ctx.tools.byName.get(call.name);
158
+ if (binding === undefined) {
159
+ const unresolved: UnresolvedToolError = {
160
+ code: 'unresolved-tool',
161
+ message: `Model requested tool "${call.name}" not registered for agent "${ctx.input.agent.id}"`,
162
+ toolId: call.name,
163
+ };
164
+ await emitTurnEvent(ctx.bindings.onEvent, {
165
+ kind: 'tool.failed',
166
+ step: partial.step,
167
+ toolId: call.name,
168
+ invocationId: call.id,
169
+ error: { code: unresolved.code, message: unresolved.message },
170
+ });
171
+ nextMessages = await retryOrFail(ctx, call, unresolved, undefined, {
172
+ toolId: call.name,
173
+ nextMessages,
174
+ iterationAppended,
175
+ });
176
+ continue;
177
+ }
178
+ const { tool, resolvedVersion, requestedRange } = binding;
179
+
180
+ // Tool-level HITL gate. Resolves the effective mode
181
+ // for this (agent, tool) pair via the effective-policy
182
+ // resolver (framework defaults → agent → tenant cap), then parks +
183
+ // enqueues an approval when required BEFORE dispatch. On reject,
184
+ // returns a synthetic negative tool result so the LLM can adapt
185
+ // without terminating the run. Same run, same conversation, same
186
+ // provenance record across the park-and-resume — the same
187
+ // framework guarantee as the session-level gate.
188
+ //
189
+ // Kernel replay: within one run, waitForToken with the same
190
+ // deterministic tokenId returns the resolved decision from the
191
+ // journal — no re-park. Cross-turn `ask_on_first_use` caching
192
+ // (via conversation metadata) is not implemented.
193
+ const effectiveHitl = ctx.hitlPolicy;
194
+ if (effectiveHitl === undefined) {
195
+ throwAgentTurnFailure({
196
+ code: 'model-invocation-failed',
197
+ message: 'dispatch-tools invoked before the turn resolved its approval rules',
198
+ cause: null,
199
+ });
200
+ }
201
+ const resolvedHitl = resolveEffectiveToolHitl(effectiveHitl, tool);
202
+ let toolRejectionPayload: { readonly rationale?: string } | null = null;
203
+ if (resolvedHitl.mode !== 'never_ask') {
204
+ const argsHash = hashToolArgs(call.arguments);
205
+ const waitTokenId = computeToolCallWaitToken({
206
+ runId: kctx.runId as unknown as string,
207
+ callId: call.id,
208
+ argsHash,
209
+ });
210
+ const timeoutMs = effectiveHitl.timeoutMs;
211
+ const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
212
+
213
+ if (ctx.bindings.hitl?.enqueue !== undefined) {
214
+ try {
215
+ await ctx.bindings.hitl.enqueue({
216
+ tenantId: ctx.input.tenantId,
217
+ subjectKind: 'tool-call:pending',
218
+ subjectRef: {
219
+ conversationId: ctx.input.conversationId,
220
+ agentId: ctx.input.agent.id,
221
+ agentVersion: ctx.input.agent.version,
222
+ toolId: tool.id,
223
+ toolVersion: resolvedVersion,
224
+ callId: call.id,
225
+ argsHash,
226
+ arguments: call.arguments as never,
227
+ },
228
+ requiredRole: resolvedHitl.requiredRole,
229
+ title: `HITL review: ${call.name}`,
230
+ description: `Tool call ${call.name} awaiting reviewer approval before dispatch.`,
231
+ waitTokenId,
232
+ provenanceRef: { runId: kctx.runId },
233
+ expiresAt: expiresAt as never,
234
+ });
235
+ } catch {
236
+ // Enqueue failure is soft — waitForToken parks either way.
237
+ }
238
+ }
239
+
240
+ try {
241
+ const decision = await kctx.waitForToken<ToolHitlDecision>(waitTokenId, {
242
+ timeoutMs,
243
+ });
244
+ if (decision.decided === 'reject') {
245
+ toolRejectionPayload =
246
+ decision.rationale !== undefined ? { rationale: decision.rationale } : {};
247
+ }
248
+ // approve → fall through to dispatch
249
+ } catch (cause) {
250
+ if (cause instanceof WaitpointCancelledError) {
251
+ throwAgentTurnFailure({
252
+ code: 'hitl-cancelled',
253
+ message: `Tool-call HITL cancelled for ${call.name}: ${cause.reason}`,
254
+ reason: cause.reason,
255
+ } as never);
256
+ }
257
+ throw cause;
258
+ }
259
+ }
260
+
261
+ const toolStarted = Date.now();
262
+ await emitTurnEvent(ctx.bindings.onEvent, {
263
+ kind: 'tool.started',
264
+ step: partial.step,
265
+ toolId: call.name,
266
+ toolVersion: resolvedVersion,
267
+ toolVersionRange: requestedRange,
268
+ invocationId: call.id,
269
+ arguments: call.arguments,
270
+ });
271
+
272
+ // If the reviewer rejected, synthesize a tool-result message
273
+ // carrying the rationale — the LLM reads it in the next
274
+ // iteration and can adapt (retry with different args, ask the
275
+ // user, apologize). NOT a run failure — reject is feedback, not
276
+ // termination.
277
+ if (toolRejectionPayload !== null) {
278
+ const rejectResult = {
279
+ status: 'rejected' as const,
280
+ ...(toolRejectionPayload.rationale !== undefined && {
281
+ rationale: toolRejectionPayload.rationale,
282
+ }),
283
+ };
284
+ nextMessages = await appendToolResult(ctx, call, rejectResult, {
285
+ toolId: tool.id as unknown as string,
286
+ nextMessages,
287
+ iterationAppended,
288
+ });
289
+ await emitTurnEvent(ctx.bindings.onEvent, {
290
+ kind: 'tool.completed',
291
+ step: partial.step,
292
+ toolId: call.name,
293
+ toolVersion: resolvedVersion,
294
+ toolVersionRange: requestedRange,
295
+ invocationId: call.id,
296
+ output: rejectResult as never,
297
+ durationMs: Date.now() - toolStarted,
298
+ });
299
+ continue;
300
+ }
301
+
302
+ const dispatched = await dispatchOne(ctx, tool, call, kctx.runId as unknown as string);
303
+ if (dispatched.kind === 'err') {
304
+ await emitTurnEvent(ctx.bindings.onEvent, {
305
+ kind: 'tool.failed',
306
+ step: partial.step,
307
+ toolId: call.name,
308
+ invocationId: call.id,
309
+ error: {
310
+ code: dispatched.error.code,
311
+ message: dispatched.error.message,
312
+ },
313
+ });
314
+ nextMessages = await retryOrFail(
315
+ ctx,
316
+ call,
317
+ dispatched.error,
318
+ dispatched.error.validationIssues,
319
+ { toolId: tool.id as unknown as string, nextMessages, iterationAppended },
320
+ );
321
+ continue;
322
+ }
323
+
324
+ ctx.appended.push(dispatched.value.persisted);
325
+ iterationAppended.push(dispatched.value.persisted);
326
+ nextMessages = [...nextMessages, dispatched.value.toolMessage];
327
+
328
+ await emitTurnEvent(ctx.bindings.onEvent, {
329
+ kind: 'tool.completed',
330
+ step: partial.step,
331
+ toolId: call.name,
332
+ toolVersion: resolvedVersion,
333
+ toolVersionRange: requestedRange,
334
+ invocationId: call.id,
335
+ output: dispatched.value.persisted.content,
336
+ durationMs: Date.now() - toolStarted,
337
+ });
338
+
339
+ if (ctx.provenance !== undefined) {
340
+ const modelCallNodeId = `model-call:${partial.step}`;
341
+ const toolCallNodeId = `tool-call:${call.id}`;
342
+ const toolResultNodeId = `tool-result:${call.id}`;
343
+ ctx.provenance.addNode({
344
+ id: toolCallNodeId,
345
+ kind: 'tool-call',
346
+ timestamp: dispatched.value.persisted.createdAt,
347
+ attributes: {
348
+ toolId: call.name,
349
+ invocationId: call.id,
350
+ // Capture the exact version the
351
+ // registry picked at run start + the range the agent asked
352
+ // for, so a replay can pin against the same version.
353
+ toolVersion: resolvedVersion,
354
+ toolVersionRange: requestedRange,
355
+ },
356
+ });
357
+ ctx.provenance.addNode({
358
+ id: toolResultNodeId,
359
+ kind: 'tool-result',
360
+ timestamp: dispatched.value.persisted.createdAt,
361
+ });
362
+ ctx.provenance.addEdge({
363
+ from: toolCallNodeId,
364
+ to: modelCallNodeId,
365
+ kind: 'invoked',
366
+ });
367
+ ctx.provenance.addEdge({
368
+ from: toolResultNodeId,
369
+ to: toolCallNodeId,
370
+ kind: 'produced',
371
+ });
372
+ }
373
+ }
374
+
375
+ const forwarded: Partial<AgentTurnIterationOutput> & {
376
+ readonly step: number;
377
+ readonly hasToolCalls: true;
378
+ } = {
379
+ step: partial.step,
380
+ finishReason: partial.finishReason,
381
+ message: assistantMsg,
382
+ iterationAppended,
383
+ iterationUsage: partial.iterationUsage,
384
+ provider: partial.provider,
385
+ nextMessages,
386
+ hasToolCalls: true,
387
+ };
388
+ return forwarded;
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Store the assistant message that makes the calls — or, resumed after a
394
+ * park in this step, take the one stored before the park.
395
+ */
396
+ async function persistToolCallMessage(
397
+ ctx: TurnContext,
398
+ assistantMsg: ModelMessage,
399
+ step: number,
400
+ ): Promise<ConversationMessage> {
401
+ const callIds = (assistantMsg.toolCalls ?? []).map((tc) => tc.id);
402
+ const stored = takeStoredBeforePark(ctx, (m) => m.role === 'agent' && sameCalls(m, callIds));
403
+ if (stored !== undefined) return stored;
404
+ const persisted = await ctx.bindings.conversationBinding.appendMessage({
405
+ tenantId: ctx.input.tenantId,
406
+ conversationId: ctx.input.conversationId,
407
+ role: 'agent',
408
+ content: {
409
+ text: assistantMsg.content,
410
+ toolCalls: (assistantMsg.toolCalls ?? []).map((tc) => ({ ...tc })),
411
+ },
412
+ actor: ctx.input.agent.id,
413
+ isIntermediate: true,
414
+ });
415
+ if (persisted.kind === 'err') throwAgentTurnFailure(persisted.error);
416
+ ctx.appended.push(persisted.value);
417
+ await emitTurnEvent(ctx.bindings.onEvent, {
418
+ kind: 'agent.message',
419
+ step,
420
+ isFinal: false,
421
+ message: persisted.value,
422
+ });
423
+ return persisted.value;
424
+ }
425
+
426
+ /** Take the first message stored before the park that matches. */
427
+ function takeStoredBeforePark(
428
+ ctx: TurnContext,
429
+ matches: (m: ConversationMessage) => boolean,
430
+ ): ConversationMessage | undefined {
431
+ const index = ctx.storedBeforePark?.findIndex(matches) ?? -1;
432
+ if (index < 0) return undefined;
433
+ return ctx.storedBeforePark?.splice(index, 1)[0];
434
+ }
435
+
436
+ function sameCalls(m: ConversationMessage, callIds: readonly string[]): boolean {
437
+ const calls = (m.content as { readonly toolCalls?: readonly { readonly id?: unknown }[] })
438
+ .toolCalls;
439
+ return (
440
+ Array.isArray(calls) &&
441
+ calls.length === callIds.length &&
442
+ calls.every((c, i) => c.id === callIds[i])
443
+ );
444
+ }
445
+
446
+ /** A stored tool result, as the model sees it. */
447
+ function toolMessageOf(stored: ConversationMessage, callId: string): ModelMessage {
448
+ return {
449
+ role: 'tool',
450
+ content: typeof stored.content === 'string' ? stored.content : JSON.stringify(stored.content),
451
+ toolCallId: callId,
452
+ };
453
+ }
454
+
455
+ interface ToolResultTarget {
456
+ /** The tool the result is for: its id, or the name the model used when the agent has no such tool. */
457
+ readonly toolId: string;
458
+ readonly nextMessages: readonly ModelMessage[];
459
+ readonly iterationAppended: ConversationMessage[];
460
+ }
461
+
462
+ /**
463
+ * Record a result the framework writes for a call (a rejection, a failed
464
+ * call sent back to the model) the way a tool's own result is recorded:
465
+ * persisted to the conversation with the call it answers, and added to
466
+ * the messages the next model call sees.
467
+ */
468
+ async function appendToolResult(
469
+ ctx: TurnContext,
470
+ call: ModelToolCall,
471
+ output: unknown,
472
+ target: ToolResultTarget,
473
+ ): Promise<ModelMessage[]> {
474
+ const persisted = await ctx.bindings.conversationBinding.appendMessage({
475
+ tenantId: ctx.input.tenantId,
476
+ conversationId: ctx.input.conversationId,
477
+ role: 'tool',
478
+ content: output as Record<string, unknown>,
479
+ toolCall: { toolId: target.toolId, invocationId: call.id },
480
+ });
481
+ if (persisted.kind === 'err') throwAgentTurnFailure(persisted.error);
482
+ ctx.appended.push(persisted.value);
483
+ target.iterationAppended.push(persisted.value);
484
+ return [
485
+ ...target.nextMessages,
486
+ { role: 'tool', content: JSON.stringify(output), toolCallId: call.id },
487
+ ];
488
+ }
489
+
490
+ /**
491
+ * A failed call the turn's policy retries goes back to the model as its
492
+ * result, while retries are left; anything else fails the turn, with the
493
+ * retries it took when the kind is one the policy retries.
494
+ */
495
+ async function retryOrFail(
496
+ ctx: TurnContext,
497
+ call: ModelToolCall,
498
+ error: UnresolvedToolError | ToolInvocationError,
499
+ issues: readonly unknown[] | undefined,
500
+ target: ToolResultTarget,
501
+ ): Promise<ModelMessage[]> {
502
+ const policy =
503
+ ctx.toolErrorPolicy ?? effectiveToolErrorPolicy(ctx.input.agent.toolErrors, undefined);
504
+ const kind = toolErrorKindOf(error);
505
+ const used = toolRetriesSoFar(target.nextMessages);
506
+ if (!policy.retryOn.has(kind)) throwAgentTurnFailure(error);
507
+ if (used >= policy.maxRetries) throwAgentTurnFailure({ ...error, toolRetries: used });
508
+ return appendToolResult(ctx, call, toolErrorResult(kind, error.message, issues), target);
509
+ }
510
+
511
+ async function dispatchOne(
512
+ ctx: TurnContext,
513
+ tool: Tool,
514
+ call: ModelToolCall,
515
+ runId: string,
516
+ ): Promise<
517
+ | {
518
+ readonly kind: 'ok';
519
+ readonly value: {
520
+ readonly persisted: ConversationMessage;
521
+ readonly toolMessage: ModelMessage;
522
+ };
523
+ }
524
+ | {
525
+ readonly kind: 'err';
526
+ readonly error: {
527
+ readonly code: 'tool-invocation-failed';
528
+ readonly message: string;
529
+ readonly toolId: string;
530
+ readonly cause: unknown;
531
+ readonly validationIssues?: readonly Readonly<Record<string, unknown>>[];
532
+ readonly receivedInput?: unknown;
533
+ };
534
+ }
535
+ > {
536
+ const toolCtx: ToolContext = {
537
+ tenantId: ctx.input.tenantId,
538
+ runId,
539
+ requestId: call.id,
540
+ abortSignal: ctx.turnAbort.signal,
541
+ // HTTP tools built via defineTool({spec: {kind: 'http'}}) resolve declared
542
+ // secret_refs at invoke time. Present iff the caller wired
543
+ // `bindings.resolveSecret` from a tenant-scoped `SecretBinding`.
544
+ ...(ctx.bindings.resolveSecret !== undefined && { resolveSecret: ctx.bindings.resolveSecret }),
545
+ };
546
+ const result = await invokeTool(tool, call.arguments, toolCtx);
547
+ if (result.kind === 'err') {
548
+ // Hoist AJV validation detail from the inner error so it survives
549
+ // `toWireError` (which filters `cause` to avoid Error-instance /
550
+ // cycle hazards). Two validation paths emit different field names
551
+ // for the AJV array:
552
+ // - `packages/handler-runtime/src/handler-runner.ts` (pack code,
553
+ // run by the pack service) → `issues`
554
+ // - `packages/tools/src/invoke.ts` (in-process dispatch) → `errors`
555
+ // Accept either. If neither shape is present, no hoisting —
556
+ // the outer `tool-invocation-failed` stays minimal.
557
+ const inner = result.error as {
558
+ readonly code?: string;
559
+ readonly issues?: readonly Readonly<Record<string, unknown>>[];
560
+ readonly errors?: readonly Readonly<Record<string, unknown>>[];
561
+ };
562
+ const rawIssues = Array.isArray(inner.issues)
563
+ ? inner.issues
564
+ : Array.isArray(inner.errors)
565
+ ? inner.errors
566
+ : undefined;
567
+ const isValidationFailure = inner.code === 'input-validation-failed' && rawIssues !== undefined;
568
+ return {
569
+ kind: 'err',
570
+ error: {
571
+ code: 'tool-invocation-failed',
572
+ message: result.error.message,
573
+ toolId: call.name,
574
+ cause: result.error,
575
+ ...(isValidationFailure && {
576
+ validationIssues: rawIssues,
577
+ receivedInput: truncateForWire(call.arguments),
578
+ }),
579
+ },
580
+ };
581
+ }
582
+ const persist = await ctx.bindings.conversationBinding.appendMessage({
583
+ tenantId: ctx.input.tenantId,
584
+ conversationId: ctx.input.conversationId,
585
+ role: 'tool',
586
+ content: (result.value as unknown as string | Record<string, unknown>) ?? '',
587
+ toolCall: { toolId: tool.id, invocationId: call.id },
588
+ });
589
+ if (persist.kind === 'err') {
590
+ // A DB failure persisting the tool result surfaces as the more
591
+ // generic invocation-failed to keep the caller's error shape stable.
592
+ return {
593
+ kind: 'err',
594
+ error: {
595
+ code: 'tool-invocation-failed',
596
+ message: persist.error.message,
597
+ toolId: tool.id,
598
+ cause: persist.error,
599
+ },
600
+ };
601
+ }
602
+ return {
603
+ kind: 'ok',
604
+ value: {
605
+ persisted: persist.value,
606
+ toolMessage: {
607
+ role: 'tool',
608
+ content: typeof result.value === 'string' ? result.value : JSON.stringify(result.value),
609
+ toolCallId: call.id,
610
+ },
611
+ },
612
+ };
613
+ }
614
+
615
+ /**
616
+ * Cap `receivedInput` on validation errors so a huge tool-arg blob
617
+ * doesn't bloat the wire response body. 4 KiB stringified is
618
+ * comfortably larger than a typical LLM tool-call payload
619
+ * (place-name, query, message) but small enough that a
620
+ * pathological array/document truncates rather than round-trips
621
+ * megabytes. Truncated payloads land as a string with a trailing
622
+ * marker so callers can tell the value was elided.
623
+ */
624
+ function truncateForWire(input: unknown, maxBytes = 4096): unknown {
625
+ let serialized: string;
626
+ try {
627
+ serialized = JSON.stringify(input);
628
+ } catch {
629
+ return '<unserializable input>';
630
+ }
631
+ if (Buffer.byteLength(serialized, 'utf8') <= maxBytes) return input;
632
+ return `${serialized.slice(0, maxBytes)}… (truncated at ${maxBytes} bytes)`;
633
+ }