@kindgi/agents 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/conversation-binding.d.ts +9 -1
  2. package/dist/conversation-binding.d.ts.map +1 -1
  3. package/dist/define.js +20 -0
  4. package/dist/define.js.map +1 -1
  5. package/dist/guardrails-gate.d.ts +7 -4
  6. package/dist/guardrails-gate.d.ts.map +1 -1
  7. package/dist/guardrails-gate.js +5 -2
  8. package/dist/guardrails-gate.js.map +1 -1
  9. package/dist/handlers/context.d.ts +14 -0
  10. package/dist/handlers/context.d.ts.map +1 -1
  11. package/dist/handlers/dispatch-tools.d.ts.map +1 -1
  12. package/dist/handlers/dispatch-tools.js +18 -42
  13. package/dist/handlers/dispatch-tools.js.map +1 -1
  14. package/dist/handlers/evaluate-guardrails.d.ts.map +1 -1
  15. package/dist/handlers/evaluate-guardrails.js +34 -1
  16. package/dist/handlers/evaluate-guardrails.js.map +1 -1
  17. package/dist/handlers/gate-decision.d.ts +62 -0
  18. package/dist/handlers/gate-decision.d.ts.map +1 -0
  19. package/dist/handlers/gate-decision.js +55 -0
  20. package/dist/handlers/gate-decision.js.map +1 -0
  21. package/dist/handlers/model-call.d.ts +10 -0
  22. package/dist/handlers/model-call.d.ts.map +1 -1
  23. package/dist/handlers/model-call.js +109 -34
  24. package/dist/handlers/model-call.js.map +1 -1
  25. package/dist/handlers/persist-provenance.d.ts.map +1 -1
  26. package/dist/handlers/persist-provenance.js +3 -1
  27. package/dist/handlers/persist-provenance.js.map +1 -1
  28. package/dist/handlers/persist-user-message.d.ts.map +1 -1
  29. package/dist/handlers/persist-user-message.js +5 -16
  30. package/dist/handlers/persist-user-message.js.map +1 -1
  31. package/dist/handlers/public-types.d.ts +16 -2
  32. package/dist/handlers/public-types.d.ts.map +1 -1
  33. package/dist/handlers/rehydrate.d.ts.map +1 -1
  34. package/dist/handlers/rehydrate.js +85 -0
  35. package/dist/handlers/rehydrate.js.map +1 -1
  36. package/dist/handlers/run-retrievals.d.ts.map +1 -1
  37. package/dist/handlers/run-retrievals.js +2 -19
  38. package/dist/handlers/run-retrievals.js.map +1 -1
  39. package/dist/handlers/setup.d.ts.map +1 -1
  40. package/dist/handlers/setup.js +15 -10
  41. package/dist/handlers/setup.js.map +1 -1
  42. package/dist/handlers/tool-hitl.d.ts +5 -9
  43. package/dist/handlers/tool-hitl.d.ts.map +1 -1
  44. package/dist/handlers/tool-hitl.js +5 -0
  45. package/dist/handlers/tool-hitl.js.map +1 -1
  46. package/dist/handlers/turn-provenance.d.ts +67 -0
  47. package/dist/handlers/turn-provenance.d.ts.map +1 -0
  48. package/dist/handlers/turn-provenance.js +160 -0
  49. package/dist/handlers/turn-provenance.js.map +1 -0
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/invoke.d.ts +14 -1
  55. package/dist/invoke.d.ts.map +1 -1
  56. package/dist/invoke.js +34 -19
  57. package/dist/invoke.js.map +1 -1
  58. package/dist/provenance-emit.d.ts +4 -2
  59. package/dist/provenance-emit.d.ts.map +1 -1
  60. package/dist/provenance-emit.js +4 -2
  61. package/dist/provenance-emit.js.map +1 -1
  62. package/dist/schema.d.ts +17 -0
  63. package/dist/schema.d.ts.map +1 -1
  64. package/dist/schema.js +11 -0
  65. package/dist/schema.js.map +1 -1
  66. package/dist/types.d.ts +15 -1
  67. package/dist/types.d.ts.map +1 -1
  68. package/migrations/0003_hesitant_captain_cross.sql +2 -0
  69. package/migrations/meta/0003_snapshot.json +321 -0
  70. package/migrations/meta/_journal.json +7 -0
  71. package/package.json +15 -15
  72. package/src/conversation-binding.ts +9 -1
  73. package/src/define.ts +20 -0
  74. package/src/guardrails-gate.ts +17 -3
  75. package/src/handlers/context.ts +14 -0
  76. package/src/handlers/dispatch-tools.ts +21 -43
  77. package/src/handlers/evaluate-guardrails.ts +39 -1
  78. package/src/handlers/gate-decision.ts +101 -0
  79. package/src/handlers/model-call.ts +149 -33
  80. package/src/handlers/persist-provenance.ts +3 -1
  81. package/src/handlers/persist-user-message.ts +3 -16
  82. package/src/handlers/public-types.ts +16 -2
  83. package/src/handlers/rehydrate.ts +120 -3
  84. package/src/handlers/run-retrievals.ts +2 -19
  85. package/src/handlers/setup.ts +17 -20
  86. package/src/handlers/tool-hitl.ts +6 -10
  87. package/src/handlers/turn-provenance.ts +230 -0
  88. package/src/index.ts +7 -0
  89. package/src/invoke.ts +46 -20
  90. package/src/provenance-emit.ts +4 -1
  91. package/src/schema.ts +11 -0
  92. package/src/types.ts +15 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kindgi/agents",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Agent primitive for Kindgi. Composes capabilities (model routing), tools (execution), memory (facts + retrieval), guardrails (declarative guardrails), and provenance into a single declarative unit. Agents are data-as-code — a defineAgent() call produces a durable definition that runs on the kernel, with first-class multi-turn conversations, streaming output, and a provenance record per turn.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -31,20 +31,20 @@
