@adjudicate/adapter-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Redis-backed `ConfirmationStore` — restart-durable pending confirmations.
3
+ *
4
+ * The in-memory `createInMemoryConfirmationStore` (see `./persistence.ts`)
5
+ * is suitable for tests and the quickstart, but a process restart loses
6
+ * every pending confirmation. Adopters running the adapter loop in a
7
+ * production process need restart durability so user-held tokens
8
+ * (REQUEST_CONFIRMATION flows that block for hours) survive deploys.
9
+ *
10
+ * This module ships a Redis-backed implementation that uses the same
11
+ * minimal `RedisLedgerClient` surface (`get`/`set`) as the rest of the
12
+ * audit + ledger stack. The store is generic over `H` (the conversation-
13
+ * history shape) — the caller supplies a serializer for their provider's
14
+ * history type.
15
+ *
16
+ * # Wire format
17
+ *
18
+ * Each pending confirmation is JSON-encoded under the key
19
+ * `${prefix}:confirm:${token}`. The TTL on the key matches the
20
+ * adopter-supplied `ttlSeconds` from `put()`.
21
+ *
22
+ * # Get-and-delete (take semantics)
23
+ *
24
+ * `take()` is single-use: a successful take deletes the key. Two
25
+ * concurrent `take(token)` calls race — Redis `GET` + `DEL` is not atomic
26
+ * without scripting, so at most one caller receives the pending
27
+ * confirmation. Production adopters needing strict at-most-once semantics
28
+ * across replicas should wire a Lua script; the JS implementation here
29
+ * accepts a tiny race window where two `take` calls might both return
30
+ * the same pending (the LATER one's adjudication then fails on
31
+ * `REPLAY_SUPPRESSED` via the kernel's ledger — fail-closed by design).
32
+ *
33
+ * # Replay safety
34
+ *
35
+ * Confirmation tokens are NOT inputs to the kernel's adjudication —
36
+ * they're externally-held opaque references. The kernel's
37
+ * `confirmationReceipt` (which IS adjudicated against) is reconstructed
38
+ * from the persisted envelope, not from the token. So storing them in
39
+ * Redis does not change replay determinism.
40
+ */
41
+
42
+ import type { IntentEnvelope } from "@adjudicate/core";
43
+ import type {
44
+ ConfirmationStore,
45
+ PendingConfirmation,
46
+ } from "./persistence.js";
47
+
48
+ /**
49
+ * Minimal Redis surface required by the Redis confirmation store. Same
50
+ * shape as `@adjudicate/audit`'s `RedisLedgerClient` so adopters can
51
+ * reuse the same connection.
52
+ */
53
+ export interface ConfirmationRedisClient {
54
+ set(
55
+ key: string,
56
+ value: string,
57
+ options?: { NX?: boolean; EX?: number },
58
+ ): Promise<string | null>;
59
+ get(key: string): Promise<string | null>;
60
+ del(key: string): Promise<unknown>;
61
+ }
62
+
63
+ export interface CreateRedisConfirmationStoreOptions<H> {
64
+ readonly client: ConfirmationRedisClient;
65
+ /**
66
+ * Adopter-supplied key namespacer. Adjudicate convention: wrap the
67
+ * raw suffix into a tenant/env-namespaced key (e.g.,
68
+ * `${APP_ENV}:adjudicate:${suffix}`). Defaults to identity.
69
+ */
70
+ readonly keyFor?: (suffix: string) => string;
71
+ /**
72
+ * Serializer for the assistant history snapshot. Required because `H`
73
+ * is the provider's opaque history shape — Anthropic's MessageParam[]
74
+ * and OpenAI's ChatCompletionMessageParam[] are both JSON-serializable
75
+ * by default. Adopters who use richer types (Date, BigInt) should plug
76
+ * in a custom serializer.
77
+ *
78
+ * Default: `JSON.stringify` / `JSON.parse`.
79
+ */
80
+ readonly serializeHistory?: (h: H) => string;
81
+ readonly deserializeHistory?: (s: string) => H;
82
+ }
83
+
84
+ interface WireFormat {
85
+ readonly envelope: IntentEnvelope;
86
+ readonly sessionId: string;
87
+ readonly historyJson: string;
88
+ readonly toolUseId: string;
89
+ readonly prompt: string;
90
+ }
91
+
92
+ /**
93
+ * Redis-backed `ConfirmationStore<H>`. Wraps the minimal `set/get/del`
94
+ * surface. `put` writes with EX (TTL); `take` does GET then DEL.
95
+ *
96
+ * Adopters share the underlying Redis client across the ledger, the
97
+ * kill switch, and the confirmation store — one connection per role
98
+ * (read/write vs subscribe) is the typical production wiring.
99
+ */
100
+ export function createRedisConfirmationStore<H = unknown>(
101
+ opts: CreateRedisConfirmationStoreOptions<H>,
102
+ ): ConfirmationStore<H> {
103
+ const keyFor = opts.keyFor ?? ((s: string) => s);
104
+ const serialize =
105
+ opts.serializeHistory ?? ((h: H) => JSON.stringify(h));
106
+ const deserialize =
107
+ opts.deserializeHistory ?? ((s: string) => JSON.parse(s) as H);
108
+
109
+ return {
110
+ async put(
111
+ token: string,
112
+ pending: PendingConfirmation<H>,
113
+ ttlSeconds: number,
114
+ ) {
115
+ const wire: WireFormat = {
116
+ envelope: pending.envelope,
117
+ sessionId: pending.sessionId,
118
+ historyJson: serialize(pending.assistantHistorySnapshot),
119
+ toolUseId: pending.toolUseId,
120
+ prompt: pending.prompt,
121
+ };
122
+ await opts.client.set(
123
+ keyFor(`confirm:${token}`),
124
+ JSON.stringify(wire),
125
+ { EX: ttlSeconds },
126
+ );
127
+ },
128
+
129
+ async take(token: string) {
130
+ const key = keyFor(`confirm:${token}`);
131
+ const raw = await opts.client.get(key);
132
+ if (raw === null) return null;
133
+ // Get-and-delete: a single use. Concurrent takes may race; the
134
+ // kernel's ledger backstops with REPLAY_SUPPRESSED on the second
135
+ // adjudication.
136
+ await opts.client.del(key);
137
+ let wire: WireFormat;
138
+ try {
139
+ wire = JSON.parse(raw) as WireFormat;
140
+ } catch {
141
+ return null;
142
+ }
143
+ let history: H;
144
+ try {
145
+ history = deserialize(wire.historyJson);
146
+ } catch {
147
+ return null;
148
+ }
149
+ return {
150
+ envelope: wire.envelope,
151
+ sessionId: wire.sessionId,
152
+ assistantHistorySnapshot: history,
153
+ toolUseId: wire.toolUseId,
154
+ prompt: wire.prompt,
155
+ };
156
+ },
157
+ };
158
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * In-memory persistence shims for the adapter loop.
3
+ *
4
+ * Two stores live here:
5
+ * - **DeferRedis + ParkRedis** — implements the combined runtime persistence
6
+ * surface so `parkDeferredIntent` (write) and `resumeDeferredIntent`
7
+ * (read + idempotent claim) both work against a single backing object.
8
+ * Production wires real Redis; the in-memory shim is for tests + the
9
+ * quickstart.
10
+ *
11
+ * - **ConfirmationStore** — separate by design. DEFER persists by
12
+ * `(session, intentHash)`; REQUEST_CONFIRMATION persists by a
13
+ * user-held token (the user clicks "yes/no" at an arbitrary later time).
14
+ * Conflating them muddles both shapes.
15
+ *
16
+ * The persistence layer is provider-neutral — the `assistantHistorySnapshot`
17
+ * on pending confirmations is typed as a generic `H` so adapters thread
18
+ * their SDK's conversation-history shape through unchanged.
19
+ */
20
+
21
+ import type { IntentEnvelope } from "@adjudicate/core";
22
+
23
+ // ── Defer / Park Redis surface ──────────────────────────────────────────────
24
+
25
+ /**
26
+ * Read + claim surface used by `resumeDeferredIntent`. Mirrors the
27
+ * `DeferRedis` interface in `@adjudicate/runtime`.
28
+ */
29
+ export interface DeferRedis {
30
+ get(key: string): Promise<string | null>;
31
+ set(
32
+ key: string,
33
+ value: string,
34
+ options: { NX: true; EX: number },
35
+ ): Promise<string | null>;
36
+ del(key: string): Promise<unknown>;
37
+ incr?(key: string): Promise<number>;
38
+ decr?(key: string): Promise<number>;
39
+ expire?(key: string, seconds: number): Promise<unknown>;
40
+ }
41
+
42
+ /**
43
+ * Write + counter surface used by `parkDeferredIntent`. Mirrors the
44
+ * `ParkRedis` interface in `@adjudicate/runtime`.
45
+ */
46
+ export interface ParkRedis {
47
+ incr(key: string): Promise<number>;
48
+ decr(key: string): Promise<number>;
49
+ expire(key: string, seconds: number, mode?: "NX"): Promise<unknown>;
50
+ set(
51
+ key: string,
52
+ value: string,
53
+ options: { EX: number },
54
+ ): Promise<string | null>;
55
+ evalIncrCheck?(
56
+ counterKey: string,
57
+ ttlSeconds: number,
58
+ max: number,
59
+ ): Promise<number>;
60
+ }
61
+
62
+ interface Entry {
63
+ readonly value: string;
64
+ expiresAt: number;
65
+ }
66
+
67
+ /**
68
+ * Combined in-memory implementation of `DeferRedis` AND `ParkRedis`.
69
+ * Suitable for tests and the quickstart. NOT suitable for production —
70
+ * lacks persistence, fan-out, and cross-process coordination.
71
+ */
72
+ export function createInMemoryDeferStore(): DeferRedis & ParkRedis {
73
+ const store = new Map<string, Entry>();
74
+ const counters = new Map<string, number>();
75
+
76
+ const isAlive = (entry: Entry | undefined): entry is Entry =>
77
+ entry !== undefined && entry.expiresAt > Date.now();
78
+
79
+ function setRaw(
80
+ key: string,
81
+ value: string,
82
+ options: { NX?: true; EX: number },
83
+ ): "OK" | null {
84
+ const existing = store.get(key);
85
+ if (options.NX && isAlive(existing)) return null;
86
+ store.set(key, {
87
+ value,
88
+ expiresAt: Date.now() + options.EX * 1000,
89
+ });
90
+ return "OK";
91
+ }
92
+
93
+ return {
94
+ async get(key) {
95
+ const entry = store.get(key);
96
+ if (!isAlive(entry)) {
97
+ if (entry !== undefined) store.delete(key);
98
+ return null;
99
+ }
100
+ return entry.value;
101
+ },
102
+ async set(
103
+ key: string,
104
+ value: string,
105
+ options: { NX?: true; EX: number },
106
+ ) {
107
+ return setRaw(key, value, options);
108
+ },
109
+ async del(key) {
110
+ const had = store.delete(key);
111
+ counters.delete(key);
112
+ return had ? 1 : 0;
113
+ },
114
+ async incr(key) {
115
+ const next = (counters.get(key) ?? 0) + 1;
116
+ counters.set(key, next);
117
+ return next;
118
+ },
119
+ async decr(key) {
120
+ const next = (counters.get(key) ?? 0) - 1;
121
+ counters.set(key, next);
122
+ return next;
123
+ },
124
+ async expire(_key: string, _seconds: number, _mode?: "NX") {
125
+ return 1;
126
+ },
127
+ };
128
+ }
129
+
130
+ // ── Confirmation store ──────────────────────────────────────────────────────
131
+
132
+ export interface PendingConfirmation<H = unknown> {
133
+ readonly envelope: IntentEnvelope;
134
+ readonly sessionId: string;
135
+ readonly assistantHistorySnapshot: H;
136
+ readonly toolUseId: string;
137
+ readonly prompt: string;
138
+ }
139
+
140
+ /**
141
+ * Persistence for REQUEST_CONFIRMATION pauses. `take()` is get-and-delete:
142
+ * a confirmation token is single-use. A repeated take after the first
143
+ * resolution returns `null` (idempotent yes-then-yes).
144
+ */
145
+ export interface ConfirmationStore<H = unknown> {
146
+ put(
147
+ token: string,
148
+ pending: PendingConfirmation<H>,
149
+ ttlSeconds: number,
150
+ ): Promise<void>;
151
+ take(token: string): Promise<PendingConfirmation<H> | null>;
152
+ }
153
+
154
+ interface ConfirmationEntry<H> {
155
+ readonly pending: PendingConfirmation<H>;
156
+ readonly expiresAt: number;
157
+ }
158
+
159
+ export function createInMemoryConfirmationStore<H = unknown>(): ConfirmationStore<H> {
160
+ const store = new Map<string, ConfirmationEntry<H>>();
161
+ return {
162
+ async put(token, pending, ttlSeconds) {
163
+ store.set(token, {
164
+ pending,
165
+ expiresAt: Date.now() + ttlSeconds * 1000,
166
+ });
167
+ },
168
+ async take(token) {
169
+ const entry = store.get(token);
170
+ if (entry === undefined) return null;
171
+ store.delete(token);
172
+ if (entry.expiresAt <= Date.now()) return null;
173
+ return entry.pending;
174
+ },
175
+ };
176
+ }
package/src/trace.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Adapter loop trace hooks — low-cardinality lifecycle events.
3
+ *
4
+ * The adapter loop's existing `AgentEvent` stream is detailed enough
5
+ * for replay and debugging but unfocused for observability — every
6
+ * tool_use, tool_result, intent_proposed, etc. is an event, which is
7
+ * the WRONG cardinality for production tracing.
8
+ *
9
+ * This module is the focused TRACE surface: a small set of phase
10
+ * transitions an operator dashboard wants to know about, with
11
+ * controlled-vocabulary attribute strings.
12
+ *
13
+ * # What goes through here
14
+ *
15
+ * - `iteration_start` — bumping the iteration counter
16
+ * - `tool_use_seen` — total count of LLM tool_use blocks per turn
17
+ * - `decision_emitted` — kernel returned a Decision
18
+ * - `paused` — DEFER/REQUEST_CONFIRMATION/ESCALATE handled
19
+ * - `completed` — loop terminated cleanly
20
+ *
21
+ * Adopters wire `TraceSink` to their observability stack:
22
+ *
23
+ * const traceSink: TraceSink = {
24
+ * onTrace(evt) {
25
+ * tracer.startSpan(evt.phase, { attributes: evt.attributes }).end();
26
+ * }
27
+ * };
28
+ *
29
+ * Pass to `createAdjudicatedAgent({ ..., traceSink })`. The loop fires
30
+ * one TraceEvent per documented phase per session. NO per-record
31
+ * fan-out. NO high-cardinality data (intent payloads, conversation
32
+ * history). Cardinality is bounded by `(pack-id × decision-kind × phase)`.
33
+ *
34
+ * # Replay safety
35
+ *
36
+ * Trace emission is fire-and-forget. The sink MUST NOT throw — the loop
37
+ * does not try/catch around it. If the sink misbehaves, adjudication
38
+ * still completes; only telemetry is lost.
39
+ */
40
+
41
+ import type { Decision } from "@adjudicate/core";
42
+
43
+ export type AdapterTracePhase =
44
+ | "iteration_start"
45
+ | "decision_emitted"
46
+ | "paused"
47
+ | "completed"
48
+ | "max_iterations_exceeded";
49
+
50
+ export type AdapterPauseReason =
51
+ | "deferred"
52
+ | "awaiting_confirmation"
53
+ | "escalated";
54
+
55
+ /**
56
+ * A single trace event from the adapter loop. The attributes are
57
+ * deliberately small + controlled-vocabulary; adopters MUST NOT add
58
+ * per-payload data here. (For replayable forensic detail use the
59
+ * AgentEvent stream, which is record-grained.)
60
+ */
61
+ export interface AdapterTraceEvent {
62
+ readonly phase: AdapterTracePhase;
63
+ readonly sessionId: string;
64
+ /** 1-based iteration counter, capped by `maxIterations`. */
65
+ readonly iteration: number;
66
+ /** Set when the event corresponds to a Decision. */
67
+ readonly decisionKind?: Decision["kind"];
68
+ /** Set on `phase === "paused"`. */
69
+ readonly pauseReason?: AdapterPauseReason;
70
+ }
71
+
72
+ /** Adopter-supplied trace sink. MUST NOT throw. */
73
+ export interface TraceSink {
74
+ onTrace(event: AdapterTraceEvent): void;
75
+ }
76
+
77
+ /**
78
+ * No-op trace sink. Default when none is supplied. Zero allocation,
79
+ * zero overhead.
80
+ */
81
+ export const noopTraceSink: TraceSink = {
82
+ onTrace() {
83
+ /* no-op */
84
+ },
85
+ };
86
+
87
+ /**
88
+ * In-memory trace sink for tests. Captures every event in
89
+ * registration order.
90
+ */
91
+ export function createInMemoryTraceSink(): TraceSink & {
92
+ readonly events: ReadonlyArray<AdapterTraceEvent>;
93
+ reset(): void;
94
+ } {
95
+ const events: AdapterTraceEvent[] = [];
96
+ return {
97
+ events,
98
+ onTrace(event) {
99
+ events.push(event);
100
+ },
101
+ reset() {
102
+ events.length = 0;
103
+ },
104
+ };
105
+ }
package/src/types.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Provider-neutral types for the adapter loop.
3
+ *
4
+ * The shapes in this module describe what the loop needs to know, NOT
5
+ * what any specific LLM SDK ships. Provider adapters
6
+ * (`@adjudicate/anthropic`, `@adjudicate/openai`, …) translate between
7
+ * their SDK's wire types and these.
8
+ *
9
+ * History `H` is opaque: the loop never inspects it. The provider bridge
10
+ * appends user messages, assistant turns, and tool results in whatever
11
+ * shape the SDK consumes.
12
+ */
13
+
14
+ import type {
15
+ AuditSink,
16
+ Decision,
17
+ IntentEnvelope,
18
+ Ledger,
19
+ PackV0,
20
+ Taint,
21
+ } from "@adjudicate/core";
22
+ import type { PromptRenderer, ToolSchema } from "@adjudicate/core/llm";
23
+ import type { RuntimeContext } from "@adjudicate/core/kernel";
24
+ import type {
25
+ ConfirmationStore,
26
+ DeferRedis,
27
+ ParkRedis,
28
+ } from "./persistence.js";
29
+ import type { TraceSink } from "./trace.js";
30
+
31
+ // ── Provider-neutral wire shapes ──────────────────────────────────────────────
32
+
33
+ /**
34
+ * Provider-neutral representation of a tool-use request emitted by the
35
+ * model. Anthropic adapters map `ToolUseBlock` → `ToolUseRequest`; OpenAI
36
+ * adapters map `function_call` / `tool_calls[].function` similarly.
37
+ */
38
+ export interface ToolUseRequest {
39
+ readonly id: string;
40
+ readonly name: string;
41
+ readonly input: unknown;
42
+ }
43
+
44
+ /**
45
+ * Provider-neutral representation of a single assistant turn (text and
46
+ * any tool-use blocks). The bridge fans out the SDK-specific response.
47
+ */
48
+ export interface AssistantTurn {
49
+ readonly textBlocks: ReadonlyArray<string>;
50
+ readonly toolUses: ReadonlyArray<ToolUseRequest>;
51
+ }
52
+
53
+ /**
54
+ * Provider-neutral tool-result payload returned to the model. The bridge
55
+ * encodes this into whatever shape the SDK consumes (Anthropic
56
+ * `tool_result` block; OpenAI `role: "tool"` message).
57
+ */
58
+ export interface ToolResultBlock {
59
+ readonly toolUseId: string;
60
+ readonly content: string;
61
+ readonly isError?: boolean;
62
+ }
63
+
64
+ // ── Adopter-supplied executor (carried through to translateDecision) ────────
65
+
66
+ /**
67
+ * Adopter-supplied side-effect runner. Called only after the kernel
68
+ * returns EXECUTE (or REWRITE — the executor receives the rewritten
69
+ * envelope, NOT the original).
70
+ *
71
+ * READ tools that the LLM proposes go through `invokeRead`; intent
72
+ * executions that the kernel authorized go through `invokeIntent`.
73
+ */
74
+ export interface AdopterExecutor<K extends string, P, S> {
75
+ invokeRead(name: string, input: unknown, state: S): Promise<unknown>;
76
+ invokeIntent(envelope: IntentEnvelope<K, P>, state: S): Promise<unknown>;
77
+ }
78
+
79
+ export interface AgentLogger {
80
+ info?: (obj: Record<string, unknown>, msg?: string) => void;
81
+ warn?: (obj: Record<string, unknown>, msg?: string) => void;
82
+ debug?: (obj: Record<string, unknown>, msg?: string) => void;
83
+ }
84
+
85
+ // ── Provider bridge contract ─────────────────────────────────────────────────
86
+
87
+ export interface ProviderRequest {
88
+ readonly systemPrompt: string;
89
+ readonly maxTokens: number;
90
+ readonly toolSchemas: ReadonlyArray<ToolSchema>;
91
+ }
92
+
93
+ /**
94
+ * The provider-neutral driver for the LLM call. Provider adapters
95
+ * implement this against their SDK; the loop calls it once per
96
+ * iteration. `H` is the opaque conversation-history shape — provider
97
+ * adapters choose what `H` is (typically `MessageParam[]` for Anthropic,
98
+ * `ChatCompletionMessageParam[]` for OpenAI). The loop never inspects it.
99
+ */
100
+ export interface ProviderBridge<H> {
101
+ /** Construct the empty initial history. */
102
+ emptyHistory(): H;
103
+
104
+ /** Append a user message to the history. */
105
+ appendUserMessage(history: H, text: string): H;
106
+
107
+ /**
108
+ * Send the prompt + history; receive the assistant turn back. The
109
+ * bridge appends the raw assistant response to history before
110
+ * returning. The provider-neutral `turn` describes what the loop
111
+ * actually needs to know about the response.
112
+ */
113
+ send(
114
+ history: H,
115
+ request: ProviderRequest,
116
+ ): Promise<{ history: H; turn: AssistantTurn }>;
117
+
118
+ /**
119
+ * Append a list of tool-result blocks to the history (typically a
120
+ * user-role message containing tool_result blocks for Anthropic; a
121
+ * series of `role: "tool"` messages for OpenAI).
122
+ */
123
+ appendToolResults(
124
+ history: H,
125
+ results: ReadonlyArray<ToolResultBlock>,
126
+ ): H;
127
+ }
128
+
129
+ // ── Public agent surface (generic over history) ──────────────────────────────
130
+
131
+ export interface AdjudicatedAgentOptions<K extends string, P, S, C, H> {
132
+ /**
133
+ * Pack the agent adjudicates against. MUST already be the output of
134
+ * `installPack(...)` or `withBasisAudit(...)`. The adapter does NOT
135
+ * double-wrap — Pack-author convention applies.
136
+ */
137
+ readonly pack: PackV0<K, P, S, C>;
138
+ /** Renderer producing system prompt + tool schemas for each iteration. */
139
+ readonly renderer: PromptRenderer<S, C>;
140
+ /** Provider bridge wrapping the SDK. */
141
+ readonly bridge: ProviderBridge<H>;
142
+ /** Persistence for DEFER. Combined park + resume surface. */
143
+ readonly deferStore: DeferRedis & ParkRedis;
144
+ /** Persistence for REQUEST_CONFIRMATION pauses (generic over H). */
145
+ readonly confirmationStore: ConfirmationStore<H>;
146
+ readonly auditSink?: AuditSink;
147
+ /** Required: Execution Ledger for replay suppression. */
148
+ readonly ledger: Ledger;
149
+ /** Optional tenant context. */
150
+ readonly runtimeContext?: RuntimeContext;
151
+ /** Hard cap on assistant↔tool ping-pong per .send() call. Defaults to 8. */
152
+ readonly maxIterations?: number;
153
+ /** Adopter-owned executor. Required. */
154
+ readonly executor: AdopterExecutor<K, P, S>;
155
+ /** `rk()` namespacer for the deferStore. Defaults to identity. */
156
+ readonly rk?: (raw: string) => string;
157
+ /** Override the nonce derived from each tool_use block. */
158
+ readonly deriveNonce?: (args: {
159
+ sessionId: string;
160
+ toolUseId: string;
161
+ payload: unknown;
162
+ }) => string;
163
+ readonly log?: AgentLogger;
164
+ /** Hash-verification policy for parked envelope blobs at resume. */
165
+ readonly verifyParkedHash?: "strict" | "warn" | "off";
166
+ /**
167
+ * Optional low-cardinality trace sink. The loop emits one event per
168
+ * iteration/decision/pause; sink must NOT throw. Defaults to no-op.
169
+ * See `./trace.ts` for the controlled-vocabulary event shape.
170
+ */
171
+ readonly traceSink?: TraceSink;
172
+ }
173
+
174
+ export interface SendInput<S, C, H> {
175
+ readonly sessionId: string;
176
+ readonly userMessage: string;
177
+ readonly state: S;
178
+ readonly context: C;
179
+ readonly history?: H;
180
+ }
181
+
182
+ export interface ResumeArgs<S, C, H> {
183
+ readonly sessionId: string;
184
+ readonly signal: string;
185
+ readonly state: S;
186
+ readonly context: C;
187
+ readonly history?: H;
188
+ }
189
+
190
+ export interface ConfirmArgs<S, C> {
191
+ readonly confirmationToken: string;
192
+ readonly accepted: boolean;
193
+ readonly state: S;
194
+ readonly context: C;
195
+ }
196
+
197
+ export type AgentOutcome =
198
+ | { kind: "completed"; assistantText: string }
199
+ | { kind: "deferred"; signal: string; intentHash: string }
200
+ | {
201
+ kind: "awaiting_confirmation";
202
+ prompt: string;
203
+ confirmationToken: string;
204
+ }
205
+ | { kind: "escalated"; to: "human" | "supervisor"; reason: string }
206
+ | { kind: "max_iterations_exceeded"; lastDecision: Decision | null };
207
+
208
+ export interface AgentTurnResult<H> {
209
+ readonly events: ReadonlyArray<AgentEvent>;
210
+ readonly history: H;
211
+ readonly outcome: AgentOutcome;
212
+ }
213
+
214
+ export type AgentEvent =
215
+ | { kind: "user_message"; text: string }
216
+ | { kind: "assistant_text"; text: string }
217
+ | { kind: "tool_use"; toolUseId: string; toolName: string; input: unknown }
218
+ | { kind: "intent_proposed"; envelope: IntentEnvelope }
219
+ | { kind: "decision"; decision: Decision; envelope: IntentEnvelope }
220
+ | { kind: "handler_result"; toolUseId: string; result: unknown }
221
+ | {
222
+ kind: "tool_result";
223
+ toolUseId: string;
224
+ payload: ToolResultBlock;
225
+ };
226
+
227
+ export interface AdjudicatedAgent<_K extends string, _P, S, C, H> {
228
+ /** One user message + (state, context) snapshot → resolved turn. */
229
+ send(input: SendInput<S, C, H>): Promise<AgentTurnResult<H>>;
230
+ /** Resume a parked DEFER (typically from an adopter's webhook handler). */
231
+ resume(args: ResumeArgs<S, C, H>): Promise<AgentTurnResult<H>>;
232
+ /** Resume a REQUEST_CONFIRMATION with a yes/no from the user. */
233
+ confirm(args: ConfirmArgs<S, C>): Promise<AgentTurnResult<H>>;
234
+ }
235
+
236
+ export type { Taint };