@inflexa-ai/harness 0.4.5 → 0.6.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.
@@ -34,6 +34,7 @@ import type { ResourcePolicy } from "../config/resource-limits.js";
34
34
  import type { ChromeConfig } from "../lib/chrome.js";
35
35
  import type { AgentDefinition } from "../loop/types.js";
36
36
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
37
+ import type { Tool } from "../tools/define-tool.js";
37
38
  import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
38
39
  import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
39
40
  import { type BioToolKeys } from "../tools/bio/keys.js";
@@ -104,6 +105,13 @@ export interface ConversationAgentDeps {
104
105
  * planning guidance and scheduling keep their legacy behavior.
105
106
  */
106
107
  readonly resourcePolicy?: ResourcePolicy;
108
+ /**
109
+ * Embedder-contributed conversation-tool seam — additional tools the host
110
+ * supplies, appended after the built-in roster. The harness stays agnostic to
111
+ * what they do; each receives the same `ToolContext` (including `ask`) as any
112
+ * built-in tool.
113
+ */
114
+ readonly hostTools?: readonly Tool[];
107
115
  }
108
116
  /** Build the conversation `AgentDefinition` with every tool fully dep-bound. */
109
117
  export declare function createConversationAgent(deps: ConversationAgentDeps): AgentDefinition;
@@ -48,7 +48,7 @@ export const CONVERSATION_AGENT_ID = "conversation-agent";
48
48
  const CONVERSATION_MAX_ITERATIONS = 50;
49
49
  /** Build the conversation `AgentDefinition` with every tool fully dep-bound. */