31
31
  "type-manifest.json"
32
32
  ],
33
33
  "dependencies": {
34
- "@kindgi/authz": "0.1.2",
35
- "@kindgi/capabilities": "0.1.2",
36
- "@kindgi/compliance": "0.1.2",
37
- "@kindgi/embedding": "0.1.2",
38
- "@kindgi/flow": "0.1.2",
39
- "@kindgi/handler": "0.1.2",
40
- "@kindgi/guardrails": "0.1.2",
41
- "@kindgi/memory": "0.1.2",
42
- "@kindgi/policy-contract": "0.1.2",
43
- "@kindgi/provenance": "0.1.2",
44
- "@kindgi/runtime": "0.1.2",
45
- "@kindgi/schema": "0.1.2",
46
- "@kindgi/tools": "0.1.2",
47
- "@kindgi/types": "0.1.2",
34
+ "@kindgi/authz": "0.1.3",
35
+ "@kindgi/capabilities": "0.1.3",
36
+ "@kindgi/compliance": "0.1.3",
37
+ "@kindgi/embedding": "0.1.3",
38
+ "@kindgi/flow": "0.1.3",
39
+ "@kindgi/handler": "0.1.3",
40
+ "@kindgi/guardrails": "0.1.3",
41
+ "@kindgi/memory": "0.1.3",
42
+ "@kindgi/policy-contract": "0.1.3",
43
+ "@kindgi/provenance": "0.1.3",
44
+ "@kindgi/runtime": "0.1.3",
45
+ "@kindgi/schema": "0.1.3",
46
+ "@kindgi/tools": "0.1.3",
47
+ "@kindgi/types": "0.1.3",
48
48
  "drizzle-orm": "^0.45.3",
49
49
  "liquidjs": "^10.29.0"
50
50
  },
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Kindgi Inc.
3
3
 
4
4
  import type { RunBinding } from '@kindgi/runtime';
5
- import type { Result, Semver, TenantId } from '@kindgi/types';
5
+ import type { ListScope, ProjectId, Result, Semver, TenantId } from '@kindgi/types';
6
6
 
7
7
  import type { AgentError } from './errors.js';
