@jxsuite/ai 0.39.5 → 0.41.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/package.json +8 -5
- package/src/catalog/catalog.ts +166 -0
- package/src/catalog/index.ts +17 -0
- package/src/catalog/mcp.ts +80 -0
- package/src/harness/index.ts +2 -1
- package/src/harness/policy.ts +2 -0
- package/src/harness/run.ts +39 -5
- package/src/harness/types.ts +44 -1
- package/src/jx-tools/doc-tools.ts +517 -0
- package/src/jx-tools/entries.ts +129 -0
- package/src/jx-tools/facts.ts +96 -0
- package/src/jx-tools/index.ts +29 -0
- package/src/jx-tools/types.ts +118 -0
- package/src/tools.ts +201 -13
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each of the Jx assistant's tools does to the world (specs/ai.md §3.10), as data: the four
|
|
3
|
+
* behaviour hints, and the one effect a host's undo and approvals are decided by.
|
|
4
|
+
*
|
|
5
|
+
* Keyed by tool name rather than carried by each tool's module so one table can be read and
|
|
6
|
+
* reviewed whole, and so a tool that has none is a test failure rather than a silent default.
|
|
7
|
+
*
|
|
8
|
+
* @module @jxsuite/ai/jx-tools
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ToolAnnotations } from "../tools.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where a tool's change lands: nowhere (`none`), in the host's view (`ui`), in the open document
|
|
16
|
+
* (`document`, which its history can undo), in the project's configuration (`project`, likewise),
|
|
17
|
+
* or straight on disk (`disk`, which nothing undoes).
|
|
18
|
+
*/
|
|
19
|
+
export type ToolEffect = "none" | "ui" | "document" | "project" | "disk";
|
|
20
|
+
|
|
21
|
+
/** One tool's facts. */
|
|
22
|
+
export interface ToolFacts {
|
|
23
|
+
readonly annotations: ToolAnnotations;
|
|
24
|
+
readonly effect: ToolEffect;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Facts from the hints in the order MCP names them: read-only, destructive, idempotent, open-world. */
|
|
28
|
+
function facts(
|
|
29
|
+
readOnlyHint: boolean,
|
|
30
|
+
destructiveHint: boolean,
|
|
31
|
+
idempotentHint: boolean,
|
|
32
|
+
openWorldHint: boolean,
|
|
33
|
+
effect: ToolEffect,
|
|
34
|
+
): ToolFacts {
|
|
35
|
+
return { annotations: { destructiveHint, idempotentHint, openWorldHint, readOnlyHint }, effect };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const INTERACTION = facts(true, false, false, false, "none");
|
|
39
|
+
const READ = facts(true, false, true, false, "none");
|
|
40
|
+
const DISK_OVERWRITE = facts(false, true, true, false, "disk");
|
|
41
|
+
const BOOTSTRAP = facts(false, false, false, true, "disk");
|
|
42
|
+
const TREE_IDEMPOTENT = facts(false, false, true, false, "document");
|
|
43
|
+
const TREE = facts(false, false, false, false, "document");
|
|
44
|
+
const UI = facts(false, false, true, false, "ui");
|
|
45
|
+
const PROJECT = facts(false, false, true, false, "project");
|
|
46
|
+
|
|
47
|
+
/** Every tool the Jx assistant offers, by name. */
|
|
48
|
+
export const JX_TOOL_FACTS = {
|
|
49
|
+
add_child: TREE,
|
|
50
|
+
/* Creates a file under a name the arguments choose, and refuses one that exists: never an
|
|
51
|
+
overwrite, but not a call to repeat either. */
|
|
52
|
+
add_new: facts(false, false, false, false, "disk"),
|
|
53
|
+
add_project_locale: PROJECT,
|
|
54
|
+
add_state: TREE_IDEMPOTENT,
|
|
55
|
+
ask_user: INTERACTION,
|
|
56
|
+
check_accessibility: READ,
|
|
57
|
+
check_popovers: READ,
|
|
58
|
+
create_component: DISK_OVERWRITE,
|
|
59
|
+
create_page: DISK_OVERWRITE,
|
|
60
|
+
create_project: BOOTSTRAP,
|
|
61
|
+
delete_node: facts(false, true, false, false, "document"),
|
|
62
|
+
disable_extension: PROJECT,
|
|
63
|
+
duplicate_node: TREE,
|
|
64
|
+
// Installs a package, which reaches the registry and which undo cannot take back.
|
|
65
|
+
enable_extension: facts(false, true, true, true, "disk"),
|
|
66
|
+
import_site: BOOTSTRAP,
|
|
67
|
+
list_files: READ,
|
|
68
|
+
list_starters: READ,
|
|
69
|
+
move_node: TREE,
|
|
70
|
+
open_document: UI,
|
|
71
|
+
read_document: READ,
|
|
72
|
+
read_file: READ,
|
|
73
|
+
search_files: READ,
|
|
74
|
+
select_node: UI,
|
|
75
|
+
set_canvas_mode: UI,
|
|
76
|
+
set_property: TREE_IDEMPOTENT,
|
|
77
|
+
set_style: TREE_IDEMPOTENT,
|
|
78
|
+
set_text: TREE_IDEMPOTENT,
|
|
79
|
+
update_state: TREE_IDEMPOTENT,
|
|
80
|
+
validate_redirects: READ,
|
|
81
|
+
write_file: DISK_OVERWRITE,
|
|
82
|
+
} satisfies Readonly<Record<string, ToolFacts>>;
|
|
83
|
+
|
|
84
|
+
/** The name of a tool the Jx assistant offers. */
|
|
85
|
+
export type JxToolName = keyof typeof JX_TOOL_FACTS;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The facts of the tool named `name`, or undefined for a name the table does not know.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} name
|
|
91
|
+
* @returns {ToolFacts | undefined}
|
|
92
|
+
*/
|
|
93
|
+
export function toolFacts(name: string): ToolFacts | undefined {
|
|
94
|
+
/* SAFETY: `Object.hasOwn` has just proven `name` is one of the table's own keys. */
|
|
95
|
+
return Object.hasOwn(JX_TOOL_FACTS, name) ? JX_TOOL_FACTS[name as JxToolName] : undefined;
|
|
96
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@jxsuite/ai/jx-tools`: the assistant's document tools, portable to any host (specs/ai.md §3.9).
|
|
3
|
+
*
|
|
4
|
+
* Each tree tool is a pure producer of `@jxsuite/schema/doc-ops` ops, and a {@link DocumentHost}
|
|
5
|
+
* applies them in its own transaction: Studio's tab history, a file on disk, a room's document.
|
|
6
|
+
* Worker-safe: the module graph is this directory, `../tools.ts` and two `@jxsuite/schema` leaves.
|
|
7
|
+
*
|
|
8
|
+
* @module @jxsuite/ai/jx-tools
|
|
9
|
+
* @license MIT
|
|
10
|
+
* @docs extending/embedding/assistant-harness
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { coerceIndex } from "@jxsuite/schema/doc-ops";
|
|
14
|
+
export { DOC_TOOLS } from "./doc-tools.ts";
|
|
15
|
+
export { docToolEntries, FROZEN_SENTENCE } from "./entries.ts";
|
|
16
|
+
export { JX_TOOL_FACTS, toolFacts } from "./facts.ts";
|
|
17
|
+
export type { JxToolName, ToolEffect, ToolFacts } from "./facts.ts";
|
|
18
|
+
export type {
|
|
19
|
+
DocEdit,
|
|
20
|
+
DocTool,
|
|
21
|
+
DocToolName,
|
|
22
|
+
DocumentHost,
|
|
23
|
+
DocumentRef,
|
|
24
|
+
EditMeta,
|
|
25
|
+
EditReceipt,
|
|
26
|
+
SelectionEffect,
|
|
27
|
+
Undoability,
|
|
28
|
+
WriteReporter,
|
|
29
|
+
} from "./types.ts";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contracts of the portable document tools (specs/ai.md §3.9): what a producer answers, and
|
|
3
|
+
* what a host provides to run one.
|
|
4
|
+
*
|
|
5
|
+
* @module @jxsuite/ai/jx-tools
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { JxDocOp } from "@jxsuite/schema/doc-ops";
|
|
10
|
+
import type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
|
|
11
|
+
import type { Actor, JSONSchema, ToolArguments, ToolContext, ToolResult } from "../tools.ts";
|
|
12
|
+
|
|
13
|
+
/** How an edit carries the host's selection across it. */
|
|
14
|
+
export type SelectionEffect =
|
|
15
|
+
/** The selection is untouched. */
|
|
16
|
+
| { readonly kind: "keep" }
|
|
17
|
+
/** Every selected path at or under one of `under` is dropped: what it named was replaced. */
|
|
18
|
+
| { readonly kind: "prune"; readonly under: readonly JxPath[] }
|
|
19
|
+
/** A selection whose primary path is `from` moves to `to`, the moved node's post-edit path. */
|
|
20
|
+
| { readonly kind: "follow"; readonly from: JxPath; readonly to: JxPath };
|
|
21
|
+
|
|
22
|
+
/** What a producer answers: the ops that make the edit, or why it refuses. */
|
|
23
|
+
export type DocEdit =
|
|
24
|
+
| {
|
|
25
|
+
readonly ok: true;
|
|
26
|
+
readonly ops: readonly JxDocOp[];
|
|
27
|
+
/** The sentence the model reads on success, naming where the edit landed. */
|
|
28
|
+
readonly summary: string;
|
|
29
|
+
readonly selection: SelectionEffect;
|
|
30
|
+
}
|
|
31
|
+
| { readonly ok: false; readonly error: string };
|
|
32
|
+
|
|
33
|
+
/** The tree tools a producer exists for. */
|
|
34
|
+
export type DocToolName =
|
|
35
|
+
| "set_property"
|
|
36
|
+
| "set_style"
|
|
37
|
+
| "set_text"
|
|
38
|
+
| "add_child"
|
|
39
|
+
| "move_node"
|
|
40
|
+
| "add_state"
|
|
41
|
+
| "update_state";
|
|
42
|
+
|
|
43
|
+
/** A tree tool as data: what the model is told, and the pure function that plans its edit. */
|
|
44
|
+
export interface DocTool {
|
|
45
|
+
readonly name: DocToolName;
|
|
46
|
+
/** A person-facing name for the tool. */
|
|
47
|
+
readonly title: string;
|
|
48
|
+
readonly description: string;
|
|
49
|
+
readonly parameters: JSONSchema;
|
|
50
|
+
/**
|
|
51
|
+
* Pure: reads `doc`, never writes it, and answers the ops the edit is. A refusal is an answer,
|
|
52
|
+
* not a throw, so a host can run it once to check and again inside its transaction.
|
|
53
|
+
*/
|
|
54
|
+
readonly edit: (doc: Readonly<JxMutableNode>, args: ToolArguments) => DocEdit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A document a host has resolved for one call. Compared by identity within that call. */
|
|
58
|
+
export interface DocumentRef {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
/** Its project-relative path, or null for one never saved. */
|
|
61
|
+
readonly path: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Who is editing, for the host's history and attribution. */
|
|
65
|
+
export interface EditMeta {
|
|
66
|
+
readonly actor: Actor;
|
|
67
|
+
readonly callId: string;
|
|
68
|
+
readonly tool: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a host's edit did. */
|
|
72
|
+
export type EditReceipt =
|
|
73
|
+
| { readonly applied: true; readonly changed: boolean }
|
|
74
|
+
| {
|
|
75
|
+
readonly applied: false;
|
|
76
|
+
/**
|
|
77
|
+
* `frozen`: the document refuses structural edits for now (a collaborator holds its source).
|
|
78
|
+
* `refused`: the producer refused against the document as it stood at commit. `invalid`: a
|
|
79
|
+
* host that cannot undo refused an edit that would leave the document invalid. `not-open`:
|
|
80
|
+
* the document is no longer the host's.
|
|
81
|
+
*/
|
|
82
|
+
readonly reason: "frozen" | "refused" | "invalid" | "not-open";
|
|
83
|
+
readonly message: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether a host can take an edit back. A host that can (`document`) commits and then reports what
|
|
88
|
+
* the edit broke; one that cannot (`none`) must validate before it commits.
|
|
89
|
+
*/
|
|
90
|
+
export type Undoability = "document" | "none";
|
|
91
|
+
|
|
92
|
+
/** Where the tree tools read and write a document. */
|
|
93
|
+
export interface DocumentHost {
|
|
94
|
+
readonly undoable: Undoability;
|
|
95
|
+
/** The document this call addresses, or the sentence refusing the call when there is none. */
|
|
96
|
+
readonly resolve: (ctx: ToolContext) => DocumentRef | string;
|
|
97
|
+
/** The document as it stands: plain data, never a reactive proxy. */
|
|
98
|
+
readonly snapshot: (ref: DocumentRef) => Readonly<JxMutableNode>;
|
|
99
|
+
/**
|
|
100
|
+
* Read the document, call `produce`, and apply its ops synchronously, with no await between them,
|
|
101
|
+
* so nothing can change the document between the plan and the write. A returned promise may
|
|
102
|
+
* settle later, for persistence only.
|
|
103
|
+
*/
|
|
104
|
+
readonly edit: (
|
|
105
|
+
ref: DocumentRef,
|
|
106
|
+
produce: (doc: Readonly<JxMutableNode>) => DocEdit,
|
|
107
|
+
meta: EditMeta,
|
|
108
|
+
) => EditReceipt | Promise<EditReceipt>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What brackets a write: the document's standing before it (`Before`, whatever the host's verdict
|
|
113
|
+
* compares), and the verdict after it.
|
|
114
|
+
*/
|
|
115
|
+
export interface WriteReporter<Before> {
|
|
116
|
+
readonly before: (ref: DocumentRef) => Promise<Before>;
|
|
117
|
+
readonly after: (ref: DocumentRef, before: Before, summary: string) => Promise<ToolResult>;
|
|
118
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -61,10 +61,70 @@ export interface JSONSchema {
|
|
|
61
61
|
*/
|
|
62
62
|
export type ToolArguments = Readonly<Record<string, JsonValue | undefined>>;
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* What a tool does to the world, as MCP's four behaviour hints name it (specs/ai.md §3.10). Every
|
|
66
|
+
* hint is stated, never left to a default: a host decides approvals and an MCP client its warnings
|
|
67
|
+
* from them.
|
|
68
|
+
*/
|
|
69
|
+
export interface ToolAnnotations {
|
|
70
|
+
/** It changes nothing. */
|
|
71
|
+
readonly readOnlyHint: boolean;
|
|
72
|
+
/** It may destroy or overwrite something a person made. */
|
|
73
|
+
readonly destructiveHint: boolean;
|
|
74
|
+
/** Calling it twice with the same arguments does what calling it once did. */
|
|
75
|
+
readonly idempotentHint: boolean;
|
|
76
|
+
/** It reaches outside the project (the network, an installer). */
|
|
77
|
+
readonly openWorldHint: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Why a call needs the author's approval before it runs. */
|
|
81
|
+
export type ApprovalReason = "destructive" | "not-undoable" | "open-world";
|
|
82
|
+
|
|
83
|
+
/** What a call asks a person, before any of the tool's own code runs (specs/ai.md §3.11). */
|
|
84
|
+
export type InteractionRequest =
|
|
85
|
+
| {
|
|
86
|
+
readonly kind: "question";
|
|
87
|
+
readonly question: string;
|
|
88
|
+
/** Short answers offered as choices; a person may always answer in their own words. */
|
|
89
|
+
readonly options: readonly string[];
|
|
90
|
+
/** One line of why the question is being asked. */
|
|
91
|
+
readonly context: string;
|
|
92
|
+
}
|
|
93
|
+
| {
|
|
94
|
+
readonly kind: "approval";
|
|
95
|
+
readonly tool: string;
|
|
96
|
+
readonly title: string;
|
|
97
|
+
/** What the call would do, for the person deciding. */
|
|
98
|
+
readonly summary: string;
|
|
99
|
+
readonly reason: ApprovalReason;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** A person's answer to an {@link InteractionRequest}. */
|
|
103
|
+
export type InteractionResponse =
|
|
104
|
+
/** `answer: null` with `skipped` is "you decide"; with neither, the question went unanswered. */
|
|
105
|
+
| { readonly kind: "question"; readonly answer: string | null; readonly skipped: boolean }
|
|
106
|
+
| { readonly kind: "approval"; readonly approved: boolean };
|
|
107
|
+
|
|
108
|
+
/** Whether a call may run: as it is, never, or once the author approves it. */
|
|
109
|
+
export type ApprovalDecision =
|
|
110
|
+
| { readonly kind: "proceed" }
|
|
111
|
+
| { readonly kind: "deny"; readonly message: string }
|
|
112
|
+
| { readonly kind: "confirm"; readonly reason: ApprovalReason; readonly summary: string };
|
|
113
|
+
|
|
114
|
+
/** Decides, per call, whether it needs the author's approval before it runs. */
|
|
115
|
+
export type ApprovalPolicy = (definition: ToolDefinition, args: ToolArguments) => ApprovalDecision;
|
|
116
|
+
|
|
117
|
+
/** The policy that approves every call: what a host with no approvals runs. */
|
|
118
|
+
export const proceedAlways: ApprovalPolicy = () => ({ kind: "proceed" });
|
|
119
|
+
|
|
64
120
|
export interface ToolDefinition {
|
|
65
121
|
name: string;
|
|
122
|
+
/** A person-facing name for the tool. A catalog requires it. */
|
|
123
|
+
title?: string;
|
|
66
124
|
description: string;
|
|
67
125
|
parameters: JSONSchema;
|
|
126
|
+
/** Its behaviour hints. A catalog requires them. */
|
|
127
|
+
annotations?: ToolAnnotations;
|
|
68
128
|
strict: boolean;
|
|
69
129
|
llmStrict: boolean;
|
|
70
130
|
/**
|
|
@@ -72,6 +132,14 @@ export interface ToolDefinition {
|
|
|
72
132
|
* made only such calls does not spend the turn's work budget.
|
|
73
133
|
*/
|
|
74
134
|
interactive?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* What the call asks a person, or the sentence refusing the call (`refusal`). A tool that
|
|
137
|
+
* declares it is never executed by the invoker: the person's answer goes to `settle`, so no tool
|
|
138
|
+
* body awaits a person.
|
|
139
|
+
*/
|
|
140
|
+
interaction?: (args: ToolArguments) => InteractionRequest | { readonly refusal: string };
|
|
141
|
+
/** The result of a call to an `interaction` tool, given the person's answer. */
|
|
142
|
+
settle?: (args: ToolArguments, response: InteractionResponse) => ToolResult;
|
|
75
143
|
execute: (args: ToolArguments, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
|
|
76
144
|
}
|
|
77
145
|
|
|
@@ -129,6 +197,10 @@ export function toolError(error: string): ToolResult {
|
|
|
129
197
|
* (default false). Only enable if the parameter schema is OpenAI-strict-compliant
|
|
130
198
|
* (additionalProperties:false everywhere, all properties in `required`, every property typed).
|
|
131
199
|
* @param {boolean} [opts.interactive] - The call suspends the turn on a person (`ask_user`)
|
|
200
|
+
* @param {string} [opts.title] - A person-facing name, which a catalog requires
|
|
201
|
+
* @param {ToolAnnotations} [opts.annotations] - Its behaviour hints, which a catalog requires
|
|
202
|
+
* @param {ToolDefinition["interaction"]} [opts.interaction] - What the call asks a person
|
|
203
|
+
* @param {ToolDefinition["settle"]} [opts.settle] - The result, given the person's answer
|
|
132
204
|
* @param {(args: ToolArguments, ctx: ToolContext) => Promise<ToolResult> | ToolResult} opts.execute -
|
|
133
205
|
* The tool implementation, handed the call's context
|
|
134
206
|
* @returns {ToolDefinition}
|
|
@@ -140,6 +212,10 @@ export function createToolDefinition({
|
|
|
140
212
|
strict = true,
|
|
141
213
|
llmStrict = false,
|
|
142
214
|
interactive,
|
|
215
|
+
title,
|
|
216
|
+
annotations,
|
|
217
|
+
interaction,
|
|
218
|
+
settle,
|
|
143
219
|
execute,
|
|
144
220
|
}: {
|
|
145
221
|
name: string;
|
|
@@ -148,6 +224,10 @@ export function createToolDefinition({
|
|
|
148
224
|
strict?: boolean;
|
|
149
225
|
llmStrict?: boolean;
|
|
150
226
|
interactive?: boolean;
|
|
227
|
+
title?: string;
|
|
228
|
+
annotations?: ToolAnnotations;
|
|
229
|
+
interaction?: ToolDefinition["interaction"];
|
|
230
|
+
settle?: ToolDefinition["settle"];
|
|
151
231
|
execute: (args: ToolArguments, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
|
|
152
232
|
}): ToolDefinition {
|
|
153
233
|
const definition: ToolDefinition = { name, description, parameters, strict, llmStrict, execute };
|
|
@@ -155,6 +235,18 @@ export function createToolDefinition({
|
|
|
155
235
|
if (interactive !== undefined) {
|
|
156
236
|
definition.interactive = interactive;
|
|
157
237
|
}
|
|
238
|
+
if (title !== undefined) {
|
|
239
|
+
definition.title = title;
|
|
240
|
+
}
|
|
241
|
+
if (annotations !== undefined) {
|
|
242
|
+
definition.annotations = annotations;
|
|
243
|
+
}
|
|
244
|
+
if (interaction !== undefined) {
|
|
245
|
+
definition.interaction = interaction;
|
|
246
|
+
}
|
|
247
|
+
if (settle !== undefined) {
|
|
248
|
+
definition.settle = settle;
|
|
249
|
+
}
|
|
158
250
|
return definition;
|
|
159
251
|
}
|
|
160
252
|
|
|
@@ -450,13 +542,25 @@ export interface ToolCall {
|
|
|
450
542
|
readonly argumentsText: string;
|
|
451
543
|
}
|
|
452
544
|
|
|
453
|
-
/**
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
545
|
+
/**
|
|
546
|
+
* What invoking a call produced: a result (`executed` is false when the tool's body never ran), or
|
|
547
|
+
* the question a person must answer before it can be invoked again with their response.
|
|
548
|
+
*/
|
|
549
|
+
export type InvokeOutcome =
|
|
550
|
+
| { readonly status: "done"; readonly result: ToolResult; readonly executed: boolean }
|
|
551
|
+
| { readonly status: "needs_input"; readonly request: InteractionRequest };
|
|
552
|
+
|
|
553
|
+
/** How a call is invoked: the host's approval policy, and a person's answer on a second invoke. */
|
|
554
|
+
export interface InvokeOptions {
|
|
555
|
+
/** Defaults to {@link proceedAlways}. */
|
|
556
|
+
readonly approval?: ApprovalPolicy;
|
|
557
|
+
/** The answer to the request a first invoke returned as `needs_input`. */
|
|
558
|
+
readonly response?: InteractionResponse;
|
|
458
559
|
}
|
|
459
560
|
|
|
561
|
+
/** What a declined call answers. */
|
|
562
|
+
export const DECLINED_SENTENCE = "The author declined this call.";
|
|
563
|
+
|
|
460
564
|
/**
|
|
461
565
|
* A result as JSON would carry it: the serialized bytes are unchanged, and `data` becomes plain
|
|
462
566
|
* JSON, so the result survives any transport that serializes it.
|
|
@@ -562,31 +666,45 @@ function parseArguments(text: string): ToolArguments {
|
|
|
562
666
|
}
|
|
563
667
|
|
|
564
668
|
/**
|
|
565
|
-
* Run one model call through a registry: parse its arguments,
|
|
566
|
-
*
|
|
669
|
+
* Run one model call through a registry, in order: parse its arguments, put an interaction tool's
|
|
670
|
+
* question to a person or settle it with their answer, ask the approval policy, execute it with its
|
|
671
|
+
* context, and return the result as JSON would carry it (specs/ai.md §3.11). Never throws: a
|
|
672
|
+
* failure is a result the model reads.
|
|
567
673
|
*
|
|
568
674
|
* Arguments that do not parse answer `Failed to parse arguments: <reason>`, with the same prefix
|
|
569
|
-
* for a non-object, so the model reads both as one kind of mistake.
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
675
|
+
* for a non-object, so the model reads both as one kind of mistake. A call that needs a person
|
|
676
|
+
* answers `needs_input` before any tool code runs; the caller invokes it again with the response.
|
|
677
|
+
* The registry keeps its own texts for an unknown tool, failed validation, a gate and a tool that
|
|
678
|
+
* threw. A registry that itself throws is answered with the parse prefix too, as the agent loop
|
|
679
|
+
* always answered it, and a result JSON cannot carry is answered as one that cannot be serialized.
|
|
573
680
|
*
|
|
574
681
|
* @param {ToolRegistry} registry
|
|
575
682
|
* @param {ToolCall} call
|
|
576
683
|
* @param {ToolContext} ctx - The call's context, forwarded to the tool unchanged
|
|
684
|
+
* @param {InvokeOptions} [options]
|
|
577
685
|
* @returns {Promise<InvokeOutcome>}
|
|
578
686
|
*/
|
|
579
687
|
export async function invokeTool(
|
|
580
688
|
registry: ToolRegistry,
|
|
581
689
|
call: ToolCall,
|
|
582
690
|
ctx: ToolContext,
|
|
691
|
+
{ approval = proceedAlways, response }: InvokeOptions = {},
|
|
583
692
|
): Promise<InvokeOutcome> {
|
|
584
693
|
let executed = false;
|
|
585
694
|
let result: ToolResult;
|
|
586
695
|
try {
|
|
587
696
|
const args = parseArguments(call.argumentsText);
|
|
588
|
-
|
|
589
|
-
|
|
697
|
+
const definition = registry.getDefinition(call.name);
|
|
698
|
+
const step = definition ? beforeExecute(registry, definition, args, approval, response) : null;
|
|
699
|
+
if (step?.status === "needs_input") {
|
|
700
|
+
return step;
|
|
701
|
+
}
|
|
702
|
+
if (step) {
|
|
703
|
+
({ result } = step);
|
|
704
|
+
} else {
|
|
705
|
+
executed = true;
|
|
706
|
+
result = await registry.execute(call.name, args, ctx);
|
|
707
|
+
}
|
|
590
708
|
} catch (error) {
|
|
591
709
|
result = toolError(`Failed to parse arguments: ${messageOf(error)}`);
|
|
592
710
|
}
|
|
@@ -605,3 +723,73 @@ export async function invokeTool(
|
|
|
605
723
|
};
|
|
606
724
|
}
|
|
607
725
|
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The steps before a body runs: an interaction tool is asked or settled and never executed, and any
|
|
729
|
+
* other tool is approved, denied, or put to the author. Null means execute.
|
|
730
|
+
*/
|
|
731
|
+
function beforeExecute(
|
|
732
|
+
registry: ToolRegistry,
|
|
733
|
+
definition: ToolDefinition,
|
|
734
|
+
args: ToolArguments,
|
|
735
|
+
approval: ApprovalPolicy,
|
|
736
|
+
response: InteractionResponse | undefined,
|
|
737
|
+
): InvokeOutcome | null {
|
|
738
|
+
const { interaction, settle } = definition;
|
|
739
|
+
if (interaction && settle) {
|
|
740
|
+
const validation = registry.validate(definition.name, args);
|
|
741
|
+
if (!validation.valid) {
|
|
742
|
+
return refused(`Validation failed: ${(validation.errors || []).join("; ")}`);
|
|
743
|
+
}
|
|
744
|
+
if (response) {
|
|
745
|
+
return {
|
|
746
|
+
status: "done",
|
|
747
|
+
result: settleSafely(definition.name, settle, args, response),
|
|
748
|
+
executed: false,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
const request = interaction(args);
|
|
752
|
+
return "refusal" in request ? refused(request.refusal) : { status: "needs_input", request };
|
|
753
|
+
}
|
|
754
|
+
const decision = approval(definition, args);
|
|
755
|
+
if (decision.kind === "deny") {
|
|
756
|
+
return refused(decision.message);
|
|
757
|
+
}
|
|
758
|
+
if (decision.kind === "confirm") {
|
|
759
|
+
if (!response) {
|
|
760
|
+
return {
|
|
761
|
+
status: "needs_input",
|
|
762
|
+
request: {
|
|
763
|
+
kind: "approval",
|
|
764
|
+
reason: decision.reason,
|
|
765
|
+
summary: decision.summary,
|
|
766
|
+
title: definition.title ?? definition.name,
|
|
767
|
+
tool: definition.name,
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
if (response.kind !== "approval" || !response.approved) {
|
|
772
|
+
return refused(DECLINED_SENTENCE);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** A result no body produced. */
|
|
779
|
+
function refused(error: string): InvokeOutcome {
|
|
780
|
+
return { status: "done", result: toolError(error), executed: false };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** `settle`'s result, or the execution error a registry would have answered for a throw. */
|
|
784
|
+
function settleSafely(
|
|
785
|
+
name: string,
|
|
786
|
+
settle: NonNullable<ToolDefinition["settle"]>,
|
|
787
|
+
args: ToolArguments,
|
|
788
|
+
response: InteractionResponse,
|
|
789
|
+
): ToolResult {
|
|
790
|
+
try {
|
|
791
|
+
return settle(args, response);
|
|
792
|
+
} catch (error) {
|
|
793
|
+
return toolError(`Tool "${name}" execution error: ${messageOf(error)}`);
|
|
794
|
+
}
|
|
795
|
+
}
|