@kindgi/agents 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conversation-binding.d.ts +9 -1
- package/dist/conversation-binding.d.ts.map +1 -1
- package/dist/define.js +20 -0
- package/dist/define.js.map +1 -1
- package/dist/guardrails-gate.d.ts +7 -4
- package/dist/guardrails-gate.d.ts.map +1 -1
- package/dist/guardrails-gate.js +5 -2
- package/dist/guardrails-gate.js.map +1 -1
- package/dist/handlers/context.d.ts +14 -0
- package/dist/handlers/context.d.ts.map +1 -1
- package/dist/handlers/dispatch-tools.d.ts.map +1 -1
- package/dist/handlers/dispatch-tools.js +57 -60
- package/dist/handlers/dispatch-tools.js.map +1 -1
- package/dist/handlers/errors.d.ts +11 -1
- package/dist/handlers/errors.d.ts.map +1 -1
- package/dist/handlers/errors.js.map +1 -1
- package/dist/handlers/evaluate-guardrails.d.ts.map +1 -1
- package/dist/handlers/evaluate-guardrails.js +34 -1
- package/dist/handlers/evaluate-guardrails.js.map +1 -1
- package/dist/handlers/gate-decision.d.ts +62 -0
- package/dist/handlers/gate-decision.d.ts.map +1 -0
- package/dist/handlers/gate-decision.js +55 -0
- package/dist/handlers/gate-decision.js.map +1 -0
- package/dist/handlers/model-call.d.ts +10 -0
- package/dist/handlers/model-call.d.ts.map +1 -1
- package/dist/handlers/model-call.js +109 -34
- package/dist/handlers/model-call.js.map +1 -1
- package/dist/handlers/persist-provenance.d.ts.map +1 -1
- package/dist/handlers/persist-provenance.js +3 -1
- package/dist/handlers/persist-provenance.js.map +1 -1
- package/dist/handlers/persist-user-message.d.ts.map +1 -1
- package/dist/handlers/persist-user-message.js +5 -16
- package/dist/handlers/persist-user-message.js.map +1 -1
- package/dist/handlers/public-types.d.ts +16 -2
- package/dist/handlers/public-types.d.ts.map +1 -1
- package/dist/handlers/rehydrate.d.ts.map +1 -1
- package/dist/handlers/rehydrate.js +85 -0
- package/dist/handlers/rehydrate.js.map +1 -1
- package/dist/handlers/run-retrievals.d.ts.map +1 -1
- package/dist/handlers/run-retrievals.js +2 -19
- package/dist/handlers/run-retrievals.js.map +1 -1
- package/dist/handlers/setup.d.ts.map +1 -1
- package/dist/handlers/setup.js +44 -24
- package/dist/handlers/setup.js.map +1 -1
- package/dist/handlers/tool-hitl.d.ts +5 -9
- package/dist/handlers/tool-hitl.d.ts.map +1 -1
- package/dist/handlers/tool-hitl.js +5 -0
- package/dist/handlers/tool-hitl.js.map +1 -1
- package/dist/handlers/turn-provenance.d.ts +67 -0
- package/dist/handlers/turn-provenance.d.ts.map +1 -0
- package/dist/handlers/turn-provenance.js +160 -0
- package/dist/handlers/turn-provenance.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/invoke.d.ts +15 -2
- package/dist/invoke.d.ts.map +1 -1
- package/dist/invoke.js +38 -22
- package/dist/invoke.js.map +1 -1
- package/dist/provenance-emit.d.ts +4 -2
- package/dist/provenance-emit.d.ts.map +1 -1
- package/dist/provenance-emit.js +4 -2
- package/dist/provenance-emit.js.map +1 -1
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +11 -0
- package/dist/schema.js.map +1 -1
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/migrations/0003_hesitant_captain_cross.sql +2 -0
- package/migrations/meta/0003_snapshot.json +321 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +19 -18
- package/src/conversation-binding.ts +9 -1
- package/src/define.ts +20 -0
- package/src/guardrails-gate.ts +17 -3
- package/src/handlers/context.ts +14 -0
- package/src/handlers/dispatch-tools.ts +76 -61
- package/src/handlers/errors.ts +13 -1
- package/src/handlers/evaluate-guardrails.ts +39 -1
- package/src/handlers/gate-decision.ts +101 -0
- package/src/handlers/model-call.ts +149 -33
- package/src/handlers/persist-provenance.ts +3 -1
- package/src/handlers/persist-user-message.ts +3 -16
- package/src/handlers/public-types.ts +16 -2
- package/src/handlers/rehydrate.ts +120 -3
- package/src/handlers/run-retrievals.ts +2 -19
- package/src/handlers/setup.ts +59 -33
- package/src/handlers/tool-hitl.ts +6 -10
- package/src/handlers/turn-provenance.ts +230 -0
- package/src/index.ts +8 -0
- package/src/invoke.ts +53 -25
- package/src/provenance-emit.ts +4 -1
- package/src/schema.ts +11 -0
- package/src/types.ts +15 -1
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
// Copyright (C) 2026 Kindgi Inc.
|
|
3
3
|
|
|
4
4
|
import type { Principal } from '@kindgi/authz';
|
|
5
|
-
import type { ProviderRegistry, TenantPolicy } from '@kindgi/capabilities';
|
|
5
|
+
import type { ProviderRegistry, TenantPolicy, UsageSink } from '@kindgi/capabilities';
|
|
6
6
|
import type { EmbeddingProviderRegistry } from '@kindgi/embedding';
|
|
7
7
|
import type { MemoryQueryBinding } from '@kindgi/memory';
|
|
8
8
|
import type { PolicyRegistry } from '@kindgi/policy-contract';
|
|
9
9
|
import type { ParentRunRef, RunBinding } from '@kindgi/runtime';
|
|
10
10
|
import type { ToolRegistry, ToolSecretRef } from '@kindgi/tools';
|
|
11
|
-
import type { ProjectId, ProvenanceId, RunId, TenantId, Timestamp } from '@kindgi/types';
|
|
11
|
+
import type { OrgId, ProjectId, ProvenanceId, RunId, TenantId, Timestamp } from '@kindgi/types';
|
|
12
12
|
|
|
13
13
|
import type { ConversationBinding } from '../conversation-binding.js';
|
|
14
14
|
import type { GuardrailsBindings } from '../guardrails-gate.js';
|
|
@@ -32,6 +32,12 @@ export interface InvokeAgentInput {
|
|
|
32
32
|
* at the caller layer.
|
|
33
33
|
*/
|
|
34
34
|
readonly projectId: ProjectId;
|
|
35
|
+
/**
|
|
36
|
+
* The project's org, when it belongs to one: the runtime resolves it
|
|
37
|
+
* from the project, never from input. The turn's tools get it as
|
|
38
|
+
* `ToolContext.orgId`, its guardrails as `trace.orgId`.
|
|
39
|
+
*/
|
|
40
|
+
readonly orgId?: OrgId;
|
|
35
41
|
readonly agent: Agent;
|
|
36
42
|
readonly conversationId: ConversationId;
|
|
37
43
|
readonly userMessage: string;
|
|
@@ -153,6 +159,12 @@ export interface InvokeAgentBindings extends GuardrailsBindings {
|
|
|
153
159
|
readonly onEvent?: OnTurnEvent;
|
|
154
160
|
readonly provenance?: ProvenanceBindings;
|
|
155
161
|
readonly hitl?: HitlBindings;
|
|
162
|
+
/**
|
|
163
|
+
* Where the turn records each model call (the runtime's cost ledger):
|
|
164
|
+
* its usage, provider, model, run, agent and step, before the step
|
|
165
|
+
* that made it goes on. Absent: calls aren't recorded.
|
|
166
|
+
*/
|
|
167
|
+
readonly usage?: UsageSink;
|
|
156
168
|
/**
|
|
157
169
|
* Declarative HTTP tools: optional secret resolver populated
|
|
158
170
|
* from the deployment's tenant-scoped `SecretBinding`. When present,
|
|
@@ -171,6 +183,8 @@ export interface HitlBindings {
|
|
|
171
183
|
|
|
172
184
|
export interface HitlEnqueueInput {
|
|
173
185
|
readonly tenantId: TenantId;
|
|
186
|
+
/** The turn's project: approval lists filter by it. */
|
|
187
|
+
readonly projectId?: ProjectId;
|
|
174
188
|
readonly subjectKind: string;
|
|
175
189
|
readonly subjectRef: Readonly<Record<string, unknown>>;
|
|
176
190
|
readonly requiredRole?: 'standard' | 'senior' | 'admin';
|
|
@@ -27,26 +27,45 @@
|
|
|
27
27
|
* A turn parked inside `setup` (the session gate) re-runs `setup`, and
|
|
28
28
|
* nothing here applies.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* - the provenance nodes of the steps that ran before the park are
|
|
31
|
+
* added again from what they left (`turn-provenance.ts`), so a
|
|
32
|
+
* resumed turn's DAG is whole: its user message, its retrievals, each
|
|
33
|
+
* model call, and the tool calls of each completed step. The step the
|
|
34
|
+
* turn parked in adds its own when it runs again;
|
|
35
|
+
* - the tool-call approvals decided so far come from the journal (the
|
|
36
|
+
* waitpoint each gate recorded, when the call parked, the decision
|
|
37
|
+
* and when it came), so each call's provenance shows the approval it
|
|
38
|
+
* waited on and who decided it.
|
|
32
39
|
*/
|
|
33
40
|
|
|
34
41
|
import type { LoopContext } from '@kindgi/handler';
|
|
35
|
-
import { type JournalEntry, bodyStepKey } from '@kindgi/runtime';
|
|
42
|
+
import { type JournalEntry, type ValueRecordedPayload, bodyStepKey } from '@kindgi/runtime';
|
|
43
|
+
import type { Timestamp } from '@kindgi/types';
|
|
36
44
|
|
|
37
45
|
import type { ConversationMessage, RetrievedFact } from '../types.js';
|
|
38
46
|
import type { TurnContext } from './context.js';
|
|
39
47
|
import { throwAgentTurnFailure } from './errors.js';
|
|
48
|
+
import { readGateDecision } from './gate-decision.js';
|
|
49
|
+
import { TOOL_GATE_RECORD_PREFIX } from './tool-hitl.js';
|
|
40
50
|
import {
|
|
41
51
|
loadTurnConversation,
|
|
42
52
|
resolveTurnEnvironment,
|
|
43
53
|
resolveTurnHitlPolicy,
|
|
44
54
|
} from './turn-environment.js';
|
|
55
|
+
import {
|
|
56
|
+
addInputNode,
|
|
57
|
+
addModelCallNode,
|
|
58
|
+
addRetrievalNodes,
|
|
59
|
+
addStepToolNodes,
|
|
60
|
+
} from './turn-provenance.js';
|
|
61
|
+
import type { ToolApproval } from './turn-provenance.js';
|
|
45
62
|
|
|
46
63
|
interface StepRecord {
|
|
47
64
|
readonly nodeId: string;
|
|
48
65
|
readonly output: unknown;
|
|
49
66
|
readonly inLoop: boolean;
|
|
67
|
+
/** When the step completed. */
|
|
68
|
+
readonly at: Timestamp;
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
/** Each step's last completion — a step at one loop iteration counts once. */
|
|
@@ -67,6 +86,7 @@ function completedSteps(journal: readonly JournalEntry[]): readonly StepRecord[]
|
|
|
67
86
|
nodeId: e.nodeId as unknown as string,
|
|
68
87
|
output: payload.output,
|
|
69
88
|
inLoop: payload.loopContext !== undefined,
|
|
89
|
+
at: e.timestamp,
|
|
70
90
|
});
|
|
71
91
|
}
|
|
72
92
|
return [...byStep.values()];
|
|
@@ -129,9 +149,106 @@ export async function rehydrateTurnContext(
|
|
|
129
149
|
ctx.usage.totalCostUsd += out.iterationUsage?.costUsd ?? 0;
|
|
130
150
|
if (out.provider !== undefined) ctx.lastProvider = out.provider;
|
|
131
151
|
}
|
|
152
|
+
ctx.toolApprovals = toolApprovalsOf(journal);
|
|
153
|
+
rebuildProvenance(ctx, steps, retrievals?.retrieved);
|
|
132
154
|
return true;
|
|
133
155
|
}
|
|
134
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The tool-call approvals the journal shows decided, by invocation id: the
|
|
159
|
+
* waitpoint each call's gate recorded (`dispatch-tools`), when the call
|
|
160
|
+
* parked on it (`wait.suspended`, the first), and the decision that
|
|
161
|
+
* resolved it (`wait.resumed`).
|
|
162
|
+
*/
|
|
163
|
+
function toolApprovalsOf(journal: readonly JournalEntry[]): ReadonlyMap<string, ToolApproval> {
|
|
164
|
+
const callOf = new Map<string, string>();
|
|
165
|
+
const parkedAt = new Map<string, Timestamp>();
|
|
166
|
+
const resumed = new Map<string, { readonly at: Timestamp; readonly value: unknown }>();
|
|
167
|
+
for (const e of journal) {
|
|
168
|
+
const gate = toolGateOf(e);
|
|
169
|
+
if (gate !== undefined) callOf.set(gate.waitTokenId, gate.invocationId);
|
|
170
|
+
const p = (e.payload ?? {}) as { readonly tokenId?: unknown; readonly value?: unknown };
|
|
171
|
+
if (typeof p.tokenId !== 'string') continue;
|
|
172
|
+
if (e.kind === 'wait.suspended' && !parkedAt.has(p.tokenId)) {
|
|
173
|
+
parkedAt.set(p.tokenId, e.timestamp);
|
|
174
|
+
}
|
|
175
|
+
if (e.kind === 'wait.resumed') resumed.set(p.tokenId, { at: e.timestamp, value: p.value });
|
|
176
|
+
}
|
|
177
|
+
const approvals = new Map<string, ToolApproval>();
|
|
178
|
+
for (const [waitTokenId, decided] of resumed) {
|
|
179
|
+
const invocationId = callOf.get(waitTokenId);
|
|
180
|
+
if (invocationId === undefined) continue;
|
|
181
|
+
approvals.set(invocationId, {
|
|
182
|
+
waitTokenId,
|
|
183
|
+
parkedAt: parkedAt.get(waitTokenId) ?? decided.at,
|
|
184
|
+
decidedAt: decided.at,
|
|
185
|
+
decision: readGateDecision(decided.value),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return approvals;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The waitpoint a tool call's gate recorded (`dispatch-tools`), if `e` is that record. */
|
|
192
|
+
function toolGateOf(
|
|
193
|
+
e: JournalEntry,
|
|
194
|
+
): { readonly waitTokenId: string; readonly invocationId: string } | undefined {
|
|
195
|
+
if (e.kind !== 'value.recorded') return undefined;
|
|
196
|
+
const p = (e.payload ?? {}) as Partial<ValueRecordedPayload>;
|
|
197
|
+
if (typeof p.key !== 'string' || !p.key.startsWith(TOOL_GATE_RECORD_PREFIX)) return undefined;
|
|
198
|
+
const waitTokenId = (p.value as { readonly waitTokenId?: unknown } | undefined)?.waitTokenId;
|
|
199
|
+
return typeof waitTokenId === 'string'
|
|
200
|
+
? { waitTokenId, invocationId: p.key.slice(TOOL_GATE_RECORD_PREFIX.length) }
|
|
201
|
+
: undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A completed model-call step's output, as far as provenance reads it. */
|
|
205
|
+
interface ModelCallStepOutput {
|
|
206
|
+
readonly step?: number;
|
|
207
|
+
readonly callId?: string;
|
|
208
|
+
readonly finishReason?: string;
|
|
209
|
+
readonly provider?: { readonly id: string; readonly model: string };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The provenance of the steps that ran before the park, in the order
|
|
214
|
+
* they ran: the user message, the retrievals, then each model call with
|
|
215
|
+
* the tool calls its completed step stored.
|
|
216
|
+
*/
|
|
217
|
+
function rebuildProvenance(
|
|
218
|
+
ctx: TurnContext,
|
|
219
|
+
steps: readonly StepRecord[],
|
|
220
|
+
retrieved: readonly RetrievedFact[] | undefined,
|
|
221
|
+
): void {
|
|
222
|
+
const input = ctx.userMessage;
|
|
223
|
+
if (ctx.provenance === undefined || input === undefined) return;
|
|
224
|
+
addInputNode(ctx.provenance, input);
|
|
225
|
+
if (retrieved !== undefined) addRetrievalNodes(ctx.provenance, retrieved, input);
|
|
226
|
+
|
|
227
|
+
const storedByStep = new Map<number, readonly ConversationMessage[]>();
|
|
228
|
+
for (const s of steps.filter((s) => s.nodeId === 'dispatch-tools' && s.inLoop)) {
|
|
229
|
+
const out = s.output as {
|
|
230
|
+
readonly step?: number;
|
|
231
|
+
readonly iterationAppended?: readonly ConversationMessage[];
|
|
232
|
+
};
|
|
233
|
+
if (out.step !== undefined) storedByStep.set(out.step, out.iterationAppended ?? []);
|
|
234
|
+
}
|
|
235
|
+
const calls = steps
|
|
236
|
+
.filter((s) => s.nodeId === 'model-call' && s.inLoop)
|
|
237
|
+
.map((s) => ({ at: s.at, out: s.output as ModelCallStepOutput }))
|
|
238
|
+
.sort((a, b) => (a.out.step ?? 0) - (b.out.step ?? 0));
|
|
239
|
+
for (const { at, out } of calls) {
|
|
240
|
+
const { step, callId, provider, finishReason } = out;
|
|
241
|
+
if (step === undefined || callId === undefined || provider === undefined) continue;
|
|
242
|
+
addModelCallNode(
|
|
243
|
+
ctx.provenance,
|
|
244
|
+
{ step, callId, provider, finishReason: finishReason ?? 'stop', at },
|
|
245
|
+
input.sequence,
|
|
246
|
+
[...(ctx.toolResultIds ?? [])],
|
|
247
|
+
);
|
|
248
|
+
addStepToolNodes(ctx, step, storedByStep.get(step) ?? []);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
135
252
|
/**
|
|
136
253
|
* The messages no completed step accounts for: those the step the turn
|
|
137
254
|
* parked in stored before it parked.
|
|
@@ -8,6 +8,7 @@ import { emitTurnEvent } from '../streaming.js';
|
|
|
8
8
|
|
|
9
9
|
import type { TurnContext } from './context.js';
|
|
10
10
|
import { throwAgentTurnFailure } from './errors.js';
|
|
11
|
+
import { addRetrievalNodes } from './turn-provenance.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Execute the agent's declared retrieval intents. Populates
|
|
@@ -49,25 +50,7 @@ export function buildRunRetrievalsHandler(ctx: TurnContext): NodeHandler {
|
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
if (ctx.provenance !== undefined && ctx.userMessage !== undefined) {
|
|
52
|
-
|
|
53
|
-
ctx.provenance.addNode({
|
|
54
|
-
id: `retrieval:${r.fact.id}`,
|
|
55
|
-
kind: 'retrieval',
|
|
56
|
-
timestamp: ctx.userMessage.createdAt,
|
|
57
|
-
...(r.fact.contentHash !== undefined && { contentHash: r.fact.contentHash }),
|
|
58
|
-
attributes: {
|
|
59
|
-
factId: r.fact.id,
|
|
60
|
-
factType: r.fact.type,
|
|
61
|
-
intentScope: r.intent.scope,
|
|
62
|
-
...(r.score !== undefined && { score: r.score }),
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
ctx.provenance.addEdge({
|
|
66
|
-
from: `retrieval:${r.fact.id}`,
|
|
67
|
-
to: `input:${ctx.userMessage.sequence}`,
|
|
68
|
-
kind: 'influenced-by',
|
|
69
|
-
});
|
|
70
|
-
}
|
|
53
|
+
addRetrievalNodes(ctx.provenance, retrieved.value, ctx.userMessage);
|
|
71
54
|
}
|
|
72
55
|
|
|
73
56
|
// The facts go in the journal: a resumed turn restores them from it
|
package/src/handlers/setup.ts
CHANGED
|
@@ -11,12 +11,14 @@ import { emitTurnEvent } from '../streaming.js';
|
|
|
11
11
|
|
|
12
12
|
import type { TurnContext } from './context.js';
|
|
13
13
|
import { throwAgentTurnFailure } from './errors.js';
|
|
14
|
+
import { SESSION_GATE_SUBJECT, readGateDecision } from './gate-decision.js';
|
|
14
15
|
import { writeRunSnapshot } from './run-snapshot.js';
|
|
15
16
|
import {
|
|
16
17
|
loadTurnConversation,
|
|
17
18
|
resolveTurnEnvironment,
|
|
18
19
|
resolveTurnHitlPolicy,
|
|
19
20
|
} from './turn-environment.js';
|
|
21
|
+
import { decisionOf } from './turn-provenance.js';
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Deterministic waitpoint token — must produce the same value on every
|
|
@@ -34,14 +36,21 @@ function computeSessionGateWaitToken(input: {
|
|
|
34
36
|
.slice(0, 40);
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/** The `record` key of the session gate's decision. */
|
|
40
|
+
const SESSION_GATE_RECORD = 'session-hitl-gate';
|
|
41
|
+
|
|
37
42
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
43
|
+
* The session gate's decision, as `setup` records it the first time
|
|
44
|
+
* the gate asks for a review: the wait it parks on, and the review it asks
|
|
45
|
+
* for. A turn resumed after the park reads it back instead of deciding again.
|
|
41
46
|
*/
|
|
42
|
-
interface
|
|
43
|
-
readonly
|
|
44
|
-
readonly
|
|
47
|
+
interface SessionGateRecord {
|
|
48
|
+
readonly waitTokenId: string;
|
|
49
|
+
readonly turnCount: number;
|
|
50
|
+
readonly threshold: number;
|
|
51
|
+
readonly reason: string;
|
|
52
|
+
readonly requiredRole: 'standard' | 'senior' | 'admin';
|
|
53
|
+
readonly timeoutMs: number;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
/**
|
|
@@ -111,33 +120,46 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
|
|
|
111
120
|
// `hitl` policy — before the gate that may park on them.
|
|
112
121
|
const effectiveHitl = await resolveTurnHitlPolicy(ctx);
|
|
113
122
|
ctx.hitlPolicy = effectiveHitl;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// Decided once: a turn that parked here resumes on the gate it parked
|
|
124
|
+
// on, whatever the policy or the conversation's turn count say by then.
|
|
125
|
+
const gate = await kctx.record(SESSION_GATE_RECORD, (): SessionGateRecord | undefined => {
|
|
126
|
+
const evaluated = evaluateSessionGate(
|
|
127
|
+
{ ...(effectiveHitl.turn !== undefined && { afterTurns: effectiveHitl.turn.afterTurns }) },
|
|
128
|
+
conversation.turnCount,
|
|
129
|
+
);
|
|
130
|
+
if (evaluated.kind !== 'hitl-required') return undefined;
|
|
131
|
+
return {
|
|
132
|
+
waitTokenId: computeSessionGateWaitToken({
|
|
133
|
+
runId: kctx.runId as unknown as string,
|
|
134
|
+
turnCount: conversation.turnCount,
|
|
135
|
+
}),
|
|
121
136
|
turnCount: conversation.turnCount,
|
|
122
|
-
|
|
123
|
-
|
|
137
|
+
threshold: evaluated.threshold,
|
|
138
|
+
reason: evaluated.reason,
|
|
139
|
+
requiredRole: effectiveHitl.defaultReviewerRole,
|
|
140
|
+
timeoutMs: effectiveHitl.timeoutMs,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
if (gate !== undefined) {
|
|
144
|
+
const { waitTokenId, timeoutMs } = gate;
|
|
124
145
|
const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
|
|
125
146
|
|
|
126
147
|
if (ctx.bindings.hitl?.enqueue !== undefined) {
|
|
127
148
|
try {
|
|
128
149
|
await ctx.bindings.hitl.enqueue({
|
|
129
150
|
tenantId: ctx.input.tenantId,
|
|
130
|
-
|
|
151
|
+
projectId: ctx.input.projectId,
|
|
152
|
+
subjectKind: SESSION_GATE_SUBJECT,
|
|
131
153
|
subjectRef: {
|
|
132
154
|
conversationId: ctx.input.conversationId,
|
|
133
155
|
agentId: ctx.input.agent.id,
|
|
134
156
|
agentVersion: ctx.input.agent.version,
|
|
135
|
-
turnCount:
|
|
136
|
-
threshold:
|
|
157
|
+
turnCount: gate.turnCount,
|
|
158
|
+
threshold: gate.threshold,
|
|
137
159
|
},
|
|
138
|
-
requiredRole:
|
|
160
|
+
requiredRole: gate.requiredRole,
|
|
139
161
|
title: `HITL review required for ${ctx.input.agent.name}`,
|
|
140
|
-
description:
|
|
162
|
+
description: gate.reason,
|
|
141
163
|
waitTokenId,
|
|
142
164
|
provenanceRef: { runId: kctx.runId },
|
|
143
165
|
expiresAt: expiresAt as never,
|
|
@@ -153,8 +175,8 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
|
|
|
153
175
|
// execution (park), persist-provenance never runs so this node is
|
|
154
176
|
// lost — that's fine. On the FINAL replay after resume, this node
|
|
155
177
|
// AND the `resume` node below both emit and get persisted.
|
|
156
|
-
const waitNodeId = `session-hitl-gate-wait:${
|
|
157
|
-
const resumeNodeId = `session-hitl-gate-resume:${
|
|
178
|
+
const waitNodeId = `session-hitl-gate-wait:${gate.turnCount}`;
|
|
179
|
+
const resumeNodeId = `session-hitl-gate-resume:${gate.turnCount}`;
|
|
158
180
|
if (ctx.provenance !== undefined) {
|
|
159
181
|
ctx.provenance.addNode({
|
|
160
182
|
id: waitNodeId,
|
|
@@ -163,17 +185,19 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
|
|
|
163
185
|
actor: `agent:${ctx.input.agent.id as unknown as string}`,
|
|
164
186
|
attributes: {
|
|
165
187
|
gate: 'session-hitl',
|
|
166
|
-
turnCount:
|
|
167
|
-
threshold:
|
|
188
|
+
turnCount: gate.turnCount,
|
|
189
|
+
threshold: gate.threshold,
|
|
168
190
|
waitTokenId,
|
|
169
191
|
},
|
|
170
192
|
});
|
|
171
193
|
}
|
|
172
194
|
|
|
173
195
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
196
|
+
// Fails closed: only an explicit approve lets the turn go on
|
|
197
|
+
// (`readGateDecision`).
|
|
198
|
+
const decision = readGateDecision(
|
|
199
|
+
await kctx.waitForToken<unknown>(waitTokenId, { timeoutMs }),
|
|
200
|
+
);
|
|
177
201
|
// Post-resume: emit the resume node + resumed-from edge. Only
|
|
178
202
|
// runs on the REPLAY path — the first execution throws
|
|
179
203
|
// SuspensionSignal inside waitForToken and never gets here.
|
|
@@ -182,11 +206,14 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
|
|
|
182
206
|
id: resumeNodeId,
|
|
183
207
|
kind: 'resume',
|
|
184
208
|
timestamp: new Date().toISOString() as never,
|
|
185
|
-
|
|
209
|
+
// Whoever decided; a decision recorded before it named them, the agent.
|
|
210
|
+
actor: decision.decidedBy ?? `agent:${ctx.input.agent.id as unknown as string}`,
|
|
186
211
|
attributes: {
|
|
187
212
|
gate: 'session-hitl',
|
|
188
|
-
decision: decision
|
|
189
|
-
...(decision.
|
|
213
|
+
decision: decisionOf(decision),
|
|
214
|
+
...(!decision.approved &&
|
|
215
|
+
decision.rationale !== undefined && { rationale: decision.rationale }),
|
|
216
|
+
...(decision.approvalId !== undefined && { approvalId: decision.approvalId }),
|
|
190
217
|
},
|
|
191
218
|
});
|
|
192
219
|
ctx.provenance.addEdge({
|
|
@@ -196,7 +223,7 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
|
|
|
196
223
|
});
|
|
197
224
|
}
|
|
198
225
|
|
|
199
|
-
if (decision.
|
|
226
|
+
if (!decision.approved) {
|
|
200
227
|
throwAgentTurnFailure({
|
|
201
228
|
code: 'hitl-rejected',
|
|
202
229
|
message: `Reviewer rejected the session-HITL gate${
|
|
@@ -205,8 +232,7 @@ export function buildSetupHandler(ctx: TurnContext): NodeHandler {
|
|
|
205
232
|
...(decision.rationale !== undefined && { rationale: decision.rationale }),
|
|
206
233
|
} as never);
|
|
207
234
|
}
|
|
208
|
-
//
|
|
209
|
-
// normally through the rest of setup.
|
|
235
|
+
// Approved: the turn proceeds through the rest of setup.
|
|
210
236
|
} catch (cause) {
|
|
211
237
|
if (cause instanceof WaitpointCancelledError) {
|
|
212
238
|
// Emit resume node with cancelled attribute so the DAG still
|
|
@@ -83,6 +83,12 @@ function canonicalStringify(value: unknown): string {
|
|
|
83
83
|
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalStringify(v)}`).join(',')}}`;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The `NodeContext.record` key of a tool call's gate, before its call id:
|
|
88
|
+
* the journal names the waitpoint a call parked on under this key.
|
|
89
|
+
*/
|
|
90
|
+
export const TOOL_GATE_RECORD_PREFIX = 'tool-hitl-gate:';
|
|
91
|
+
|
|
86
92
|
/**
|
|
87
93
|
* Deterministic waitpoint token for a tool-call gate. Includes both
|
|
88
94
|
* the model-generated call id (unique per iteration) AND the args hash
|
|
@@ -100,16 +106,6 @@ export function computeToolCallWaitToken(input: {
|
|
|
100
106
|
.slice(0, 40);
|
|
101
107
|
}
|
|
102
108
|
|
|
103
|
-
/**
|
|
104
|
-
* Shape the tool-level waitpoint resolves to when the reviewer decides.
|
|
105
|
-
* Same shape as session-gate decisions — the approvals-complete route
|
|
106
|
-
* materializes it identically.
|
|
107
|
-
*/
|
|
108
|
-
export interface ToolHitlDecision {
|
|
109
|
-
readonly decided: 'approve' | 'reject';
|
|
110
|
-
readonly rationale?: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
109
|
/**
|
|
114
110
|
* In-conversation cache of decisions for `ask_on_first_use`. Persisted
|
|
115
111
|
* on `agent_conversations.metadata.hitlToolDecisions` — a flat map of
|