@opencxh/domain 1.264.4 → 1.265.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.
@@ -45,7 +45,20 @@ export interface ToolCatalogEntry {
45
45
  surface?: "interactive";
46
46
  /** What it leaves for a human to finish. See {@link AiToolDescriptor.produces}. */
47
47
  produces?: "draft";
48
+ /** Whether it can be taken back. See {@link AiToolDescriptor.reversible}. */
49
+ reversible?: "self" | "none" | "idempotent";
48
50
  }
51
+ /**
52
+ * How an action can be taken back. Reads the fail-safe default in one place, so no caller has to
53
+ * remember that absent means irreversible.
54
+ *
55
+ * A **read** is `"idempotent"` by construction: it changed nothing, so there is nothing to
56
+ * reverse and running it again is free. Only writes have a real answer here.
57
+ */
58
+ export declare function toolReversible(tool: {
59
+ access?: "read" | "write";
60
+ reversible?: "self" | "none" | "idempotent";
61
+ }): "self" | "none" | "idempotent";
49
62
  /**
50
63
  * Can this tool reach outside the building? A read never does; an `effect: "internal"` write says
51
64
  * so itself. Everything else — including a tool that declares neither — counts as outward, so a
@@ -199,6 +212,41 @@ export interface AiToolDescriptor extends AiTool {
199
212
  * whichever one its description matched. See `tools/mode.ts` in the ai app.
200
213
  */
201
214
  produces?: "draft";
215
+ /**
216
+ * Can this be taken back, and how?
217
+ *
218
+ * The question a rule engine forces: one badly written rule does not touch one conversation
219
+ * but forty, and "undo this run" then has to say honestly what it can and cannot put back.
220
+ * This generalises `DocumentOperation.undoable`, which already carries the idea for document
221
+ * edits — *"'I changed it, undo if you disagree' is a promise that has to hold."*
222
+ *
223
+ * - `"self"` — **the same call with the previous value undoes it** (status, assignee, an
224
+ * attribute, an SLA profile). The executor records what was there before; undo replays that
225
+ * through this same tool, and nothing else has to know anything.
226
+ *
227
+ * Deliberately narrow. `add_tag` is reversible too, but by a *different* tool
228
+ * (`remove_tag`), and expressing that needs a second field naming the counter-action. That
229
+ * field can arrive the day an undo actually wants it; until then such a tool stays on the
230
+ * default and simply is not offered as undoable. Under-promising is the safe direction.
231
+ * - `"none"` — irreversible. A sent mail, a webhook, a row at a third party. The gate belongs
232
+ * *before* it; afterwards there is nothing to do but send a second message, and that is a
233
+ * human's decision, not an undo.
234
+ * - `"idempotent"` — running it again is the repair (a sync landing). Replay, never reverse.
235
+ *
236
+ * **Absent means `"none"`.** The same fail-safe-by-absence as {@link AiToolDescriptor.effect},
237
+ * and for the same reason. Guessing wrong this way costs a button that stays grey; the other
238
+ * way it costs a promise that breaks.
239
+ *
240
+ * **What `"self"` demands before you declare it, and why nothing does yet.** Replaying the
241
+ * previous value only works if the tool can express *every* previous value — including "there
242
+ * was nothing here". Today several cannot: `update_interaction` does
243
+ * `if (assignedUserId) patch.assignedUserId = …`, so an empty string is falsy and an
244
+ * assignment cannot be taken back; the same shape as the older scar where `null` never
245
+ * survives the invoke transport and clearing a field needs an explicit clear-list. So a tool
246
+ * becomes `"self"` when its clear-semantics are fixed, not when someone decides it feels
247
+ * reversible — which is exactly why the default is the careful one.
248
+ */
249
+ reversible?: "self" | "none" | "idempotent";
202
250
  }
