@jsm-mit/sultana-agent-tools-package 0.2.0 → 0.3.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/README.md CHANGED
@@ -23,6 +23,55 @@ const tools = createSalonTools({
23
23
  `salonId` is captured at construction and is **never a tool argument** — a model holding these
24
24
  tools cannot reach another salon's data whatever it sends.
25
25
 
26
+ ### Confirmations across turns
27
+
28
+ A host that runs the owner set in a chat keeps one `ConfirmationLedger` per conversation and builds
29
+ the tools again for every incoming owner message, with that message's turn:
30
+
31
+ ```ts
32
+ import { ConfirmationLedger, createSalonTools, SALON_AGENT_PERSONA_PL } from "@jsm-mit/sultana-agent-tools-package";
33
+
34
+ const ledger = new ConfirmationLedger(); // one per conversation, kept in memory
35
+
36
+ // on every owner message — one agent turn:
37
+ const tools = createSalonTools({ canisterId, identity, salonId, confirmations: ledger.forTurn() });
38
+ ```
39
+
40
+ With `confirmations`, the two-step protocol is enforced by the tools, not only asked for by the
41
+ persona:
42
+
43
+ 1. `confirmed: false` makes no canister write and returns `confirmation_required` with a
44
+ `summary` and an `echo` that now carries a `confirmationId` (`c-` and 8 hex digits).
45
+ 2. `confirmed: true` goes through only with that `confirmationId`, the **same arguments** (key
46
+ order does not matter, array order does; `confirmed`, `confirmationId` and
47
+ `confirmationPhrase` are not compared), from a **later turn**, within 15 minutes, and once.
48
+ Anything else is `status: "error"`, `code: "invalid_arguments"`, returned before the tool calls
49
+ the canister at all:
50
+
51
+ | why | summary |
52
+ |---|---|
53
+ | same turn as the preview | Zgoda musi przyjść w osobnej wiadomości właścicielki — najpierw pokaż podsumowanie i poczekaj na odpowiedź. |
54
+ | no id, unknown, used or expired | Brak ważnego podglądu tej zmiany — wywołaj narzędzie z confirmed=false i pokaż właścicielce podsumowanie. |
55
+ | other tool or other arguments | Argumenty różnią się od pokazanego podglądu — pokaż nowy podgląd (confirmed=false). |
56
+
57
+ Every write tool's schema gets a `confirmationId` parameter when a ledger is passed; without one,
58
+ the schemas and the protocol are what they were in 0.2.0 (a model can still confirm in the same
59
+ breath — only the persona stops it). The id is used up right before the write, not at the first
60
+ check: a confirmation refused for a mistyped name can be sent again with the same id once the owner
61
+ has typed it right. Two parallel calls with one id cannot both write: the second is refused right before its write.
62
+
63
+ `ConfirmationLedger` takes `{ ttlMs, now, newId }` for tests; `createSalonToolsFromPort(port,
64
+ { confirmations })` is the same over any port. `forTurn()` returns `TurnConfirmations` —
65
+ `issue(toolName, args)`, `check(toolName, args, id)` (the verdict, the id kept) and
66
+ `redeem(toolName, args, id)` (the verdict, the id used up) — for a host that wants the protocol
67
+ around tools of its own.
68
+
69
+ `SALON_AGENT_PERSONA_PL` is written for this: every write first with `confirmed=false`, the
70
+ summary shown, the owner's answer awaited in her **next** message, then `confirmed=true` with the
71
+ `confirmationId` and the same arguments; a removal only with the name the owner typed herself; and
72
+ with read tools only, the agent says that changes need the "Salon Assistant" page open in the
73
+ panel.
74
+
26
75
  ### Read-only set
27
76
 
28
77
  An assistant that runs without the owner's identity takes the read-only set and its own persona:
@@ -84,12 +133,18 @@ and `find_service_type`.
84
133
  - **`execute` never throws.** Every call returns `{status: "ok" | "confirmation_required" | "error"}`
85
134
  so an agent can read a failure and talk about it.
86
135
  - **Confirmation is a schema field, not a prompt rule.** Called with `confirmed: false`, a write
87
- tool makes no canister call and returns a sentence built from *resolved* arguments — worker names
88
- and service-type labels already looked up — plus the exact arguments to send back. A persona can
89
- be talked out of a rule; a required field cannot.
136
+ tool makes no canister write and returns a sentence built from *resolved* arguments — worker names
137
+ and service-type labels already looked up — plus the arguments to send back in `echo`. A persona
138
+ can be talked out of a rule; a required field cannot. With a `ConfirmationLedger` (above) the
139
+ confirmation must also come from a later turn, with the preview's id.
90
140
  - **Irreversible operations need the entity's own name** typed back in `confirmationPhrase`.
91
- - **A partial edit is merged.** `updateSalonService` replaces the whole record on the canister, so
92
- "just change the price" would otherwise unassign the team.
141
+ `remove_service` and `remove_promo` leave the name OUT of `echo`, and their summary asks the
142
+ model to have the owner write it: a model that just sends the echo back deletes nothing.
143
+ - **A partial edit is merged — at write time.** `updateSalonService` replaces the whole record on
144
+ the canister, so "just change the price" would otherwise unassign the team. The preview summary
145
+ shows the merged record, but `update_service`'s `echo` carries only `salonServiceId` and the
146
+ fields the model sent; the confirmed call reads the record again and merges then, so a change
147
+ made in the panel between preview and confirmation is kept.
93
148
  - **Units are fixed at the boundary**: price is whole zloty, duration is minutes rounded up to a
94
149
  multiple of 5 (the availability grid), and the tool says when it rounded.
95
150
  - **Service-type ids come from the catalogue.** `find_service_type` uses the same matcher as both
@@ -111,7 +166,9 @@ and `find_service_type`.
111
166
 
112
167
  `npm run sandbox -- --fake` is the loop to iterate in: a real model, real tool calls, an in-memory
113
168
  salon. Every call is printed with its arguments and its result, so a tool description that misleads
114
- the model shows up immediately — and a wrong answer costs no cycles and no daily action quota.
169
+ the model shows up immediately — and a wrong answer costs no cycles and no daily action quota. The
170
+ sandbox builds the tools again for every line you type, with a turn of one `ConfirmationLedger`,
171
+ as a host does — so the confirmation id and the next-message rule are in play there too.
115
172
 
116
173
  Drop `--fake` to run the same conversation against a canister with the owner's identity
117
174
  (`OWNER1_PEM`). Start with `npm run whoami`: it prints the principal the PEM makes you, the salons
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The two-step write protocol, enforced in code rather than by the persona.
3
+ *
4
+ * A write tool called with `confirmed: false` issues a confirmation id for exactly those arguments.
5
+ * The write itself goes through only when a LATER turn — the owner's next message — sends the same
6
+ * arguments back with that id. A model that previews and confirms in one breath, confirms
7
+ * something it never showed, or reuses an id, is refused before any canister call.
8
+ *
9
+ * The ledger lives in memory next to the conversation. A host keeps one ledger per conversation and
10
+ * calls `forTurn()` once per incoming owner message, when it builds that turn's tools.
11
+ */
12
+ export declare const DEFAULT_CONFIRMATION_TTL_MS: number;
13
+ export interface ConfirmationLedgerOptions {
14
+ /** How long a preview can still be confirmed. Defaults to 15 minutes. */
15
+ ttlMs?: number;
16
+ /** Milliseconds since the epoch. For tests. */
17
+ now?: () => number;
18
+ /** Confirmation id generator. For tests. */
19
+ newId?: () => string;
20
+ }
21
+ export type ConfirmationRefusal =
22
+ /** No `confirmationId` was sent. */
23
+ "missing"
24
+ /** The id was never issued, was already used, or was cleared out after expiring. */
25
+ | "unknown"
26
+ /** The id comes from this very turn — the owner has not answered the preview yet. */
27
+ | "same_turn"
28
+ /** The preview is older than the ledger's TTL. */
29
+ | "expired"
30
+ /** The id was issued for another tool or other arguments. */
31
+ | "mismatch";
32
+ export type RedeemResult = {
33
+ ok: true;
34
+ } | {
35
+ ok: false;
36
+ reason: ConfirmationRefusal;
37
+ };
38
+ /** One turn's view of the ledger. Ids issued here can only be redeemed from a later turn. */
39
+ export interface TurnConfirmations {
40
+ /** Records a preview of `toolName` with `args` and returns its id (`c-` and 8 hex digits). */
41
+ issue(toolName: string, args: Record<string, unknown>): string;
42
+ /** Whether `redeem` would succeed now — without using the id up. An expired entry is cleared. */
43
+ check(toolName: string, args: Record<string, unknown>, confirmationId: string | undefined): RedeemResult;
44
+ /** The same verdict as `check`; on success the id is used up and cannot confirm anything again. */
45
+ redeem(toolName: string, args: Record<string, unknown>, confirmationId: string | undefined): RedeemResult;
46
+ }
47
+ export declare class ConfirmationLedger {
48
+ private readonly ttlMs;
49
+ private readonly now;
50
+ private readonly newId;
51
+ private readonly pending;
52
+ private turn;
53
+ constructor(options?: ConfirmationLedgerOptions);
54
+ /** Starts a new turn. Call it once per incoming owner message and hand the result to that
55
+ * turn's tools; every call is a later turn than the one before. */
56
+ forTurn(): TurnConfirmations;
57
+ private issue;
58
+ private verdict;
59
+ private isExpired;
60
+ }
61
+ //# sourceMappingURL=confirmations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"confirmations.d.ts","sourceRoot":"","sources":["../src/confirmations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,2BAA2B,QAAiB,CAAC;AAK1D,MAAM,WAAW,yBAAyB;IACtC,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,mBAAmB;AAC3B,oCAAoC;AAClC,SAAS;AACX,oFAAoF;GAClF,SAAS;AACX,qFAAqF;GACnF,WAAW;AACb,kDAAkD;GAChD,SAAS;AACX,6DAA6D;GAC3D,UAAU,CAAC;AAEjB,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAA;CAAE,CAAC;AAErF,6FAA6F;AAC7F,MAAM,WAAW,iBAAiB;IAC9B,8FAA8F;IAC9F,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC;IAC/D,iGAAiG;IACjG,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY,CAAC;IACzG,mGAAmG;IACnG,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY,CAAC;CAC7G;AASD,qBAAa,kBAAkB;IAC3B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IACrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0C;IAClE,OAAO,CAAC,IAAI,CAAK;gBAEL,OAAO,GAAE,yBAA8B;IAMnD;uEACmE;IAC5D,OAAO,IAAI,iBAAiB;IAWnC,OAAO,CAAC,KAAK;IAeb,OAAO,CAAC,OAAO;IA6Bf,OAAO,CAAC,SAAS;CAGpB"}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The two-step write protocol, enforced in code rather than by the persona.
3
+ *
4
+ * A write tool called with `confirmed: false` issues a confirmation id for exactly those arguments.
5
+ * The write itself goes through only when a LATER turn — the owner's next message — sends the same
6
+ * arguments back with that id. A model that previews and confirms in one breath, confirms
7
+ * something it never showed, or reuses an id, is refused before any canister call.
8
+ *
9
+ * The ledger lives in memory next to the conversation. A host keeps one ledger per conversation and
10
+ * calls `forTurn()` once per incoming owner message, when it builds that turn's tools.
11
+ */
12
+ export const DEFAULT_CONFIRMATION_TTL_MS = 15 * 60 * 1000;
13
+ /** Argument keys that carry the protocol, not the change — never part of what is compared. */
14
+ const PROTOCOL_KEYS = new Set(["confirmed", "confirmationId", "confirmationPhrase"]);
15
+ export class ConfirmationLedger {
16
+ ttlMs;
17
+ now;
18
+ newId;
19
+ pending = new Map();
20
+ turn = 0;
21
+ constructor(options = {}) {
22
+ this.ttlMs = options.ttlMs ?? DEFAULT_CONFIRMATION_TTL_MS;
23
+ this.now = options.now ?? Date.now;
24
+ this.newId = options.newId ?? randomConfirmationId;
25
+ }
26
+ /** Starts a new turn. Call it once per incoming owner message and hand the result to that
27
+ * turn's tools; every call is a later turn than the one before. */
28
+ forTurn() {
29
+ this.turn += 1;
30
+ const turn = this.turn;
31
+ return {
32
+ issue: (toolName, args) => this.issue(turn, toolName, args),
33
+ check: (toolName, args, confirmationId) => this.verdict(turn, toolName, args, confirmationId, false),
34
+ redeem: (toolName, args, confirmationId) => this.verdict(turn, toolName, args, confirmationId, true),
35
+ };
36
+ }
37
+ issue(turn, toolName, args) {
38
+ // Previews nobody confirmed would otherwise pile up for the life of the conversation.
39
+ const now = this.now();
40
+ for (const [id, entry] of this.pending) {
41
+ if (this.isExpired(entry, now))
42
+ this.pending.delete(id);
43
+ }
44
+ // A 32-bit collision only replaces an older preview, whose id then fails as a mismatch —
45
+ // never lets a change through.
46
+ const id = this.newId();
47
+ this.pending.set(id, { toolName, argsKey: confirmationArgsKey(args), turn, issuedAt: now });
48
+ return id;
49
+ }
50
+ verdict(turn, toolName, args, confirmationId, redeem) {
51
+ const id = confirmationId?.trim();
52
+ if (!id)
53
+ return { ok: false, reason: "missing" };
54
+ const entry = this.pending.get(id);
55
+ if (!entry)
56
+ return { ok: false, reason: "unknown" };
57
+ if (this.isExpired(entry, this.now())) {
58
+ this.pending.delete(id);
59
+ return { ok: false, reason: "expired" };
60
+ }
61
+ if (entry.turn >= turn)
62
+ return { ok: false, reason: "same_turn" };
63
+ if (entry.toolName !== toolName || entry.argsKey !== confirmationArgsKey(args)) {
64
+ return { ok: false, reason: "mismatch" };
65
+ }
66
+ if (redeem)
67
+ this.pending.delete(id);
68
+ return { ok: true };
69
+ }
70
+ isExpired(entry, now) {
71
+ return now - entry.issuedAt > this.ttlMs;
72
+ }
73
+ }
74
+ /** Canonical JSON of the arguments without the protocol keys: object keys sorted at every depth,
75
+ * array order kept — `{b, a}` and `{a, b}` are the same change, `[1, 2]` and `[2, 1]` are not. */
76
+ function confirmationArgsKey(args) {
77
+ const change = {};
78
+ for (const [key, value] of Object.entries(args)) {
79
+ if (!PROTOCOL_KEYS.has(key))
80
+ change[key] = value;
81
+ }
82
+ return JSON.stringify(sortKeys(change));
83
+ }
84
+ function sortKeys(value) {
85
+ if (Array.isArray(value))
86
+ return value.map(sortKeys);
87
+ if (value === null || typeof value !== "object")
88
+ return value;
89
+ const sorted = {};
90
+ for (const key of Object.keys(value).sort()) {
91
+ sorted[key] = sortKeys(value[key]);
92
+ }
93
+ return sorted;
94
+ }
95
+ /** `crypto.getRandomValues` exists in Node and in every browser, secure context or not. */
96
+ function randomConfirmationId() {
97
+ const bytes = new Uint8Array(4);
98
+ globalThis.crypto.getRandomValues(bytes);
99
+ return `c-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
100
+ }
@@ -1,8 +1,19 @@
1
1
  import type { Identity } from "@icp-sdk/core/agent";
2
+ import type { TurnConfirmations } from "./confirmations.js";
2
3
  import { type IcSalonCorePortOptions } from "./ic-salon-core-port.js";
3
4
  import type { SalonCorePort } from "./salon-core-port.js";
4
5
  import type { AgentTool } from "./types.js";
5
- export interface CreateSalonToolsOptions extends Omit<IcSalonCorePortOptions, "language" | "access"> {
6
+ export interface CreateSalonToolsFromPortOptions {
7
+ /**
8
+ * This turn's confirmations — `ledger.forTurn()` from the conversation's `ConfirmationLedger`,
9
+ * taken once per incoming owner message. With it, every write tool issues a `confirmationId`
10
+ * on `confirmed: false` and refuses `confirmed: true` unless that id comes back, with the same
11
+ * arguments, from a later turn. Omit it and the tools keep the old protocol, where only the
12
+ * persona stops a model from confirming on its own.
13
+ */
14
+ confirmations?: TurnConfirmations;
15
+ }
16
+ export interface CreateSalonToolsOptions extends Omit<IcSalonCorePortOptions, "language" | "access">, CreateSalonToolsFromPortOptions {
6
17
  /** Language of service-type labels. Defaults to Polish. */
7
18
  language?: string;
8
19
  }
@@ -12,7 +23,7 @@ export interface CreateSalonToolsOptions extends Omit<IcSalonCorePortOptions, "l
12
23
  */
13
24
  export declare function createSalonTools(options: CreateSalonToolsOptions): AgentTool[];
14
25
  /** The same tool set over any port — the seam tests and the sandbox use. */
15
- export declare function createSalonToolsFromPort(port: SalonCorePort): AgentTool[];
26
+ export declare function createSalonToolsFromPort(port: SalonCorePort, options?: CreateSalonToolsFromPortOptions): AgentTool[];
16
27
  export interface CreateSalonReadToolsOptions {
17
28
  canisterId: string;
18
29
  salonId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"create-salon-tools.d.ts","sourceRoot":"","sources":["../src/create-salon-tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAmB,KAAK,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACvF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAI1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,WAAW,uBAAwB,SAAQ,IAAI,CAAC,sBAAsB,EAAE,UAAU,GAAG,QAAQ,CAAC;IAChG,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,SAAS,EAAE,CAG9E;AAED,4EAA4E;AAC5E,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,EAAE,CAEzE;AAED,MAAM,WAAW,2BAA2B;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,2BAA2B,GAAG,SAAS,EAAE,CAGtF;AAED;mEACmE;AACnE,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,EAAE,CAE7E"}
1
+ {"version":3,"file":"create-salon-tools.d.ts","sourceRoot":"","sources":["../src/create-salon-tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAmB,KAAK,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACvF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAI1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,WAAW,+BAA+B;IAC5C;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,iBAAiB,CAAC;CACrC;AAED,MAAM,WAAW,uBACb,SAAQ,IAAI,CAAC,sBAAsB,EAAE,UAAU,GAAG,QAAQ,CAAC,EACvD,+BAA+B;IACnC,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,SAAS,EAAE,CAK9E;AAED,4EAA4E;AAC5E,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,GAAE,+BAAoC,GAAG,SAAS,EAAE,CAQxH;AAED,MAAM,WAAW,2BAA2B;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,2BAA2B,GAAG,SAAS,EAAE,CAGtF;AAED;mEACmE;AACnE,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,EAAE,CAE7E"}
@@ -7,12 +7,18 @@ import { createServiceTools, findServiceTypeTool, listServicesTool } from "./too
7
7
  * argument, so a model holding these tools cannot reach another salon's data whatever it sends.
8
8
  */
9
9
  export function createSalonTools(options) {
10
- const port = new IcSalonCorePort({ ...options, language: options.language ?? "pl", access: "owner" });
11
- return createSalonToolsFromPort(port);
10
+ const { confirmations, language, ...portOptions } = options;
11
+ const port = new IcSalonCorePort({ ...portOptions, language: language ?? "pl", access: "owner" });
12
+ return createSalonToolsFromPort(port, { confirmations });
12
13
  }
13
14
  /** The same tool set over any port — the seam tests and the sandbox use. */
14
- export function createSalonToolsFromPort(port) {
15
- return [...createServiceTools(port), ...createScheduleTools(port), ...createPromoTools(port)];
15
+ export function createSalonToolsFromPort(port, options = {}) {
16
+ const { confirmations } = options;
17
+ return [
18
+ ...createServiceTools(port, confirmations),
19
+ ...createScheduleTools(port, confirmations),
20
+ ...createPromoTools(port, confirmations),
21
+ ];
16
22
  }
17
23
  /**
18
24
  * The read-only tool set for ONE salon: `list_services`, `list_promos` and `find_service_type`,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
+ export { ConfirmationLedger, DEFAULT_CONFIRMATION_TTL_MS } from "./confirmations.js";
2
+ export type { ConfirmationLedgerOptions, ConfirmationRefusal, RedeemResult, TurnConfirmations, } from "./confirmations.js";
1
3
  export { createSalonReadTools, createSalonReadToolsFromPort, createSalonTools, createSalonToolsFromPort, } from "./create-salon-tools.js";
2
- export type { CreateSalonReadToolsOptions, CreateSalonToolsOptions } from "./create-salon-tools.js";
4
+ export type { CreateSalonReadToolsOptions, CreateSalonToolsFromPortOptions, CreateSalonToolsOptions, } from "./create-salon-tools.js";
3
5
  export { IcSalonCorePort } from "./ic-salon-core-port.js";
4
6
  export type { IcSalonCorePortOptions } from "./ic-salon-core-port.js";
5
7
  export { SALON_AGENT_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,oBAAoB,EACpB,4BAA4B,EAC5B,gBAAgB,EAChB,wBAAwB,GAC3B,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,2BAA2B,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACpG,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,YAAY,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC7E,YAAY,EACR,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,SAAS,EACT,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,iBAAiB,EACjB,SAAS,EACT,UAAU,GACb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,GAAG,EAAE,iBAAiB,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AACxD,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,2BAA2B,EAAE,MAAM,oBAAoB,CAAC;AACrF,YAAY,EACR,yBAAyB,EACzB,mBAAmB,EACnB,YAAY,EACZ,iBAAiB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACH,oBAAoB,EACpB,4BAA4B,EAC5B,gBAAgB,EAChB,wBAAwB,GAC3B,MAAM,yBAAyB,CAAC;AACjC,YAAY,EACR,2BAA2B,EAC3B,+BAA+B,EAC/B,uBAAuB,GAC1B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,YAAY,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC7E,YAAY,EACR,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,SAAS,EACT,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,iBAAiB,EACjB,SAAS,EACT,UAAU,GACb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,GAAG,EAAE,iBAAiB,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AACxD,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { ConfirmationLedger, DEFAULT_CONFIRMATION_TTL_MS } from "./confirmations.js";
1
2
  export { createSalonReadTools, createSalonReadToolsFromPort, createSalonTools, createSalonToolsFromPort, } from "./create-salon-tools.js";
2
3
  export { IcSalonCorePort } from "./ic-salon-core-port.js";
3
4
  export { SALON_AGENT_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
package/dist/persona.d.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  * confirmation protocol, which reads come before which writes), so keeping them next to the tools
6
6
  * is the only way they cannot drift apart. An agent host may append its own persona — who the
7
7
  * agent is, how it speaks — on top of this.
8
+ *
9
+ * The two-step rule is written for a host that passes a `ConfirmationLedger` turn to the tools:
10
+ * the tools then refuse a confirmation from the same turn, so the persona and the code agree.
8
11
  */
9
- export declare const SALON_AGENT_PERSONA_PL = "Jeste\u015B asystentem w\u0142a\u015Bciciela salonu w aplikacji Sultana.\nRozmawiasz po polsku, kr\u00F3tko i konkretnie. Wykonujesz zmiany w salonie za pomoc\u0105 narz\u0119dzi.\n\nZasady pracy:\n- Zanim zmienisz albo usuniesz us\u0142ug\u0119, wywo\u0142aj list_services i ustal jej id. Nie zgaduj id.\n- Zanim podasz serviceTypeIds, wywo\u0142aj find_service_type. Nigdy nie wymy\u015Blaj id typu us\u0142ugi.\n- Zanim przypiszesz pracownik\u00F3w, wywo\u0142aj list_workers.\n- Cena to pe\u0142ne z\u0142ote (liczba ca\u0142kowita). Czas trwania to minuty, wielokrotno\u015B\u0107 5.\n \"p\u00F3\u0142torej godziny\" to 90 minut, \"kwadrans\" to 15 minut.\n- Ka\u017Cde narz\u0119dzie zapisuj\u0105ce ma pole confirmed. Najpierw wywo\u0142aj je z confirmed=false,\n poka\u017C w\u0142a\u015Bcicielowi zdanie, kt\u00F3re wr\u00F3ci w polu summary, i poczekaj na zgod\u0119.\n Dopiero po wyra\u017Anym \"tak\" wywo\u0142aj to samo narz\u0119dzie z confirmed=true.\n- Przy usuwaniu popro\u015B w\u0142a\u015Bciciela o przepisanie dok\u0142adnej nazwy usuwanej rzeczy\n i wstaw j\u0105 w confirmationPhrase.\n- Je\u015Bli narz\u0119dzie zwr\u00F3ci status \"error\", przeczytaj summary i powiedz w\u0142a\u015Bcicielowi,\n co posz\u0142o nie tak. Nie powtarzaj tego samego wywo\u0142ania w k\u00F3\u0142ko.\n- Je\u015Bli brakuje Ci danych do wykonania polecenia, dopytaj o jedn\u0105 rzecz naraz.\n- Nie obiecuj zmian, kt\u00F3rych nie wykona\u0142e\u015B narz\u0119dziem.";
12
+ export declare const SALON_AGENT_PERSONA_PL = "Jeste\u015B asystentem w\u0142a\u015Bcicielki salonu w aplikacji Sultana.\nRozmawiasz po polsku, kr\u00F3tko i konkretnie. Wykonujesz zmiany w salonie za pomoc\u0105 narz\u0119dzi.\n\nZasady pracy:\n- Zanim zmienisz albo usuniesz us\u0142ug\u0119, wywo\u0142aj list_services i ustal jej id. Nie zgaduj id.\n- Zanim podasz serviceTypeIds, wywo\u0142aj find_service_type. Nigdy nie wymy\u015Blaj id typu us\u0142ugi.\n- Zanim przypiszesz pracownik\u00F3w, wywo\u0142aj list_workers.\n- Cena to pe\u0142ne z\u0142ote (liczba ca\u0142kowita). Czas trwania to minuty, wielokrotno\u015B\u0107 5.\n \"p\u00F3\u0142torej godziny\" to 90 minut, \"kwadrans\" to 15 minut.\n- Gdy masz narz\u0119dzia zapisuj\u0105ce, list_services i list_promos pokazuj\u0105 te\u017C us\u0142ugi\n i promocje wy\u0142\u0105czone \u2014 nie tylko to, co widz\u0105 klientki.\n- Ka\u017Cda zmiana to dwa kroki w dw\u00F3ch r\u00F3\u017Cnych wiadomo\u015Bciach w\u0142a\u015Bcicielki:\n 1. Wywo\u0142aj narz\u0119dzie zapisuj\u0105ce z confirmed=false. Poka\u017C w\u0142a\u015Bcicielce zdanie z pola summary,\n zapytaj o zgod\u0119 i na tym zako\u0144cz odpowied\u017A.\n 2. Dopiero gdy w swojej NAST\u0118PNEJ wiadomo\u015Bci wyra\u017Anie si\u0119 zgodzi, wywo\u0142aj to samo narz\u0119dzie\n z confirmed=true, z confirmationId z tego podgl\u0105du i z tymi samymi argumentami (pole echo).\n Nigdy nie potwierdzaj w tej samej odpowiedzi, w kt\u00F3rej pokaza\u0142e\u015B podgl\u0105d.\n Gdy w\u0142a\u015Bcicielka chce co\u015B inaczej, zr\u00F3b nowy podgl\u0105d i zn\u00F3w poczekaj na zgod\u0119.\n- Przy usuwaniu popro\u015B w\u0142a\u015Bcicielk\u0119, \u017Ceby sama napisa\u0142a dok\u0142adn\u0105 nazw\u0119 usuwanej rzeczy,\n i wstaw w confirmationPhrase to, co napisa\u0142a. Nigdy nie wpisuj nazwy za ni\u0105.\n- Je\u015Bli masz tylko narz\u0119dzia do odczytu (list_\u2026, find_\u2026), nie mo\u017Cesz niczego zmieni\u0107 \u2014\n powiedz, \u017Ce zmiany wymagaj\u0105 otwartej strony \u201ESalon Assistant\u201D w panelu.\n- Je\u015Bli narz\u0119dzie zwr\u00F3ci status \"error\", przeczytaj summary i powiedz w\u0142a\u015Bcicielce,\n co posz\u0142o nie tak. Nie powtarzaj tego samego wywo\u0142ania w k\u00F3\u0142ko.\n- Je\u015Bli brakuje Ci danych do wykonania polecenia, dopytaj o jedn\u0105 rzecz naraz.\n- Nie obiecuj zmian, kt\u00F3rych nie wykona\u0142e\u015B narz\u0119dziem.";
10
13
  /**
11
14
  * The operating rules for the read-only set (`createSalonReadTools`), as a system prompt.
12
15
  *
@@ -1 +1 @@
1
- {"version":3,"file":"persona.d.ts","sourceRoot":"","sources":["../src/persona.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,28CAiBoB,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,6wCAee,CAAC"}
1
+ {"version":3,"file":"persona.d.ts","sourceRoot":"","sources":["../src/persona.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,i2EAyBoB,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,6wCAee,CAAC"}
package/dist/persona.js CHANGED
@@ -5,8 +5,11 @@
5
5
  * confirmation protocol, which reads come before which writes), so keeping them next to the tools
6
6
  * is the only way they cannot drift apart. An agent host may append its own persona — who the
7
7
  * agent is, how it speaks — on top of this.
8
+ *
9
+ * The two-step rule is written for a host that passes a `ConfirmationLedger` turn to the tools:
10
+ * the tools then refuse a confirmation from the same turn, so the persona and the code agree.
8
11
  */
9
- export const SALON_AGENT_PERSONA_PL = `Jesteś asystentem właściciela salonu w aplikacji Sultana.
12
+ export const SALON_AGENT_PERSONA_PL = `Jesteś asystentem właścicielki salonu w aplikacji Sultana.
10
13
  Rozmawiasz po polsku, krótko i konkretnie. Wykonujesz zmiany w salonie za pomocą narzędzi.
11
14
 
12
15
  Zasady pracy:
@@ -15,12 +18,20 @@ Zasady pracy:
15
18
  - Zanim przypiszesz pracowników, wywołaj list_workers.
16
19
  - Cena to pełne złote (liczba całkowita). Czas trwania to minuty, wielokrotność 5.
17
20
  "półtorej godziny" to 90 minut, "kwadrans" to 15 minut.
18
- - Każde narzędzie zapisujące ma pole confirmed. Najpierw wywołaj je z confirmed=false,
19
- pokaż właścicielowi zdanie, które wróci w polu summary, i poczekaj na zgodę.
20
- Dopiero po wyraźnym "tak" wywołaj to samo narzędzie z confirmed=true.
21
- - Przy usuwaniu poproś właściciela o przepisanie dokładnej nazwy usuwanej rzeczy
22
- i wstaw w confirmationPhrase.
23
- - Jeśli narzędzie zwróci status "error", przeczytaj summary i powiedz właścicielowi,
21
+ - Gdy masz narzędzia zapisujące, list_services i list_promos pokazują też usługi
22
+ i promocje wyłączone nie tylko to, co widzą klientki.
23
+ - Każda zmiana to dwa kroki w dwóch różnych wiadomościach właścicielki:
24
+ 1. Wywołaj narzędzie zapisujące z confirmed=false. Pokaż właścicielce zdanie z pola summary,
25
+ zapytaj o zgodę i na tym zakończ odpowiedź.
26
+ 2. Dopiero gdy w swojej NASTĘPNEJ wiadomości wyraźnie się zgodzi, wywołaj to samo narzędzie
27
+ z confirmed=true, z confirmationId z tego podglądu i z tymi samymi argumentami (pole echo).
28
+ Nigdy nie potwierdzaj w tej samej odpowiedzi, w której pokazałeś podgląd.
29
+ Gdy właścicielka chce coś inaczej, zrób nowy podgląd i znów poczekaj na zgodę.
30
+ - Przy usuwaniu poproś właścicielkę, żeby sama napisała dokładną nazwę usuwanej rzeczy,
31
+ i wstaw w confirmationPhrase to, co napisała. Nigdy nie wpisuj nazwy za nią.
32
+ - Jeśli masz tylko narzędzia do odczytu (list_…, find_…), nie możesz niczego zmienić —
33
+ powiedz, że zmiany wymagają otwartej strony „Salon Assistant” w panelu.
34
+ - Jeśli narzędzie zwróci status "error", przeczytaj summary i powiedz właścicielce,
24
35
  co poszło nie tak. Nie powtarzaj tego samego wywołania w kółko.
25
36
  - Jeśli brakuje Ci danych do wykonania polecenia, dopytaj o jedną rzecz naraz.
26
37
  - Nie obiecuj zmian, których nie wykonałeś narzędziem.`;
@@ -1,6 +1,7 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
1
2
  import type { SalonAccess, SalonCorePort } from "../salon-core-port.js";
2
3
  import { type AgentTool } from "../types.js";
3
- export declare function createPromoTools(port: SalonCorePort): AgentTool[];
4
+ export declare function createPromoTools(port: SalonCorePort, confirmations?: TurnConfirmations): AgentTool[];
4
5
  /** `access` must match the port: `"public"` only for a port read anonymously, because the canister
5
6
  * gives the salon's owner the whole library whatever the port calls. */
6
7
  export declare function listPromosTool(port: SalonCorePort, access?: SalonAccess): AgentTool;
@@ -1 +1 @@
1
- {"version":3,"file":"promos.d.ts","sourceRoot":"","sources":["../../src/tools/promos.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA8B,WAAW,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACpG,OAAO,EAA8B,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAqBzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,EAAE,CAYjE;AA2CD;wEACwE;AACxE,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,GAAE,WAAqB,GAAG,SAAS,CAmB5F"}
1
+ {"version":3,"file":"promos.d.ts","sourceRoot":"","sources":["../../src/tools/promos.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,EAA8B,WAAW,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACpG,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAsBtD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,aAAa,CAAC,EAAE,iBAAiB,GAAG,SAAS,EAAE,CAYpG;AA2CD;wEACwE;AACxE,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,GAAE,WAAqB,GAAG,SAAS,CAmB5F"}
@@ -1,7 +1,8 @@
1
1
  import { MAX_ACTIVE_PROMOS_PER_SALON, MAX_PROMOS_PER_SALON } from "@jsm-mit/sultana-core-motoko-package";
2
2
  import { toToolError } from "../errors.js";
3
- import { err, needsConfirmation, ok } from "../types.js";
3
+ import { err, ok } from "../types.js";
4
4
  import { ArgumentError, phrasesMatch, readBoolean, readOptionalNumber, readOptionalString, readOptionalStringArray, readString, } from "./args.js";
5
+ import { writeGate } from "./write-gate.js";
5
6
  const MAX_PROMO_NAME_LENGTH = 60;
6
7
  const MAX_TEXT_LINES = 2;
7
8
  const MAX_CHIPS = 2;
@@ -9,17 +10,17 @@ const CONFIRMED_FIELD = {
9
10
  type: "boolean",
10
11
  description: "false dla podglądu: narzędzie nic nie zapisze i odda opis zmiany do zatwierdzenia przez właściciela. true dopiero po potwierdzeniu.",
11
12
  };
12
- export function createPromoTools(port) {
13
+ export function createPromoTools(port, confirmations) {
13
14
  return [
14
15
  listMediaTool(port),
15
16
  listPromosTool(port),
16
- addPromoTool(port),
17
- updatePromoTool(port),
18
- setPromoActiveTool(port),
19
- removePromoTool(port),
17
+ addPromoTool(port, confirmations),
18
+ updatePromoTool(port, confirmations),
19
+ setPromoActiveTool(port, confirmations),
20
+ removePromoTool(port, confirmations),
20
21
  listDiscountCodesTool(port),
21
- addDiscountCodeTool(port),
22
- removeDiscountCodeTool(port),
22
+ addDiscountCodeTool(port, confirmations),
23
+ removeDiscountCodeTool(port, confirmations),
23
24
  ];
24
25
  }
25
26
  function listMediaTool(port) {
@@ -81,12 +82,13 @@ export function listPromosTool(port, access = "owner") {
81
82
  },
82
83
  };
83
84
  }
84
- function addPromoTool(port) {
85
+ function addPromoTool(port, confirmations) {
86
+ const gate = writeGate("add_promo", confirmations);
85
87
  return {
86
88
  name: "add_promo",
87
89
  progress: "Zakładam promocję…",
88
90
  description: "Zakłada nową promocję na materiale z biblioteki. Najpierw wywołaj list_media i wybierz assetId. Nowa promocja jest nieaktywna — włącz ją potem przez set_promo_active.",
89
- parameters: {
91
+ parameters: gate.parameters({
90
92
  type: "object",
91
93
  properties: {
92
94
  name: { type: "string", description: `Nazwa kampanii, maksymalnie ${MAX_PROMO_NAME_LENGTH} znaków.` },
@@ -104,13 +106,19 @@ function addPromoTool(port) {
104
106
  },
105
107
  required: ["name", "assetId", "confirmed"],
106
108
  additionalProperties: false,
107
- },
109
+ }),
108
110
  execute: async (args) => {
109
111
  try {
112
+ const refusal = gate.refusal(args);
113
+ if (refusal)
114
+ return refusal;
110
115
  const input = await buildPromoInput(port, args, null);
111
116
  if (!readBoolean(args, "confirmed")) {
112
- return needsConfirmation(`Założę promocję ${await describePromo(port, input)}.`, { ...args, confirmed: true });
117
+ return gate.preview(`Założę promocję ${await describePromo(port, input)}.`, args);
113
118
  }
119
+ const refused = gate.redeem(args);
120
+ if (refused)
121
+ return refused;
114
122
  const promoId = await port.addPromo(input);
115
123
  return ok(`Założono promocję „${input.name}”. Jest jeszcze nieaktywna.`, { promoId });
116
124
  }
@@ -122,12 +130,13 @@ function addPromoTool(port) {
122
130
  },
123
131
  };
124
132
  }
125
- function updatePromoTool(port) {
133
+ function updatePromoTool(port, confirmations) {
134
+ const gate = writeGate("update_promo", confirmations);
126
135
  return {
127
136
  name: "update_promo",
128
137
  progress: "Zmieniam promocję…",
129
138
  description: "Zmienia istniejącą promocję. Podaj tylko pola, które mają się zmienić — reszta zostanie zachowana. Promocja zachowuje swoje miejsce w kanale, więc poprawka nie restartuje kampanii.",
130
- parameters: {
139
+ parameters: gate.parameters({
131
140
  type: "object",
132
141
  properties: {
133
142
  promoId: { type: "string", description: "Id promocji z list_promos." },
@@ -142,20 +151,23 @@ function updatePromoTool(port) {
142
151
  },
143
152
  required: ["promoId", "confirmed"],
144
153
  additionalProperties: false,
145
- },
154
+ }),
146
155
  execute: async (args) => {
147
156
  try {
157
+ const refusal = gate.refusal(args);
158
+ if (refusal)
159
+ return refusal;
148
160
  const promoId = readString(args, "promoId");
149
161
  const current = await findPromo(port, promoId);
150
162
  if (!current)
151
163
  return err("not_found", `W tym salonie nie ma promocji o id ${promoId}.`);
152
164
  const input = await buildPromoInput(port, args, current);
153
165
  if (!readBoolean(args, "confirmed")) {
154
- return needsConfirmation(`Zmienię promocję „${current.name}” na: ${await describePromo(port, input)}.`, {
155
- ...args,
156
- confirmed: true,
157
- });
166
+ return gate.preview(`Zmienię promocję „${current.name}” na: ${await describePromo(port, input)}.`, args);
158
167
  }
168
+ const refused = gate.redeem(args);
169
+ if (refused)
170
+ return refused;
159
171
  await port.updatePromo(promoId, input);
160
172
  return ok(`Zmieniono promocję „${input.name}”.`);
161
173
  }
@@ -167,12 +179,13 @@ function updatePromoTool(port) {
167
179
  },
168
180
  };
169
181
  }
170
- function setPromoActiveTool(port) {
182
+ function setPromoActiveTool(port, confirmations) {
183
+ const gate = writeGate("set_promo_active", confirmations);
171
184
  return {
172
185
  name: "set_promo_active",
173
186
  progress: "Zmieniam status promocji…",
174
187
  description: `Włącza promocję w kanale albo ją wyłącza. Naraz może być aktywnych najwyżej ${MAX_ACTIVE_PROMOS_PER_SALON} — żeby włączyć kolejną, najpierw wyłącz inną.`,
175
- parameters: {
188
+ parameters: gate.parameters({
176
189
  type: "object",
177
190
  properties: {
178
191
  promoId: { type: "string", description: "Id promocji z list_promos." },
@@ -181,9 +194,12 @@ function setPromoActiveTool(port) {
181
194
  },
182
195
  required: ["promoId", "active", "confirmed"],
183
196
  additionalProperties: false,
184
- },
197
+ }),
185
198
  execute: async (args) => {
186
199
  try {
200
+ const refusal = gate.refusal(args);
201
+ if (refusal)
202
+ return refusal;
187
203
  const promoId = readString(args, "promoId");
188
204
  const current = await findPromo(port, promoId);
189
205
  if (!current)
@@ -191,11 +207,11 @@ function setPromoActiveTool(port) {
191
207
  const active = readBoolean(args, "active");
192
208
  const verb = active ? "włączę" : "wyłączę";
193
209
  if (!readBoolean(args, "confirmed")) {
194
- return needsConfirmation(`${verb[0].toUpperCase()}${verb.slice(1)} promocję „${current.name}”.`, {
195
- ...args,
196
- confirmed: true,
197
- });
210
+ return gate.preview(`${verb[0].toUpperCase()}${verb.slice(1)} promocję „${current.name}”.`, args);
198
211
  }
212
+ const refused = gate.redeem(args);
213
+ if (refused)
214
+ return refused;
199
215
  await port.setPromoActive(promoId, active);
200
216
  return ok(`Promocja „${current.name}” jest teraz ${active ? "aktywna" : "wyłączona"}.`);
201
217
  }
@@ -207,12 +223,13 @@ function setPromoActiveTool(port) {
207
223
  },
208
224
  };
209
225
  }
210
- function removePromoTool(port) {
226
+ function removePromoTool(port, confirmations) {
227
+ const gate = writeGate("remove_promo", confirmations);
211
228
  return {
212
229
  name: "remove_promo",
213
230
  progress: "Kasuję promocję…",
214
231
  description: "Kasuje promocję z biblioteki. Nieodwracalne — właściciel musi przepisać jej nazwę w confirmationPhrase. Żeby tylko zdjąć ją z kanału, użyj set_promo_active.",
215
- parameters: {
232
+ parameters: gate.parameters({
216
233
  type: "object",
217
234
  properties: {
218
235
  promoId: { type: "string", description: "Id promocji z list_promos." },
@@ -221,20 +238,29 @@ function removePromoTool(port) {
221
238
  },
222
239
  required: ["promoId", "confirmed"],
223
240
  additionalProperties: false,
224
- },
241
+ }),
225
242
  execute: async (args) => {
226
243
  try {
227
244
  const promoId = readString(args, "promoId");
245
+ // No confirmationPhrase in the echo: the name is the owner's to type, so a model that
246
+ // simply sends the echo back cannot delete anything.
247
+ const echo = { promoId };
248
+ const refusal = gate.refusal(args, echo);
249
+ if (refusal)
250
+ return refusal;
228
251
  const current = await findPromo(port, promoId);
229
252
  if (!current)
230
253
  return err("not_found", `W tym salonie nie ma promocji o id ${promoId}.`);
231
254
  if (!readBoolean(args, "confirmed")) {
232
- return needsConfirmation(`Skasuję promocję „${current.name}”. Tego nie da się cofnąć — poproś właściciela o przepisanie nazwy. Jeśli chodzi tylko o zdjęcie z kanału, lepiej ją wyłączyć.`, { promoId, confirmationPhrase: current.name, confirmed: true });
255
+ return gate.preview(`Skasuję promocję „${current.name}”. Tego nie da się cofnąć — poproś właścicielkę, żeby sama napisała dokładną nazwę promocji, i wstaw ją w confirmationPhrase. Jeśli chodzi tylko o zdjęcie z kanału, lepiej ją wyłączyć.`, echo);
233
256
  }
234
257
  const phrase = readOptionalString(args, "confirmationPhrase");
235
258
  if (!phrase || !phrasesMatch(phrase, current.name)) {
236
- return err("invalid_arguments", `Aby skasować promocję, właściciel musi przepisać jej nazwę: „${current.name}”.`);
259
+ return err("invalid_arguments", `Aby skasować promocję, właścicielka musi sama napisać jej dokładną nazwę: „${current.name}”.`);
237
260
  }
261
+ const refused = gate.redeem(args, echo);
262
+ if (refused)
263
+ return refused;
238
264
  await port.removePromo(promoId);
239
265
  return ok(`Skasowano promocję „${current.name}”.`);
240
266
  }
@@ -265,12 +291,13 @@ function listDiscountCodesTool(port) {
265
291
  },
266
292
  };
267
293
  }
268
- function addDiscountCodeTool(port) {
294
+ function addDiscountCodeTool(port, confirmations) {
295
+ const gate = writeGate("add_discount_code", confirmations);
269
296
  return {
270
297
  name: "add_discount_code",
271
298
  progress: "Dodaję kod rabatowy…",
272
299
  description: "Dodaje kod rabatowy salonu. Podaj albo percent, albo amountPln — nie oba. Kod nie ma daty ważności ani limitu użyć; działa, dopóki go nie usuniesz.",
273
- parameters: {
300
+ parameters: gate.parameters({
274
301
  type: "object",
275
302
  properties: {
276
303
  code: { type: "string", description: "Treść kodu, którą wpisze klientka." },
@@ -281,9 +308,12 @@ function addDiscountCodeTool(port) {
281
308
  },
282
309
  required: ["code", "confirmed"],
283
310
  additionalProperties: false,
284
- },
311
+ }),
285
312
  execute: async (args) => {
286
313
  try {
314
+ const refusal = gate.refusal(args);
315
+ if (refusal)
316
+ return refusal;
287
317
  const code = readString(args, "code");
288
318
  const percent = readOptionalNumber(args, "percent");
289
319
  const amountPln = readOptionalNumber(args, "amountPln");
@@ -301,8 +331,11 @@ function addDiscountCodeTool(port) {
301
331
  const value = percent !== undefined ? `${percent}%` : `${amountPln} zł`;
302
332
  const scope = scopeServiceId ? "na jedną usługę" : "na cały salon";
303
333
  if (!readBoolean(args, "confirmed")) {
304
- return needsConfirmation(`Dodam kod rabatowy „${code}”: ${value}, ${scope}.`, { ...args, confirmed: true });
334
+ return gate.preview(`Dodam kod rabatowy „${code}”: ${value}, ${scope}.`, args);
305
335
  }
336
+ const refused = gate.redeem(args);
337
+ if (refused)
338
+ return refused;
306
339
  await port.addDiscountCode({ code, percent, amountPln, scopeServiceId });
307
340
  return ok(`Dodano kod rabatowy „${code}” (${value}, ${scope}).`);
308
341
  }
@@ -314,12 +347,13 @@ function addDiscountCodeTool(port) {
314
347
  },
315
348
  };
316
349
  }
317
- function removeDiscountCodeTool(port) {
350
+ function removeDiscountCodeTool(port, confirmations) {
351
+ const gate = writeGate("remove_discount_code", confirmations);
318
352
  return {
319
353
  name: "remove_discount_code",
320
354
  progress: "Usuwam kod rabatowy…",
321
355
  description: "Usuwa kod rabatowy salonu. Kanister odmówi, dopóki jakakolwiek promocja ten kod rozgłasza — wtedy najpierw zmień promocję.",
322
- parameters: {
356
+ parameters: gate.parameters({
323
357
  type: "object",
324
358
  properties: {
325
359
  code: { type: "string", description: "Treść kodu z list_discount_codes." },
@@ -327,18 +361,24 @@ function removeDiscountCodeTool(port) {
327
361
  },
328
362
  required: ["code", "confirmed"],
329
363
  additionalProperties: false,
330
- },
364
+ }),
331
365
  // No confirmation phrase here, unlike a service or a promo: a removed code can simply be
332
366
  // added back with the same text, and nothing else in the salon points at it.
333
367
  execute: async (args) => {
334
368
  try {
369
+ const refusal = gate.refusal(args);
370
+ if (refusal)
371
+ return refusal;
335
372
  const code = readString(args, "code");
336
373
  const codes = await port.listDiscountCodes();
337
374
  if (!codes.some((entry) => entry.code === code))
338
375
  return err("not_found", `Salon nie ma kodu „${code}”.`);
339
376
  if (!readBoolean(args, "confirmed")) {
340
- return needsConfirmation(`Usunę kod rabatowy „${code}”.`, { ...args, confirmed: true });
377
+ return gate.preview(`Usunę kod rabatowy „${code}”.`, args);
341
378
  }
379
+ const refused = gate.redeem(args);
380
+ if (refused)
381
+ return refused;
342
382
  await port.removeDiscountCode(code);
343
383
  return ok(`Usunięto kod rabatowy „${code}”.`);
344
384
  }
@@ -1,4 +1,5 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
1
2
  import type { SalonCorePort } from "../salon-core-port.js";
2
3
  import { type AgentTool } from "../types.js";
3
- export declare function createScheduleTools(port: SalonCorePort): AgentTool[];
4
+ export declare function createScheduleTools(port: SalonCorePort, confirmations?: TurnConfirmations): AgentTool[];
4
5
  //# sourceMappingURL=schedule.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../../src/tools/schedule.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAyB,MAAM,uBAAuB,CAAC;AAClF,OAAO,EAA8B,KAAK,SAAS,EAAmB,MAAM,aAAa,CAAC;AAqB1F,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,EAAE,CASpE"}
1
+ {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../../src/tools/schedule.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,EAAE,aAAa,EAAyB,MAAM,uBAAuB,CAAC;AAClF,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAsBtD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,EAAE,aAAa,CAAC,EAAE,iBAAiB,GAAG,SAAS,EAAE,CASvG"}
@@ -1,7 +1,8 @@
1
1
  import { formatMinutesToTime, parseTimeRangesToSlots, parseTimeToMinutes } from "@jsm-mit/sultana-core-motoko-package";
2
2
  import { toToolError } from "../errors.js";
3
- import { err, needsConfirmation, ok } from "../types.js";
3
+ import { err, ok } from "../types.js";
4
4
  import { ArgumentError, readBoolean, readOptionalString, readString } from "./args.js";
5
+ import { writeGate } from "./write-gate.js";
5
6
  /** 0 = Monday, matching the canister's own day index. */
6
7
  const DAY_NAMES_PL = ["poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota", "niedziela"];
7
8
  const CONFIRMED_FIELD = {
@@ -14,14 +15,14 @@ const RANGES_FIELD = {
14
15
  type: "string",
15
16
  description: 'Zakresy godzin oddzielone przecinkiem, np. "10-18" albo "9:30-13,14-18".',
16
17
  };
17
- export function createScheduleTools(port) {
18
+ export function createScheduleTools(port, confirmations) {
18
19
  return [
19
20
  getWeeklyHoursTool(port),
20
- setWeeklyHoursTool(port),
21
+ setWeeklyHoursTool(port, confirmations),
21
22
  getDayScheduleTool(port),
22
- setDayOffTool(port),
23
- setBusyHoursTool(port),
24
- clearDailyOverrideTool(port),
23
+ setDayOffTool(port, confirmations),
24
+ setBusyHoursTool(port, confirmations),
25
+ clearDailyOverrideTool(port, confirmations),
25
26
  ];
26
27
  }
27
28
  function getWeeklyHoursTool(port) {
@@ -53,12 +54,13 @@ function getWeeklyHoursTool(port) {
53
54
  },
54
55
  };
55
56
  }
56
- function setWeeklyHoursTool(port) {
57
+ function setWeeklyHoursTool(port, confirmations) {
58
+ const gate = writeGate("set_weekly_hours", confirmations);
57
59
  return {
58
60
  name: "set_weekly_hours",
59
61
  progress: "Ustawiam grafik…",
60
62
  description: "Ustawia godziny PRACY pracownika w wybranych dniach tygodnia. Zastępuje dotychczasowe godziny w tych dniach. Pusty zakres oznacza dzień niepracujący.",
61
- parameters: {
63
+ parameters: gate.parameters({
62
64
  type: "object",
63
65
  properties: {
64
66
  workerId: WORKER_FIELD,
@@ -72,20 +74,23 @@ function setWeeklyHoursTool(port) {
72
74
  },
73
75
  required: ["workerId", "days", "ranges", "confirmed"],
74
76
  additionalProperties: false,
75
- },
77
+ }),
76
78
  execute: async (args) => {
77
79
  try {
80
+ const refusal = gate.refusal(args);
81
+ if (refusal)
82
+ return refusal;
78
83
  const worker = await resolveWorker(port, readString(args, "workerId"));
79
84
  const days = readDays(args);
80
85
  const parsed = normalizeRanges(readOptionalString(args, "ranges") ?? "");
81
86
  const dayNames = days.map((day) => DAY_NAMES_PL[day]).join(", ");
82
87
  const hours = parsed.ranges.length === 0 ? "dzień wolny" : formatRanges(parsed.ranges).join(", ");
83
88
  if (!readBoolean(args, "confirmed")) {
84
- return needsConfirmation(`Ustawię ${worker.name}: ${dayNames} — ${hours}.${noteSuffix(parsed.notes)}`, {
85
- ...args,
86
- confirmed: true,
87
- });
89
+ return gate.preview(`Ustawię ${worker.name}: ${dayNames} — ${hours}.${noteSuffix(parsed.notes)}`, args);
88
90
  }
91
+ const refused = gate.redeem(args);
92
+ if (refused)
93
+ return refused;
89
94
  for (const day of days) {
90
95
  await port.setWeeklyHours(worker.id, day, parsed.ranges);
91
96
  }
@@ -127,24 +132,31 @@ function getDayScheduleTool(port) {
127
132
  },
128
133
  };
129
134
  }
130
- function setDayOffTool(port) {
135
+ function setDayOffTool(port, confirmations) {
136
+ const gate = writeGate("set_day_off", confirmations);
131
137
  return {
132
138
  name: "set_day_off",
133
139
  progress: "Ustawiam dzień wolny…",
134
140
  description: "Oznacza cały wskazany dzień jako wolny dla pracownika.",
135
- parameters: {
141
+ parameters: gate.parameters({
136
142
  type: "object",
137
143
  properties: { workerId: WORKER_FIELD, date: DATE_FIELD, confirmed: CONFIRMED_FIELD },
138
144
  required: ["workerId", "date", "confirmed"],
139
145
  additionalProperties: false,
140
- },
146
+ }),
141
147
  execute: async (args) => {
142
148
  try {
149
+ const refusal = gate.refusal(args);
150
+ if (refusal)
151
+ return refusal;
143
152
  const worker = await resolveWorker(port, readString(args, "workerId"));
144
153
  const date = readDate(args);
145
154
  if (!readBoolean(args, "confirmed")) {
146
- return needsConfirmation(`Ustawię ${worker.name} dzień wolny: ${date}.`, { ...args, confirmed: true });
155
+ return gate.preview(`Ustawię ${worker.name} dzień wolny: ${date}.`, args);
147
156
  }
157
+ const refused = gate.redeem(args);
158
+ if (refused)
159
+ return refused;
148
160
  await port.setDayOff(worker.id, date);
149
161
  return ok(`${worker.name} ma wolne ${date}.`);
150
162
  }
@@ -156,19 +168,23 @@ function setDayOffTool(port) {
156
168
  },
157
169
  };
158
170
  }
159
- function setBusyHoursTool(port) {
171
+ function setBusyHoursTool(port, confirmations) {
172
+ const gate = writeGate("set_busy_hours", confirmations);
160
173
  return {
161
174
  name: "set_busy_hours",
162
175
  progress: "Zapisuję godziny zajęte…",
163
176
  description: "Ustawia godziny ZAJĘTE w konkretnym dniu — nadpisuje grafik tygodniowy tylko tego dnia. Używaj do jednorazowych nieobecności („we wtorek jestem zajęta od 12 do 15”).",
164
- parameters: {
177
+ parameters: gate.parameters({
165
178
  type: "object",
166
179
  properties: { workerId: WORKER_FIELD, date: DATE_FIELD, ranges: RANGES_FIELD, confirmed: CONFIRMED_FIELD },
167
180
  required: ["workerId", "date", "ranges", "confirmed"],
168
181
  additionalProperties: false,
169
- },
182
+ }),
170
183
  execute: async (args) => {
171
184
  try {
185
+ const refusal = gate.refusal(args);
186
+ if (refusal)
187
+ return refusal;
172
188
  const worker = await resolveWorker(port, readString(args, "workerId"));
173
189
  const date = readDate(args);
174
190
  const parsed = normalizeRanges(readString(args, "ranges"));
@@ -177,11 +193,11 @@ function setBusyHoursTool(port) {
177
193
  }
178
194
  const hours = formatRanges(parsed.ranges).join(", ");
179
195
  if (!readBoolean(args, "confirmed")) {
180
- return needsConfirmation(`Zaznaczę ${worker.name} jako zajętą ${date}: ${hours}.${noteSuffix(parsed.notes)}`, {
181
- ...args,
182
- confirmed: true,
183
- });
196
+ return gate.preview(`Zaznaczę ${worker.name} jako zajętą ${date}: ${hours}.${noteSuffix(parsed.notes)}`, args);
184
197
  }
198
+ const refused = gate.redeem(args);
199
+ if (refused)
200
+ return refused;
185
201
  await port.setDailyBusy(worker.id, date, parsed.ranges);
186
202
  return ok(`${worker.name}, ${date}: zajęte ${hours}.${noteSuffix(parsed.notes)}`);
187
203
  }
@@ -193,27 +209,31 @@ function setBusyHoursTool(port) {
193
209
  },
194
210
  };
195
211
  }
196
- function clearDailyOverrideTool(port) {
212
+ function clearDailyOverrideTool(port, confirmations) {
213
+ const gate = writeGate("clear_daily_override", confirmations);
197
214
  return {
198
215
  name: "clear_daily_override",
199
216
  progress: "Przywracam grafik tygodniowy…",
200
217
  description: "Usuwa nadpisanie dla wskazanego dnia — od tej chwili obowiązuje zwykły grafik tygodniowy.",
201
- parameters: {
218
+ parameters: gate.parameters({
202
219
  type: "object",
203
220
  properties: { workerId: WORKER_FIELD, date: DATE_FIELD, confirmed: CONFIRMED_FIELD },
204
221
  required: ["workerId", "date", "confirmed"],
205
222
  additionalProperties: false,
206
- },
223
+ }),
207
224
  execute: async (args) => {
208
225
  try {
226
+ const refusal = gate.refusal(args);
227
+ if (refusal)
228
+ return refusal;
209
229
  const worker = await resolveWorker(port, readString(args, "workerId"));
210
230
  const date = readDate(args);
211
231
  if (!readBoolean(args, "confirmed")) {
212
- return needsConfirmation(`Usunę nadpisanie dnia ${date} dla ${worker.name} — wróci grafik tygodniowy.`, {
213
- ...args,
214
- confirmed: true,
215
- });
232
+ return gate.preview(`Usunę nadpisanie dnia ${date} dla ${worker.name} — wróci grafik tygodniowy.`, args);
216
233
  }
234
+ const refused = gate.redeem(args);
235
+ if (refused)
236
+ return refused;
217
237
  await port.clearDailyOverride(worker.id, date);
218
238
  return ok(`${worker.name}, ${date}: wrócił grafik tygodniowy.`);
219
239
  }
@@ -1,6 +1,7 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
1
2
  import type { SalonAccess, SalonCorePort } from "../salon-core-port.js";
2
3
  import { type AgentTool } from "../types.js";
3
- export declare function createServiceTools(port: SalonCorePort): AgentTool[];
4
+ export declare function createServiceTools(port: SalonCorePort, confirmations?: TurnConfirmations): AgentTool[];
4
5
  /** `access` must match the port: `"public"` only for a port that reads the visitor's view. */
5
6
  export declare function listServicesTool(port: SalonCorePort, access?: SalonAccess): AgentTool;
6
7
  export declare function findServiceTypeTool(port: SalonCorePort): AgentTool;
@@ -1 +1 @@
1
- {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/tools/services.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAkC,MAAM,uBAAuB,CAAC;AACxG,OAAO,EAA8B,KAAK,SAAS,EAAmB,MAAM,aAAa,CAAC;AAoB1F,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,EAAE,CASnE;AAoBD,8FAA8F;AAC9F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,GAAE,WAAqB,GAAG,SAAS,CAmB9F;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,CAiClE"}
1
+ {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/tools/services.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAkC,MAAM,uBAAuB,CAAC;AACxG,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAwBtD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,EAAE,aAAa,CAAC,EAAE,iBAAiB,GAAG,SAAS,EAAE,CAStG;AAoBD,8FAA8F;AAC9F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,GAAE,WAAqB,GAAG,SAAS,CAmB9F;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,CAiClE"}
@@ -1,18 +1,21 @@
1
1
  import { toToolError } from "../errors.js";
2
- import { err, needsConfirmation, ok } from "../types.js";
2
+ import { err, ok } from "../types.js";
3
3
  import { ArgumentError, normalizeDuration, normalizePrice, phrasesMatch, readBoolean, readNumber, readOptionalNumber, readOptionalString, readOptionalStringArray, readString, } from "./args.js";
4
+ import { writeGate } from "./write-gate.js";
4
5
  const CONFIRMED_FIELD = {
5
6
  type: "boolean",
6
7
  description: "false dla podglądu: narzędzie nic nie zapisze i odda opis zmiany do zatwierdzenia przez właściciela. true dopiero po tym, jak właściciel potwierdzi.",
7
8
  };
8
- export function createServiceTools(port) {
9
+ /** The fields of a service a model may set — what `update_service` echoes back when it was sent. */
10
+ const SERVICE_FIELDS = ["name", "pricePln", "durationMinutes", "serviceTypeIds", "workerIds", "active"];
11
+ export function createServiceTools(port, confirmations) {
9
12
  return [
10
13
  listServicesTool(port),
11
14
  findServiceTypeTool(port),
12
15
  listWorkersTool(port),
13
- addServiceTool(port),
14
- updateServiceTool(port),
15
- removeServiceTool(port),
16
+ addServiceTool(port, confirmations),
17
+ updateServiceTool(port, confirmations),
18
+ removeServiceTool(port, confirmations),
16
19
  ];
17
20
  }
18
21
  /** What the model reads about the list depends on whose view the port returns: a public read has
@@ -102,12 +105,13 @@ function listWorkersTool(port) {
102
105
  },
103
106
  };
104
107
  }
105
- function addServiceTool(port) {
108
+ function addServiceTool(port, confirmations) {
109
+ const gate = writeGate("add_service", confirmations);
106
110
  return {
107
111
  name: "add_service",
108
112
  progress: "Dodaję usługę…",
109
113
  description: "Dodaje nową usługę do katalogu salonu. Najpierw ustal typ usługi przez find_service_type i pracowników przez list_workers.",
110
- parameters: {
114
+ parameters: gate.parameters({
111
115
  type: "object",
112
116
  properties: {
113
117
  name: { type: "string", description: "Nazwa usługi widoczna dla klientek." },
@@ -128,17 +132,23 @@ function addServiceTool(port) {
128
132
  },
129
133
  required: ["name", "pricePln", "durationMinutes", "confirmed"],
130
134
  additionalProperties: false,
131
- },
135
+ }),
132
136
  execute: async (args) => {
133
137
  try {
134
- const draft = await buildDraft(port, args, null);
138
+ // The echo is the draft laid over the model's own arguments, and a draft read from an
139
+ // echo is that echo again — resending either one confirms the same change.
140
+ const draft = readDraft(args, null);
141
+ const echo = { ...args, ...draft.input };
142
+ const refusal = gate.refusal(args, echo);
143
+ if (refusal)
144
+ return refusal;
145
+ await assertDraftReferences(port, draft.input);
135
146
  if (!readBoolean(args, "confirmed")) {
136
- return needsConfirmation(`Dodam usługę: ${await describe(port, draft.input)}.${noteSuffix(draft.notes)}`, {
137
- ...args,
138
- ...draft.input,
139
- confirmed: true,
140
- });
147
+ return gate.preview(`Dodam usługę: ${await describe(port, draft.input)}.${noteSuffix(draft.notes)}`, echo);
141
148
  }
149
+ const refused = gate.redeem(args, echo);
150
+ if (refused)
151
+ return refused;
142
152
  const serviceId = await port.addService(draft.input);
143
153
  return ok(`Dodano usługę „${draft.input.name}”.${noteSuffix(draft.notes)}`, { serviceId });
144
154
  }
@@ -150,12 +160,13 @@ function addServiceTool(port) {
150
160
  },
151
161
  };
152
162
  }
153
- function updateServiceTool(port) {
163
+ function updateServiceTool(port, confirmations) {
164
+ const gate = writeGate("update_service", confirmations);
154
165
  return {
155
166
  name: "update_service",
156
167
  progress: "Zmieniam usługę…",
157
168
  description: "Zmienia istniejącą usługę. Podaj tylko te pola, które mają się zmienić — reszta zostanie zachowana. Id weź z list_services.",
158
- parameters: {
169
+ parameters: gate.parameters({
159
170
  type: "object",
160
171
  properties: {
161
172
  salonServiceId: { type: "string", description: "Id usługi z list_services." },
@@ -169,17 +180,27 @@ function updateServiceTool(port) {
169
180
  },
170
181
  required: ["salonServiceId", "confirmed"],
171
182
  additionalProperties: false,
172
- },
183
+ }),
173
184
  execute: async (args) => {
174
185
  try {
175
186
  const serviceId = readString(args, "salonServiceId");
187
+ const echo = requestedServiceChange(serviceId, args);
188
+ const refusal = gate.refusal(args, echo);
189
+ if (refusal)
190
+ return refusal;
176
191
  const current = await findService(port, serviceId);
177
192
  if (!current)
178
193
  return err("not_found", `W tym salonie nie ma usługi o id ${serviceId}.`);
179
- const draft = await buildDraft(port, args, current);
194
+ // The summary shows the merged record the owner will end up with; the write merges
195
+ // again onto the record as it is by then.
196
+ const draft = readDraft(args, current);
197
+ await assertDraftReferences(port, draft.input);
180
198
  if (!readBoolean(args, "confirmed")) {
181
- return needsConfirmation(`Zmienię usługę „${current.name}” na: ${await describe(port, draft.input)}.${noteSuffix(draft.notes)}`, { ...args, ...draft.input, confirmed: true });
199
+ return gate.preview(`Zmienię usługę „${current.name}” na: ${await describe(port, draft.input)}.${noteSuffix(draft.notes)}`, echo);
182
200
  }
201
+ const refused = gate.redeem(args, echo);
202
+ if (refused)
203
+ return refused;
183
204
  await port.updateService(serviceId, draft.input);
184
205
  return ok(`Zmieniono usługę „${draft.input.name}”.${noteSuffix(draft.notes)}`);
185
206
  }
@@ -191,12 +212,13 @@ function updateServiceTool(port) {
191
212
  },
192
213
  };
193
214
  }
194
- function removeServiceTool(port) {
215
+ function removeServiceTool(port, confirmations) {
216
+ const gate = writeGate("remove_service", confirmations);
195
217
  return {
196
218
  name: "remove_service",
197
219
  progress: "Usuwam usługę…",
198
220
  description: "Usuwa usługę z katalogu. Operacja jest nieodwracalna — właściciel musi przepisać dokładną nazwę usługi w polu confirmationPhrase.",
199
- parameters: {
221
+ parameters: gate.parameters({
200
222
  type: "object",
201
223
  properties: {
202
224
  salonServiceId: { type: "string", description: "Id usługi z list_services." },
@@ -205,20 +227,29 @@ function removeServiceTool(port) {
205
227
  },
206
228
  required: ["salonServiceId", "confirmed"],
207
229
  additionalProperties: false,
208
- },
230
+ }),
209
231
  execute: async (args) => {
210
232
  try {
211
233
  const serviceId = readString(args, "salonServiceId");
234
+ // No confirmationPhrase in the echo: the name is the owner's to type, so a model that
235
+ // simply sends the echo back cannot delete anything.
236
+ const echo = { salonServiceId: serviceId };
237
+ const refusal = gate.refusal(args, echo);
238
+ if (refusal)
239
+ return refusal;
212
240
  const current = await findService(port, serviceId);
213
241
  if (!current)
214
242
  return err("not_found", `W tym salonie nie ma usługi o id ${serviceId}.`);
215
243
  if (!readBoolean(args, "confirmed")) {
216
- return needsConfirmation(`Usunę usługę „${current.name}” (${current.pricePln} zł, ${current.durationMinutes} min). Tego nie da się cofnąć — poproś właściciela o przepisanie nazwy usługi.`, { salonServiceId: serviceId, confirmationPhrase: current.name, confirmed: true });
244
+ return gate.preview(`Usunę usługę „${current.name}” (${current.pricePln} zł, ${current.durationMinutes} min). Tego nie da się cofnąć — poproś właścicielkę, żeby sama napisała dokładną nazwę usługi, i wstaw w confirmationPhrase.`, echo);
217
245
  }
218
246
  const phrase = readOptionalString(args, "confirmationPhrase");
219
247
  if (!phrase || !phrasesMatch(phrase, current.name)) {
220
- return err("invalid_arguments", `Aby usunąć usługę, właściciel musi przepisać jej nazwę: „${current.name}”.`);
248
+ return err("invalid_arguments", `Aby usunąć usługę, właścicielka musi sama napisać jej dokładną nazwę: „${current.name}”.`);
221
249
  }
250
+ const refused = gate.redeem(args, echo);
251
+ if (refused)
252
+ return refused;
222
253
  await port.removeService(serviceId);
223
254
  return ok(`Usunięto usługę „${current.name}”.`);
224
255
  }
@@ -230,12 +261,23 @@ function removeServiceTool(port) {
230
261
  },
231
262
  };
232
263
  }
264
+ /** `update_service`'s echo: the id and only the fields the model sent, as it sent them — never the
265
+ * merged record, which would pin fields the owner did not mention to what they were at preview. */
266
+ function requestedServiceChange(serviceId, args) {
267
+ const change = { salonServiceId: serviceId };
268
+ for (const field of SERVICE_FIELDS) {
269
+ if (args[field] !== undefined && args[field] !== null)
270
+ change[field] = args[field];
271
+ }
272
+ return change;
273
+ }
233
274
  /**
234
- * Builds the full record the canister needs. `current` is null for a new service and the existing
235
- * record for an edit — **this merge is the whole point**: `updateSalonService` replaces every
236
- * field, so a "just the price" call that forgot `workerIds` would unassign the entire team.
275
+ * Builds the full record the canister needs, without calling it. `current` is null for a new
276
+ * service and the existing record for an edit — **this merge is the whole point**:
277
+ * `updateSalonService` replaces every field, so a "just the price" call that forgot `workerIds`
278
+ * would unassign the entire team.
237
279
  */
238
- async function buildDraft(port, args, current) {
280
+ function readDraft(args, current) {
239
281
  const notes = [];
240
282
  const name = current ? (readOptionalString(args, "name") ?? current.name) : readString(args, "name");
241
283
  const rawPrice = readOptionalNumber(args, "pricePln");
@@ -249,10 +291,12 @@ async function buildDraft(port, args, current) {
249
291
  const serviceTypeIds = readOptionalStringArray(args, "serviceTypeIds") ?? current?.serviceTypeIds ?? [];
250
292
  const workerIds = readOptionalStringArray(args, "workerIds") ?? current?.workerIds ?? [];
251
293
  const active = args.active === undefined || args.active === null ? (current?.active ?? true) : readBoolean(args, "active");
252
- await assertKnownServiceTypes(port, serviceTypeIds);
253
- await assertKnownWorkers(port, workerIds);
254
294
  return { input: { name, pricePln: price, durationMinutes: duration, active, serviceTypeIds, workerIds }, notes };
255
295
  }
296
+ async function assertDraftReferences(port, input) {
297
+ await assertKnownServiceTypes(port, input.serviceTypeIds);
298
+ await assertKnownWorkers(port, input.workerIds);
299
+ }
256
300
  function normalizeOrNote(normalized, notes) {
257
301
  if (normalized.note)
258
302
  notes.push(normalized.note);
@@ -0,0 +1,29 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
2
+ import { type ToolParametersSchema, type ToolResult } from "../types.js";
3
+ /**
4
+ * The confirmation protocol of ONE write tool, in one place for all of them.
5
+ *
6
+ * `echo` is the change as the tool describes it back to the model — the arguments to send again
7
+ * with `confirmed: true`; it defaults to `args` itself. A tool must build it from `args` the same
8
+ * way on both calls, and building it from an echo must give that echo back, so a model that resends
9
+ * its own arguments and a model that resends the echo confirm the same change.
10
+ *
11
+ * Without a ledger the gate lets every `confirmed: true` through, which is the protocol as it was
12
+ * before the ledger: the rule then lives in the persona only.
13
+ */
14
+ export interface WriteGate {
15
+ /** The tool's schema, with `confirmationId` added when the tool set has a ledger. */
16
+ parameters(schema: ToolParametersSchema): ToolParametersSchema;
17
+ /** The `confirmed: false` answer: `echo` goes back with `confirmed: true` and, with a ledger,
18
+ * the id of this preview. */
19
+ preview(summary: string, echo: Record<string, unknown>): ToolResult;
20
+ /** Run first, before any canister call. For a `confirmed: true` call whose id would not redeem,
21
+ * the refusal to return; otherwise `null`. Does not use the id up, so a write that then fails
22
+ * on its arguments (a mistyped name, say) can be retried with the same id. */
23
+ refusal(args: Record<string, unknown>, echo?: Record<string, unknown>): ToolResult | null;
24
+ /** Run right before the write: uses the id up. `null` lets the write go ahead; a refusal here
25
+ * means a parallel call used the id first, or the preview expired in the meantime. */
26
+ redeem(args: Record<string, unknown>, echo?: Record<string, unknown>): ToolResult | null;
27
+ }
28
+ export declare function writeGate(toolName: string, confirmations: TurnConfirmations | undefined): WriteGate;
29
+ //# sourceMappingURL=write-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"write-gate.d.ts","sourceRoot":"","sources":["../../src/tools/write-gate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqC,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAChG,OAAO,EAA0B,KAAK,oBAAoB,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAkBjG;;;;;;;;;;GAUG;AACH,MAAM,WAAW,SAAS;IACtB,qFAAqF;IACrF,UAAU,CAAC,MAAM,EAAE,oBAAoB,GAAG,oBAAoB,CAAC;IAC/D;iCAC6B;IAC7B,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,CAAC;IACpE;;kFAE8E;IAC9E,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,GAAG,IAAI,CAAC;IAC1F;0FACsF;IACtF,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,GAAG,IAAI,CAAC;CAC5F;AAED,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,iBAAiB,GAAG,SAAS,GAAG,SAAS,CA6BnG"}
@@ -0,0 +1,37 @@
1
+ import { err, needsConfirmation } from "../types.js";
2
+ import { readBoolean, readOptionalString } from "./args.js";
3
+ const CONFIRMATION_ID_FIELD = {
4
+ type: "string",
5
+ description: "Id z podglądu (confirmed=false). Wymagane przy confirmed=true.",
6
+ };
7
+ const NO_PREVIEW = "Brak ważnego podglądu tej zmiany — wywołaj narzędzie z confirmed=false i pokaż właścicielce podsumowanie.";
8
+ const REFUSALS = {
9
+ missing: NO_PREVIEW,
10
+ unknown: NO_PREVIEW,
11
+ expired: NO_PREVIEW,
12
+ same_turn: "Zgoda musi przyjść w osobnej wiadomości właścicielki — najpierw pokaż podsumowanie i poczekaj na odpowiedź.",
13
+ mismatch: "Argumenty różnią się od pokazanego podglądu — pokaż nowy podgląd (confirmed=false).",
14
+ };
15
+ export function writeGate(toolName, confirmations) {
16
+ const decide = (args, verdict) => {
17
+ if (!confirmations || !readBoolean(args, "confirmed"))
18
+ return null;
19
+ const result = verdict(confirmations, readOptionalString(args, "confirmationId"));
20
+ return result.ok ? null : err("invalid_arguments", REFUSALS[result.reason]);
21
+ };
22
+ return {
23
+ parameters: (schema) => {
24
+ if (!confirmations)
25
+ return schema;
26
+ return { ...schema, properties: { ...schema.properties, confirmationId: CONFIRMATION_ID_FIELD } };
27
+ },
28
+ preview: (summary, echo) => {
29
+ if (!confirmations)
30
+ return needsConfirmation(summary, { ...echo, confirmed: true });
31
+ const confirmationId = confirmations.issue(toolName, echo);
32
+ return needsConfirmation(summary, { ...echo, confirmed: true, confirmationId });
33
+ },
34
+ refusal: (args, echo = args) => decide(args, (turn, confirmationId) => turn.check(toolName, echo, confirmationId)),
35
+ redeem: (args, echo = args) => decide(args, (turn, confirmationId) => turn.redeem(toolName, echo, confirmationId)),
36
+ };
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsm-mit/sultana-agent-tools-package",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Agent tool layer for a Sultana salon: framework-agnostic tools (services, promos, schedule) over the Sultana core canister wrapper, plus a read-only set that needs no identity.",
5
5
  "homepage": "https://github.com/JSM-Sultana/sultana-agent-tools-package#readme",
6
6
  "bugs": {
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@icp-sdk/core": "^6.1.0",
39
- "@jsm-mit/sultana-core-motoko-package": "^0.16.0"
39
+ "@jsm-mit/sultana-core-motoko-package": "^0.17.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@jsm-mit/utils-package": "^0.5.0",