50
50
  export function createConversationAgent(deps) {
51
- const { provider, pool, embedding, workspaceFs, model, executeAnalysisWorkflow, ephemeralWorkflow, resolveWorkspaceRoot, runAuthorizer, runLauncher, createPreviewPublisher, bioKeys, templatesDir, skillsDir, chrome, resourcePolicy, } = deps;
51
+ const { provider, pool, embedding, workspaceFs, model, executeAnalysisWorkflow, ephemeralWorkflow, resolveWorkspaceRoot, runAuthorizer, runLauncher, createPreviewPublisher, bioKeys, templatesDir, skillsDir, chrome, resourcePolicy, hostTools, } = deps;
52
52
  const workingMemory = createWorkingMemory(pool);
53
53
  const ncbi = createNcbiTools(bioKeys);
54
54
  const chemDb = createChemDbTools(bioKeys);
@@ -126,6 +126,9 @@ export function createConversationAgent(deps) {
126
126
  createGrepTool(workspaceFs),
127
127
  // Working memory.
128
128
  createUpdateWorkingMemoryTool(workingMemory, pool),
129
+ // Host-contributed tools ride the same context and dispatch path as the
130
+ // built-ins; appended last so the built-in roster order stays fixed.
131
+ ...(hostTools ?? []),
129
132
  ];
130
133
  return {
131
134
  id: CONVERSATION_AGENT_ID,
@@ -2,9 +2,10 @@
2
2
  * Cortex chat data parts — custom parts emitted via writer.write() / writer.custom()
3
3
  * that flow through the chat stream and are rendered by the frontend.
4
4
  *
5
- * 15 part types across four categories:
5
+ * Categories:
6
6
  * Presentation: data-presentation (agent-synthesized content),
7
7
  * data-plan (plan by id), data-file-reference (files by path)
8
+ * Approval: data-ask (a tool's pending user-approval prompt)
8
9
  * Run lifecycle: run-started, dag-state, run-completed, run-failed, run-synthesis,
9
10
  * synthesis-progress
10
11
  * Per-step live: step-activity, step-file-tree
@@ -80,6 +81,23 @@ export interface FileReferencePart {
80
81
  title?: string;
81
82
  files: FileReferenceEntry[];
82
83
  }
84
+ /**
85
+ * A tool's pending request for an explicit user decision. Re-emitted under the
86
+ * same `id` when the ask reaches a terminal status, so reconciling readers fold
87
+ * pending → outcome latest-wins.
88
+ */
89
+ export interface AskPart {
90
+ type: "data-ask";
91
+ /** The ask's ledger uuidv7 id — what a surface passes back to `answer`. */
92
+ id: string;
93
+ /** Human-facing headline for the pending approval. */
94
+ title: string;
95
+ /** The exact command or operation being approved — what the user sees granted. */
96
+ command: string;
97
+ /** Optional additional detail a surface may render. */
98
+ detail?: string;
99
+ status: "pending" | "resolved" | "rejected" | "aborted" | "expired";
100
+ }
83
101
  export interface RunStartedPart {
84
102
  type: "data-run-started";
85
103
  runId: string;
@@ -303,4 +321,4 @@ export interface DataPreviewFailedPart {
303
321
  /** Optional taxonomy of failure kinds. */
304
322
  errorKind?: "render" | "submit" | "build" | "timeout" | "internal";
305
323
  }
306
- export type CortexChatPart = PresentationPart | PlanPart | RunCardPart | FileReferencePart | RunStartedPart | DagStatePart | StepActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | PreviewPart | DataPreviewFailedPart;
324
+ export type CortexChatPart = PresentationPart | PlanPart | RunCardPart | FileReferencePart | AskPart | RunStartedPart | DagStatePart | StepActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | PreviewPart | DataPreviewFailedPart;
@@ -2,9 +2,10 @@
2
2
  * Cortex chat data parts — custom parts emitted via writer.write() / writer.custom()
3
3
  * that flow through the chat stream and are rendered by the frontend.
4
4
  *
5
- * 15 part types across four categories:
5
+ * Categories:
6
6
  * Presentation: data-presentation (agent-synthesized content),
7
7
  * data-plan (plan by id), data-file-reference (files by path)
8
+ * Approval: data-ask (a tool's pending user-approval prompt)
8
9
  * Run lifecycle: run-started, dag-state, run-completed, run-failed, run-synthesis,
9
10
  * synthesis-progress
10
11
  * Per-step live: step-activity, step-file-tree
@@ -1,4 +1,4 @@
1
- export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, FileReferenceEntry, FileReferencePart, RunStartedPart, StepStatus, DagStepState, DagStatePart, StepPhase, StepActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepBlockedPart, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, PreviewPart, DataPreviewFailedPart, CortexChatPart, } from "./chat-parts.js";
1
+ export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, FileReferenceEntry, FileReferencePart, AskPart, RunStartedPart, StepStatus, DagStepState, DagStatePart, StepPhase, StepActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepBlockedPart, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, PreviewPart, DataPreviewFailedPart, CortexChatPart, } from "./chat-parts.js";
2
2
  export type { EventSource, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, FinishEvent, ChatErrorEvent, CortexChatEvent } from "./chat-events.js";
3
3
  export type { TextPart, ToolCallPart, CortexPart, CortexMessage } from "./message.js";
4
4
  export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./part-registry.js";
@@ -3,6 +3,7 @@ export const PART_REGISTRY = {
3
3
  "data-plan": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
4
4
  "data-run-card": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
5
5
  "data-file-reference": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
6
+ "data-ask": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: true },
6
7
  "data-run-started": { emitter: "workflow", consumer: "sidebar", transient: true, reconciling: false },
7
8
  "data-dag-state": { emitter: "workflow", consumer: "sidebar", transient: false, reconciling: true },
8
9
  "data-step-activity": { emitter: "workflow", consumer: "sidebar", transient: true, reconciling: true },
@@ -103,6 +103,27 @@ export declare const FileReferencePartSchema: z.ZodObject<{
103
103
  caption: z.ZodOptional<z.ZodString>;
104
104
  }, z.core.$strip>>;
105
105
  }, z.core.$strip>;
106
+ export declare const AskStatusSchema: z.ZodEnum<{
107
+ pending: "pending";
108
+ aborted: "aborted";
109
+ resolved: "resolved";
110
+ rejected: "rejected";
111
+ expired: "expired";
112
+ }>;
113
+ export declare const AskPartSchema: z.ZodObject<{
114
+ type: z.ZodLiteral<"data-ask">;
115
+ id: z.ZodString;
116
+ title: z.ZodString;
117
+ command: z.ZodString;
118
+ detail: z.ZodOptional<z.ZodString>;
119
+ status: z.ZodEnum<{
120
+ pending: "pending";
121
+ aborted: "aborted";
122
+ resolved: "resolved";
123
+ rejected: "rejected";
124
+ expired: "expired";
125
+ }>;
126
+ }, z.core.$strip>;
106
127
  export declare const RunStartedPartSchema: z.ZodObject<{
107
128
  type: z.ZodLiteral<"data-run-started">;
108
129
  runId: z.ZodString;
@@ -478,6 +499,19 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
478
499
  runId: z.ZodOptional<z.ZodString>;
479
500
  caption: z.ZodOptional<z.ZodString>;
480
501
  }, z.core.$strip>>;
502
+ }, z.core.$strip>, z.ZodObject<{
503
+ type: z.ZodLiteral<"data-ask">;
504
+ id: z.ZodString;
505
+ title: z.ZodString;
506
+ command: z.ZodString;
507
+ detail: z.ZodOptional<z.ZodString>;
508
+ status: z.ZodEnum<{
509
+ pending: "pending";
510
+ aborted: "aborted";
511
+ resolved: "resolved";
512
+ rejected: "rejected";
513
+ expired: "expired";
514
+ }>;
481
515
  }, z.core.$strip>, z.ZodObject<{
482
516
  type: z.ZodLiteral<"data-run-started">;
483
517
  runId: z.ZodString;
@@ -78,6 +78,16 @@ export const FileReferencePartSchema = z.object({
78
78
  title: z.string().optional(),
79
79
  files: z.array(FileReferenceEntrySchema).min(1).max(10),
80
80
  });
81
+ // ── Ask (user approval) ─────────────────────────────────────────────
82
+ export const AskStatusSchema = z.enum(["pending", "resolved", "rejected", "aborted", "expired"]);
83
+ export const AskPartSchema = z.object({
84
+ type: z.literal("data-ask"),
85
+ id: z.string(),
86
+ title: z.string(),
87
+ command: z.string(),
88
+ detail: z.string().optional(),
89
+ status: AskStatusSchema,
90
+ });
81
91
  // ── Run Started ─────────────────────────────────────────────────────
82
92
  export const RunStartedPartSchema = z.object({
83
93
  type: z.literal("data-run-started"),
@@ -278,6 +288,7 @@ export const CortexChatPartSchema = z.discriminatedUnion("type", [
278
288
  PlanPartSchema,
279
289
  RunCardPartSchema,
280
290
  FileReferencePartSchema,
291
+ AskPartSchema,
281
292
  RunStartedPartSchema,
282
293
  DagStatePartSchema,
283
294
  StepActivityPartSchema,
package/dist/index.d.ts CHANGED
@@ -26,6 +26,10 @@ export { createDbosRunLauncher } from "./execution/dbos-run-launcher.js";
26
26
  export type { RunLauncher, LaunchOptions, LaunchRunOptions, LaunchOutcome } from "./execution/run-launcher.js";
27
27
  export { defineTool, isToolError } from "./tools/define-tool.js";
28
28
  export type { Tool, ToolDefinition, ToolContext, ToolError } from "./tools/define-tool.js";
29
+ export { createAskGateway } from "./tools/approval/gateway.js";
30
+ export type { AskGateway, AskGatewayDeps, AskContext, AnswerOutcome, PendingAsk } from "./tools/approval/gateway.js";
31
+ export { UnavailableAsk, AskRejectedError } from "./tools/approval/contract.js";
32
+ export type { Ask, AskRequest, AskReply, AskApproval } from "./tools/approval/contract.js";
29
33
  export { runAgent, finalText } from "./loop/run-agent.js";
30
34
  export type { RunAgentOptions, RunAgentResult, AgentFinish } from "./loop/run-agent.js";
31
35
  export type { AgentDefinition, RunStep, EmitFn, EmitEvent, EventSource } from "./loop/types.js";
@@ -103,7 +107,7 @@ export { queryStepsByRun } from "./state/step-executions.js";
103
107
  export type { CortexRunRow, StepExecutionRow, RunStatus } from "./state/schema.js";
104
108
  export type { DbError } from "./lib/db-result.js";
105
109
  export { REFERENCE_DATA_CATALOG, REFERENCE_DATA_CATALOG_VERSION, ReferenceArtifactPathSchema, ReferenceArtifactSchema, ReferenceDataCatalogSchema, ReferenceDatasetSchema, ReferenceSha256Schema, UnknownReferenceDatasetError, referenceArtifactKey, resolveReferenceInstallPlan, } from "./reference-data/catalog.js";
106
- export type { ReferenceArtifact, ReferenceDataCatalog, ReferenceDataset, ReferenceInstallPlan, ReferenceInstallPlanDataset, } from "./reference-data/catalog.js";
110
+ export type { ReferenceArtifact, ReferenceDataCatalog, ReferenceDataset, ReferenceInstallPlan, ReferenceInstallPlanDataset } from "./reference-data/catalog.js";
107
111
  export { REFERENCE_INSTALL_RECEIPT_VERSION, ReferenceInstallReceiptSchema, parseReferenceInstallReceipt } from "./reference-data/receipt.js";
108
112
  export type { ReferenceInstallReceipt, ReferenceReceiptArtifact } from "./reference-data/receipt.js";
109
113
  export { queryActiveSandboxes } from "./state/active-sandboxes.js";
package/dist/index.js CHANGED
@@ -25,6 +25,13 @@ export { UnavailablePreviewPublisher } from "./tools/report/preview-publisher.js
25
25
  export { createDbosRunLauncher } from "./execution/dbos-run-launcher.js";
26
26
  // Tool primitive.
27
27
  export { defineTool, isToolError } from "./tools/define-tool.js";
28
+ // Seam: tool approval (`ctx.ask`). `createAskGateway` realizes the `Ask` seam
29
+ // over the injected `Pool` and carries the outward `answer`/`pending` API plus
30
+ // the boot-time `sweepExpired`; `UnavailableAsk` is the shipped deny-by-default
31
+ // realization for an unwired or non-interactive host. The `AskRequest`/`AskReply`
32
+ // contract and the `AskRejectedError` denial throw come from the seam contract.
33
+ export { createAskGateway } from "./tools/approval/gateway.js";
34
+ export { UnavailableAsk, AskRejectedError } from "./tools/approval/contract.js";
28
35
  // Agent loop.
29
36
  export { runAgent, finalText } from "./loop/run-agent.js";
30
37
  // `passthroughStep` is the in-process `RunStep`: the host request/chat path runs
@@ -1,9 +1,10 @@
1
1
  import { type FinishReason } from "ai";
2
2
  import type { AgentSession } from "../auth/types.js";
3
3
  import type { AgentChat, PromptCachePolicy } from "../providers/types.js";
4
+ import { type AskApproval, type AskRequest } from "../tools/approval/contract.js";
4
5
  import type { AgentDefinition, EmitFn, LoopMessage, RunStep } from "./types.js";
5
6
  export interface AgentFinish {
6
- readonly reason: FinishReason | "max_iterations";
7
+ readonly reason: FinishReason | "max_iterations" | "denied";
7
8
  readonly cappedOut: boolean;
8
9
  readonly truncationRecoveries: number;
9
10
  }
@@ -20,6 +21,16 @@ export interface RunAgentOptions {
20
21
  readonly provider: AgentChat;
21
22
  readonly signal: AbortSignal;
22
23
  readonly emit: EmitFn;
24
+ /**
25
+ * Per-turn user-approval seam threaded into every tool's `ToolContext` as
26
+ * `ctx.ask`. A conversation tool calls it to pause for an explicit user
27
+ * decision; the caller (the turn) wires the realization that surfaces the
28
+ * prompt and returns the reply. Omitted on non-interactive paths (workflow
29
+ * contexts, headless embedders): approval then resolves to the shipped
30
+ * deny-by-default `UnavailableAsk`, so a tool that asks where nothing can
31
+ * answer is denied rather than left waiting on a surface that never responds.
32
+ */
33
+ readonly ask?: (request: AskRequest) => Promise<AskApproval>;
23
34
  readonly runStep: RunStep;
24
35
  readonly formatStepName?: StepNameFormatter;
25
36
  readonly isFatalLoopError?: (err: unknown) => boolean;
@@ -3,6 +3,7 @@ import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
3
3
  import { classifyProviderError } from "../providers/errors.js";
4
4
  import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
5
5
  import { resultStep } from "./run-step.js";
6
+ import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
6
7
  import { isToolError } from "../tools/define-tool.js";
7
8
  import { addChatUsage, recordAgentRun } from "./metrics.js";
8
9
  const TRUNCATED_PROSE_STEER = "Your previous reply was cut off at the output-token limit; continue concisely, or finish via your terminal tool.";
@@ -31,11 +32,18 @@ export async function runAgent(agent, initial, session, opts) {
31
32
  inputSchema: jsonSchema(t.jsonSchema),
32
33
  }),
33
34
  ]));
35
+ // Approval resolves once to the caller's seam, or the deny-by-default one
36
+ // when it wires none: a tool that pauses for a user decision where no
37
+ // interactive surface is present is denied rather than left waiting on an
38
+ // answer that cannot come.
39
+ const unavailableAsk = new UnavailableAsk();
40
+ const ask = opts.ask ?? ((request) => unavailableAsk.ask(request));
34
41
  const toolCtx = (tu) => ({
35
42
  session,
36
43
  signal,
37
44
  emit,
38
45
  runStep: (name, fn) => runStep(`${formatStepName.tool(tu.toolName, tu.toolCallId)}:${name}`, fn),
46
+ ask,
39
47
  });
40
48
  let iterations = 0;
41
49
  let truncationRecoveries = 0;
@@ -44,6 +52,16 @@ export async function runAgent(agent, initial, session, opts) {
44
52
  // byte-identical to be read back.
45
53
  const providerOptions = promptCacheProviderOptions(opts.promptCache ?? DEFAULT_PROMPT_CACHE);
46
54
  const usage = {};
55
+ // The user said no. A subsequent model call would only let the agent argue
56
+ // with the decision, or spend a call acknowledging it; the denial tool result
57
+ // is itself what the surface renders, so the turn ends the moment a denial
58
+ // lands in a dispatch round — after the concurrent siblings in that same round
59
+ // have completed and been appended. Mirrors the clean-stop terminal path.
60
+ const stopOnDenial = async (i) => {
61
+ await emit({ type: "iteration", source, index: i, final: true });
62
+ recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
63
+ return { messages, finish: { reason: "denied", cappedOut: false, truncationRecoveries } };
64
+ };
47
65
  for (let i = 0; i < agent.maxIterations; i++) {
48
66
  iterations = i + 1;
49
67
  const request = {
@@ -81,6 +99,8 @@ export async function runAgent(agent, initial, session, opts) {
81
99
  }
82
100
  results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
83
101
  messages.push({ role: "tool", content: results });
102
+ if (hasDenial(results))
103
+ return stopOnDenial(i);
84
104
  continue;
85
105
  }
86
106
  if (reply.finishReason !== "tool-calls") {
@@ -104,6 +124,8 @@ export async function runAgent(agent, initial, session, opts) {
104
124
  });
105
125
  }
106
126
  messages.push({ role: "tool", content: results });
127
+ if (hasDenial(results))
128
+ return stopOnDenial(i);
107
129
  }
108
130
  // Cache defeater (known; not fixed here). Emptying the tool set changes the
109
131
  // very front of the request prefix — tool definitions are cached ahead of
@@ -176,6 +198,8 @@ async function execute(tu, tool, input, ctx, isFatalLoopError) {
176
198
  catch (err) {
177
199
  if (isFatalLoopError(err))
178
200
  throw err;
201
+ if (isAskRejected(err))
202
+ return deniedResult(tu, err.feedback);
179
203
  return errorResult(tu, toolErrorContent(err));
180
204
  }
181
205
  }
@@ -206,9 +230,33 @@ function errorResult(toolCall, content) {
206
230
  output: { type: "error-text", value: content },
207
231
  };
208
232
  }
233
+ function isAskRejected(err) {
234
+ // Name-based fallback recognizes a rejection thrown from a different module
235
+ // realm, where `instanceof` against this file's class reference would miss it.
236
+ return err instanceof AskRejectedError || (err instanceof Error && err.name === "AskRejectedError");
237
+ }
238
+ /**
239
+ * Map a rejected approval to a model-visible `execution-denied` tool result. The
240
+ * prose is the model's only account of the denial; `isErrorOutput` already
241
+ * treats `execution-denied` as an error result.
242
+ */
243
+ function deniedResult(toolCall, feedback) {
244
+ const reason = feedback === undefined || feedback.length === 0
245
+ ? "The user rejected this action."
246
+ : `The user rejected this action with the following feedback: ${feedback}`;
247
+ return {
248
+ type: "tool-result",
249
+ toolCallId: toolCall.toolCallId,
250
+ toolName: toolCall.toolName,
251
+ output: { type: "execution-denied", reason },
252
+ };
253
+ }
209
254
  function isErrorOutput(result) {
210
255
  return result.output.type === "error-text" || result.output.type === "error-json" || result.output.type === "execution-denied";
211
256
  }
257
+ function hasDenial(results) {
258
+ return results.some((r) => r.output.type === "execution-denied");
259
+ }
212
260
  export function finalText(messages) {
213
261
  const last = messages.at(-1);
214
262
  if (last === undefined || last.role !== "assistant")
@@ -245,6 +245,51 @@ CREATE TABLE IF NOT EXISTS cortex_working_memory (
245
245
  data JSONB NOT NULL,
246
246
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
247
247
  );
248
+
249
+ -- Tool-approval ask ledger — the single source of truth for approval state, one
250
+ -- row per ctx.ask invocation. ctx.ask inserts a 'pending' row and polls it to a
251
+ -- terminal status. The outward answer() writes the decision. reply holds the
252
+ -- recorded AskReply (NULL while pending). Analysis-scoped audit of every
253
+ -- approval-gated action, including grant auto-approvals (recorded 'resolved'
254
+ -- with no prompt shown). A pending row a dead process orphans is swept to
255
+ -- 'expired' at boot.
256
+ CREATE TABLE IF NOT EXISTS cortex_asks (
257
+ id TEXT PRIMARY KEY,
258
+ analysis_id TEXT NOT NULL,
259
+ thread_id TEXT,
260
+ title TEXT NOT NULL,
261
+ command TEXT NOT NULL,
262
+ detail TEXT,
263
+ -- The key the standing grant behind an 'always' is recorded under: the displayed
264
+ -- command unless the tool supplied a broader grant key. Written on every insert,
265
+ -- defaulting to the command when absent. Nullable only so the additive migration
266
+ -- can add it to an already-created table without a per-row backfill.
267
+ grant_key TEXT,
268
+ status TEXT NOT NULL,
269
+ reply JSONB,
270
+ created_at TEXT NOT NULL,
271
+ resolved_at TEXT
272
+ );
273
+
274
+ CREATE INDEX IF NOT EXISTS idx_cortex_asks_analysis
275
+ ON cortex_asks(analysis_id);
276
+
277
+ -- Backs the pending() enumeration and the boot sweep. Partial so it stays
278
+ -- compact against the far larger terminal-row mass.
279
+ CREATE INDEX IF NOT EXISTS idx_cortex_asks_pending
280
+ ON cortex_asks(status) WHERE status = 'pending';
281
+
282
+ -- Standing approval grants behind an 'always' reply — keyed by the analysis and
283
+ -- the ask's grant key (the displayed command when the tool supplied no broader
284
+ -- key), so a later matching ask auto-approves without pausing. Lives for the
285
+ -- analysis lifecycle, surviving process restarts, and never applies to another
286
+ -- analysis.
287
+ CREATE TABLE IF NOT EXISTS cortex_ask_grants (
288
+ analysis_id TEXT NOT NULL,
289
+ grant_key TEXT NOT NULL,
290
+ created_at TEXT NOT NULL,
291
+ PRIMARY KEY (analysis_id, grant_key)
292
+ );
248
293
  `;
249
294
  /**
250
295
  * Initialize Cortex state tables. Safe to call on every startup and across
@@ -367,6 +412,26 @@ export async function initCortexState(pool, injected) {
367
412
  // dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
368
413
  "ALTER TABLE cortex_analysis_state ALTER COLUMN data_profile_status DROP NOT NULL",
369
414
  "ALTER TABLE messages ADD COLUMN IF NOT EXISTS message_envelope JSONB",
415
+ // Grant key an 'always' records the standing grant under. Nullable
416
+ // and unbackfilled: every new row writes it (command when absent), and
417
+ // orphaned legacy pending rows are swept to 'expired' before any answer
418
+ // reads it, so no existing row is left needing a value.
419
+ "ALTER TABLE cortex_asks ADD COLUMN IF NOT EXISTS grant_key TEXT",
420
+ // The grants table's key column is named for what it stores. A table
421
+ // created when the column was `command` is renamed in place — its
422
+ // values carry over unchanged, because a grant recorded without a
423
+ // grant key is keyed on its exact command. Guarded via
424
+ // information_schema (Postgres has no RENAME COLUMN IF EXISTS) and
425
+ // scoped to current_schema() so parallel test schemas in the same
426
+ // database never trigger each other's rename.
427
+ `DO $$ BEGIN
428
+ IF EXISTS (
429
+ SELECT 1 FROM information_schema.columns
430
+ WHERE table_schema = current_schema() AND table_name = 'cortex_ask_grants' AND column_name = 'command'
431
+ ) THEN
432
+ ALTER TABLE cortex_ask_grants RENAME COLUMN command TO grant_key;
433
+ END IF;
434
+ END $$`,
370
435
  ];
371
436
  for (const sql of addMigrations) {
372
437
  await client.query(sql);
@@ -3,9 +3,11 @@
3
3
  * runner ignores it; imported by the tool unit tests.
4
4
  */
5
5
  import { makeSession } from "../../providers/__fixtures__/session.js";
6
+ import { UnavailableAsk } from "../approval/contract.js";
6
7
  /** Build a `ToolContext` for tests; `emit` records into `emitted`. */
7
8
  export function makeToolContext(signal) {
8
9
  const emitted = [];
10
+ const deny = new UnavailableAsk();
9
11
  const ctx = {
10
12
  session: makeSession(),
11
13
  signal: signal ?? new AbortController().signal,
@@ -13,6 +15,7 @@ export function makeToolContext(signal) {
13
15
  emitted.push(event);
14
16
  },
15
17
  runStep: (_name, fn) => fn(),
18
+ ask: (request) => deny.ask(request),
16
19
  };
17
20
  return { ctx, emitted };
18
21
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The tool-approval seam contract: the request a conversation tool pauses on,
3
+ * the decision the user returns, and the deny-by-default realization shipped for
4
+ * hosts that wire no interactive surface.
5
+ *
6
+ * The seam is generic — nothing here names a tool or a domain. A tool describes
7
+ * the concrete action it needs approved (`AskRequest`); a surface renders it, the
8
+ * user decides, and the decision comes back as an `AskApproval` on approval or an
9
+ * `AskRejectedError` throw on denial.
10
+ *
11
+ * These are standalone types so `define-tool.ts` can type-import them into
12
+ * `ToolContext` without an import cycle back through the tools layer.
13
+ */
14
+ /**
15
+ * The human-facing content a surface renders to describe the exact action being
16
+ * approved. `command` is the concrete operation the user sees and approves — what
17
+ * they saw is precisely what is granted, nothing broader. No tool- or
18
+ * domain-specific fields: the harness stays agnostic to what is being approved.
19
+ */
20
+ export interface AskRequest {
21
+ readonly title: string;
22
+ readonly command: string;
23
+ readonly detail?: string;
24
+ /**
25
+ * Keys the standing grant an `always` records when the class being blessed is
26
+ * broader than the displayed `command` — a tool that grants a whole family of
27
+ * operations passes the family's key here while still showing one concrete
28
+ * command. It is a generic, opaque key string, not a tool- or domain-specific
29
+ * field, and the surface never renders it. Absent, the grant keys on `command`.
30
+ */
31
+ readonly grantKey?: string;
32
+ }
33
+ /**
34
+ * A user's decision on an approval request — three distinct outcomes, never a
35
+ * boolean. `once` approves the single pending invocation; `always` approves it
36
+ * AND records a standing grant for the matched action; `reject` denies and MAY
37
+ * carry model-facing feedback.
38
+ *
39
+ * This full three-variant union is the wire type the outward `answer` API takes;
40
+ * `ctx.ask` itself resolves only with the `AskApproval` subset (denial throws).
41
+ */
42
+ export type AskReply = {
43
+ readonly kind: "once";
44
+ } | {
45
+ readonly kind: "always";
46
+ } | {
47
+ readonly kind: "reject";
48
+ readonly feedback?: string;
49
+ };
50
+ /**
51
+ * The subset of `AskReply` `ctx.ask` may resolve with. Denial never returns — it
52
+ * throws `AskRejectedError` — so a resolved approval is only ever `once` or
53
+ * `always`, and a caller never has to inspect for a reject variant.
54
+ */
55
+ export type AskApproval = Exclude<AskReply, {
56
+ kind: "reject";
57
+ }>;
58
+ /**
59
+ * The throw a `reject` reply raises out of `ctx.ask`, carrying the optional
60
+ * model-facing feedback.
61
+ *
62
+ * Denial is a throw rather than a returned reject variant so a tool never has to
63
+ * inspect the reply to learn it was denied: only asks throw, so the throw itself
64
+ * is the denial signal, and the loop needs no per-tool "was denied" flag — it maps
65
+ * the caught error to an execution-denied tool result and stops the turn.
66
+ */
67
+ export declare class AskRejectedError extends Error {
68
+ readonly feedback?: string;
69
+ constructor(feedback?: string);
70
+ }
71
+ /**
72
+ * The user-approval seam. `ask` pauses on an approval request and resolves with
73
+ * the approval decision, or throws `AskRejectedError` when the user denies.
74
+ * Realizations are wired by the embedder; an unwired host resolves to
75
+ * `UnavailableAsk`.
76
+ */
77
+ export interface Ask {
78
+ ask(request: AskRequest): Promise<AskApproval>;
79
+ }
80
+ /**
81
+ * Deny-by-default realization — the shipped `Ask` for hosts that wire no
82
+ * interactive surface (workflow contexts, headless embedders). Every request is
83
+ * denied, so a tool that calls `ctx.ask` where nothing can answer is rejected
84
+ * rather than left waiting on a surface that will never respond.
85
+ */
86
+ export declare class UnavailableAsk implements Ask {
87
+ ask(_request: AskRequest): Promise<AskApproval>;
88
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The tool-approval seam contract: the request a conversation tool pauses on,
3
+ * the decision the user returns, and the deny-by-default realization shipped for
4
+ * hosts that wire no interactive surface.
5
+ *
6
+ * The seam is generic — nothing here names a tool or a domain. A tool describes
7
+ * the concrete action it needs approved (`AskRequest`); a surface renders it, the
8
+ * user decides, and the decision comes back as an `AskApproval` on approval or an
9
+ * `AskRejectedError` throw on denial.
10
+ *
11
+ * These are standalone types so `define-tool.ts` can type-import them into
12
+ * `ToolContext` without an import cycle back through the tools layer.
13
+ */
14
+ /**
15
+ * The throw a `reject` reply raises out of `ctx.ask`, carrying the optional
16
+ * model-facing feedback.
17
+ *
18
+ * Denial is a throw rather than a returned reject variant so a tool never has to
19
+ * inspect the reply to learn it was denied: only asks throw, so the throw itself
20
+ * is the denial signal, and the loop needs no per-tool "was denied" flag — it maps
21
+ * the caught error to an execution-denied tool result and stops the turn.
22
+ */
23
+ export class AskRejectedError extends Error {
24
+ feedback;
25
+ constructor(feedback) {
26
+ super(feedback ? `approval rejected: ${feedback}` : "approval rejected");
27
+ this.name = "AskRejectedError";
28
+ this.feedback = feedback;
29
+ }
30
+ }
31
+ /**
32
+ * Deny-by-default realization — the shipped `Ask` for hosts that wire no
33
+ * interactive surface (workflow contexts, headless embedders). Every request is
34
+ * denied, so a tool that calls `ctx.ask` where nothing can answer is rejected
35
+ * rather than left waiting on a surface that will never respond.
36
+ */
37
+ export class UnavailableAsk {
38
+ async ask(_request) {
39
+ throw new AskRejectedError("approval is unavailable in this environment");
40
+ }
41
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The poll-based tool-approval gateway — the constructed `Ask` seam realization
3
+ * an embedder wires over its Postgres pool, plus the outward `answer` / `pending`
4
+ * API it drives and the boot-time `sweepExpired`.
5
+ *
6
+ * `ask` is the per-turn seam an embedder binds: a conversation tool's `ctx.ask`
7
+ * pauses on it. The database is the single source of truth for ask state — `ask`
8
+ * inserts a `pending` ledger row, emits the `data-ask` part, and polls that row to
9
+ * a terminal status; there is no in-memory resolver map. `answer` is one guarded
10
+ * write. See the poll site below for why.
11
+ */
12
+ import type { Pool } from "pg";
13
+ import type { EmitFn } from "../../loop/types.js";
14
+ import { type AskApproval, type AskReply, type AskRequest } from "./contract.js";
15
+ import { type AnswerOutcome, type PendingAsk } from "./queries.js";
16
+ export type { AnswerOutcome, PendingAsk } from "./queries.js";
17
+ /** Construction deps: the harness application pool the ledger lives in. */
18
+ export interface AskGatewayDeps {
19
+ readonly pool: Pool;
20
+ }
21
+ /**
22
+ * Per-turn context an embedder binds into the `Ask` seam. It scopes the ask to an
23
+ * analysis/thread, carries the turn's abort `signal`, and supplies the `emit` sink
24
+ * the `data-ask` part flows through.
25
+ */
26
+ export interface AskContext {
27
+ readonly analysisId: string;
28
+ readonly threadId?: string;
29
+ readonly signal: AbortSignal;
30
+ readonly emit: EmitFn;
31
+ }
32
+ /** The gateway surface: the per-turn seam plus the outward embedder-driven API. */
33
+ export interface AskGateway {
34
+ ask(request: AskRequest, ctx: AskContext): Promise<AskApproval>;
35
+ answer(id: string, reply: AskReply): Promise<AnswerOutcome>;
36
+ pending(): Promise<PendingAsk[]>;
37
+ sweepExpired(): Promise<number>;
38
+ }
39
+ export declare function createAskGateway(deps: AskGatewayDeps): AskGateway;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The poll-based tool-approval gateway — the constructed `Ask` seam realization
3
+ * an embedder wires over its Postgres pool, plus the outward `answer` / `pending`
4
+ * API it drives and the boot-time `sweepExpired`.
5
+ *
6
+ * `ask` is the per-turn seam an embedder binds: a conversation tool's `ctx.ask`
7
+ * pauses on it. The database is the single source of truth for ask state — `ask`
8
+ * inserts a `pending` ledger row, emits the `data-ask` part, and polls that row to
9
+ * a terminal status; there is no in-memory resolver map. `answer` is one guarded
10
+ * write. See the poll site below for why.
11
+ */
12
+ import { unwrapOrThrow } from "../../lib/result.js";
13
+ import { AskRejectedError } from "./contract.js";
14
+ import { answerAsk, insertGrantedAsk, insertPendingAsk, markAborted, selectAskResolution, selectGrant, selectPending, sweepExpired as sweepExpiredRows, } from "./queries.js";
15
+ import { uuidv7 } from "./uuidv7.js";
16
+ /** Fixed poll cadence — imperceptible against human decision speed, low chatter. */
17
+ const POLL_INTERVAL_MS = 200;
18
+ export function createAskGateway(deps) {
19
+ const { pool } = deps;
20
+ async function ask(request, ctx) {
21
+ const id = uuidv7();
22
+ const now = new Date().toISOString();
23
+ const row = {
24
+ id,
25
+ analysisId: ctx.analysisId,
26
+ threadId: ctx.threadId ?? null,
27
+ title: request.title,
28
+ command: request.command,
29
+ detail: request.detail ?? null,
30
+ grantKey: request.grantKey ?? null,
31
+ createdAt: now,
32
+ };
33
+ // A standing grant for this ask's grant key — the command unless the tool
34
+ // supplied a broader key — short-circuits the prompt: record the invocation as
35
+ // `resolved` for the audit trail and return without pausing.
36
+ if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, request.grantKey ?? request.command))) {
37
+ unwrapOrThrow(await insertGrantedAsk(pool, row, now));
38
+ return { kind: "always" };
39
+ }
40
+ unwrapOrThrow(await insertPendingAsk(pool, row));
41
+ await emitAskPart(ctx.emit, id, request, "pending");
42
+ // DESIGN(WHY): we poll the ask ledger instead of holding an in-memory
43
+ // resolver map. The DB is the single source of truth, so answering is a pure
44
+ // UPDATE, a crash-orphaned pending row just ends the poll, and an answer that
45
+ // arrives in a different process than the loop (a hosted deployment) still
46
+ // lands. A resolver map would be a second state to reconcile and cannot be
47
+ // resolved once its process dies. Latency is invisible at human decision speed
48
+ // and can be erased later with LISTEN/NOTIFY.
49
+ // TODO(refine): these ask records might move into provenance later.
50
+ for (;;) {
51
+ if (ctx.signal.aborted) {
52
+ unwrapOrThrow(await markAborted(pool, id, new Date().toISOString()));
53
+ await emitAskPart(ctx.emit, id, request, "aborted");
54
+ throwAbort(ctx.signal);
55
+ }
56
+ const resolution = unwrapOrThrow(await selectAskResolution(pool, id));
57
+ if (resolution === null) {
58
+ // The row vanished out from under the poll — nothing can answer it
59
+ // now, so deny rather than spin forever. Not reachable through the
60
+ // ordinary status machine (rows are only ever updated, never deleted).
61
+ await emitAskPart(ctx.emit, id, request, "expired");
62
+ throw new AskRejectedError("approval request is no longer available");
63
+ }
64
+ if (resolution.status !== "pending") {
65
+ await emitAskPart(ctx.emit, id, request, resolution.status);
66
+ return settle(resolution.status, resolution.reply, ctx.signal);
67
+ }
68
+ await sleepUntil(POLL_INTERVAL_MS, ctx.signal);
69
+ }
70
+ }
71
+ async function answer(id, reply) {
72
+ return unwrapOrThrow(await answerAsk(pool, id, reply, new Date().toISOString()));
73
+ }
74
+ async function pending() {
75
+ return unwrapOrThrow(await selectPending(pool));
76
+ }
77
+ async function sweepExpired() {
78
+ return unwrapOrThrow(await sweepExpiredRows(pool, new Date().toISOString()));
79
+ }
80
+ return { ask, answer, pending, sweepExpired };
81
+ }
82
+ /** Emit (or reconcile, under the same id) the ask's `data-ask` chat part. */
83
+ function emitAskPart(emit, id, request, status) {
84
+ return emit({
85
+ type: "data-ask",
86
+ data: {
87
+ id,
88
+ title: request.title,
89
+ command: request.command,
90
+ ...(request.detail !== undefined ? { detail: request.detail } : {}),
91
+ status,
92
+ },
93
+ });
94
+ }
95
+ /** Resolve an approval or throw the terminal denial the ledger recorded. */
96
+ function settle(status, reply, signal) {
97
+ switch (status) {
98
+ case "resolved":
99
+ // Only `once` / `always` reach `resolved`; guard the JSONB round-trip and
100
+ // default to the narrowest approval if a malformed reply ever slipped in.
101
+ return reply && (reply.kind === "once" || reply.kind === "always") ? reply : { kind: "once" };
102
+ case "rejected":
103
+ throw new AskRejectedError(reply && reply.kind === "reject" ? reply.feedback : undefined);
104
+ case "expired":
105
+ throw new AskRejectedError("approval request expired unanswered");
106
+ case "aborted":
107
+ throwAbort(signal);
108
+ }
109
+ }
110
+ /**
111
+ * Re-raise the turn's cancellation verbatim — `signal.reason` IS the loop's own
112
+ * cancellation, so its existing turn-abort path recognizes it unchanged. The
113
+ * fallback covers a signal aborted with no explicit reason.
114
+ */
115
+ function throwAbort(signal) {
116
+ throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
117
+ }
118
+ /** Delay `ms`, resolving early if `signal` aborts, leaving no dangling timer. */
119
+ function sleepUntil(ms, signal) {
120
+ return new Promise((resolve) => {
121
+ if (signal.aborted) {
122
+ resolve();
123
+ return;
124
+ }
125
+ const onAbort = () => {
126
+ clearTimeout(timer);
127
+ resolve();
128
+ };
129
+ const timer = setTimeout(() => {
130
+ signal.removeEventListener("abort", onAbort);
131
+ resolve();
132
+ }, ms);
133
+ signal.addEventListener("abort", onAbort, { once: true });
134
+ });
135
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Postgres data access for the tool-approval ledger (`cortex_asks`) and its
3
+ * standing grants (`cortex_ask_grants`).
4
+ *
5
+ * Every operation returns `ResultAsync<T, DbError>` in the `state/` house style —
6
+ * the `pg` throws are caught only inside `tryQuery`/`tryMutation`/`withTransaction`
7
+ * (`lib/db-result.ts`); everything above flows `Result`. Absence rides the ok
8
+ * channel (`ok(null)` / `ok(0)`), never an `err`. The gateway unwraps these at its
9
+ * seam boundary.
10
+ */
11
+ import { type ResultAsync } from "neverthrow";
12
+ import type { Pool, PoolClient } from "pg";
13
+ import { type DbError } from "../../lib/db-result.js";
14
+ import type { AskReply } from "./contract.js";
15
+ type Querier = Pool | PoolClient;
16
+ /** The ask ledger's status machine — one `pending` state, four terminal ones. */
17
+ export type AskStatus = "pending" | "resolved" | "rejected" | "aborted" | "expired";
18
+ /** The discriminated outcome of an out-of-band `answer` against a ledger row. */
19
+ export type AnswerOutcome = "applied" | "not_found" | "already_terminal";
20
+ /** An unresolved ask, as enumerated by `pending()`. */
21
+ export interface PendingAsk {
22
+ readonly id: string;
23
+ readonly analysisId: string;
24
+ readonly threadId: string | null;
25
+ readonly title: string;
26
+ readonly command: string;
27
+ readonly detail: string | null;
28
+ readonly createdAt: string;
29
+ }
30
+ /** The status + recorded reply a poll reads to observe a decision. */
31
+ export interface AskResolution {
32
+ readonly status: AskStatus;
33
+ readonly reply: AskReply | null;
34
+ }
35
+ /** Column payload shared by the pending insert and the grant short-circuit insert. */
36
+ export interface AskRow {
37
+ readonly id: string;
38
+ readonly analysisId: string;
39
+ readonly threadId: string | null;
40
+ readonly title: string;
41
+ readonly command: string;
42
+ readonly detail: string | null;
43
+ /** Grant key an `always` keys its standing grant under; `null` falls back to `command`. */
44
+ readonly grantKey: string | null;
45
+ readonly createdAt: string;
46
+ }
47
+ /** Insert a fresh `pending` ask before its decision is awaited. */
48
+ export declare function insertPendingAsk(querier: Querier, row: AskRow): ResultAsync<void, DbError>;
49
+ /**
50
+ * Insert an already-`resolved` ask recording an auto-approval a standing grant
51
+ * short-circuited: no prompt was shown, but the ledger stays a complete audit of
52
+ * every approval-gated action. The recorded reply is `{ kind: "always" }`.
53
+ */
54
+ export declare function insertGrantedAsk(querier: Querier, row: AskRow, resolvedAt: string): ResultAsync<void, DbError>;
55
+ /** Does a standing grant cover `(analysisId, grantKey)` in this analysis? */
56
+ export declare function selectGrant(querier: Querier, analysisId: string, grantKey: string): ResultAsync<boolean, DbError>;
57
+ /** Read a row's current status + recorded reply. `null` when no such row exists. */
58
+ export declare function selectAskResolution(querier: Querier, id: string): ResultAsync<AskResolution | null, DbError>;
59
+ /**
60
+ * Mark a still-`pending` row `aborted`. Guarded by `WHERE status = 'pending'`, so
61
+ * a row already answered out of band is left untouched.
62
+ */
63
+ export declare function markAborted(querier: Querier, id: string, resolvedAt: string): ResultAsync<void, DbError>;
64
+ /** Enumerate every unresolved ask, oldest first. */
65
+ export declare function selectPending(querier: Querier): ResultAsync<PendingAsk[], DbError>;
66
+ /** Sweep every `pending` row to `expired`, returning the count swept. */
67
+ export declare function sweepExpired(querier: Querier, expiredAt: string): ResultAsync<number, DbError>;
68
+ /**
69
+ * Record a decision against a still-`pending` row and, for an `always` reply,
70
+ * write the standing grant — both in one transaction, so a crash between them can
71
+ * never record an approval without the grant it promised.
72
+ *
73
+ * The guarded `UPDATE ... WHERE status = 'pending'` makes a duplicate/stale answer
74
+ * a reported no-op: one row updated → `applied`; zero rows → a follow-up read
75
+ * discriminates `already_terminal` (the row exists but left `pending`) from
76
+ * `not_found` (no such id).
77
+ */
78
+ export declare function answerAsk(pool: Pool, id: string, reply: AskReply, now: string): ResultAsync<AnswerOutcome, DbError>;
79
+ export {};
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Postgres data access for the tool-approval ledger (`cortex_asks`) and its
3
+ * standing grants (`cortex_ask_grants`).
4
+ *
5
+ * Every operation returns `ResultAsync<T, DbError>` in the `state/` house style —
6
+ * the `pg` throws are caught only inside `tryQuery`/`tryMutation`/`withTransaction`
7
+ * (`lib/db-result.ts`); everything above flows `Result`. Absence rides the ok
8
+ * channel (`ok(null)` / `ok(0)`), never an `err`. The gateway unwraps these at its
9
+ * seam boundary.
10
+ */
11
+ import { okAsync } from "neverthrow";
12
+ import { tryMutation, tryQuery, withTransaction } from "../../lib/db-result.js";
13
+ /** Insert a fresh `pending` ask before its decision is awaited. */
14
+ export function insertPendingAsk(querier, row) {
15
+ return tryMutation("asks.insertPending", async () => {
16
+ await querier.query({
17
+ text: `INSERT INTO cortex_asks
18
+ (id, analysis_id, thread_id, title, command, detail, grant_key, status, created_at)
19
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8)`,
20
+ values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, row.grantKey ?? row.command, row.createdAt],
21
+ });
22
+ });
23
+ }
24
+ /**
25
+ * Insert an already-`resolved` ask recording an auto-approval a standing grant
26
+ * short-circuited: no prompt was shown, but the ledger stays a complete audit of
27
+ * every approval-gated action. The recorded reply is `{ kind: "always" }`.
28
+ */
29
+ export function insertGrantedAsk(querier, row, resolvedAt) {
30
+ return tryMutation("asks.insertGranted", async () => {
31
+ await querier.query({
32
+ text: `INSERT INTO cortex_asks
33
+ (id, analysis_id, thread_id, title, command, detail, grant_key, status, reply, created_at, resolved_at)
34
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'resolved', $8::jsonb, $9, $10)`,
35
+ values: [
36
+ row.id,
37
+ row.analysisId,
38
+ row.threadId,
39
+ row.title,
40
+ row.command,
41
+ row.detail,
42
+ row.grantKey ?? row.command,
43
+ JSON.stringify({ kind: "always" }),
44
+ row.createdAt,
45
+ resolvedAt,
46
+ ],
47
+ });
48
+ });
49
+ }
50
+ /** Does a standing grant cover `(analysisId, grantKey)` in this analysis? */
51
+ export function selectGrant(querier, analysisId, grantKey) {
52
+ return tryQuery("asks.selectGrant", async () => {
53
+ const result = await querier.query({
54
+ text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND grant_key = $2`,
55
+ values: [analysisId, grantKey],
56
+ });
57
+ return (result.rowCount ?? 0) > 0;
58
+ });
59
+ }
60
+ /** Read a row's current status + recorded reply. `null` when no such row exists. */
61
+ export function selectAskResolution(querier, id) {
62
+ return tryQuery("asks.selectResolution", async () => {
63
+ const result = await querier.query({
64
+ text: `SELECT status, reply FROM cortex_asks WHERE id = $1`,
65
+ values: [id],
66
+ });
67
+ const row = result.rows[0];
68
+ if (!row)
69
+ return null;
70
+ return { status: row.status, reply: row.reply ?? null };
71
+ });
72
+ }
73
+ /**
74
+ * Mark a still-`pending` row `aborted`. Guarded by `WHERE status = 'pending'`, so
75
+ * a row already answered out of band is left untouched.
76
+ */
77
+ export function markAborted(querier, id, resolvedAt) {
78
+ return tryMutation("asks.markAborted", async () => {
79
+ await querier.query({
80
+ text: `UPDATE cortex_asks SET status = 'aborted', resolved_at = $2 WHERE id = $1 AND status = 'pending'`,
81
+ values: [id, resolvedAt],
82
+ });
83
+ });
84
+ }
85
+ /** Enumerate every unresolved ask, oldest first. */
86
+ export function selectPending(querier) {
87
+ return tryQuery("asks.selectPending", async () => {
88
+ const result = await querier.query({
89
+ text: `SELECT id, analysis_id, thread_id, title, command, detail, created_at
90
+ FROM cortex_asks WHERE status = 'pending' ORDER BY created_at ASC`,
91
+ });
92
+ return result.rows.map((row) => ({
93
+ id: row.id,
94
+ analysisId: row.analysis_id,
95
+ threadId: row.thread_id ?? null,
96
+ title: row.title,
97
+ command: row.command,
98
+ detail: row.detail ?? null,
99
+ createdAt: row.created_at,
100
+ }));
101
+ });
102
+ }
103
+ /** Sweep every `pending` row to `expired`, returning the count swept. */
104
+ export function sweepExpired(querier, expiredAt) {
105
+ return tryMutation("asks.sweepExpired", async () => {
106
+ const result = await querier.query({
107
+ text: `UPDATE cortex_asks SET status = 'expired', resolved_at = $1 WHERE status = 'pending'`,
108
+ values: [expiredAt],
109
+ });
110
+ return result.rowCount ?? 0;
111
+ });
112
+ }
113
+ /**
114
+ * Record a decision against a still-`pending` row and, for an `always` reply,
115
+ * write the standing grant — both in one transaction, so a crash between them can
116
+ * never record an approval without the grant it promised.
117
+ *
118
+ * The guarded `UPDATE ... WHERE status = 'pending'` makes a duplicate/stale answer
119
+ * a reported no-op: one row updated → `applied`; zero rows → a follow-up read
120
+ * discriminates `already_terminal` (the row exists but left `pending`) from
121
+ * `not_found` (no such id).
122
+ */
123
+ export function answerAsk(pool, id, reply, now) {
124
+ const status = reply.kind === "reject" ? "rejected" : "resolved";
125
+ return withTransaction(pool, "asks.answer", (client) => tryMutation("asks.answer:update", () => client.query({
126
+ text: `UPDATE cortex_asks SET status = $1, reply = $2::jsonb, resolved_at = $3
127
+ WHERE id = $4 AND status = 'pending'
128
+ RETURNING analysis_id, COALESCE(grant_key, command) AS grant_key`,
129
+ values: [status, JSON.stringify(reply), now, id],
130
+ })).andThen((updated) => {
131
+ if ((updated.rowCount ?? 0) === 0)
132
+ return discriminateMiss(client, id);
133
+ if (reply.kind !== "always")
134
+ return okAsync("applied");
135
+ // The cast's non-null `grant_key` is sound without any boot-order
136
+ // caveat: every insert writes grant_key (command when the tool supplied
137
+ // none), and the COALESCE above covers any pending row that predates
138
+ // the column — `command` is NOT NULL, so the key can never be null.
139
+ const granted = updated.rows[0];
140
+ return tryMutation("asks.answer:grant", async () => {
141
+ await client.query({
142
+ text: `INSERT INTO cortex_ask_grants (analysis_id, grant_key, created_at)
143
+ VALUES ($1, $2, $3) ON CONFLICT (analysis_id, grant_key) DO NOTHING`,
144
+ values: [granted.analysis_id, granted.grant_key, now],
145
+ });
146
+ }).map(() => "applied");
147
+ }));
148
+ }
149
+ /** A zero-row `UPDATE` means the id is absent or already terminal — tell them apart. */
150
+ function discriminateMiss(client, id) {
151
+ return tryQuery("asks.answer:discriminate", async () => {
152
+ const result = await client.query({ text: `SELECT 1 FROM cortex_asks WHERE id = $1`, values: [id] });
153
+ return (result.rowCount ?? 0) > 0 ? "already_terminal" : "not_found";
154
+ });
155
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * UUIDv7 generator for the ask ledger id.
3
+ *
4
+ * The package pins no uuidv7 dependency and Node's `crypto.randomUUID` mints
5
+ * only v4, so a small self-contained helper covers the one call site that needs
6
+ * a time-ordered id: `cortex_asks.id`. v7's leading 48-bit millisecond timestamp
7
+ * makes ledger ids monotonically sortable, so a `created_at`-ordered scan and the
8
+ * id order agree without a second sort key.
9
+ */
10
+ /**
11
+ * Mint a RFC 9562 UUIDv7 — 48-bit big-endian unix-millis prefix, version/variant
12
+ * nibbles patched over otherwise-random bytes.
13
+ */
14
+ export declare function uuidv7(): string;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * UUIDv7 generator for the ask ledger id.
3
+ *
4
+ * The package pins no uuidv7 dependency and Node's `crypto.randomUUID` mints
5
+ * only v4, so a small self-contained helper covers the one call site that needs
6
+ * a time-ordered id: `cortex_asks.id`. v7's leading 48-bit millisecond timestamp
7
+ * makes ledger ids monotonically sortable, so a `created_at`-ordered scan and the
8
+ * id order agree without a second sort key.
9
+ */
10
+ import { randomBytes } from "node:crypto";
11
+ /**
12
+ * Mint a RFC 9562 UUIDv7 — 48-bit big-endian unix-millis prefix, version/variant
13
+ * nibbles patched over otherwise-random bytes.
14
+ */
15
+ export function uuidv7() {
16
+ const bytes = randomBytes(16);
17
+ // 48-bit timestamp occupies the first six bytes, big-endian. Date.now() fits
18
+ // in 48 bits well past the year 10000, so writeUIntBE never overflows.
19
+ bytes.writeUIntBE(Date.now(), 0, 6);
20
+ // Version 7 in the high nibble of byte 6; variant 0b10 in the top bits of byte 8.
21
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
22
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
23
+ const hex = bytes.toString("hex");
24
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
25
+ }
@@ -7,8 +7,9 @@
7
7
  * `defineTool(...)`; a dependency-bearing tool is a factory closure that
8
8
  * captures its deps and calls `defineTool`.
9
9
  *
10
- * `ToolContext` carries exactly the four request-scoped values every tool
11
- * may need — no pool, no sandbox, no logger. The error contract: an expected
10
+ * `ToolContext` carries only request-scoped values every tool may need
11
+ * (`session`, `signal`, `emit`, `runStep`, `ask`) — no pool, no sandbox, no
12
+ * logger. The error contract: an expected
12
13
  * outcome ("not found", "no results") stays in the ok channel as a data
13
14
  * variant (`ok({ found: false })`); an unexpected failure is an `err(ToolError)`
14
15
  * or a throw. The loop (`dispatchTool`) owns the `is_error` envelope — it maps
@@ -18,6 +19,7 @@ import type { Result } from "neverthrow";
18
19
  import { z } from "zod";
19
20
  import type { AgentSession } from "../auth/types.js";
20
21
  import type { EmitFn, RunStep } from "../loop/types.js";
22
+ import type { AskApproval, AskRequest } from "./approval/contract.js";
21
23
  export type { EmitFn };
22
24
  /**
23
25
  * The error channel of a tool's `Result`. `error` is the message surfaced to
@@ -37,9 +39,9 @@ export type ToolExecutionMode = "step" | "workflow" | "inline";
37
39
  export declare function isToolError(value: unknown): value is ToolError;
38
40
  /**
39
41
  * The request-scoped values passed to every tool's `execute`. No injected
40
- * dependencies (see the harness-durable-runtime spec) — `session`, `signal`, `emit`, plus the `runStep`
41
- * durability seam (`passthroughStep` in chat, `DBOS.runStep` in workflows)
42
- * a tool uses to wrap its own durable work.
42
+ * dependencies (see the harness-durable-runtime spec) — `session`, `signal`, `emit`, the `runStep`
43
+ * durability seam (`passthroughStep` in chat, `DBOS.runStep` in workflows) a
44
+ * tool uses to wrap its own durable work, and the `ask` user-approval seam.
43
45
  */
44
46
  export interface ToolContext {
45
47
  readonly session: AgentSession;
@@ -50,6 +52,14 @@ export interface ToolContext {
50
52
  * under the tool's own step name, so a tool just passes a short local label.
51
53
  */
52
54
  readonly runStep: RunStep;
55
+ /**
56
+ * Pause for an explicit user decision on a concrete action. Resolves with the
57
+ * approval (`once`/`always`) or throws `AskRejectedError` on denial. Resolves
58
+ * to a deny-by-default realization when the embedder wires none, so a tool
59
+ * that calls it in a non-interactive context is denied rather than left
60
+ * waiting on a surface that cannot answer.
61
+ */
62
+ readonly ask: (request: AskRequest) => Promise<AskApproval>;
53
63
  }
54
64
  /**
55
65
  * A packaged tool: identity, the Zod input contract, the emitted AI SDK input
@@ -7,8 +7,9 @@
7
7
  * `defineTool(...)`; a dependency-bearing tool is a factory closure that
8
8
  * captures its deps and calls `defineTool`.
9
9
  *
10
- * `ToolContext` carries exactly the four request-scoped values every tool
11
- * may need — no pool, no sandbox, no logger. The error contract: an expected
10
+ * `ToolContext` carries only request-scoped values every tool may need
11
+ * (`session`, `signal`, `emit`, `runStep`, `ask`) — no pool, no sandbox, no
12
+ * logger. The error contract: an expected
12
13
  * outcome ("not found", "no results") stays in the ok channel as a data
13
14
  * variant (`ok({ found: false })`); an unexpected failure is an `err(ToolError)`
14
15
  * or a throw. The loop (`dispatchTool`) owns the `is_error` envelope — it maps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.4.5",
3
+ "version": "0.6.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",