pi-roundtable 0.9.11 → 0.9.13

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/docs/migration-0.9.md +3 -1
  3. package/docs/plugins.md +151 -23
  4. package/examples/fake-surface.test.ts +137 -0
  5. package/examples/fake-surface.ts +71 -14
  6. package/package.json +1 -1
  7. package/src/cli/checks/identity.ts +73 -5
  8. package/src/core/agents/agent-claim.ts +12 -0
  9. package/src/core/agents/agent-prompt.ts +3 -1
  10. package/src/core/agents/agent-team.ts +1 -0
  11. package/src/core/agents/team-turns.ts +11 -4
  12. package/src/core/builtin/agent-server.ts +6 -0
  13. package/src/core/config/access.ts +23 -3
  14. package/src/core/config/adapters.ts +11 -0
  15. package/src/core/config/schema.ts +14 -0
  16. package/src/core/contract/providers.ts +5 -1
  17. package/src/core/define-roundtable.ts +1 -0
  18. package/src/core/define.ts +44 -6
  19. package/src/core/discord/card-audience.ts +104 -7
  20. package/src/core/discord/discord-owners.ts +19 -13
  21. package/src/core/discord/owner-card-types.ts +73 -0
  22. package/src/core/discord/owner-cards.ts +56 -77
  23. package/src/core/domain/conversation.ts +7 -0
  24. package/src/core/domain/ports.ts +4 -1
  25. package/src/core/holds.ts +60 -0
  26. package/src/core/i18n/cards.ts +3 -0
  27. package/src/core/identity/access-policy.ts +65 -2
  28. package/src/core/identity/caching-principal-store.ts +2 -2
  29. package/src/core/identity/identity-links.ts +96 -0
  30. package/src/core/identity/identity-plugin.ts +10 -3
  31. package/src/core/identity/identity-schema.ts +3 -1
  32. package/src/core/identity/identity-service.ts +5 -3
  33. package/src/core/identity/principal-store.ts +59 -142
  34. package/src/core/identity/principal-tables.ts +10 -2
  35. package/src/core/identity/principal-types.ts +146 -0
  36. package/src/core/interactions/prompts.ts +47 -3
  37. package/src/core/modules/schedules/precheck-tools.ts +115 -20
  38. package/src/core/routing/conversation-turns.ts +4 -1
  39. package/src/core/runtime/conversation-sessions.ts +8 -0
  40. package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
  41. package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
  42. package/src/core/runtime/extensions/gate-refusals.ts +133 -0
  43. package/src/core/runtime/extensions/held-calls.ts +91 -0
  44. package/src/core/runtime/extensions/hold-lookups.ts +108 -0
  45. package/src/core/runtime/pi-agent-runtime.ts +7 -3
  46. package/src/core/runtime/prompt-slot.ts +10 -2
  47. package/src/core/runtime/runtime-plugin.ts +3 -0
  48. package/src/core/runtime/runtime-types.ts +8 -0
  49. package/src/core/runtime/session-factory.ts +22 -0
  50. package/src/core/services.ts +11 -0
  51. package/src/core/sessions.ts +7 -0
  52. package/src/core/shared/generate-image-tool.ts +173 -0
  53. package/src/core/testing/surface-contract-support.ts +194 -0
  54. package/src/core/testing/surface-contract.ts +54 -120
  55. package/src/core/tool-turn.ts +5 -1
  56. package/src/index.ts +7 -0