8
8
  import type {
@@ -21,6 +21,8 @@ export interface OpenConversationInput {
21
21
  readonly agentVersion: Semver;
22
22
  readonly title: string;
23
23
  readonly participantId?: string;
24
+ /** The project the conversation is in: its turns' project. Lists filter by it. */
25
+ readonly projectId?: ProjectId;
24
26
  readonly scope: Readonly<Record<string, unknown>>;
25
27
  readonly metadata?: Readonly<Record<string, unknown>>;
26
28
  }
@@ -72,6 +74,12 @@ export interface ConversationPageCursor {
72
74
 
73
75
  export interface ListConversationsPageInput {
74
76
  readonly tenantId: TenantId;
77
+ /**
78
+ * Only one project's conversations, or every project's in an org.
79
+ * Absent: the tenant's. A conversation opened without a project is
80
+ * listed only without a scope.
81
+ */
82
+ readonly scope?: ListScope;
75
83
  readonly agentId?: AgentId;
76
84
  /** When set, filter to open (no closedAt) or closed (closedAt set) rows only. */
77
85
  readonly status?: 'open' | 'closed';
package/src/define.ts CHANGED
@@ -448,6 +448,7 @@ function validateConversationPolicy(policy?: ConversationPolicy): Issue[] {
448
448
  message: 'hitl.timeoutMs must be a positive integer (ms)',
449
449
  });
450
450
  }
451
+ out.push(...validateOnTimeout(h.onTimeout));
451
452
  const ROLES: ReadonlySet<string> = new Set(['standard', 'senior', 'admin']);
452
453
  if (
453
454
  h.defaultReviewerRole !== undefined &&
@@ -490,6 +491,25 @@ function validateConversationPolicy(policy?: ConversationPolicy): Issue[] {
490
491
  return out;
491
492
  }
492
493
 
494
+ /**
495
+ * `hitl.onTimeout`: only `'escalate'`, what the runtime does when an
496
+ * approval's time runs out. `'auto-approve'` / `'auto-reject'` aren't
497
+ * implemented, so they're refused instead of accepted and ignored.
498
+ */
499
+ function validateOnTimeout(onTimeout: unknown): Issue[] {
500
+ if (onTimeout === undefined || onTimeout === 'escalate') return [];
501
+ const path = '/conversationPolicy/hitl/onTimeout';
502
+ if (onTimeout === 'auto-approve' || onTimeout === 'auto-reject') {
503
+ return [
504
+ {
505
+ path,
506
+ message: `hitl.onTimeout '${onTimeout}' isn't supported: an approval that times out escalates one reviewer tier, and at admin it expires (the turn fails with hitl-cancelled). Use 'escalate', or leave it out.`,
507
+ },
508
+ ];
509
+ }
510
+ return [{ path, message: "hitl.onTimeout must be 'escalate'" }];
511
+ }
512
+
493
513
  type OutputOutcome =
494
514
  | { readonly kind: 'none' }
495
515
  | { readonly kind: 'ok'; readonly value: AgentOutputSpec }
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (C) 2026 Kindgi Inc.
3
3
 
4
- import type { ProviderRegistry, TenantPolicy } from '@kindgi/capabilities';
4
+ import type { ProviderRegistry, TenantPolicy, UsageSink } from '@kindgi/capabilities';
5
5
  import type { ComplianceProvider } from '@kindgi/compliance';
6
6
  import {
7
7
  type CheckRegistry,
@@ -15,7 +15,15 @@ import {
15
15
  type ToolResultRecord,
16
16
  evaluateAll,
17
17
  } from '@kindgi/guardrails';
18
- import type { AgentId, GuardrailId, ProjectId, Result, RunId, TenantId } from '@kindgi/types';
18
+ import type {
19
+ AgentId,
20
+ GuardrailId,
21
+ OrgId,
22
+ ProjectId,
23
+ Result,
24
+ RunId,
25
+ TenantId,
26
+ } from '@kindgi/types';
19
27
 
20
28
  import type { Agent, ConversationId, ConversationMessage } from './types.js';
21
29
 
@@ -66,6 +74,8 @@ export function buildRunTrace(input: {
66
74
  readonly tenantId: TenantId;
67
75
  /** Lets the engine record compliance evidence for a failed check. */
68
76
  readonly projectId: ProjectId;
77
+ /** The project's org, when it has one. */
78
+ readonly orgId?: OrgId;
69
79
  readonly conversationId: ConversationId;
70
80
  /** 1-based number of this turn in the conversation. */
71
81
  readonly turnNumber: number;
@@ -133,6 +143,7 @@ export function buildRunTrace(input: {
133
143
  runId: input.runId,
134
144
  tenantId: input.tenantId,
135
145
  projectId: input.projectId,
146
+ ...(input.orgId !== undefined && { orgId: input.orgId }),
136
147
  agentId: input.agent.id as unknown as AgentId,
137
148
  output,
138
149
  toolCalls,
@@ -179,7 +190,8 @@ export function resolveGuardrails(
179
190
  * doesn't halt on its own. `tenantPolicy` (the policy the turn was
180
191
  * routed under) also governs which models llm-judge guardrails may use;
181
192
  * `abortSignal` (the turn's) reaches every check, so a slow judge or
182
- * pack check stops when the turn does.
193
+ * pack check stops when the turn does. `usage` (the turn's sink) records
194
+ * every llm-judge call, as the turn's own model calls are.
183
195
  */
184
196
  export async function evaluateGate(
185
197
  guardrails: readonly Guardrail[],
@@ -187,6 +199,7 @@ export async function evaluateGate(
187
199
  bindings: GuardrailsBindings,
188
200
  tenantPolicy?: TenantPolicy,
189
201
  abortSignal?: AbortSignal,
202
+ usage?: UsageSink,
190
203
  ): Promise<readonly EvaluationOutcome[]> {
191
204
  if (guardrails.length === 0 || bindings.checks === undefined) return [];
192
205
  const evalBindings: EvaluationBindings = {
@@ -194,6 +207,7 @@ export async function evaluateGate(
194
207
  ...(bindings.compliance !== undefined && { compliance: bindings.compliance }),
195
208
  ...(tenantPolicy !== undefined && { tenantPolicy }),
196
209
  ...(abortSignal !== undefined && { abortSignal }),
210
+ ...(usage !== undefined && { usage }),
197
211
  };
198
212
  return await evaluateAll(guardrails, bindings.checks, trace, evalBindings);
199
213
  }
@@ -143,6 +143,20 @@ export interface TurnContext {
143
143
  * Present only when the builder was wired AND persistence succeeded.
144
144
  */
145
145
  persistedProvenance?: import('@kindgi/provenance').Provenance;
146
+ /**
147
+ * This turn's tool results so far, by invocation id: a model call read
148
+ * them all (they're in its input), so its provenance node is
149
+ * `influenced-by` each. Filled by `dispatch-tools`, and by the rebuild
150
+ * of a resumed turn.
151
+ */
152
+ toolResultIds?: string[];
153
+ /**
154
+ * The tool-call approvals this turn's journal shows decided, by
155
+ * invocation id: each call's provenance shows the approval it waited on.
156
+ * Populated by `rehydrateTurnContext` (a decision only reaches a turn
157
+ * that parked, and resumes).
158
+ */
159
+ toolApprovals?: ReadonlyMap<string, import('./turn-provenance.js').ToolApproval>;
146
160
  /**
147
161
  * The tenant policy this turn's model was routed under (bound policy
148
162
  * merged with the policy registry's). Populated by `setup`; guardrail
@@ -19,13 +19,15 @@ import {
19
19
  type UnresolvedToolError,
20
20
  throwAgentTurnFailure,
21
21
  } from './errors.js';
22
+ import { TOOL_CALL_GATE_SUBJECT, readGateDecision } from './gate-decision.js';
22
23
  import {
23
24
  effectiveToolErrorPolicy,
24
25
  toolErrorKindOf,
25
26
  toolErrorResult,
26
27
  toolRetriesSoFar,
27
28
  } from './tool-errors.js';
28
- import { type ToolHitlDecision, computeToolCallWaitToken, hashToolArgs } from './tool-hitl.js';
29
+ import { TOOL_GATE_RECORD_PREFIX, computeToolCallWaitToken, hashToolArgs } from './tool-hitl.js';
30
+ import { addStepToolNodes } from './turn-provenance.js';
29
31
 
30
32
  /**
31
33
  * Resolves the effective HITL mode + reviewer role for a specific
@@ -111,7 +113,7 @@ async function decideToolGate(
111
113
  cause: null,
112
114
  });
113
115
  }
114
- return kctx.record(`tool-hitl-gate:${call.id}`, (): ToolGateRecord | undefined => {
116
+ return kctx.record(`${TOOL_GATE_RECORD_PREFIX}${call.id}`, (): ToolGateRecord | undefined => {
115
117
  const resolved = resolveEffectiveToolHitl(effectiveHitl, tool);
116
118
  if (resolved.mode === 'never_ask') return undefined;
117
119
  const argsHash = hashToolArgs(call.arguments);
@@ -251,7 +253,8 @@ export function buildDispatchToolsHandler(ctx: TurnContext): NodeHandler {
251
253
  try {
252
254
  await ctx.bindings.hitl.enqueue({
253
255
  tenantId: ctx.input.tenantId,
254
- subjectKind: 'tool-call:pending',
256
+ projectId: ctx.input.projectId,
257
+ subjectKind: TOOL_CALL_GATE_SUBJECT,
255
258
  subjectRef: {
256
259
  conversationId: ctx.input.conversationId,
257
260
  agentId: ctx.input.agent.id,
@@ -275,14 +278,16 @@ export function buildDispatchToolsHandler(ctx: TurnContext): NodeHandler {
275
278
  }
276
279
 
277
280
  try {
278
- const decision = await kctx.waitForToken<ToolHitlDecision>(waitTokenId, {
279
- timeoutMs,
280
- });
281
- if (decision.decided === 'reject') {
281
+ // Fails closed: only an explicit approve runs the tool. A reject,
282
+ // or an answer that isn't a decision at all, gives the model a
283
+ // rejected result instead.
284
+ const decision = readGateDecision(
285
+ await kctx.waitForToken<unknown>(waitTokenId, { timeoutMs }),
286
+ );
287
+ if (!decision.approved) {
282
288
  toolRejectionPayload =
283
289
  decision.rationale !== undefined ? { rationale: decision.rationale } : {};
284
290
  }
285
- // approve → fall through to dispatch
286
291
  } catch (cause) {
287
292
  if (cause instanceof WaitpointCancelledError) {
288
293
  throwAgentTurnFailure({
@@ -372,43 +377,13 @@ export function buildDispatchToolsHandler(ctx: TurnContext): NodeHandler {
372
377
  output: dispatched.value.persisted.content,
373
378
  durationMs: Date.now() - toolStarted,
374
379
  });
375
-
376
- if (ctx.provenance !== undefined) {
377
- const modelCallNodeId = `model-call:${partial.step}`;
378
- const toolCallNodeId = `tool-call:${call.id}`;
379
- const toolResultNodeId = `tool-result:${call.id}`;
380
- ctx.provenance.addNode({
381
- id: toolCallNodeId,
382
- kind: 'tool-call',
383
- timestamp: dispatched.value.persisted.createdAt,
384
- attributes: {
385
- toolId: call.name,
386
- invocationId: call.id,
387
- // Capture the exact version the
388
- // registry picked at run start + the range the agent asked
389
- // for, so a replay can pin against the same version.
390
- toolVersion: resolvedVersion,
391
- toolVersionRange: requestedRange,
392
- },
393
- });
394
- ctx.provenance.addNode({
395
- id: toolResultNodeId,
396
- kind: 'tool-result',
397
- timestamp: dispatched.value.persisted.createdAt,
398
- });
399
- ctx.provenance.addEdge({
400
- from: toolCallNodeId,
401
- to: modelCallNodeId,
402
- kind: 'invoked',
403
- });
404
- ctx.provenance.addEdge({
405
- from: toolResultNodeId,
406
- to: toolCallNodeId,
407
- kind: 'produced',
408
- });
409
- }
410
380
  }
411
381
 
382
+ // Every call's nodes, once the step has all its results: the ones it
383
+ // ran, the rejected and failed ones, and those it took from before a
384
+ // park in this step.
385
+ addStepToolNodes(ctx, partial.step, iterationAppended);
386
+
412
387
  const forwarded: Partial<AgentTurnIterationOutput> & {
413
388
  readonly step: number;
414
389
  readonly hasToolCalls: true;
@@ -573,6 +548,9 @@ async function dispatchOne(
573
548
  const toolCtx: ToolContext = {
574
549
  tenantId: ctx.input.tenantId,
575
550
  runId,
551
+ // The run's own project and org, never the model's arguments.
552
+ projectId: ctx.input.projectId,
553
+ ...(ctx.input.orgId !== undefined && { orgId: ctx.input.orgId }),
576
554
  requestId: call.id,
577
555
  abortSignal: ctx.turnAbort.signal,
578
556
  // HTTP tools built via defineTool({spec: {kind: 'http'}}) resolve declared
@@ -1,7 +1,9 @@
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 { UsageSink } from '@kindgi/capabilities';
5
+ import type { EvaluationOutcome } from '@kindgi/guardrails';
6
+ import type { NodeContext, NodeHandler } from '@kindgi/handler';
5
7
  import type { Timestamp } from '@kindgi/types';
6
8
 
7
9
  import {
@@ -77,6 +79,7 @@ export function buildEvaluateGuardrailsHandler(ctx: TurnContext): NodeHandler {
77
79
  runId: kctx.runId,
78
80
  tenantId: ctx.input.tenantId,
79
81
  projectId: ctx.input.projectId,
82
+ ...(ctx.input.orgId !== undefined && { orgId: ctx.input.orgId }),
80
83
  conversationId: ctx.input.conversationId,
81
84
  turnNumber: (ctx.conversation?.turnCount ?? 0) + 1,
82
85
  agent: ctx.input.agent,
@@ -93,7 +96,9 @@ export function buildEvaluateGuardrailsHandler(ctx: TurnContext): NodeHandler {
93
96
  ctx.bindings,
94
97
  ctx.tenantPolicy,
95
98
  ctx.turnAbort.signal,
99
+ judgeUsageSink(ctx, kctx),
96
100
  );
101
+ throwIfJudgeCallsUnrecorded(outcomes);
97
102
  const categorized = categorizeOutcomes(outcomes);
98
103
 
99
104
  const allViolations = [...categorized.blocking, ...categorized.warnings, ...categorized.other];
@@ -151,3 +156,36 @@ export function buildEvaluateGuardrailsHandler(ctx: TurnContext): NodeHandler {
151
156
  };
152
157
  };
153
158
  }
159
+
160
+ /**
161
+ * The turn's usage sink for its llm-judge calls, adding what only the
162
+ * turn knows: the step that made them and the agent's version.
163
+ */
164
+ function judgeUsageSink(ctx: TurnContext, kctx: NodeContext): UsageSink | undefined {
165
+ const sink = ctx.bindings.usage;
166
+ if (sink === undefined) return undefined;
167
+ return {
168
+ record: (call) =>
169
+ sink.record({
170
+ nodeId: kctx.nodeId as unknown as string,
171
+ agentVersion: ctx.input.agent.version,
172
+ ...call,
173
+ }),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * A judge call that answered but couldn't be recorded fails the step, as
179
+ * the turn's own model calls do: no answered call is left unrecorded.
180
+ */
181
+ function throwIfJudgeCallsUnrecorded(outcomes: readonly EvaluationOutcome[]): void {
182
+ for (const outcome of outcomes) {
183
+ if (outcome.kind === 'err' && outcome.error.code === 'judge-usage-unrecorded') {
184
+ throwAgentTurnFailure({
185
+ code: 'persistence-error',
186
+ message: outcome.error.message,
187
+ cause: outcome.error,
188
+ });
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The reviewer's answer at an agent turn's approval gates: the tool-call
6
+ * gate (`dispatch-tools`) and the session gate (`setup`). A gate parks the
7
+ * turn on a waitpoint; the approvals route resumes it with the decision
8
+ * (`GateDecisionValue`): approve or reject, the reviewer's rationale, and
9
+ * who decided which approval.
10
+ *
11
+ * A gate fails closed. Only an explicit approve lets the tool run (or the
12
+ * session go on). Anything else blocks: a reject, and also a resume value
13
+ * that isn't a decision at all (a `value` that replaced it, a malformed
14
+ * payload), so no answer can be read as consent by omission.
15
+ */
16
+
17
+ /** The approval subject an agent's tool-call gate parks on. */
18
+ export const TOOL_CALL_GATE_SUBJECT = 'tool-call:pending';
19
+
20
+ /** The approval subject an agent's session gate (`afterTurns`) parks on. */
21
+ export const SESSION_GATE_SUBJECT = 'agent-turn:session-hitl-gate';
22
+
23
+ /**
24
+ * The approval subjects an agent turn parks on. Their resume value is the
25
+ * reviewer's decision and nothing else, so the approvals route takes no
26
+ * `value` for them.
27
+ */
28
+ export const AGENT_GATE_SUBJECTS: ReadonlySet<string> = new Set([
29
+ TOOL_CALL_GATE_SUBJECT,
30
+ SESSION_GATE_SUBJECT,
31
+ ]);
32
+
33
+ /**
34
+ * A gate's resume value: the reviewer's decision, as the approvals route
35
+ * (and the runtime, delivering a decision whose delivery was lost) completes
36
+ * the gate's waitpoint with it. The run's journal keeps it as it came.
37
+ */
38
+ export interface GateDecisionValue {
39
+ readonly decided: 'approve' | 'reject';
40
+ readonly rationale?: string;
41
+ /**
42
+ * Who decided, as an actor: `user:<userId>`, the reviewer's user. Absent
43
+ * from values written before it was recorded.
44
+ */
45
+ readonly decidedBy?: string;
46
+ /** The approval decided. Absent from values written before it was recorded. */
47
+ readonly approvalId?: string;
48
+ }
49
+
50
+ /** Who decided a gate, and which approval, when its value says. */
51
+ interface GateDecider {
52
+ readonly decidedBy?: string;
53
+ readonly approvalId?: string;
54
+ }
55
+
56
+ /** What a gate's resume value decides. */
57
+ export type GateDecision = GateDecider &
58
+ (
59
+ | { readonly approved: true }
60
+ | {
61
+ readonly approved: false;
62
+ /** `rejected`: the reviewer said no. `unreadable`: the answer wasn't a decision. */
63
+ readonly reason: 'rejected' | 'unreadable';
64
+ /** The reviewer's rationale, or why an unreadable answer blocks. */
65
+ readonly rationale: string | undefined;
66
+ }
67
+ );
68
+
69
+ /** Why a gate blocks on an answer that isn't a decision. */
70
+ export const UNREADABLE_DECISION =
71
+ "the approval's answer wasn't a decision (approve or reject), so it counts as a rejection";
72
+
73
+ /**
74
+ * The decision in a gate's resume value. Fails closed: only
75
+ * `{ decided: 'approve' }` approves.
76
+ */
77
+ export function readGateDecision(value: unknown): GateDecision {
78
+ if (typeof value !== 'object' || value === null) {
79
+ return { approved: false, reason: 'unreadable', rationale: UNREADABLE_DECISION };
80
+ }
81
+ const { decided, rationale, decidedBy, approvalId } = value as {
82
+ decided?: unknown;
83
+ rationale?: unknown;
84
+ decidedBy?: unknown;
85
+ approvalId?: unknown;
86
+ };
87
+ const decider: GateDecider = {
88
+ ...(typeof decidedBy === 'string' && { decidedBy }),
89
+ ...(typeof approvalId === 'string' && { approvalId }),
90
+ };
91
+ if (decided === 'approve') return { approved: true, ...decider };
92
+ if (decided === 'reject') {
93
+ return {
94
+ approved: false,
95
+ reason: 'rejected',
96
+ rationale: typeof rationale === 'string' ? rationale : undefined,
97
+ ...decider,
98
+ };
99
+ }
100
+ return { approved: false, reason: 'unreadable', rationale: UNREADABLE_DECISION };
101
+ }