@inflexa-ai/harness 0.4.4 → 0.5.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.
- package/dist/contracts/chat-parts.d.ts +20 -2
- package/dist/contracts/chat-parts.js +2 -1
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/part-registry.js +1 -0
- package/dist/contracts/schemas/chat-parts.d.ts +34 -0
- package/dist/contracts/schemas/chat-parts.js +11 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +7 -0
- package/dist/loop/run-agent.d.ts +12 -1
- package/dist/loop/run-agent.js +48 -0
- package/dist/sandbox/docker-client.d.ts +6 -1
- package/dist/sandbox/docker-client.js +34 -3
- package/dist/state/init.js +39 -0
- package/dist/tools/__fixtures__/tool-context.js +3 -0
- package/dist/tools/approval/contract.d.ts +80 -0
- package/dist/tools/approval/contract.js +41 -0
- package/dist/tools/approval/gateway.d.ts +39 -0
- package/dist/tools/approval/gateway.js +133 -0
- package/dist/tools/approval/queries.d.ts +77 -0
- package/dist/tools/approval/queries.js +140 -0
- package/dist/tools/approval/uuidv7.d.ts +14 -0
- package/dist/tools/approval/uuidv7.js +25 -0
- package/dist/tools/define-tool.d.ts +15 -5
- package/dist/tools/define-tool.js +3 -2
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
package/dist/loop/run-agent.d.ts
CHANGED
|
@@ -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;
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -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")
|
|
@@ -44,7 +44,12 @@ export interface DockerClientConfig {
|
|
|
44
44
|
resolveWorkspaceRoot: ResolveWorkspaceRoot;
|
|
45
45
|
/** Host lib store; bind-mounted read-only at `/mnt/libs` when set. */
|
|
46
46
|
libStorePath?: string;
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Host ref store; bind-mounted read-only at `/mnt/refs`. Embedders pass the
|
|
49
|
+
* configured store location unconditionally — existence is (re-)checked at each
|
|
50
|
+
* sandbox creation, so a store installed mid-session is mounted into subsequent
|
|
51
|
+
* sandboxes without a runtime restart.
|
|
52
|
+
*/
|
|
48
53
|
refStorePath?: string;
|
|
49
54
|
/**
|
|
50
55
|
* Force the container platform (e.g. `linux/amd64`). Set by hosts that mount
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* Either way the workload ends as uid 1000 with `no-new-privileges` and no
|
|
29
29
|
* effective capabilities.
|
|
30
30
|
*/
|
|
31
|
-
import { existsSync, statSync } from "node:fs";
|
|
31
|
+
import { existsSync, lstatSync, statSync } from "node:fs";
|
|
32
32
|
import { join } from "node:path";
|
|
33
33
|
import Docker from "dockerode";
|
|
34
34
|
import { ResultAsync, err, ok } from "neverthrow";
|
|
@@ -80,6 +80,30 @@ function libStoreUsable(libStorePath) {
|
|
|
80
80
|
}
|
|
81
81
|
return existsSync(join(current, "packages.txt")) && existsSync(join(current, "meta.json"));
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether `refStorePath` is, right now, a real directory fit to be a bind authority.
|
|
85
|
+
* Deliberately shallow — it checks existence-as-a-directory only, never the store's
|
|
86
|
+
* interior: the harness knows nothing of the ref store's layout, and dataset
|
|
87
|
+
* completeness/receipts are the embedder's concern, so (unlike `libStoreUsable`, which
|
|
88
|
+
* validates harness-known `current` layout) there is nothing here to validate beyond
|
|
89
|
+
* "a directory is present."
|
|
90
|
+
*
|
|
91
|
+
* `lstatSync` (not `statSync`) does NOT follow symlinks, so a symlinked path is rejected
|
|
92
|
+
* even when it resolves to a directory: the bind authority must itself be the store
|
|
93
|
+
* directory, not an indirection that may later point elsewhere.
|
|
94
|
+
*
|
|
95
|
+
* Binding a missing source makes Docker auto-create a root-owned directory at the path —
|
|
96
|
+
* the same hazard `libStoreUsable` documents — which would also brick the embedder's
|
|
97
|
+
* later store install (its store tool cannot write into a root-owned dir).
|
|
98
|
+
*/
|
|
99
|
+
function refStoreUsable(refStorePath) {
|
|
100
|
+
try {
|
|
101
|
+
return lstatSync(refStorePath).isDirectory();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
83
107
|
async function pollHealth(fetchImpl, url, timeoutMs) {
|
|
84
108
|
const deadline = Date.now() + timeoutMs;
|
|
85
109
|
let lastErr;
|
|
@@ -187,9 +211,16 @@ export function createDockerSandboxOps(config) {
|
|
|
187
211
|
if (config.libStorePath && !libsMounted) {
|
|
188
212
|
logger.warn("lib store configured but `current` is missing or incomplete at sandbox creation — mounting no library store (sandbox degrades to available:false)", { libStorePath: config.libStorePath, sandboxId });
|
|
189
213
|
}
|
|
214
|
+
// Re-checked here too, so a ref store installed after boot is mounted into
|
|
215
|
+
// the next sandbox. Unlike libs, a missing ref store is NOT warned: a missing
|
|
216
|
+
// lib store is a degradation (it was configured and working, then broke),
|
|
217
|
+
// whereas a missing ref store is the normal cold state — the user simply has
|
|
218
|
+
// not installed reference data yet — and warning on every create would be
|
|
219
|
+
// noise. A store that appears mid-session is picked up by the next create.
|
|
220
|
+
const refsMounted = !!config.refStorePath && refStoreUsable(config.refStorePath);
|
|
190
221
|
const plan = buildMountPlan(meta, {
|
|
191
222
|
libs: libsMounted,
|
|
192
|
-
refs:
|
|
223
|
+
refs: refsMounted,
|
|
193
224
|
});
|
|
194
225
|
const hostTreePath = config.resolveWorkspaceRoot(meta.analysisId);
|
|
195
226
|
// `stepWritePrefix` (not a raw `join`) so the RW bind source runs through
|
|
@@ -200,7 +231,7 @@ export function createDockerSandboxOps(config) {
|
|
|
200
231
|
`${hostTreePath}:${plan.readonlyTreePath}:ro`,
|
|
201
232
|
...(plan.writableStepPath ? [`${hostStepPath}:${plan.writableStepPath}:rw`] : []),
|
|
202
233
|
...(libsMounted && config.libStorePath ? [`${config.libStorePath}:${plan.libsPath}:ro`] : []),
|
|
203
|
-
...(config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
|
|
234
|
+
...(refsMounted && config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
|
|
204
235
|
];
|
|
205
236
|
const createFailed = (status, cause) => ({
|
|
206
237
|
type: "container_create_failed",
|
package/dist/state/init.js
CHANGED
|
@@ -245,6 +245,45 @@ 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
|
+
status TEXT NOT NULL,
|
|
264
|
+
reply JSONB,
|
|
265
|
+
created_at TEXT NOT NULL,
|
|
266
|
+
resolved_at TEXT
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
CREATE INDEX IF NOT EXISTS idx_cortex_asks_analysis
|
|
270
|
+
ON cortex_asks(analysis_id);
|
|
271
|
+
|
|
272
|
+
-- Backs the pending() enumeration and the boot sweep. Partial so it stays
|
|
273
|
+
-- compact against the far larger terminal-row mass.
|
|
274
|
+
CREATE INDEX IF NOT EXISTS idx_cortex_asks_pending
|
|
275
|
+
ON cortex_asks(status) WHERE status = 'pending';
|
|
276
|
+
|
|
277
|
+
-- Standing approval grants behind an 'always' reply — keyed by the analysis and
|
|
278
|
+
-- the exact command string the user approved, so a later matching ask
|
|
279
|
+
-- auto-approves without pausing. Lives for the analysis lifecycle, surviving
|
|
280
|
+
-- process restarts, and never applies to another analysis.
|
|
281
|
+
CREATE TABLE IF NOT EXISTS cortex_ask_grants (
|
|
282
|
+
analysis_id TEXT NOT NULL,
|
|
283
|
+
command TEXT NOT NULL,
|
|
284
|
+
created_at TEXT NOT NULL,
|
|
285
|
+
PRIMARY KEY (analysis_id, command)
|
|
286
|
+
);
|
|
248
287
|
`;
|
|
249
288
|
/**
|
|
250
289
|
* Initialize Cortex state tables. Safe to call on every startup and across
|
|
@@ -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,80 @@
|
|
|
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
|
+
/**
|
|
26
|
+
* A user's decision on an approval request — three distinct outcomes, never a
|
|
27
|
+
* boolean. `once` approves the single pending invocation; `always` approves it
|
|
28
|
+
* AND records a standing grant for the matched action; `reject` denies and MAY
|
|
29
|
+
* carry model-facing feedback.
|
|
30
|
+
*
|
|
31
|
+
* This full three-variant union is the wire type the outward `answer` API takes;
|
|
32
|
+
* `ctx.ask` itself resolves only with the `AskApproval` subset (denial throws).
|
|
33
|
+
*/
|
|
34
|
+
export type AskReply = {
|
|
35
|
+
readonly kind: "once";
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: "always";
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: "reject";
|
|
40
|
+
readonly feedback?: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The subset of `AskReply` `ctx.ask` may resolve with. Denial never returns — it
|
|
44
|
+
* throws `AskRejectedError` — so a resolved approval is only ever `once` or
|
|
45
|
+
* `always`, and a caller never has to inspect for a reject variant.
|
|
46
|
+
*/
|
|
47
|
+
export type AskApproval = Exclude<AskReply, {
|
|
48
|
+
kind: "reject";
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* The throw a `reject` reply raises out of `ctx.ask`, carrying the optional
|
|
52
|
+
* model-facing feedback.
|
|
53
|
+
*
|
|
54
|
+
* Denial is a throw rather than a returned reject variant so a tool never has to
|
|
55
|
+
* inspect the reply to learn it was denied: only asks throw, so the throw itself
|
|
56
|
+
* is the denial signal, and the loop needs no per-tool "was denied" flag — it maps
|
|
57
|
+
* the caught error to an execution-denied tool result and stops the turn.
|
|
58
|
+
*/
|
|
59
|
+
export declare class AskRejectedError extends Error {
|
|
60
|
+
readonly feedback?: string;
|
|
61
|
+
constructor(feedback?: string);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The user-approval seam. `ask` pauses on an approval request and resolves with
|
|
65
|
+
* the approval decision, or throws `AskRejectedError` when the user denies.
|
|
66
|
+
* Realizations are wired by the embedder; an unwired host resolves to
|
|
67
|
+
* `UnavailableAsk`.
|
|
68
|
+
*/
|
|
69
|
+
export interface Ask {
|
|
70
|
+
ask(request: AskRequest): Promise<AskApproval>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Deny-by-default realization — the shipped `Ask` for hosts that wire no
|
|
74
|
+
* interactive surface (workflow contexts, headless embedders). Every request is
|
|
75
|
+
* denied, so a tool that calls `ctx.ask` where nothing can answer is rejected
|
|
76
|
+
* rather than left waiting on a surface that will never respond.
|
|
77
|
+
*/
|
|
78
|
+
export declare class UnavailableAsk implements Ask {
|
|
79
|
+
ask(_request: AskRequest): Promise<AskApproval>;
|
|
80
|
+
}
|
|
@@ -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,133 @@
|
|
|
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
|
+
createdAt: now,
|
|
31
|
+
};
|
|
32
|
+
// A standing grant for this exact command short-circuits the prompt: record
|
|
33
|
+
// the invocation as `resolved` for the audit trail and return without pausing.
|
|
34
|
+
if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, request.command))) {
|
|
35
|
+
unwrapOrThrow(await insertGrantedAsk(pool, row, now));
|
|
36
|
+
return { kind: "always" };
|
|
37
|
+
}
|
|
38
|
+
unwrapOrThrow(await insertPendingAsk(pool, row));
|
|
39
|
+
await emitAskPart(ctx.emit, id, request, "pending");
|
|
40
|
+
// DESIGN(WHY): we poll the ask ledger instead of holding an in-memory
|
|
41
|
+
// resolver map. The DB is the single source of truth, so answering is a pure
|
|
42
|
+
// UPDATE, a crash-orphaned pending row just ends the poll, and an answer that
|
|
43
|
+
// arrives in a different process than the loop (a hosted deployment) still
|
|
44
|
+
// lands. A resolver map would be a second state to reconcile and cannot be
|
|
45
|
+
// resolved once its process dies. Latency is invisible at human decision speed
|
|
46
|
+
// and can be erased later with LISTEN/NOTIFY.
|
|
47
|
+
// TODO(refine): these ask records might move into provenance later.
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (ctx.signal.aborted) {
|
|
50
|
+
unwrapOrThrow(await markAborted(pool, id, new Date().toISOString()));
|
|
51
|
+
await emitAskPart(ctx.emit, id, request, "aborted");
|
|
52
|
+
throwAbort(ctx.signal);
|
|
53
|
+
}
|
|
54
|
+
const resolution = unwrapOrThrow(await selectAskResolution(pool, id));
|
|
55
|
+
if (resolution === null) {
|
|
56
|
+
// The row vanished out from under the poll — nothing can answer it
|
|
57
|
+
// now, so deny rather than spin forever. Not reachable through the
|
|
58
|
+
// ordinary status machine (rows are only ever updated, never deleted).
|
|
59
|
+
await emitAskPart(ctx.emit, id, request, "expired");
|
|
60
|
+
throw new AskRejectedError("approval request is no longer available");
|
|
61
|
+
}
|
|
62
|
+
if (resolution.status !== "pending") {
|
|
63
|
+
await emitAskPart(ctx.emit, id, request, resolution.status);
|
|
64
|
+
return settle(resolution.status, resolution.reply, ctx.signal);
|
|
65
|
+
}
|
|
66
|
+
await sleepUntil(POLL_INTERVAL_MS, ctx.signal);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function answer(id, reply) {
|
|
70
|
+
return unwrapOrThrow(await answerAsk(pool, id, reply, new Date().toISOString()));
|
|
71
|
+
}
|
|
72
|
+
async function pending() {
|
|
73
|
+
return unwrapOrThrow(await selectPending(pool));
|
|
74
|
+
}
|
|
75
|
+
async function sweepExpired() {
|
|
76
|
+
return unwrapOrThrow(await sweepExpiredRows(pool, new Date().toISOString()));
|
|
77
|
+
}
|
|
78
|
+
return { ask, answer, pending, sweepExpired };
|
|
79
|
+
}
|
|
80
|
+
/** Emit (or reconcile, under the same id) the ask's `data-ask` chat part. */
|
|
81
|
+
function emitAskPart(emit, id, request, status) {
|
|
82
|
+
return emit({
|
|
83
|
+
type: "data-ask",
|
|
84
|
+
data: {
|
|
85
|
+
id,
|
|
86
|
+
title: request.title,
|
|
87
|
+
command: request.command,
|
|
88
|
+
...(request.detail !== undefined ? { detail: request.detail } : {}),
|
|
89
|
+
status,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Resolve an approval or throw the terminal denial the ledger recorded. */
|
|
94
|
+
function settle(status, reply, signal) {
|
|
95
|
+
switch (status) {
|
|
96
|
+
case "resolved":
|
|
97
|
+
// Only `once` / `always` reach `resolved`; guard the JSONB round-trip and
|
|
98
|
+
// default to the narrowest approval if a malformed reply ever slipped in.
|
|
99
|
+
return reply && (reply.kind === "once" || reply.kind === "always") ? reply : { kind: "once" };
|
|
100
|
+
case "rejected":
|
|
101
|
+
throw new AskRejectedError(reply && reply.kind === "reject" ? reply.feedback : undefined);
|
|
102
|
+
case "expired":
|
|
103
|
+
throw new AskRejectedError("approval request expired unanswered");
|
|
104
|
+
case "aborted":
|
|
105
|
+
throwAbort(signal);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Re-raise the turn's cancellation verbatim — `signal.reason` IS the loop's own
|
|
110
|
+
* cancellation, so its existing turn-abort path recognizes it unchanged. The
|
|
111
|
+
* fallback covers a signal aborted with no explicit reason.
|
|
112
|
+
*/
|
|
113
|
+
function throwAbort(signal) {
|
|
114
|
+
throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
115
|
+
}
|
|
116
|
+
/** Delay `ms`, resolving early if `signal` aborts, leaving no dangling timer. */
|
|
117
|
+
function sleepUntil(ms, signal) {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
if (signal.aborted) {
|
|
120
|
+
resolve();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const onAbort = () => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
resolve();
|
|
126
|
+
};
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
signal.removeEventListener("abort", onAbort);
|
|
129
|
+
resolve();
|
|
130
|
+
}, ms);
|
|
131
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
readonly createdAt: string;
|
|
44
|
+
}
|
|
45
|
+
/** Insert a fresh `pending` ask before its decision is awaited. */
|
|
46
|
+
export declare function insertPendingAsk(querier: Querier, row: AskRow): ResultAsync<void, DbError>;
|
|
47
|
+
/**
|
|
48
|
+
* Insert an already-`resolved` ask recording an auto-approval a standing grant
|
|
49
|
+
* short-circuited: no prompt was shown, but the ledger stays a complete audit of
|
|
50
|
+
* every approval-gated action. The recorded reply is `{ kind: "always" }`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function insertGrantedAsk(querier: Querier, row: AskRow, resolvedAt: string): ResultAsync<void, DbError>;
|
|
53
|
+
/** Does a standing grant cover `(analysisId, command)` in this analysis? */
|
|
54
|
+
export declare function selectGrant(querier: Querier, analysisId: string, command: string): ResultAsync<boolean, DbError>;
|
|
55
|
+
/** Read a row's current status + recorded reply. `null` when no such row exists. */
|
|
56
|
+
export declare function selectAskResolution(querier: Querier, id: string): ResultAsync<AskResolution | null, DbError>;
|
|
57
|
+
/**
|
|
58
|
+
* Mark a still-`pending` row `aborted`. Guarded by `WHERE status = 'pending'`, so
|
|
59
|
+
* a row already answered out of band is left untouched.
|
|
60
|
+
*/
|
|
61
|
+
export declare function markAborted(querier: Querier, id: string, resolvedAt: string): ResultAsync<void, DbError>;
|
|
62
|
+
/** Enumerate every unresolved ask, oldest first. */
|
|
63
|
+
export declare function selectPending(querier: Querier): ResultAsync<PendingAsk[], DbError>;
|
|
64
|
+
/** Sweep every `pending` row to `expired`, returning the count swept. */
|
|
65
|
+
export declare function sweepExpired(querier: Querier, expiredAt: string): ResultAsync<number, DbError>;
|
|
66
|
+
/**
|
|
67
|
+
* Record a decision against a still-`pending` row and, for an `always` reply,
|
|
68
|
+
* write the standing grant — both in one transaction, so a crash between them can
|
|
69
|
+
* never record an approval without the grant it promised.
|
|
70
|
+
*
|
|
71
|
+
* The guarded `UPDATE ... WHERE status = 'pending'` makes a duplicate/stale answer
|
|
72
|
+
* a reported no-op: one row updated → `applied`; zero rows → a follow-up read
|
|
73
|
+
* discriminates `already_terminal` (the row exists but left `pending`) from
|
|
74
|
+
* `not_found` (no such id).
|
|
75
|
+
*/
|
|
76
|
+
export declare function answerAsk(pool: Pool, id: string, reply: AskReply, now: string): ResultAsync<AnswerOutcome, DbError>;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
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, status, created_at)
|
|
19
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)`,
|
|
20
|
+
values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, 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, status, reply, created_at, resolved_at)
|
|
34
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'resolved', $7::jsonb, $8, $9)`,
|
|
35
|
+
values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, JSON.stringify({ kind: "always" }), row.createdAt, resolvedAt],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Does a standing grant cover `(analysisId, command)` in this analysis? */
|
|
40
|
+
export function selectGrant(querier, analysisId, command) {
|
|
41
|
+
return tryQuery("asks.selectGrant", async () => {
|
|
42
|
+
const result = await querier.query({
|
|
43
|
+
text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND command = $2`,
|
|
44
|
+
values: [analysisId, command],
|
|
45
|
+
});
|
|
46
|
+
return (result.rowCount ?? 0) > 0;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Read a row's current status + recorded reply. `null` when no such row exists. */
|
|
50
|
+
export function selectAskResolution(querier, id) {
|
|
51
|
+
return tryQuery("asks.selectResolution", async () => {
|
|
52
|
+
const result = await querier.query({
|
|
53
|
+
text: `SELECT status, reply FROM cortex_asks WHERE id = $1`,
|
|
54
|
+
values: [id],
|
|
55
|
+
});
|
|
56
|
+
const row = result.rows[0];
|
|
57
|
+
if (!row)
|
|
58
|
+
return null;
|
|
59
|
+
return { status: row.status, reply: row.reply ?? null };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Mark a still-`pending` row `aborted`. Guarded by `WHERE status = 'pending'`, so
|
|
64
|
+
* a row already answered out of band is left untouched.
|
|
65
|
+
*/
|
|
66
|
+
export function markAborted(querier, id, resolvedAt) {
|
|
67
|
+
return tryMutation("asks.markAborted", async () => {
|
|
68
|
+
await querier.query({
|
|
69
|
+
text: `UPDATE cortex_asks SET status = 'aborted', resolved_at = $2 WHERE id = $1 AND status = 'pending'`,
|
|
70
|
+
values: [id, resolvedAt],
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Enumerate every unresolved ask, oldest first. */
|
|
75
|
+
export function selectPending(querier) {
|
|
76
|
+
return tryQuery("asks.selectPending", async () => {
|
|
77
|
+
const result = await querier.query({
|
|
78
|
+
text: `SELECT id, analysis_id, thread_id, title, command, detail, created_at
|
|
79
|
+
FROM cortex_asks WHERE status = 'pending' ORDER BY created_at ASC`,
|
|
80
|
+
});
|
|
81
|
+
return result.rows.map((row) => ({
|
|
82
|
+
id: row.id,
|
|
83
|
+
analysisId: row.analysis_id,
|
|
84
|
+
threadId: row.thread_id ?? null,
|
|
85
|
+
title: row.title,
|
|
86
|
+
command: row.command,
|
|
87
|
+
detail: row.detail ?? null,
|
|
88
|
+
createdAt: row.created_at,
|
|
89
|
+
}));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Sweep every `pending` row to `expired`, returning the count swept. */
|
|
93
|
+
export function sweepExpired(querier, expiredAt) {
|
|
94
|
+
return tryMutation("asks.sweepExpired", async () => {
|
|
95
|
+
const result = await querier.query({
|
|
96
|
+
text: `UPDATE cortex_asks SET status = 'expired', resolved_at = $1 WHERE status = 'pending'`,
|
|
97
|
+
values: [expiredAt],
|
|
98
|
+
});
|
|
99
|
+
return result.rowCount ?? 0;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Record a decision against a still-`pending` row and, for an `always` reply,
|
|
104
|
+
* write the standing grant — both in one transaction, so a crash between them can
|
|
105
|
+
* never record an approval without the grant it promised.
|
|
106
|
+
*
|
|
107
|
+
* The guarded `UPDATE ... WHERE status = 'pending'` makes a duplicate/stale answer
|
|
108
|
+
* a reported no-op: one row updated → `applied`; zero rows → a follow-up read
|
|
109
|
+
* discriminates `already_terminal` (the row exists but left `pending`) from
|
|
110
|
+
* `not_found` (no such id).
|
|
111
|
+
*/
|
|
112
|
+
export function answerAsk(pool, id, reply, now) {
|
|
113
|
+
const status = reply.kind === "reject" ? "rejected" : "resolved";
|
|
114
|
+
return withTransaction(pool, "asks.answer", (client) => tryMutation("asks.answer:update", () => client.query({
|
|
115
|
+
text: `UPDATE cortex_asks SET status = $1, reply = $2::jsonb, resolved_at = $3
|
|
116
|
+
WHERE id = $4 AND status = 'pending'
|
|
117
|
+
RETURNING analysis_id, command`,
|
|
118
|
+
values: [status, JSON.stringify(reply), now, id],
|
|
119
|
+
})).andThen((updated) => {
|
|
120
|
+
if ((updated.rowCount ?? 0) === 0)
|
|
121
|
+
return discriminateMiss(client, id);
|
|
122
|
+
if (reply.kind !== "always")
|
|
123
|
+
return okAsync("applied");
|
|
124
|
+
const granted = updated.rows[0];
|
|
125
|
+
return tryMutation("asks.answer:grant", async () => {
|
|
126
|
+
await client.query({
|
|
127
|
+
text: `INSERT INTO cortex_ask_grants (analysis_id, command, created_at)
|
|
128
|
+
VALUES ($1, $2, $3) ON CONFLICT (analysis_id, command) DO NOTHING`,
|
|
129
|
+
values: [granted.analysis_id, granted.command, now],
|
|
130
|
+
});
|
|
131
|
+
}).map(() => "applied");
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
/** A zero-row `UPDATE` means the id is absent or already terminal — tell them apart. */
|
|
135
|
+
function discriminateMiss(client, id) {
|
|
136
|
+
return tryQuery("asks.answer:discriminate", async () => {
|
|
137
|
+
const result = await client.query({ text: `SELECT 1 FROM cortex_asks WHERE id = $1`, values: [id] });
|
|
138
|
+
return (result.rowCount ?? 0) > 0 ? "already_terminal" : "not_found";
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -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
|
|
11
|
-
*
|
|
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`,
|
|
41
|
-
* durability seam (`passthroughStep` in chat, `DBOS.runStep` in workflows)
|
|
42
|
-
*
|
|
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
|
|
11
|
-
*
|
|
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