@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.
- package/dist/agents/conversation-agent.d.ts +8 -0
- package/dist/agents/conversation-agent.js +4 -1
- 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/state/init.js +65 -0
- package/dist/tools/__fixtures__/tool-context.js +3 -0
- package/dist/tools/approval/contract.d.ts +88 -0
- package/dist/tools/approval/contract.js +41 -0
- package/dist/tools/approval/gateway.d.ts +39 -0
- package/dist/tools/approval/gateway.js +135 -0
- package/dist/tools/approval/queries.d.ts +79 -0
- package/dist/tools/approval/queries.js +155 -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
|
@@ -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
|
-
*
|
|
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")
|
package/dist/state/init.js
CHANGED
|
@@ -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
|
|
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