@@ -0,0 +1,173 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import type { ImageDrawer, ReferenceImage } from "../contract/providers.ts";
6
+ import {
7
+ attachReplyFile,
8
+ REPLY_FILE_LIMITS,
9
+ ReplyFileError,
10
+ } from "../reply-files.ts";
11
+ import { toolError, toolText } from "./tool-result.ts";
12
+ import {
13
+ findWorkspaceFile,
14
+ readWorkspaceFile,
15
+ WorkspaceFileError,
16
+ type WorkspaceRoots,
17
+ } from "./workspace-files.ts";
18
+
19
+ export const GENERATE_IMAGE_TOOL = "generate_image";
20
+
21
+ /** The limits on the reference images one call passes to the drawer. */
22
+ export const REFERENCE_IMAGE_LIMITS = Object.freeze({
23
+ maxImages: 4,
24
+ maxImageBytes: 20 * 1024 * 1024,
25
+ maxTotalBytes: 40 * 1024 * 1024,
26
+ });
27
+
28
+ const MAX_PROMPT_CHARS = 4000;
29
+
30
+ /** The image type of `data` by its content, or undefined for anything else. */
31
+ export function imageTypeOf(
32
+ data: Uint8Array,
33
+ ): { mimeType: string; ext: string } | undefined {
34
+ const starts = (bytes: readonly number[], at = 0) =>
35
+ bytes.every((byte, i) => data[at + i] === byte);
36
+ if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
37
+ return { mimeType: "image/png", ext: "png" };
38
+ if (starts([0xff, 0xd8, 0xff])) return { mimeType: "image/jpeg", ext: "jpg" };
39
+ if (starts([0x47, 0x49, 0x46, 0x38]))
40
+ return { mimeType: "image/gif", ext: "gif" };
41
+ if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8))
42
+ return { mimeType: "image/webp", ext: "webp" };
43
+ return undefined;
44
+ }
45
+
46
+ async function readReferences(
47
+ paths: readonly string[],
48
+ roots: WorkspaceRoots,
49
+ ): Promise<ReferenceImage[]> {
50
+ const { maxImages, maxImageBytes, maxTotalBytes } = REFERENCE_IMAGE_LIMITS;
51
+ if (paths.length > maxImages)
52
+ throw new WorkspaceFileError(
53
+ `${paths.length} reference images given; at most ${maxImages} are allowed.`,
54
+ );
55
+ const files = await Promise.all(
56
+ paths.map((path) => findWorkspaceFile(path, roots)),
57
+ );
58
+ let total = 0;
59
+ for (const file of files) {
60
+ if (file.size > maxImageBytes)
61
+ throw new WorkspaceFileError(
62
+ `${file.path} is ${file.size} bytes; a reference image may contain at most ${maxImageBytes} bytes.`,
63
+ );
64
+ total += file.size;
65
+ }
66
+ if (total > maxTotalBytes)
67
+ throw new WorkspaceFileError(
68
+ `the reference images total ${total} bytes; at most ${maxTotalBytes} are allowed.`,
69
+ );
70
+ const references: ReferenceImage[] = [];
71
+ for (const file of files) {
72
+ const data = await readWorkspaceFile(file, maxImageBytes);
73
+ const type = imageTypeOf(data);
74
+ if (!type)
75
+ throw new WorkspaceFileError(
76
+ `${file.path} is not a PNG, JPEG, WebP or GIF image.`,
77
+ );
78
+ references.push({
79
+ data: Buffer.from(data).toString("base64"),
80
+ mimeType: type.mimeType,
81
+ });
82
+ }
83
+ return references;
84
+ }
85
+
86
+ /**
87
+ * Registers `generate_image`, which draws a new picture with the host's image provider, saves it
88
+ * under `<scratch dir or workspace>/drawings`, and attaches it to the turn's reply.
89
+ */
90
+ export function generateImageExtension(
91
+ draw: ImageDrawer,
92
+ roots: WorkspaceRoots,
93
+ ): ExtensionFactory {
94
+ return (pi) => {
95
+ pi.registerTool({
96
+ name: GENERATE_IMAGE_TOOL,
97
+ label: "Generate image",
98
+ description:
99
+ 'Generate a new picture from a description. Describe the subject, style and composition; say "no text" when you will add text to it later. The picture is saved to a file whose path the result names, and attached to your reply.',
100
+ parameters: Type.Object(
101
+ {
102
+ prompt: Type.String({
103
+ minLength: 1,
104
+ maxLength: MAX_PROMPT_CHARS,
105
+ description:
106
+ "What to draw: subject, style, composition, and anything to leave out.",
107
+ }),
108
+ reference_paths: Type.Optional(
109
+ Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), {
110
+ maxItems: REFERENCE_IMAGE_LIMITS.maxImages,
111
+ description:
112
+ "Paths of PNG, JPEG, WebP or GIF images inside the workspace or the scratch dir to draw from.",
113
+ }),
114
+ ),
115
+ },
116
+ { additionalProperties: false },
117
+ ),
118
+ execute: async (_toolCallId, params, signal) => {
119
+ let references: ReferenceImage[];
120
+ try {
121
+ references = await readReferences(
122
+ params.reference_paths ?? [],
123
+ roots,
124
+ );
125
+ } catch (error) {
126
+ if (error instanceof WorkspaceFileError)
127
+ return toolError(`Not drawn: ${error.message}`);
128
+ throw error;
129
+ }
130
+ signal?.throwIfAborted();
131
+ let data: Uint8Array;
132
+ try {
133
+ data = await draw(
134
+ params.prompt,
135
+ references,
136
+ signal ? { signal } : {},
137
+ );
138
+ } catch (error) {
139
+ signal?.throwIfAborted();
140
+ return toolError(
141
+ `The image provider did not draw it: ${error instanceof Error ? error.message : String(error)}`,
142
+ );
143
+ }
144
+ signal?.throwIfAborted();
145
+ const type = imageTypeOf(data);
146
+ if (!type)
147
+ return toolError(
148
+ "The image provider returned something that is not a PNG, JPEG, WebP or GIF image.",
149
+ );
150
+ const dir = join(roots.scratchDir ?? roots.workspace, "drawings");
151
+ await mkdir(dir, { recursive: true });
152
+ const name = `generated-${new Date().toISOString().replace(/[:.]/g, "-")}.${type.ext}`;
153
+ const path = join(dir, name);
154
+ await writeFile(path, data);
155
+ try {
156
+ if (data.byteLength > REPLY_FILE_LIMITS.maxFileBytes)
157
+ throw new ReplyFileError(
158
+ `it is ${data.byteLength} bytes; a reply file may contain at most ${REPLY_FILE_LIMITS.maxFileBytes} bytes.`,
159
+ );
160
+ attachReplyFile({ name, data });
161
+ } catch (error) {
162
+ if (!(error instanceof ReplyFileError)) throw error;
163
+ return toolText(
164
+ `Drew the picture and saved it to ${path} (${data.byteLength} bytes), but it is not attached: ${error.message}`,
165
+ );
166
+ }
167
+ return toolText(
168
+ `Drew the picture, saved it to ${path} (${data.byteLength} bytes); it will appear with your reply.`,
169
+ );
170
+ },
171
+ });
172
+ };
173
+ }
@@ -0,0 +1,194 @@
1
+ import type { InboundMessage } from "../contract/channels.ts";
2
+ import {
3
+ type Approval,
4
+ type ApprovedBy,
5
+ type PromptScope,
6
+ promptScope,
7
+ } from "../interactions/prompts.ts";
8
+ import type { Speaker } from "../speakers.ts";
9
+ import type {
10
+ SurfaceContractAnswerer,
11
+ SurfaceContractSubject,
12
+ SurfaceObservation,
13
+ } from "./surface-contract.ts";
14
+
15
+ export interface Running {
16
+ subject: SurfaceContractSubject;
17
+ delivered: InboundMessage[];
18
+ }
19
+
20
+ export class ContractBroken extends Error {
21
+ override name = "ContractBroken";
22
+ }
23
+
24
+ export function expectThat(condition: boolean, message: string): void {
25
+ if (!condition) throw new ContractBroken(message);
26
+ }
27
+
28
+ export async function until(
29
+ done: () => boolean,
30
+ what: string,
31
+ ms = 2000,
32
+ ): Promise<void> {
33
+ const deadline = Date.now() + ms;
34
+ while (!done()) {
35
+ if (Date.now() > deadline)
36
+ throw new ContractBroken(`timed out waiting until ${what}`);
37
+ await Bun.sleep(5);
38
+ }
39
+ }
40
+
41
+ export const seen = (running: Running, kind: SurfaceObservation["kind"]) =>
42
+ running.subject.observations().filter((o) => o.kind === kind);
43
+
44
+ export const speakerOf = (subject: SurfaceContractSubject): Speaker =>
45
+ subject.speaker ?? {
46
+ id: "contract-speaker",
47
+ name: "Ada",
48
+ tier: "owner",
49
+ principalId: "contract-speaker",
50
+ };
51
+
52
+ /** The person's prompt scope in a conversation of this visibility. */
53
+ export const scopeOf = (
54
+ subject: SurfaceContractSubject,
55
+ visibility: "private" | "shared" = "shared",
56
+ ): PromptScope => promptScope(speakerOf(subject), visibility);
57
+
58
+ /** The newest prompt the person has seen, once one more than `before` is open. */
59
+ export async function nextPrompt(
60
+ running: Running,
61
+ before: number,
62
+ ): Promise<string> {
63
+ await until(
64
+ () => seen(running, "prompt").length > before,
65
+ "the person sees the approval",
66
+ );
67
+ const prompt = seen(running, "prompt").at(-1);
68
+ if (prompt?.kind !== "prompt") throw new ContractBroken("no prompt seen");
69
+ return prompt.id;
70
+ }
71
+
72
+ /** Asks for one approval and answers it as the person does. */
73
+ export async function approval(running: Running, approved: boolean) {
74
+ const { surface, channel, answer } = running.subject;
75
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
76
+ if (!prompts || !answer)
77
+ throw new ContractBroken("the surface shows no prompts");
78
+ const before = seen(running, "prompt").length;
79
+ const result = prompts.confirm(
80
+ "Approve?",
81
+ "Send the report.",
82
+ undefined,
83
+ speakerOf(running.subject).tier,
84
+ );
85
+ await answer(await nextPrompt(running, before), approved);
86
+ return result;
87
+ }
88
+
89
+ /**
90
+ * Opens the person's approval in a conversation of `visibility`, has `other` answer it by its id,
91
+ * and checks that only the person's own answer settles it.
92
+ */
93
+ export async function answeredOnlyByThem(
94
+ running: Running,
95
+ other: SurfaceContractAnswerer | undefined,
96
+ visibility: "private" | "shared",
97
+ approvers?: readonly string[],
98
+ ): Promise<void> {
99
+ const { surface, channel, answer } = running.subject;
100
+ const prompts = surface.prompts?.(
101
+ channel,
102
+ scopeOf(running.subject, visibility),
103
+ );
104
+ if (!prompts || !answer || !other)
105
+ throw new ContractBroken("the surface shows no prompts");
106
+ await other.join?.();
107
+ const before = seen(running, "prompt").length;
108
+ let settled: Approval | undefined;
109
+ const result = prompts
110
+ .confirm(
111
+ "Approve?",
112
+ "Send the report.",
113
+ undefined,
114
+ speakerOf(running.subject).tier,
115
+ undefined,
116
+ undefined,
117
+ approvers ? { approvers } : undefined,
118
+ )
119
+ .then((value) => {
120
+ settled = value;
121
+ return value;
122
+ });
123
+ const id = await nextPrompt(running, before);
124
+ await other.answer(id, true);
125
+ await Bun.sleep(50);
126
+ expectThat(
127
+ settled === undefined,
128
+ `another principal's answer settled the approval as ${settled}`,
129
+ );
130
+ await answer(id, false);
131
+ const own = await result;
132
+ expectThat(own === "declined", `the person's answer came back ${own}`);
133
+ }
134
+
135
+ /**
136
+ * Opens the person's approval in a shared conversation naming the approver, and checks that
137
+ * another person and the person themselves cannot answer it, and the approver can, who is then
138
+ * reported as the approver.
139
+ */
140
+ export async function namedApproverAnswers(running: Running): Promise<void> {
141
+ const { surface, channel, answer, stranger, approver } = running.subject;
142
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
143
+ if (!prompts || !answer || !stranger || !approver)
144
+ throw new ContractBroken("the surface shows no prompts");
145
+ await stranger.join?.();
146
+ await approver.join?.();
147
+ const before = seen(running, "prompt").length;
148
+ let settled: Approval | undefined;
149
+ let reported: ApprovedBy | undefined;
150
+ const result = prompts
151
+ .confirm(
152
+ "Approve?",
153
+ "Send the report.",
154
+ undefined,
155
+ speakerOf(running.subject).tier,
156
+ undefined,
157
+ undefined,
158
+ {
159
+ approvers: [approver.principalId],
160
+ approvedBy: (by) => {
161
+ reported = by;
162
+ },
163
+ },
164
+ )
165
+ .then((value) => {
166
+ settled = value;
167
+ return value;
168
+ });
169
+ const id = await nextPrompt(running, before);
170
+ await stranger.answer(id, true);
171
+ await Bun.sleep(50);
172
+ expectThat(
173
+ settled === undefined,
174
+ `another person's answer settled the named approver's approval as ${settled}`,
175
+ );
176
+ await answer(id, true);
177
+ await Bun.sleep(50);
178
+ expectThat(
179
+ settled === undefined,
180
+ `the speaker's answer settled an approval that names others as ${settled}`,
181
+ );
182
+ await approver.answer(id, true);
183
+ const own = await result;
184
+ expectThat(own === "approved", `the approver's answer came back ${own}`);
185
+ // The runtime accepts an approval of a call that names approvers only from one it names.
186
+ expectThat(
187
+ reported !== undefined,
188
+ "the approval did not report who approved it through approvedBy",
189
+ );
190
+ expectThat(
191
+ reported?.principalId === approver.principalId,
192
+ `the approval reported ${reported?.principalId} as its approver, not ${approver.principalId}`,
193
+ );
194
+ }
@@ -1,15 +1,23 @@
1
1
  import { describe, test } from "bun:test";