203
251
  /**
204
252
  * Body of `POST /provider/ai-tools/invoke`. Replaces five inline structural types (the caller in
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { Agent } from '../entities/agent/types';
2
+ import { AssistantMode } from '../entities/ai-message/types';
3
+ /**
4
+ * May this tool be offered to the model this turn — and if it writes, may it run?
5
+ *
6
+ * **One function for the layers that used to be five files.** "Why could it not do that" was
7
+ * answered by `collectTools` (the profile filter), `buildToolRuntime` (the unattended filter),
8
+ * `resolveToolset` (procedure and mode narrowing), `gate.ts` (the suggest gate) and
9
+ * `mode.ts` — each with its own copy of "what counts as a write" and its own idea of the order.
10
+ * They had already drifted once: only the autonomous path could hold a write, only the
11
+ * interactive one could pin a tool.
12
+ *
13
+ * Pure and per-tool on purpose: every one of these rules is about a single tool, and a reader
14
+ * chasing a missing tool should be able to run this in their head.
15
+ *
16
+ * What stays outside: the **scope gate** at the receiving app
17
+ * (`platform-api/ai-tool/registerAiToolProvider`, which authorises `<kind>:<id>` against the
18
+ * acting identity). That belongs to the receiver, not to the caller, and it is the one layer a
19
+ * caller must not be able to answer for itself.
20
+ */
21
+ export type ToolVerdict =
22
+ /** Not offered to the model at all. */
23
+ "hidden"
24
+ /** Offered, and it runs. */
25
+ | "free"
26
+ /** Offered, but a call is captured for approval instead of executed. */
27
+ | "held";
28
+ /** What a verdict is made of. The declared half of a tool plus where it came from. */
29
+ export interface ToolFacts {
30
+ name: string;
31
+ access?: "read" | "write";
32
+ effect?: "internal" | "outward";
33
+ surface?: "interactive";
34
+ produces?: "draft";
35
+ /** Runs on the acting user's own connection (a personal MCP server). */
36
+ personal?: boolean;
37
+ }
38
+ export interface ToolVerdictContext {
39
+ /**
40
+ * Tool names the profile switched on. **`undefined` means no filter** — the catalog asks that
41
+ * way, and it is the difference between "this profile enabled nothing" (an empty set, nothing
42
+ * passes) and "do not filter".
43
+ */
44
+ allowed?: ReadonlySet<string>;
45
+ /** Whether the acting user's own connections take part. Default `"off"`. */
46
+ personalTools?: "off" | "allow";
47
+ /** A procedure's narrowing. Narrows only — a name not already allowed does not come back. */
48
+ narrow?: ReadonlySet<string>;
49
+ /**
50
+ * Is a person watching this turn?
51
+ *
52
+ * `false` hides personal tools and anything delivering to a client surface: an unattended run
53
+ * has nobody to deliver to, and a tool reporting success for something that lands nowhere is
54
+ * worse than a missing tool.
55
+ */
56
+ attended: boolean;
57
+ /** Interactive only: what the user said this turn is for. Narrows. */
58
+ mode?: AssistantMode;
59
+ /** A read lane: nothing that writes, whatever else it declares. */
60
+ readOnly?: boolean;
61
+ /** Is the approval gate on for this turn? Then writes are held instead of run. */
62
+ gate?: boolean;
63
+ /**
64
+ * The agent's mandate, when an agent runs this turn. It may call a tool a read that declares
65
+ * itself a write, and vice versa — an admin's explicit choice about this colleague.
66
+ */
67
+ policy?: Agent["toolPolicy"];
68
+ }
69
+ /**
70
+ * Does this tool mutate anything? **The mandate decides, then the tool, then the safe default.**
71
+ *
72
+ * `policy` → `access` → "write"
73
+ *
74
+ * The agent's policy sits deliberately on top: that is an admin's explicit statement about what
75
+ * this colleague may do unsupervised, and it may be stricter than what the tool says about
76
+ * itself. Without `access`, "unknown = write" applies and a read tool would ask for needless
77
+ * approval — which is why every descriptor should declare it.
78
+ */
79
+ export declare function isWriteTool(tool: ToolFacts, policy?: Agent["toolPolicy"]): boolean;
80
+ export declare function toolVerdict(tool: ToolFacts, ctx: ToolVerdictContext): ToolVerdict;
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencxh/domain",
3
- "version": "1.264.4",
3
+ "version": "1.265.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",