pi-roundtable 0.9.12 → 0.10.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/CHANGELOG.md +34 -0
- package/docs/migration-0.9.md +3 -1
- package/docs/plugins.md +146 -21
- package/examples/fake-surface.test.ts +137 -0
- package/examples/fake-surface.ts +71 -14
- package/package.json +1 -1
- package/src/cli/checks/identity.ts +73 -5
- package/src/core/agents/agent-claim.ts +12 -0
- package/src/core/agents/team-turns.ts +11 -4
- package/src/core/builtin/agent-server.ts +6 -0
- package/src/core/config/access.ts +23 -3
- package/src/core/config/adapters.ts +11 -0
- package/src/core/config/schema.ts +14 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/define.ts +44 -6
- package/src/core/discord/card-audience.ts +104 -7
- package/src/core/discord/discord-owners.ts +19 -13
- package/src/core/discord/owner-card-types.ts +73 -0
- package/src/core/discord/owner-cards.ts +56 -77
- package/src/core/domain/conversation.ts +7 -0
- package/src/core/domain/ports.ts +4 -1
- package/src/core/holds.ts +60 -0
- package/src/core/i18n/cards.ts +3 -0
- package/src/core/identity/access-policy.ts +65 -2
- package/src/core/identity/caching-principal-store.ts +2 -2
- package/src/core/identity/identity-links.ts +96 -0
- package/src/core/identity/identity-plugin.ts +10 -3
- package/src/core/identity/identity-schema.ts +3 -1
- package/src/core/identity/identity-service.ts +5 -3
- package/src/core/identity/principal-store.ts +59 -142
- package/src/core/identity/principal-tables.ts +10 -2
- package/src/core/identity/principal-types.ts +146 -0
- package/src/core/interactions/prompts.ts +47 -3
- package/src/core/modules/schedules/precheck-tools.ts +115 -20
- package/src/core/routing/conversation-turns.ts +4 -1
- package/src/core/runtime/conversation-sessions.ts +8 -0
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
- package/src/core/runtime/extensions/gate-refusals.ts +133 -0
- package/src/core/runtime/extensions/held-calls.ts +91 -0
- package/src/core/runtime/extensions/hold-lookups.ts +108 -0
- package/src/core/runtime/pi-agent-runtime.ts +7 -3
- package/src/core/runtime/prompt-slot.ts +10 -2
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/services.ts +11 -0
- package/src/core/sessions.ts +7 -0
- package/src/core/testing/surface-contract-support.ts +194 -0
- package/src/core/testing/surface-contract.ts +54 -120
- package/src/core/tool-turn.ts +5 -1
- package/src/index.ts +7 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HeldCall,
|
|
3
|
+
PendingConfirmation,
|
|
4
|
+
} from "../../domain/conversation.ts";
|
|
5
|
+
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
6
|
+
import type { ApprovedBy } from "../../interactions/prompts.ts";
|
|
7
|
+
import type { Speaker } from "../../speakers.ts";
|
|
8
|
+
import { mayApproveNamed } from "./held-calls.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The speaker of a turn as the gate records them; `name` is what an approval by their message is
|
|
12
|
+
* attributed to, and `tier` tells whether they approve as an owner.
|
|
13
|
+
*/
|
|
14
|
+
export type SpeakerRef = Pick<Speaker, "id" | "principalId"> & {
|
|
15
|
+
name?: string;
|
|
16
|
+
tier?: Speaker["tier"];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A call an approval released, and who gave it, when that is known. */
|
|
20
|
+
export interface Release {
|
|
21
|
+
call: HeldCall;
|
|
22
|
+
by?: ApprovedBy;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a call needs of the gate: nothing, a hold, or a release that spends an approval. */
|
|
26
|
+
export type Needing = { held: HeldCall } | { released: ApprovedBy | undefined };
|
|
27
|
+
|
|
28
|
+
/** The text for a held call that the speaker's own message confirms. */
|
|
29
|
+
export function askSpeaker(addressee: OwnerIdentity, call: HeldCall): string {
|
|
30
|
+
const o = ownerWords(addressee);
|
|
31
|
+
return `Held for ${o.name}'s confirmation: this would ${call.action}. Do not retry it in this turn. Tell ${o.him} exactly what will happen and ask ${o.him} to confirm; once ${o.he} agrees in ${o.his} next message, make the same call with the same input and it runs.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* For a call that names approvers the speaker is neither among nor an owner of: the text that
|
|
36
|
+
* tells the model the truth about it, from the names of those who may approve; undefined when
|
|
37
|
+
* the speaker may.
|
|
38
|
+
*/
|
|
39
|
+
export function unlessNamed(
|
|
40
|
+
call: HeldCall,
|
|
41
|
+
speaker: SpeakerRef | undefined,
|
|
42
|
+
addressee: OwnerIdentity,
|
|
43
|
+
isPrivate: boolean,
|
|
44
|
+
): ((names: string[], missing: boolean) => string) | undefined {
|
|
45
|
+
if (!call.approverIds || mayApproveNamed(speaker, call)) return undefined;
|
|
46
|
+
const o = ownerWords(addressee);
|
|
47
|
+
const anyone = (call.approverIds?.length ?? 0) > 0;
|
|
48
|
+
return (names, missing) => {
|
|
49
|
+
// Names that could not be read still leave the people the tool named.
|
|
50
|
+
const named =
|
|
51
|
+
names.length === 0 && anyone
|
|
52
|
+
? ["the people named for it"]
|
|
53
|
+
: missing
|
|
54
|
+
? [...names, "others named for it"]
|
|
55
|
+
: names;
|
|
56
|
+
const who = orList([...named, "an owner"]);
|
|
57
|
+
const approval = `Only ${who} can approve it.`;
|
|
58
|
+
if (isPrivate)
|
|
59
|
+
return `Held, and ${o.name} cannot approve it here: this would ${call.action}. ${approval} A private conversation can only be answered by its person, so it stays held and nothing ${o.he} says here releases it. Do not retry it, and do not ask ${o.him} to confirm it or to say yes. Tell ${o.him} plainly that it was not done and needs ${who}.`;
|
|
60
|
+
return `Held, and ${o.name} cannot approve it: this would ${call.action}. ${approval} Do not retry it in this turn, and do not ask ${o.name} to confirm it. Tell ${o.him} plainly that it was not done, and ask ${who} to confirm in this channel; once one of them agrees in a message, make the same call with the same input and it runs.`;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The names of the principals, as far as they are known; ids are never shown. `missing` says some could not be read. */
|
|
65
|
+
export async function namesOf(
|
|
66
|
+
nameOf: ((principalId: string) => Promise<string | undefined>) | undefined,
|
|
67
|
+
principalIds: readonly string[],
|
|
68
|
+
): Promise<{ names: string[]; missing: boolean }> {
|
|
69
|
+
if (!nameOf) return { names: [], missing: principalIds.length > 0 };
|
|
70
|
+
const names = await Promise.all(
|
|
71
|
+
[...new Set(principalIds)].map(async (id) => {
|
|
72
|
+
try {
|
|
73
|
+
return await nameOf(id);
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
return {
|
|
80
|
+
names: [...new Set(names.filter((name): name is string => !!name))],
|
|
81
|
+
missing: names.some((name) => !name),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The pending hold with `call` added: a partial release kept calls that name approvers, which
|
|
87
|
+
* belong to no speaker, so what this turn holds is its speaker's from now.
|
|
88
|
+
*/
|
|
89
|
+
export function remembered(
|
|
90
|
+
current: PendingConfirmation | undefined,
|
|
91
|
+
kept: PendingConfirmation | undefined,
|
|
92
|
+
selectionId: string,
|
|
93
|
+
speaker: SpeakerRef | undefined,
|
|
94
|
+
call: HeldCall,
|
|
95
|
+
): PendingConfirmation {
|
|
96
|
+
// A partial release kept calls that name approvers, which belong to no speaker: what this turn
|
|
97
|
+
// holds is its speaker's, from now.
|
|
98
|
+
let pending = current;
|
|
99
|
+
if (pending && pending === kept) {
|
|
100
|
+
pending = { ...pending, calls: [...pending.calls] };
|
|
101
|
+
pending.selectionId = selectionId;
|
|
102
|
+
pending.heldAt = new Date();
|
|
103
|
+
delete pending.speakerId;
|
|
104
|
+
delete pending.principalId;
|
|
105
|
+
if (speaker) {
|
|
106
|
+
pending.speakerId = speaker.id;
|
|
107
|
+
pending.principalId = speaker.principalId;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
pending ??= {
|
|
111
|
+
selectionId,
|
|
112
|
+
heldAt: new Date(),
|
|
113
|
+
calls: [],
|
|
114
|
+
...(speaker
|
|
115
|
+
? {
|
|
116
|
+
speakerId: speaker.id,
|
|
117
|
+
principalId: speaker.principalId,
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
120
|
+
};
|
|
121
|
+
if (
|
|
122
|
+
!pending.calls.some((c) => c.tool === call.tool && c.input === call.input)
|
|
123
|
+
)
|
|
124
|
+
pending.calls.push(call);
|
|
125
|
+
return pending;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** "a", "a or b", "a, b or c". */
|
|
129
|
+
function orList(items: readonly string[]): string {
|
|
130
|
+
const last = items.at(-1);
|
|
131
|
+
if (items.length < 2 || last === undefined) return items.join("");
|
|
132
|
+
return `${items.slice(0, -1).join(", ")} or ${last}`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HeldCall,
|
|
3
|
+
PendingConfirmation,
|
|
4
|
+
} from "../../domain/conversation.ts";
|
|
5
|
+
import type { OwnerIdentity } from "../../identity.ts";
|
|
6
|
+
import type { ApprovedBy } from "../../interactions/prompts.ts";
|
|
7
|
+
import type { Speaker } from "../../speakers.ts";
|
|
8
|
+
|
|
9
|
+
/** Held actions older than this cannot be approved any more. */
|
|
10
|
+
export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
|
|
11
|
+
|
|
12
|
+
/** The held actions when they can still be approved at `now`; expired ones count as none. */
|
|
13
|
+
export function unexpired(
|
|
14
|
+
pending: PendingConfirmation | undefined,
|
|
15
|
+
now = new Date(),
|
|
16
|
+
): PendingConfirmation | undefined {
|
|
17
|
+
if (!pending) return undefined;
|
|
18
|
+
return now.getTime() - pending.heldAt.getTime() > CONFIRMATION_TTL_MS
|
|
19
|
+
? undefined
|
|
20
|
+
: pending;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** JSON with object keys sorted at every level, so equal inputs compare equal. */
|
|
24
|
+
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
25
|
+
export function canonicalJson(value: unknown): string {
|
|
26
|
+
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
27
|
+
return JSON.stringify(value, (_key, v: unknown) =>
|
|
28
|
+
isPlainObject(v) ? withSortedKeys(v) : v,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
33
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The same object with its keys in code-unit order. */
|
|
38
|
+
function withSortedKeys(
|
|
39
|
+
value: Record<string, unknown>,
|
|
40
|
+
): Record<string, unknown> {
|
|
41
|
+
return Object.fromEntries(
|
|
42
|
+
Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b)),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function byCodeUnit(a: string, b: string): number {
|
|
47
|
+
if (a < b) return -1;
|
|
48
|
+
return a > b ? 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The owner's approving message, preceded by the held calls it releases, so the model makes them again. */
|
|
52
|
+
export function confirmedTurnText(
|
|
53
|
+
pending: PendingConfirmation,
|
|
54
|
+
text: string,
|
|
55
|
+
owner: OwnerIdentity,
|
|
56
|
+
): string {
|
|
57
|
+
const calls = pending.calls
|
|
58
|
+
.map((call) => `- ${call.action}: ${call.tool} ${call.input}`)
|
|
59
|
+
.join("\n");
|
|
60
|
+
return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a call's `approverIds` leave the person free to approve it: always, for a call that names
|
|
65
|
+
* none, since whoever asked decides who the person is; for one that does, only an owner or a named
|
|
66
|
+
* principal. Nobody known, such as a turn without a speaker, may approve a call that names approvers.
|
|
67
|
+
*/
|
|
68
|
+
export function mayApproveNamed(
|
|
69
|
+
person:
|
|
70
|
+
| (Pick<Speaker, "principalId"> & { tier?: Speaker["tier"] })
|
|
71
|
+
| undefined,
|
|
72
|
+
call: Pick<HeldCall, "approverIds">,
|
|
73
|
+
): boolean {
|
|
74
|
+
if (!call.approverIds) return true;
|
|
75
|
+
return (
|
|
76
|
+
person !== undefined &&
|
|
77
|
+
(person.tier === "owner" || call.approverIds.includes(person.principalId))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether an approval reported for a call is one its `approverIds` accept: from a named principal or an owner. */
|
|
82
|
+
export function namedApprover(
|
|
83
|
+
call: Pick<HeldCall, "approverIds">,
|
|
84
|
+
by: ApprovedBy | undefined,
|
|
85
|
+
): boolean {
|
|
86
|
+
if (!call.approverIds) return true;
|
|
87
|
+
return (
|
|
88
|
+
by !== undefined &&
|
|
89
|
+
(by.owner === true || call.approverIds.includes(by.principalId))
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ToolTurn } from "../../define.ts";
|
|
2
|
+
import { type HoldCheck, type HoldContext, isPromise } from "../../holds.ts";
|
|
3
|
+
|
|
4
|
+
/** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
|
|
5
|
+
export interface HoldTurn {
|
|
6
|
+
/** Aborts when the turn stops; a call being described then opens no card. */
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
/** The turn a hold receives, with `signal` as the signal that ends its wait. */
|
|
9
|
+
toolTurn(signal: AbortSignal): ToolTurn;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The answer of a description wait the turn's stop cut short. */
|
|
13
|
+
export const STOPPED = Symbol("stopped");
|
|
14
|
+
|
|
15
|
+
/** What waiting for a call's description comes to: its text, or the turn stopped first. */
|
|
16
|
+
export type Described = string | undefined | typeof STOPPED;
|
|
17
|
+
|
|
18
|
+
/** A held call's description with who may approve it instead of the speaker; see `HoldRule.approversInTurn`. */
|
|
19
|
+
export type Judged =
|
|
20
|
+
| { action: string; approvers?: readonly string[] }
|
|
21
|
+
| undefined
|
|
22
|
+
| typeof STOPPED;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a lookup that answers later comes to: its answer; `fallback` when it rejects or outlasts
|
|
26
|
+
* `timeoutMs`; `STOPPED` when the turn stops first. Whatever the lookup is still doing is
|
|
27
|
+
* aborted when the wait ends.
|
|
28
|
+
*/
|
|
29
|
+
export function awaited<T>(
|
|
30
|
+
answer: Promise<T>,
|
|
31
|
+
fallback: T,
|
|
32
|
+
lookup: AbortController,
|
|
33
|
+
stop: AbortSignal | undefined,
|
|
34
|
+
timeoutMs: number,
|
|
35
|
+
): Promise<T | typeof STOPPED> {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const onStop = () => end(STOPPED);
|
|
38
|
+
const timer = setTimeout(() => end(fallback), timeoutMs);
|
|
39
|
+
let ended = false;
|
|
40
|
+
const end = (found: T | typeof STOPPED) => {
|
|
41
|
+
if (ended) return;
|
|
42
|
+
ended = true;
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
stop?.removeEventListener("abort", onStop);
|
|
45
|
+
lookup.abort();
|
|
46
|
+
resolve(found);
|
|
47
|
+
};
|
|
48
|
+
// Handled first, so a rejection after the wait ended is never left unhandled.
|
|
49
|
+
answer.then(end, () => end(fallback));
|
|
50
|
+
if (stop?.aborted) return end(STOPPED);
|
|
51
|
+
stop?.addEventListener("abort", onStop, { once: true });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A description joined by the approvers the holds name for a call they hold, looked up as the
|
|
57
|
+
* turn with the same wait as the description; a lookup that rejects or outlasts the timeout names
|
|
58
|
+
* none, so only the owners approve the call. A call no rule holds asks no one.
|
|
59
|
+
*/
|
|
60
|
+
export function joinApprovers(
|
|
61
|
+
described: Described | Promise<Described>,
|
|
62
|
+
from: { holds: HoldCheck; context: HoldContext; timeoutMs: number },
|
|
63
|
+
tool: string,
|
|
64
|
+
input: Record<string, unknown>,
|
|
65
|
+
turn: HoldTurn,
|
|
66
|
+
): Judged | Promise<Judged> {
|
|
67
|
+
const lookup = from.holds.approversInTurn;
|
|
68
|
+
if (!lookup) return withoutApprovers(described);
|
|
69
|
+
const join = (action: Described): Judged | Promise<Judged> => {
|
|
70
|
+
if (action === STOPPED || action === undefined) return action;
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
if (turn.signal?.aborted) controller.abort();
|
|
73
|
+
let named:
|
|
74
|
+
| readonly string[]
|
|
75
|
+
| undefined
|
|
76
|
+
| Promise<readonly string[] | undefined>;
|
|
77
|
+
try {
|
|
78
|
+
named = lookup.call(
|
|
79
|
+
from.holds,
|
|
80
|
+
tool,
|
|
81
|
+
input,
|
|
82
|
+
from.context,
|
|
83
|
+
turn.toolTurn(controller.signal),
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
named = [];
|
|
87
|
+
}
|
|
88
|
+
if (!isPromise(named)) {
|
|
89
|
+
controller.abort();
|
|
90
|
+
return named ? { action, approvers: named } : { action };
|
|
91
|
+
}
|
|
92
|
+
return awaited(named, [], controller, turn.signal, from.timeoutMs).then(
|
|
93
|
+
(approvers) => {
|
|
94
|
+
if (approvers === STOPPED) return STOPPED;
|
|
95
|
+
return approvers ? { action, approvers } : { action };
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
return isPromise(described) ? described.then(join) : join(described);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function withoutApprovers(
|
|
103
|
+
described: Described | Promise<Described>,
|
|
104
|
+
): Judged | Promise<Judged> {
|
|
105
|
+
const wrap = (action: Described): Judged =>
|
|
106
|
+
action === STOPPED || action === undefined ? action : { action };
|
|
107
|
+
return isPromise(described) ? described.then(wrap) : wrap(described);
|
|
108
|
+
}
|
|
@@ -160,7 +160,10 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
160
160
|
);
|
|
161
161
|
if (session.thinkingLevel !== level) session.setThinkingLevel(level);
|
|
162
162
|
const gate = await this.#sessions.gate(key, request.agent !== undefined);
|
|
163
|
-
|
|
163
|
+
// What this turn's message may release: a held call that named approvers is theirs alone.
|
|
164
|
+
const pending = request.confirmed
|
|
165
|
+
? gate.releasable(request.speaker)
|
|
166
|
+
: gate.pending();
|
|
164
167
|
const registered = new Set(session.getAllTools().map((tool) => tool.name));
|
|
165
168
|
// The speaker's tier limits the tools.
|
|
166
169
|
const { tier } = request.speaker;
|
|
@@ -216,8 +219,9 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
216
219
|
request.confirmed === true,
|
|
217
220
|
who,
|
|
218
221
|
request.speaker,
|
|
222
|
+
conversation.visibility === "private",
|
|
219
223
|
);
|
|
220
|
-
if (request.confirmed && pending) {
|
|
224
|
+
if (request.confirmed && pending && pending.calls.length > 0) {
|
|
221
225
|
logger.info(
|
|
222
226
|
{
|
|
223
227
|
channel: request.channel,
|
|
@@ -287,7 +291,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
287
291
|
try {
|
|
288
292
|
const attachments = request.attachments ?? NO_ATTACHMENTS;
|
|
289
293
|
const text =
|
|
290
|
-
request.confirmed && pending
|
|
294
|
+
request.confirmed && pending && pending.calls.length > 0
|
|
291
295
|
? confirmedTurnText(pending, request.text, who)
|
|
292
296
|
: request.text;
|
|
293
297
|
const prompted = running.run(() =>
|
|
@@ -61,9 +61,17 @@ export class PromptSlot {
|
|
|
61
61
|
const prompts = this.#prompts;
|
|
62
62
|
if (!prompts) return undefined;
|
|
63
63
|
return {
|
|
64
|
-
confirm: (title, message, signal, minTier, wait, details) =>
|
|
64
|
+
confirm: (title, message, signal, minTier, wait, details, options) =>
|
|
65
65
|
this.#waiting(() =>
|
|
66
|
-
prompts.confirm(
|
|
66
|
+
prompts.confirm(
|
|
67
|
+
title,
|
|
68
|
+
message,
|
|
69
|
+
signal,
|
|
70
|
+
minTier,
|
|
71
|
+
wait,
|
|
72
|
+
details,
|
|
73
|
+
options,
|
|
74
|
+
),
|
|
67
75
|
),
|
|
68
76
|
ask: (title, question: OwnerQuestion, signal, wait) =>
|
|
69
77
|
this.#waiting(() => prompts.ask(title, question, signal, wait)),
|
|
@@ -370,6 +370,7 @@ export class SessionFactory {
|
|
|
370
370
|
},
|
|
371
371
|
speaker: () => this.#deps.speaker(turnKey),
|
|
372
372
|
origin: () => this.#deps.origin(turnKey),
|
|
373
|
+
approvedBy: (toolCallId) => gate.approvedBy(toolCallId),
|
|
373
374
|
client: () => this.#deps.client(turnKey),
|
|
374
375
|
runTask: (task) =>
|
|
375
376
|
this.#deps.runTask({ turn: turnKey, home: channel }, task),
|
package/src/core/services.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type ServiceKey, serviceKey } from "./contract/services.ts";
|
|
|
10
10
|
import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
|
|
11
11
|
import type { PendingConfirmation } from "./domain/conversation.ts";
|
|
12
12
|
import type { HttpRoute } from "./http/listeners.ts";
|
|
13
|
+
import type { IdentityLinks } from "./identity/identity-links.ts";
|
|
13
14
|
import type { IdentityService } from "./identity/identity-service.ts";
|
|
14
15
|
import type { ThinkingSetting } from "./models.ts";
|
|
15
16
|
import type {
|
|
@@ -46,6 +47,16 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
|
|
|
46
47
|
*/
|
|
47
48
|
export const IDENTITY: ServiceKey<IdentityService> =
|
|
48
49
|
serviceKey<IdentityService>("roundtable.identity");
|
|
50
|
+
/**
|
|
51
|
+
* Links a person's own account to their principal, which `IDENTITY` cannot. Provided by the
|
|
52
|
+
* `identity` plugin only when the configuration lists providers in `access.selfLink`; read it with
|
|
53
|
+
* `find` when your plugin works without it, and with `get` when it cannot.
|
|
54
|
+
*/
|
|
55
|
+
export const IDENTITY_LINKS: ServiceKey<IdentityLinks> =
|
|
56
|
+
serviceKey<IdentityLinks>("roundtable.identity-links", {
|
|
57
|
+
absent:
|
|
58
|
+
'Self-linking is switched off. List the providers people may link in the configuration, such as access: { selfLink: { providers: ["discord"] } }.',
|
|
59
|
+
});
|
|
49
60
|
/**
|
|
50
61
|
* The runtime every conversation turn runs on: the agent server's, and each turn run through
|
|
51
62
|
* `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
|
package/src/core/sessions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ClientPort } from "./domain/client.ts";
|
|
|
3
3
|
import { PluginError } from "./errors.ts";
|
|
4
4
|
import type { Principal } from "./identity/principal-store.ts";
|
|
5
5
|
import type { OwnerIdentity } from "./identity.ts";
|
|
6
|
+
import type { ApprovedBy } from "./interactions/prompts.ts";
|
|
6
7
|
import type { Speaker } from "./speakers.ts";
|
|
7
8
|
|
|
8
9
|
/** `<surface>:<channel id>`, for example `discord:1234`. A DM is one channel. */
|
|
@@ -99,6 +100,12 @@ export interface SessionContext {
|
|
|
99
100
|
* method, which reads as undefined.
|
|
100
101
|
*/
|
|
101
102
|
origin?(): string | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Who approved the held call whose tool call has this id, while it runs; undefined for a call
|
|
105
|
+
* that ran without a hold or whose approver the surface did not name. A context built before
|
|
106
|
+
* this method has none, which reads as undefined.
|
|
107
|
+
*/
|
|
108
|
+
approvedBy?(toolCallId: string): ApprovedBy | undefined;
|
|
102
109
|
/**
|
|
103
110
|
* What the client that started the running turn can do for its tools, as `TurnRequest.client`
|
|
104
111
|
* says; undefined between turns and for a turn whose transport gives none. A context built
|
|
@@ -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
|
+
}
|