2
- import type { InboundMessage } from "../contract/channels.ts";
3
2
  import type { ChatSurface } from "../contract/surface.ts";
4
3
  import { parseChannelKey } from "../contract/surface.ts";
5
4
  import type { TurnProgress } from "../domain/progress.ts";
6
- import {
7
- type Approval,
8
- type PromptScope,
9
- promptScope,
10
- } from "../interactions/prompts.ts";
5
+ import type { Approval } from "../interactions/prompts.ts";
11
6
  import type { ChannelKey } from "../sessions.ts";
12
7
  import type { Speaker } from "../speakers.ts";
8
+ import {
9
+ answeredOnlyByThem,
10
+ approval,
11
+ ContractBroken,
12
+ expectThat,
13
+ namedApproverAnswers,
14
+ nextPrompt,
15
+ type Running,
16
+ scopeOf,
17
+ seen,
18
+ speakerOf,
19
+ until,
20
+ } from "./surface-contract-support.ts";
13
21
 
14
22
  /** What the person on a surface's network has seen, as the contract reads it. */
15
23
  export type SurfaceObservation =
@@ -55,6 +63,20 @@ export interface SurfaceContractSubject {
55
63
  * check is skipped.
56
64
  */
57
65
  owner?: SurfaceContractAnswerer;
66
+ /**
67
+ * Another principal, not an owner, who holds the tier of the person's approvals and whom an
68
+ * approval may name in `approvers`; they know the id of the person's open prompt and answer it.
69
+ * A surface must report them through `ApprovalOptions.approvedBy`, because the runtime accepts
70
+ * an approval of a call that names approvers only from one it names. Without it the
71
+ * named-approver checks are skipped.
72
+ */
73
+ approver?: SurfaceContractAnswerer & { principalId: string };
74
+ /**
75
+ * Set by a surface that serves only conversations private to one person, such as the web chat:
76
+ * it has no shared conversation in which an approval is the named approvers', so it skips that
77
+ * check and keeps the one that its person alone answers a private conversation's.
78
+ */
79
+ privateOnly?: boolean;
58
80
  close?(): Promise<void>;
59
81
  }
