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.
- package/CHANGELOG.md +29 -0
- package/docs/migration-0.9.md +3 -1
- package/docs/plugins.md +151 -23
- 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/agent-prompt.ts +3 -1
- package/src/core/agents/agent-team.ts +1 -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/contract/providers.ts +5 -1
- 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/runtime-plugin.ts +3 -0
- package/src/core/runtime/runtime-types.ts +8 -0
- package/src/core/runtime/session-factory.ts +22 -0
- package/src/core/services.ts +11 -0
- package/src/core/sessions.ts +7 -0
- package/src/core/shared/generate-image-tool.ts +173 -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)),
|
|
@@ -168,6 +168,9 @@ export function runtimePlugin(
|
|
|
168
168
|
}),
|
|
169
169
|
confirmations: heldActions,
|
|
170
170
|
toolTiers: context.toolTiers,
|
|
171
|
+
...(providers.filled.has("images")
|
|
172
|
+
? { images: providers.images }
|
|
173
|
+
: {}),
|
|
171
174
|
stopping: () => context.queue.closed,
|
|
172
175
|
get agents() {
|
|
173
176
|
return agents();
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ExtensionFactory,
|
|
5
5
|
ModelRuntime,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { ImageDrawer } from "../contract/providers.ts";
|
|
7
8
|
import type { AgentSessions, LoadedSkill } from "../contract/runtime.ts";
|
|
8
9
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
9
10
|
import type { InterimTextMode } from "../domain/interim.ts";
|
|
@@ -97,6 +98,8 @@ export interface PiAgentRuntimeOptions {
|
|
|
97
98
|
interimPrimaryChars?: number;
|
|
98
99
|
/** How long a new session waits for its MCP tools to register. */
|
|
99
100
|
mcpConnectTimeoutMs?: number;
|
|
101
|
+
/** The host's image provider, when one fills the slot; sessions then offer `generate_image`. */
|
|
102
|
+
images?: ImageDrawer;
|
|
100
103
|
/** Defaults to true: bridge shared turns refuse raw private exchanges inaccessible to the reader. */
|
|
101
104
|
memory?: boolean;
|
|
102
105
|
}
|
|
@@ -113,6 +116,8 @@ export interface CoreExtensions {
|
|
|
113
116
|
readAttachment: ExtensionFactory;
|
|
114
117
|
/** `attach_file`, for a session with a workspace. */
|
|
115
118
|
attachFile?: ExtensionFactory;
|
|
119
|
+
/** `generate_image`, for a host with an image provider. */
|
|
120
|
+
generateImage?: ExtensionFactory;
|
|
116
121
|
confirmationGate: ExtensionFactory;
|
|
117
122
|
askUser: ExtensionFactory;
|
|
118
123
|
selfCompactGuard: ExtensionFactory;
|
|
@@ -144,6 +149,9 @@ export function sessionExtensions(
|
|
|
144
149
|
...(core.attachFile
|
|
145
150
|
? [{ name: "attach-file", factory: core.attachFile }]
|
|
146
151
|
: []),
|
|
152
|
+
...(core.generateImage
|
|
153
|
+
? [{ name: "generate-image", factory: core.generateImage }]
|
|
154
|
+
: []),
|
|
147
155
|
{ name: "confirmation-gate", factory: core.confirmationGate },
|
|
148
156
|
{ name: "ask-user", factory: core.askUser },
|
|
149
157
|
{ name: "self-compact-guard", factory: core.selfCompactGuard },
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ToolDefinition,
|
|
11
11
|
} from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { channelSegment } from "../attachments/attachment-dir.ts";
|
|
13
|
+
import type { ImageDrawer } from "../contract/providers.ts";
|
|
13
14
|
import type { ClientPort } from "../domain/client.ts";
|
|
14
15
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
15
16
|
import { ConfigError } from "../domain/errors.ts";
|
|
@@ -28,8 +29,13 @@ import {
|
|
|
28
29
|
ATTACH_FILE_TOOL,
|
|
29
30
|
attachFileExtension,
|
|
30
31
|
} from "../shared/attach-file-tool.ts";
|
|
32
|
+
import {
|
|
33
|
+
GENERATE_IMAGE_TOOL,
|
|
34
|
+
generateImageExtension,
|
|
35
|
+
} from "../shared/generate-image-tool.ts";
|
|
31
36
|
import { packageDir } from "../shared/package-dir.ts";
|
|
32
37
|
import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
|
|
38
|
+
import type { WorkspaceRoots } from "../shared/workspace-files.ts";
|
|
33
39
|
import { type Speaker, THE_SPEAKER } from "../speakers.ts";
|
|
34
40
|
import { currentToolNames } from "../tool-tiers.ts";
|
|
35
41
|
import { toolTurn } from "../tool-turn.ts";
|
|
@@ -56,6 +62,7 @@ import type { PromptSlot } from "./prompt-slot.ts";
|
|
|
56
62
|
import type {
|
|
57
63
|
ChannelSession,
|
|
58
64
|
CompactionEnd,
|
|
65
|
+
CoreExtensions,
|
|
59
66
|
PiAgentRuntimeOptions,
|
|
60
67
|
} from "./runtime-types.ts";
|
|
61
68
|
import {
|
|
@@ -142,6 +149,8 @@ export class SessionFactory {
|
|
|
142
149
|
ASK_USER_TOOL,
|
|
143
150
|
// Registered only by a session with a workspace.
|
|
144
151
|
ATTACH_FILE_TOOL,
|
|
152
|
+
// Registered only with an image provider.
|
|
153
|
+
GENERATE_IMAGE_TOOL,
|
|
145
154
|
...groups
|
|
146
155
|
.filter((group) => selection.groups.includes(group.name))
|
|
147
156
|
.flatMap((group) => group.tools),
|
|
@@ -361,6 +370,7 @@ export class SessionFactory {
|
|
|
361
370
|
},
|
|
362
371
|
speaker: () => this.#deps.speaker(turnKey),
|
|
363
372
|
origin: () => this.#deps.origin(turnKey),
|
|
373
|
+
approvedBy: (toolCallId) => gate.approvedBy(toolCallId),
|
|
364
374
|
client: () => this.#deps.client(turnKey),
|
|
365
375
|
runTask: (task) =>
|
|
366
376
|
this.#deps.runTask({ turn: turnKey, home: channel }, task),
|
|
@@ -381,6 +391,10 @@ export class SessionFactory {
|
|
|
381
391
|
...(context.workspace
|
|
382
392
|
? { attachFile: attachFileExtension(context.workspace) }
|
|
383
393
|
: {}),
|
|
394
|
+
...imageTools(
|
|
395
|
+
this.#options.images,
|
|
396
|
+
context.workspace ?? { workspace: cwd },
|
|
397
|
+
),
|
|
384
398
|
confirmationGate: confirmationGateExtension(gate, slot, (signal) =>
|
|
385
399
|
toolTurn(context, signal),
|
|
386
400
|
),
|
|
@@ -442,6 +456,14 @@ export class SessionFactory {
|
|
|
442
456
|
}
|
|
443
457
|
}
|
|
444
458
|
|
|
459
|
+
/** `generate_image` for a session of a host with an image provider; nothing without one. */
|
|
460
|
+
export function imageTools(
|
|
461
|
+
images: ImageDrawer | undefined,
|
|
462
|
+
roots: WorkspaceRoots,
|
|
463
|
+
): Pick<CoreExtensions, "generateImage"> {
|
|
464
|
+
return images ? { generateImage: generateImageExtension(images, roots) } : {};
|
|
465
|
+
}
|
|
466
|
+
|
|
445
467
|
/** Pi's bash tool with TMPDIR set to `scratchDir`, so `mktemp` and tools write there. */
|
|
446
468
|
export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
|
|
447
469
|
// SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
|
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
|