60
82
 
@@ -71,11 +93,6 @@ export interface SurfaceContractFailure {
71
93
  error: unknown;
72
94
  }
73
95
 
74
- interface Running {
75
- subject: SurfaceContractSubject;
76
- delivered: InboundMessage[];
77
- }
78
-
79
96
  interface Check {
80
97
  name: string;
81
98
  /** Whether the check applies to this subject; a check that does not is skipped. */
@@ -83,72 +100,6 @@ interface Check {
83
100
  run(running: Running): Promise<void>;
84
101
  }
85
102
 
86
- class ContractBroken extends Error {
87
- override name = "ContractBroken";
88
- }
89
-
90
- function expectThat(condition: boolean, message: string): void {
91
- if (!condition) throw new ContractBroken(message);
92
- }
93
-
94
- async function until(
95
- done: () => boolean,
96
- what: string,
97
- ms = 2000,
98
- ): Promise<void> {
99
- const deadline = Date.now() + ms;
100
- while (!done()) {
101
- if (Date.now() > deadline)
102
- throw new ContractBroken(`timed out waiting until ${what}`);
103
- await Bun.sleep(5);
104
- }
105
- }
106
-
107
- const seen = (running: Running, kind: SurfaceObservation["kind"]) =>
108
- running.subject.observations().filter((o) => o.kind === kind);
109
-
110
- const speakerOf = (subject: SurfaceContractSubject): Speaker =>
111
- subject.speaker ?? {
112
- id: "contract-speaker",
113
- name: "Ada",
114
- tier: "owner",
115
- principalId: "contract-speaker",
116
- };
117
-
118
- /** The person's prompt scope in a conversation of this visibility. */
119
- const scopeOf = (
120
- subject: SurfaceContractSubject,
121
- visibility: "private" | "shared" = "shared",
122
- ): PromptScope => promptScope(speakerOf(subject), visibility);
123
-
124
- /** The newest prompt the person has seen, once one more than `before` is open. */
125
- async function nextPrompt(running: Running, before: number): Promise<string> {
126
- await until(
127
- () => seen(running, "prompt").length > before,
128
- "the person sees the approval",
129
- );
130
- const prompt = seen(running, "prompt").at(-1);
131
- if (prompt?.kind !== "prompt") throw new ContractBroken("no prompt seen");
132
- return prompt.id;
133
- }
134
-
135
- /** Asks for one approval and answers it as the person does. */
136
- async function approval(running: Running, approved: boolean) {
137
- const { surface, channel, answer } = running.subject;
138
- const prompts = surface.prompts?.(channel, scopeOf(running.subject));
139
- if (!prompts || !answer)
140
- throw new ContractBroken("the surface shows no prompts");
141
- const before = seen(running, "prompt").length;
142
- const result = prompts.confirm(
143
- "Approve?",
144
- "Send the report.",
145
- undefined,
146
- speakerOf(running.subject).tier,
147
- );
148
- await answer(await nextPrompt(running, before), approved);
149
- return result;
150
- }
151
-
152
103
  const CHECKS: readonly Check[] = [
153
104
  {
154
105
  name: "names a surface prefix without a colon, and owns the subject's channel",
@@ -331,6 +282,28 @@ const CHECKS: readonly Check[] = [
331
282
  run: (running) =>
332
283
  answeredOnlyByThem(running, running.subject.owner, "private"),
333
284
  },
285
+ {
286
+ name: "a named approver answers an approval instead of the person; the person and another person cannot",
287
+ applies: (subject) =>
288
+ subject.surface.prompts !== undefined &&
289
+ subject.answer !== undefined &&
290
+ subject.stranger !== undefined &&
291
+ subject.approver !== undefined &&
292
+ subject.privateOnly !== true,
293
+ run: namedApproverAnswers,
294
+ },
295
+ {
296
+ name: "a private conversation is answered by its person alone, whoever else a call names",
297
+ applies: (subject) =>
298
+ subject.surface.prompts !== undefined &&
299
+ subject.answer !== undefined &&
300
+ subject.approver !== undefined,
301
+ run: (running) =>
302
+ answeredOnlyByThem(running, running.subject.approver, "private", [
303
+ speakerOf(running.subject).principalId,
304
+ running.subject.approver?.principalId ?? "",
305
+ ]),
306
+ },
334
307
  {
335
308
  name: "a private conversation's approval above the person's tier goes to no one: it comes back at once, unshown",
336
309
  applies: (subject) =>
@@ -361,48 +334,6 @@ const CHECKS: readonly Check[] = [
361
334
  },
362
335
  ];
363
336
 
364
- /**
365
- * Opens the person's approval in a conversation of `visibility`, has `other` answer it by its id,
366
- * and checks that only the person's own answer settles it.
367
- */
368
- async function answeredOnlyByThem(
369
- running: Running,
370
- other: SurfaceContractAnswerer | undefined,
371
- visibility: "private" | "shared",
372
- ): Promise<void> {
373
- const { surface, channel, answer } = running.subject;
374
- const prompts = surface.prompts?.(
375
- channel,
376
- scopeOf(running.subject, visibility),
377
- );
378
- if (!prompts || !answer || !other)
379
- throw new ContractBroken("the surface shows no prompts");
380
- await other.join?.();
381
- const before = seen(running, "prompt").length;
382
- let settled: Approval | undefined;
383
- const result = prompts
384
- .confirm(
385
- "Approve?",
386
- "Send the report.",
387
- undefined,
388
- speakerOf(running.subject).tier,
389
- )
390
- .then((value) => {
391
- settled = value;
392
- return value;
393
- });
394
- const id = await nextPrompt(running, before);
395
- await other.answer(id, true);
396
- await Bun.sleep(50);
397
- expectThat(
398
- settled === undefined,
399
- `another principal's answer settled the approval as ${settled}`,
400
- );
401
- await answer(id, false);
402
- const own = await result;
403
- expectThat(own === "declined", `the person's answer came back ${own}`);
404
- }
405
-
406
337
  async function runCheck(
407
338
  check: Check,
408
339
  make: () => Promise<SurfaceContractSubject>,
@@ -445,7 +376,10 @@ export async function checkSurfaceContract(
445
376
  * where it offers them, delivers files, ends typing and stop controls idempotently, shows
446
377
  * progress, resolves approvals as the person answers or as a stopped turn cancels them, ignores
447
378
  * another person's answer to the person's approval, and, in a private conversation, an owner's
448
- * too, and lets an approval above the person's tier there come back at once, shown to no one. Each
379
+ * too, and lets an approval above the person's tier there come back at once, shown to no one.
380
+ * Where it can name approvers, an approval that does is answered by them, reported as the
381
+ * approver, and not by the person or another person, and a private conversation's is answered by
382
+ * its person alone, whoever the call names besides them. Each
449
383
  * check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
450
384
  */
451
385
  export function describeSurfaceContract(
@@ -31,13 +31,16 @@ function clientIn(port: ClientPort, turn: AbortSignal | undefined): ClientPort {
31
31
 
32
32
  /**
33
33
  * The turn a tool's `run` and `hold` receive, built from the session that makes the call and the
34
- * signal that stops it.
34
+ * signal that stops it. For a call being run, `toolCallId` finds who approved it.
35
35
  */
36
36
  export function toolTurn(
37
37
  context: SessionContext,
38
38
  signal: AbortSignal | undefined,
39
+ toolCallId?: string,
39
40
  ): ToolTurn {
40
41
  const origin = context.origin?.();
42
+ const approvedBy =
43
+ toolCallId === undefined ? undefined : context.approvedBy?.(toolCallId);
41
44
  const client = context.client?.();
42
45
  return {
43
46
  speaker: context.speaker(),
@@ -45,6 +48,7 @@ export function toolTurn(
45
48
  agent: context.agent,
46
49
  signal,
47
50
  ...(origin === undefined ? {} : { origin }),
51
+ ...(approvedBy === undefined ? {} : { approvedBy }),
48
52
  ...(client === undefined ? {} : { client: clientIn(client, signal) }),
49
53
  ...(context.workspace ? { workspace: context.workspace } : {}),
50
54
  attachFile: attachReplyFile,
package/src/index.ts CHANGED
@@ -166,8 +166,12 @@ export type {
166
166
  AccessOwner,
167
167
  AccessRules,
168
168
  AccessTier,
169
+ Provisioning,
170
+ ProvisioningRule,
171
+ SelfLinkRules,
169
172
  } from "./core/identity/access-policy.ts";
170
173
  export type { ActorFacts } from "./core/identity/actor-facts.ts";
174
+ export type { IdentityLinks } from "./core/identity/identity-links.ts";
171
175
  export type { IdentityService } from "./core/identity/identity-service.ts";
172
176
  export type {
173
177
  IdentityLink,
@@ -182,6 +186,8 @@ export type {
182
186
  Approval,
183
187
  ApprovalDetails,
184
188
  ApprovalFile,
189
+ ApprovalOptions,
190
+ ApprovedBy,
185
191
  AskOption,
186
192
  LateAnswer,
187
193
  OwnerAnswer,
@@ -293,6 +299,7 @@ export {
293
299
  CONVERSATIONS,
294
300
  DELEGATION,
295
301
  IDENTITY,
302
+ IDENTITY_LINKS,
296
303
  MEMORY,
297
304
  PRECHECKS,
298
305
  RUNTIME,