pi-roundtable 0.22.4 → 0.23.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 +61 -1
- package/README.md +36 -10
- package/README.zh-TW.md +51 -13
- package/docs/migrating-0.8.md +2 -2
- package/docs/plugins.md +350 -28
- package/examples/discord-adapter.ts +9 -1
- package/examples/seeds.test.ts +2 -2
- package/examples/seeds.ts +1 -2
- package/package.json +7 -3
- package/src/characters/catalog.ts +1055 -0
- package/src/characters/characters.css +68 -0
- package/src/characters/colors.ts +108 -0
- package/src/characters/compose.ts +281 -0
- package/src/characters/css.ts +63 -0
- package/src/characters/index.ts +33 -0
- package/src/characters/parse.ts +196 -0
- package/src/characters/parts-face.ts +354 -0
- package/src/characters/parts-hair.ts +56 -0
- package/src/characters/parts-hats.ts +57 -0
- package/src/characters/parts-outfits.ts +81 -0
- package/src/characters/parts-props.ts +137 -0
- package/src/characters/spec.ts +224 -0
- package/src/characters/suggest.ts +101 -0
- package/src/cli/checks/characters.ts +23 -0
- package/src/cli/checks/images.ts +5 -3
- package/src/cli/doctor.ts +2 -0
- package/src/core/agents/agent-claim.ts +30 -11
- package/src/core/agents/agent-dashboard.ts +1 -1
- package/src/core/agents/agent-messages.ts +43 -15
- package/src/core/agents/agent-prompt.ts +2 -4
- package/src/core/agents/agent-rules.ts +42 -3
- package/src/core/agents/agent-schema.ts +37 -15
- package/src/core/agents/agent-store.ts +57 -13
- package/src/core/agents/agent-team-fixture.ts +90 -14
- package/src/core/agents/agent-team.ts +83 -50
- package/src/core/agents/agent-tools.ts +94 -41
- package/src/core/agents/avatar-studio.ts +40 -4
- package/src/core/agents/character-png.ts +40 -0
- package/src/core/agents/group-messages.ts +7 -4
- package/src/core/agents/group-turns.ts +27 -14
- package/src/core/agents/team-editing.ts +99 -58
- package/src/core/agents/team-keys.ts +177 -11
- package/src/core/agents/team-lifecycle.ts +151 -84
- package/src/core/agents/team-options.ts +13 -6
- package/src/core/agents/team-status.ts +31 -13
- package/src/core/agents/team-text.ts +13 -3
- package/src/core/agents/team-turn-types.ts +25 -7
- package/src/core/agents/team-turns.ts +143 -28
- package/src/core/attachments/attachment-fetcher.ts +162 -64
- package/src/core/attachments/prompt-block.ts +7 -1
- package/src/core/attachments/tool-attachment.ts +20 -1
- package/src/core/attachments/turn-attachments.ts +18 -2
- package/src/core/builtin/agent-server.ts +103 -40
- package/src/core/builtin/discord.ts +4 -17
- package/src/core/builtin/modules.ts +6 -5
- package/src/core/builtin/threads.ts +37 -0
- package/src/core/config/agent-team.ts +43 -0
- package/src/core/config/config.ts +50 -59
- package/src/core/config/limits.ts +93 -0
- package/src/core/config/ops.ts +34 -0
- package/src/core/config/schema.ts +26 -0
- package/src/core/contract/channels.ts +20 -0
- package/src/core/contract/surface.ts +77 -7
- package/src/core/define-roundtable.ts +70 -42
- package/src/core/discord/agent-commands.ts +108 -86
- package/src/core/discord/agent-panel.ts +11 -2
- package/src/core/discord/connection.ts +1 -1
- package/src/core/discord/discord-surface.ts +10 -1
- package/src/core/discord/dispatch-thread-host.ts +1 -1
- package/src/core/domain/conversation.ts +24 -0
- package/src/core/holds.ts +221 -15
- package/src/core/host-registration.ts +2 -1
- package/src/core/http/listeners.ts +63 -3
- package/src/core/http/websocket.ts +2 -1
- package/src/core/i18n/agent-panel.ts +14 -21
- package/src/core/interactions/prompts.ts +12 -0
- package/src/core/modes/autonomy.ts +23 -0
- package/src/core/modes/conversation-modes.ts +213 -0
- package/src/core/modes/modes-plugin.ts +24 -0
- package/src/core/modules/delegation/delegator.ts +4 -4
- package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
- package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
- package/src/core/modules/host-shell/shell-lexer.ts +493 -0
- package/src/core/modules/host-shell/shell-paths.ts +85 -4
- package/src/core/modules/host-shell/shell-policy.ts +341 -332
- package/src/core/modules/host-shell/shell-programs.ts +298 -0
- package/src/core/modules/host-shell/shell-quoting.ts +237 -0
- package/src/core/modules/host-shell/shell-scripts.ts +114 -0
- package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
- package/src/core/modules/schedules/precheck-tools.ts +17 -0
- package/src/core/modules/schedules/schedules.ts +9 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/presence/direct-channels.ts +30 -0
- package/src/core/presentation/reply-body.ts +25 -0
- package/src/core/registry/contributions.ts +31 -7
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/conversation-turns.ts +2 -15
- package/src/core/routing/surface-port.ts +5 -1
- package/src/core/runtime/extensions/approval-card.ts +1 -5
- package/src/core/runtime/extensions/approval-fields.ts +3 -7
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
- package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
- package/src/core/runtime/extensions/gate-refusals.ts +23 -0
- package/src/core/runtime/extensions/held-calls.ts +16 -1
- package/src/core/runtime/extensions/hold-describer.ts +58 -8
- package/src/core/runtime/extensions/hold-lookups.ts +19 -19
- package/src/core/runtime/extensions/yolo.ts +105 -0
- package/src/core/runtime/pi-agent-runtime.ts +3 -5
- package/src/core/runtime/runtime-plugin.ts +42 -1
- package/src/core/runtime/runtime-types.ts +11 -0
- package/src/core/runtime/turn-budget.ts +32 -5
- package/src/core/runtime/turn-limits.ts +14 -3
- package/src/core/services.ts +61 -9
- package/src/core/shared/attachment-reader.ts +6 -0
- package/src/core/shared/unix-server.ts +10 -1
- package/src/core/testing/modules.ts +12 -8
- package/src/core/testing/surface-threads.ts +58 -0
- package/src/core/testing/thread-host.ts +17 -5
- package/src/core/threads/dispatch-threads.ts +299 -0
- package/src/core/threads/host-threads.ts +19 -0
- package/src/core/tool-set.snapshot.json +5 -0
- package/src/index.ts +27 -1
- package/src/kit/characters.ts +5 -0
- package/src/kit/index.ts +2 -0
- package/src/kit/shell.ts +1 -0
- package/src/kit/support.ts +1 -1
- package/src/kit/threads.ts +1 -1
- package/src/testing.ts +4 -0
- package/templates/adapters/web/package.json.tmpl +1 -1
- package/templates/agents.ts +1 -1
- package/templates/package.json.tmpl +1 -1
- package/src/core/discord/dispatch-threads.ts +0 -194
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { COMMAND_PREFIX_WORDS } from "./shell-lexer.ts";
|
|
3
|
+
|
|
4
|
+
/** A variable assignment word, `NAME=value` or `NAME+=value`, as it stands before a command. */
|
|
5
|
+
export const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Words that run the next word as the command, with the options of each that take the word after
|
|
9
|
+
* them as their argument (`env -u NAME`, `timeout -s KILL`): that word is not the command.
|
|
10
|
+
*/
|
|
11
|
+
export const WRAPPERS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
12
|
+
[
|
|
13
|
+
"env",
|
|
14
|
+
new Set(["-u", "--unset", "-C", "--chdir", "-P", "-S", "--split-string"]),
|
|
15
|
+
],
|
|
16
|
+
["nohup", new Set()],
|
|
17
|
+
["time", new Set(["-f", "--format", "-o", "--output"])],
|
|
18
|
+
["nice", new Set(["-n", "--adjustment"])],
|
|
19
|
+
["ionice", new Set(["-c", "--class", "-n", "--classdata", "-p", "-P", "-u"])],
|
|
20
|
+
["timeout", new Set(["-s", "--signal", "-k", "--kill-after"])],
|
|
21
|
+
["stdbuf", new Set(["-i", "--input", "-o", "--output", "-e", "--error"])],
|
|
22
|
+
["exec", new Set(["-a"])],
|
|
23
|
+
["command", new Set()],
|
|
24
|
+
["builtin", new Set()],
|
|
25
|
+
[
|
|
26
|
+
"xargs",
|
|
27
|
+
new Set(["-n", "-I", "-J", "-P", "-L", "-s", "-d", "-E", "-a", "-R", "-S"]),
|
|
28
|
+
],
|
|
29
|
+
["setsid", new Set()],
|
|
30
|
+
["unbuffer", new Set()],
|
|
31
|
+
["caffeinate", new Set(["-t", "-w"])],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Whether a word is the duration `timeout` takes, or the number a wrapper option leaves behind. */
|
|
35
|
+
const DURATION = /^\d+(?:\.\d+)?[smhd]?$/;
|
|
36
|
+
|
|
37
|
+
/** The string `xargs` replaces with each input line (`-I str`, `-Istr`, `-J str`, `-i`, `--replace`), given one of its options and the word after it. */
|
|
38
|
+
function xargsReplace(
|
|
39
|
+
option: string,
|
|
40
|
+
next: string | undefined,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
if (option === "-I" || option === "-J") return next;
|
|
43
|
+
if (option.startsWith("-I") || option.startsWith("-J"))
|
|
44
|
+
return option.slice(2);
|
|
45
|
+
if (option === "-i" || option === "--replace") return "{}";
|
|
46
|
+
if (option.startsWith("-i") || option.startsWith("--replace="))
|
|
47
|
+
return option.slice(
|
|
48
|
+
option.indexOf("=") === -1 ? 2 : option.indexOf("=") + 1,
|
|
49
|
+
);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The string that stands for the input in an `xargs` of `prefix` (the words `program` dropped), if it has one. */
|
|
54
|
+
export function replaceString(prefix: readonly string[]): string | undefined {
|
|
55
|
+
return prefix.find((word) => word.startsWith("xargs -I "))?.slice(9);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Drops leading variable assignments, wrappers, and their options, leaving the real program;
|
|
60
|
+
* `prefix` collects what was dropped. An `env -S` splits its argument into a command line, so it is
|
|
61
|
+
* recorded as `env -S` for the caller to hold, and the string an `xargs` replaces as `xargs -I str`.
|
|
62
|
+
*/
|
|
63
|
+
export function program(words: string[], prefix: string[] = []): string[] {
|
|
64
|
+
let rest = words;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const [first] = rest;
|
|
67
|
+
if (first === undefined) return rest;
|
|
68
|
+
if (COMMAND_PREFIX_WORDS.has(first)) {
|
|
69
|
+
rest = rest.slice(1);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (ASSIGNMENT_WORD.test(first)) {
|
|
73
|
+
prefix.push(first);
|
|
74
|
+
rest = rest.slice(1);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const wrapper = basename(first);
|
|
78
|
+
const takesArgument = WRAPPERS.get(wrapper);
|
|
79
|
+
if (takesArgument) {
|
|
80
|
+
prefix.push(wrapper);
|
|
81
|
+
rest = rest.slice(1);
|
|
82
|
+
// Wrapper options (with the argument some take) and the timeout's duration.
|
|
83
|
+
while (
|
|
84
|
+
rest[0] !== undefined &&
|
|
85
|
+
(rest[0].startsWith("-") ||
|
|
86
|
+
DURATION.test(rest[0]) ||
|
|
87
|
+
ASSIGNMENT_WORD.test(rest[0]))
|
|
88
|
+
) {
|
|
89
|
+
const option = rest[0];
|
|
90
|
+
rest = rest.slice(1);
|
|
91
|
+
if (wrapper === "xargs") {
|
|
92
|
+
const replace = xargsReplace(option, rest[0]);
|
|
93
|
+
if (replace !== undefined) prefix.push(`xargs -I ${replace}`);
|
|
94
|
+
}
|
|
95
|
+
if (!takesArgument.has(option)) continue;
|
|
96
|
+
if (wrapper === "env" && /^(-S|--split-string)/.test(option))
|
|
97
|
+
prefix.push("env -S");
|
|
98
|
+
rest = rest.slice(1);
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
return rest;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -417,6 +417,7 @@ export function precheckScriptHoldRule(options: {
|
|
|
417
417
|
};
|
|
418
418
|
return {
|
|
419
419
|
name: "precheck-scripts",
|
|
420
|
+
relay: true,
|
|
420
421
|
describe(tool, input, context) {
|
|
421
422
|
const held = heldCalls(tool, input, context);
|
|
422
423
|
if (held.length === 0) return undefined;
|
|
@@ -430,6 +431,22 @@ export function precheckScriptHoldRule(options: {
|
|
|
430
431
|
held.map((call) => call.held),
|
|
431
432
|
);
|
|
432
433
|
},
|
|
434
|
+
// The classes of every held call the script makes, so yolo mode approves saving it only when it
|
|
435
|
+
// would approve each of them: a scheduled run makes them with no one to ask. A call no rule
|
|
436
|
+
// classes gives the name of the rule that holds it, or of this rule when none is known.
|
|
437
|
+
holdClasses(tool, input, context) {
|
|
438
|
+
const holds = options.holds();
|
|
439
|
+
return scriptCalls(tool, input, context).flatMap((call) => {
|
|
440
|
+
const own = holds.classesOf?.(call.name, call.input, context) ?? [];
|
|
441
|
+
// A call computed when the script runs may be held for more than its rules say now, so it
|
|
442
|
+
// is also unclassed: under its own rules' bare names, or the rule that may hold it.
|
|
443
|
+
if (own.length > 0 && call.literal) return own;
|
|
444
|
+
const may = holds.mayHold?.(call.name);
|
|
445
|
+
const bare = own.map((id) => id.split(".")[0] as string);
|
|
446
|
+
if (own.length > 0) return [...own, ...new Set(bare.concat(may ?? []))];
|
|
447
|
+
return [may ?? "precheck-scripts"];
|
|
448
|
+
});
|
|
449
|
+
},
|
|
433
450
|
// Whoever approves must be able to approve each held call the script makes.
|
|
434
451
|
approvalTier(tool, input, context) {
|
|
435
452
|
const tiers = options.tiers?.();
|
|
@@ -44,8 +44,14 @@ export interface OwnerSchedules {
|
|
|
44
44
|
target?: BackgroundTarget;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Finds another agent's channel, for reading its schedules, on the asker's surface when that one
|
|
49
|
+
* hosts agent rooms (`near` is the asker's own conversation); throws ScheduleError when there is none.
|
|
50
|
+
*/
|
|
51
|
+
export type AgentChannelLookup = (
|
|
52
|
+
agent: string,
|
|
53
|
+
near: ChannelKey,
|
|
54
|
+
) => ChannelKey;
|
|
49
55
|
|
|
50
56
|
/** schedule_list for agents: an optional agent name reads that agent's schedules instead. */
|
|
51
57
|
function withAgentOption(spec: ScheduleToolSpec): ScheduleToolSpec {
|
|
@@ -99,7 +105,7 @@ export function schedulesExtension(
|
|
|
99
105
|
);
|
|
100
106
|
const peer =
|
|
101
107
|
agents && typeof input.agent === "string" && input.agent
|
|
102
|
-
? agents(input.agent)
|
|
108
|
+
? agents(input.agent, channel)
|
|
103
109
|
: undefined;
|
|
104
110
|
const target = peer ?? (await channelFor(channel, author.principalId));
|
|
105
111
|
const answer = await callScheduleTool(
|
package/src/core/plugin.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { ToolContribution } from "./define.ts";
|
|
|
15
15
|
import type { TurnProgress } from "./domain/progress.ts";
|
|
16
16
|
import { PluginError } from "./errors.ts";
|
|
17
17
|
import type { HoldCheck, HoldRule } from "./holds.ts";
|
|
18
|
-
import type { HttpRoute } from "./http/listeners.ts";
|
|
18
|
+
import type { HttpRoute, ListenerAddress } from "./http/listeners.ts";
|
|
19
19
|
import type { Locale } from "./i18n/index.ts";
|
|
20
20
|
import type { Logger } from "./log.ts";
|
|
21
21
|
import type {
|
|
@@ -321,6 +321,13 @@ export interface PluginContext {
|
|
|
321
321
|
* never throws for that. The value is a secret: keep it out of logs and error messages.
|
|
322
322
|
*/
|
|
323
323
|
apiKey(provider: string): Promise<string | undefined>;
|
|
324
|
+
/**
|
|
325
|
+
* Where the host's listener `id` serves, as configured: a unix socket (with its mode) or a TCP
|
|
326
|
+
* port (with its hostname, absent when the host did not set one). `undefined` for an id the
|
|
327
|
+
* host has no listener for. A plugin that serves without authentication reads it to refuse an
|
|
328
|
+
* address that reaches beyond the machine.
|
|
329
|
+
*/
|
|
330
|
+
listener(id: string): Readonly<ListenerAddress> | undefined;
|
|
324
331
|
}
|
|
325
332
|
|
|
326
333
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SurfacePort } from "../contract/surface.ts";
|
|
2
2
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
3
|
import type { Notifier } from "../domain/ports.ts";
|
|
4
|
+
import type { Logger } from "../log.ts";
|
|
4
5
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -34,6 +35,13 @@ export interface DirectChannelProvider {
|
|
|
34
35
|
* names, through the surface that serves it.
|
|
35
36
|
*/
|
|
36
37
|
deliver?(principalId: string, text: string): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Called with the notice after another provider delivered it, so this one can echo it, such as
|
|
40
|
+
* a web app pushing a prompt to a phone that Discord's direct message already reached. It runs
|
|
41
|
+
* only after a delivery, on every provider but the one that delivered; its failure is logged
|
|
42
|
+
* and never changes what `notify` returns.
|
|
43
|
+
*/
|
|
44
|
+
mirror?(principalId: string, text: string): Promise<void>;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
/** Where a person was reached: the provider, and their conversation on it. */
|
|
@@ -67,6 +75,7 @@ export interface DirectChannels extends Notifier {
|
|
|
67
75
|
export function directChannelsPort(
|
|
68
76
|
providers: () => readonly DirectChannelProvider[],
|
|
69
77
|
surfaces: Pick<SurfacePort, "sendReply">,
|
|
78
|
+
logger?: Pick<Logger, "warn">,
|
|
70
79
|
): DirectChannels {
|
|
71
80
|
const reach = async (
|
|
72
81
|
principalId: string,
|
|
@@ -77,6 +86,26 @@ export function directChannelsPort(
|
|
|
77
86
|
}
|
|
78
87
|
return undefined;
|
|
79
88
|
};
|
|
89
|
+
const mirrored = async (
|
|
90
|
+
delivered: DirectChannelProvider,
|
|
91
|
+
principalId: string,
|
|
92
|
+
text: string,
|
|
93
|
+
): Promise<void> => {
|
|
94
|
+
await Promise.all(
|
|
95
|
+
providers()
|
|
96
|
+
.filter((provider) => provider !== delivered && provider.mirror)
|
|
97
|
+
.map(async (provider) => {
|
|
98
|
+
try {
|
|
99
|
+
await provider.mirror?.(principalId, text);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
logger?.warn(
|
|
102
|
+
{ err: error, provider: provider.name },
|
|
103
|
+
"a direct channel did not mirror a notice",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
};
|
|
80
109
|
return {
|
|
81
110
|
providers: () => providers(),
|
|
82
111
|
reach,
|
|
@@ -98,6 +127,7 @@ export function directChannelsPort(
|
|
|
98
127
|
await surfaces.sendReply(reached.channel, {
|
|
99
128
|
chunks: splitReply(text),
|
|
100
129
|
});
|
|
130
|
+
await mirrored(reached.provider, principalId, text);
|
|
101
131
|
return true;
|
|
102
132
|
},
|
|
103
133
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReplyFile, TurnResult } from "../domain/conversation.ts";
|
|
2
|
+
import { failureNotice } from "./failure-notice.ts";
|
|
3
|
+
import { splitReply } from "./reply-splitter.ts";
|
|
4
|
+
import { thinkingLine } from "./thinking-line.ts";
|
|
5
|
+
|
|
6
|
+
/** What a turn posts: its answer with a thinking line, or the failure or stopped notice. */
|
|
7
|
+
export function replyBody(result: TurnResult): {
|
|
8
|
+
thinking?: string;
|
|
9
|
+
chunks: string[];
|
|
10
|
+
files?: ReplyFile[];
|
|
11
|
+
/** Set on the notice for a turn that ended in an error, not on a stopped one. */
|
|
12
|
+
failed?: true;
|
|
13
|
+
} {
|
|
14
|
+
if (!result.ok)
|
|
15
|
+
return {
|
|
16
|
+
chunks: [failureNotice(result)],
|
|
17
|
+
...(result.stopped ? {} : { failed: true as const }),
|
|
18
|
+
};
|
|
19
|
+
const thinking = result.thinking ? thinkingLine(result.thinking) : undefined;
|
|
20
|
+
return {
|
|
21
|
+
...(thinking ? { thinking } : {}),
|
|
22
|
+
chunks: splitReply(result.text),
|
|
23
|
+
...(result.files?.length ? { files: result.files } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CharacterError, resolveCharacter } from "../../characters/index.ts";
|
|
1
2
|
import type { AgentSeed } from "../agents/agent-store.ts";
|
|
2
3
|
import type { BackgroundTarget, ChannelClaim } from "../contract/channels.ts";
|
|
3
4
|
import type { ChatSurface } from "../contract/surface.ts";
|
|
@@ -126,6 +127,24 @@ function checkKeys(plugin: string, contribution: Contribution): void {
|
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
/** Refuses a seed whose `character` the characters module would not resolve. */
|
|
131
|
+
function checkSeedCharacters(
|
|
132
|
+
plugin: string,
|
|
133
|
+
seeds: readonly AgentSeed[],
|
|
134
|
+
): void {
|
|
135
|
+
for (const seed of seeds) {
|
|
136
|
+
if (seed.character === undefined) continue;
|
|
137
|
+
try {
|
|
138
|
+
resolveCharacter(seed.character);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof CharacterError)) throw error;
|
|
141
|
+
throw new PluginError(
|
|
142
|
+
`plugin ${plugin}: seed "${seed.name}" has a bad character, field "${error.field}": ${error.message}.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
/** The selections' tools and groups, each once, in the order the plugins gave them. */
|
|
130
149
|
function mergeSelections(
|
|
131
150
|
selections: readonly (() => ToolSelection)[],
|
|
@@ -295,13 +314,17 @@ export async function collectContributions(
|
|
|
295
314
|
let collected = false;
|
|
296
315
|
const context: Omit<PluginContext, "services"> = {
|
|
297
316
|
...base,
|
|
298
|
-
directChannels: directChannelsPort(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
317
|
+
directChannels: directChannelsPort(
|
|
318
|
+
() => {
|
|
319
|
+
if (!collected)
|
|
320
|
+
throw new NotLinkedError(
|
|
321
|
+
"direct channels are linked once every plugin is set up. Use them from a service's start, a handler, or a session tool, not during setup.",
|
|
322
|
+
);
|
|
323
|
+
return registry.directChannels;
|
|
324
|
+
},
|
|
325
|
+
base.surfaces,
|
|
326
|
+
base.logger,
|
|
327
|
+
),
|
|
305
328
|
};
|
|
306
329
|
const serviceNames = new Names("service");
|
|
307
330
|
const sessionTools = new Names("session tool");
|
|
@@ -439,6 +462,7 @@ export async function collectContributions(
|
|
|
439
462
|
registry.backgroundTargets.push(...backgroundTargets);
|
|
440
463
|
registry.directChannels.push(...directChannels);
|
|
441
464
|
registry.dashboard.push(...dashboard);
|
|
465
|
+
checkSeedCharacters(plugin.name, seeds);
|
|
442
466
|
registry.seeds.push(...seeds);
|
|
443
467
|
registry.prompt.push(...prompt);
|
|
444
468
|
registry.requiredTools.push(...requiredTools);
|
|
@@ -30,7 +30,7 @@ function defaults(judgeModel: JudgeModel | undefined): Providers {
|
|
|
30
30
|
judge: judgeModel ? new ModelJudge(judgeModel) : unconfiguredJudge(),
|
|
31
31
|
images: async () => {
|
|
32
32
|
throw new ProviderError(
|
|
33
|
-
"no image provider is configured
|
|
33
|
+
"no image provider is configured: a plugin fills the images slot to let agents make pictures",
|
|
34
34
|
);
|
|
35
35
|
},
|
|
36
36
|
runtime: () => {
|
|
@@ -13,9 +13,7 @@ import { HostStoppingError, PluginError } from "../errors.ts";
|
|
|
13
13
|
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
14
14
|
import type { Logger } from "../log.ts";
|
|
15
15
|
import type { EventSink } from "../plugin.ts";
|
|
16
|
-
import {
|
|
17
|
-
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
18
|
-
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
16
|
+
import { replyBody } from "../presentation/reply-body.ts";
|
|
19
17
|
import { withReplyFiles } from "../reply-files.ts";
|
|
20
18
|
import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
|
|
21
19
|
import type { Speaker } from "../speakers.ts";
|
|
@@ -226,7 +224,7 @@ export function conversationTurns(
|
|
|
226
224
|
if (!result.ok && !result.stopped)
|
|
227
225
|
logger.error({ channel, kind, err: result.error }, "turn failed");
|
|
228
226
|
if (input.reply) await input.reply(result);
|
|
229
|
-
else await surfaces.sendReply(channel,
|
|
227
|
+
else await surfaces.sendReply(channel, replyBody(result));
|
|
230
228
|
} catch (error) {
|
|
231
229
|
logger.error({ channel, kind, err: error }, "reply not posted");
|
|
232
230
|
} finally {
|
|
@@ -236,14 +234,3 @@ export function conversationTurns(
|
|
|
236
234
|
},
|
|
237
235
|
};
|
|
238
236
|
}
|
|
239
|
-
|
|
240
|
-
/** The default post: the answer with its thinking line, or the failure or stopped notice. */
|
|
241
|
-
function replyOf(result: TurnResult) {
|
|
242
|
-
if (!result.ok) return { chunks: [failureNotice(result)] };
|
|
243
|
-
const thinking = result.thinking ? thinkingLine(result.thinking) : undefined;
|
|
244
|
-
return {
|
|
245
|
-
...(thinking ? { thinking } : {}),
|
|
246
|
-
chunks: splitReply(result.text),
|
|
247
|
-
...(result.files?.length ? { files: result.files } : {}),
|
|
248
|
-
};
|
|
249
|
-
}
|
|
@@ -56,7 +56,11 @@ export function surfacePort(
|
|
|
56
56
|
of(channel)?.unreact?.(channel, messageId, emoji),
|
|
57
57
|
prompts: (channel, scope) =>
|
|
58
58
|
of(channel)?.prompts?.(channel, scopeOf(scope, options)),
|
|
59
|
-
|
|
59
|
+
roomSurfaces: () =>
|
|
60
|
+
linked()
|
|
61
|
+
.filter((surface) => surface.agentRooms === true)
|
|
62
|
+
.map((surface) => surface.surface),
|
|
63
|
+
interim: (channel, as) => of(channel)?.interim?.(channel, as),
|
|
60
64
|
progress: async (channel, event) => {
|
|
61
65
|
await of(channel)?.progress?.(channel, event);
|
|
62
66
|
},
|
|
@@ -5,6 +5,7 @@ import { messages } from "../../i18n/index.ts";
|
|
|
5
5
|
import type { ApprovalDetails } from "../../interactions/prompts.ts";
|
|
6
6
|
import { fieldList, schemaFields } from "./approval-fields.ts";
|
|
7
7
|
import { countPoints, firstPoints, prefix } from "./card-text.ts";
|
|
8
|
+
import { isPlainObject } from "./held-calls.ts";
|
|
8
9
|
|
|
9
10
|
/** The fewest characters of a long string value in a held call's input its approval card keeps, when the input does not fit whole. */
|
|
10
11
|
const CARD_VALUE_CHARS = 200;
|
|
@@ -193,8 +194,3 @@ function fileSize(path: string, workspace: string | undefined) {
|
|
|
193
194
|
return undefined;
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
|
-
|
|
197
|
-
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
198
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
199
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
200
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { messages } from "../../i18n/index.ts";
|
|
2
2
|
import { countPoints, firstPoints } from "./card-text.ts";
|
|
3
|
+
import { isPlainObject } from "./held-calls.ts";
|
|
3
4
|
|
|
4
5
|
/** The longest label a card shows for a field, in code points. */
|
|
5
6
|
const LABEL_CHARS = 40;
|
|
@@ -27,11 +28,6 @@ export interface FieldLimits {
|
|
|
27
28
|
totalChars: number;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
// pi-lens-ignore: no-unknown-parameters — a tool's parameter schema is untyped JSON
|
|
31
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
31
|
/**
|
|
36
32
|
* The arguments of a call in the order its tool's parameter schema lists them, each labelled by
|
|
37
33
|
* its property's `title` or else its name; absent arguments are left out, and an argument the
|
|
@@ -43,7 +39,7 @@ export function schemaFields(
|
|
|
43
39
|
// pi-lens-ignore: no-unknown-parameters — a tool's parameter schema is untyped JSON
|
|
44
40
|
schema: unknown,
|
|
45
41
|
): CardField[] | undefined {
|
|
46
|
-
if (!input || !
|
|
42
|
+
if (!input || !isPlainObject(schema) || !isPlainObject(schema.properties))
|
|
47
43
|
return undefined;
|
|
48
44
|
const properties = schema.properties;
|
|
49
45
|
const fields: CardField[] = [];
|
|
@@ -53,7 +49,7 @@ export function schemaFields(
|
|
|
53
49
|
const value = input[name];
|
|
54
50
|
if (value === undefined) continue;
|
|
55
51
|
const title =
|
|
56
|
-
|
|
52
|
+
isPlainObject(property) && typeof property.title === "string"
|
|
57
53
|
? property.title.trim()
|
|
58
54
|
: "";
|
|
59
55
|
fields.push({ label: title || name, value });
|
|
@@ -38,8 +38,9 @@ export function confirmationGateExtension(
|
|
|
38
38
|
const turn: HoldTurn | undefined = turnOf
|
|
39
39
|
? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
|
|
40
40
|
: undefined;
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
let decision: Awaited<ReturnType<ConfirmationGate["decide"]>>;
|
|
42
|
+
try {
|
|
43
|
+
decision = await gate.decide(
|
|
43
44
|
event.toolName,
|
|
44
45
|
event.input as Record<string, unknown>,
|
|
45
46
|
ask,
|
|
@@ -48,6 +49,16 @@ export function confirmationGateExtension(
|
|
|
48
49
|
pi.getAllTools?.().find((tool) => tool.name === event.toolName)
|
|
49
50
|
?.parameters,
|
|
50
51
|
);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// A hold rule that fails must not let the call run unjudged.
|
|
54
|
+
if (ctx.signal?.aborted) throw error;
|
|
55
|
+
return {
|
|
56
|
+
block: true,
|
|
57
|
+
reason: `The call was blocked because its safety check failed (${error instanceof Error ? error.message : String(error)}). It was not run.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const { reason, approvedOnCard, approvedBy, approvedPrompt, refused } =
|
|
61
|
+
decision;
|
|
51
62
|
if (approvedOnCard) approved.add(event.toolCallId);
|
|
52
63
|
if (!reason && (approvedBy || approvedPrompt))
|
|
53
64
|
gate.grant(event.toolCallId, {
|
|
@@ -2,7 +2,6 @@ import type {
|
|
|
2
2
|
HeldCall,
|
|
3
3
|
PendingConfirmation,
|
|
4
4
|
} from "../../domain/conversation.ts";
|
|
5
|
-
import type { HoldRefusal } from "../../domain/progress.ts";
|
|
6
5
|
import {
|
|
7
6
|
HOLD_DESCRIBE_TIMEOUT_MS,
|
|
8
7
|
type HoldCheck,
|
|
@@ -12,19 +11,17 @@ import {
|
|
|
12
11
|
} from "../../holds.ts";
|
|
13
12
|
import { messages } from "../../i18n/index.ts";
|
|
14
13
|
import type { OwnerIdentity } from "../../identity.ts";
|
|
15
|
-
import type {
|
|
16
|
-
ApprovedBy,
|
|
17
|
-
LateAnswer,
|
|
18
|
-
Prompts,
|
|
19
|
-
} from "../../interactions/prompts.ts";
|
|
14
|
+
import type { ApprovedBy, Prompts } from "../../interactions/prompts.ts";
|
|
20
15
|
import type { ToolTiers } from "../../tool-tiers.ts";
|
|
21
16
|
import { approvalCard, approvalDetails } from "./approval-card.ts";
|
|
22
17
|
import { askOnCard } from "./card-ask.ts";
|
|
23
18
|
import {
|
|
19
|
+
type AutoApprover,
|
|
24
20
|
approverOf,
|
|
25
21
|
askSpeaker,
|
|
26
22
|
awaitingText,
|
|
27
23
|
declinedText,
|
|
24
|
+
type GateDecision,
|
|
28
25
|
type Needing,
|
|
29
26
|
namesOf,
|
|
30
27
|
type Release,
|
|
@@ -39,6 +36,7 @@ import {
|
|
|
39
36
|
mayApproveNamed,
|
|
40
37
|
namedApprover,
|
|
41
38
|
sameCall,
|
|
39
|
+
spendApproval,
|
|
42
40
|
unexpired,
|
|
43
41
|
} from "./held-calls.ts";
|
|
44
42
|
import { HoldDescriber } from "./hold-describer.ts";
|
|
@@ -55,8 +53,9 @@ export {
|
|
|
55
53
|
unexpired,
|
|
56
54
|
} from "./held-calls.ts";
|
|
57
55
|
|
|
58
|
-
import {
|
|
56
|
+
import { Withdrawals } from "./withdrawals.ts";
|
|
59
57
|
|
|
58
|
+
export type { AutoApprover, GateDecision } from "./gate-refusals.ts";
|
|
60
59
|
export type { HoldTurn } from "./hold-lookups.ts";
|
|
61
60
|
|
|
62
61
|
/**
|
|
@@ -73,6 +72,8 @@ export class ConfirmationGate {
|
|
|
73
72
|
#speaker: SpeakerRef | undefined;
|
|
74
73
|
/** Whether the running turn's conversation is private to its speaker, who then alone answers its cards. */
|
|
75
74
|
#private = false;
|
|
75
|
+
/** The running turn's auto-approver, asked before any card opens. */
|
|
76
|
+
#auto: AutoApprover | undefined;
|
|
76
77
|
#pending: PendingConfirmation | undefined;
|
|
77
78
|
#approved: Release[] = [];
|
|
78
79
|
/** Calls approved on their cards after the turn that asked stopped waiting; each runs once, in any turn. */
|
|
@@ -160,7 +161,8 @@ export class ConfirmationGate {
|
|
|
160
161
|
* approve them. When `confirmed`, the speaker's message approved the held calls, and they are
|
|
161
162
|
* the approver the calls' `run` sees; a call that named approvers is released only when the
|
|
162
163
|
* speaker is one of them or an owner, and otherwise stays held. `isPrivate` says the turn's
|
|
163
|
-
* conversation is private to its speaker: they alone then answer its cards.
|
|
164
|
+
* conversation is private to its speaker: they alone then answer its cards. `auto` may approve
|
|
165
|
+
* a call the turn makes without a card (yolo mode); it does not change a message's approval.
|
|
164
166
|
*/
|
|
165
167
|
beginTurn(
|
|
166
168
|
selectionId: string,
|
|
@@ -168,10 +170,12 @@ export class ConfirmationGate {
|
|
|
168
170
|
addressee?: OwnerIdentity,
|
|
169
171
|
speaker?: SpeakerRef,
|
|
170
172
|
isPrivate = false,
|
|
173
|
+
auto?: AutoApprover,
|
|
171
174
|
): void {
|
|
172
175
|
this.#selectionId = selectionId;
|
|
173
176
|
this.#speaker = speaker;
|
|
174
177
|
this.#private = isPrivate;
|
|
178
|
+
this.#auto = auto;
|
|
175
179
|
this.#addressee = addressee ?? this.#owner;
|
|
176
180
|
const by = approverOf(speaker);
|
|
177
181
|
const held = this.pending();
|
|
@@ -287,16 +291,7 @@ export class ConfirmationGate {
|
|
|
287
291
|
ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
|
|
288
292
|
turn?: HoldTurn,
|
|
289
293
|
parameters?: unknown,
|
|
290
|
-
): Promise<{
|
|
291
|
-
reason?: string;
|
|
292
|
-
approvedOnCard?: true;
|
|
293
|
-
/** Who approved the call, when it was held and a surface or a confirming message said who. */
|
|
294
|
-
approvedBy?: ApprovedBy;
|
|
295
|
-
/** The id of the card whose approval released the call, in this turn or answered late; absent for a call a message confirmed. */
|
|
296
|
-
approvedPrompt?: string;
|
|
297
|
-
/** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
|
|
298
|
-
refused?: HoldRefusal;
|
|
299
|
-
}> {
|
|
294
|
+
): Promise<GateDecision> {
|
|
300
295
|
const found = this.#needingIn(tool, input, turn);
|
|
301
296
|
const needing = isPromise(found) ? await found : found;
|
|
302
297
|
if (needing === STOPPED) return { reason: "The turn was stopped." };
|
|
@@ -320,6 +315,14 @@ export class ConfirmationGate {
|
|
|
320
315
|
if (watch) watch.card = false;
|
|
321
316
|
return { reason: await this.#held(call), refused };
|
|
322
317
|
};
|
|
318
|
+
// Outside a turn there is no speaker whose conversation could be in yolo mode.
|
|
319
|
+
const auto = turn
|
|
320
|
+
? this.#auto?.(call, { speakerOnly: needing.speakerOnly === true })
|
|
321
|
+
: undefined;
|
|
322
|
+
if (auto) {
|
|
323
|
+
watch?.end();
|
|
324
|
+
return { approvedBy: auto };
|
|
325
|
+
}
|
|
323
326
|
if (!ask) return held("held");
|
|
324
327
|
// In a private conversation only its person answers: a call that names others is not theirs.
|
|
325
328
|
if (this.#private && !mayApproveNamed(this.#speaker, call))
|
|
@@ -331,7 +334,14 @@ export class ConfirmationGate {
|
|
|
331
334
|
message: approvalCard(call, this.#fileRoot, {}, parameters),
|
|
332
335
|
signal: ask.signal,
|
|
333
336
|
minTier: higherTier(this.#tiers?.minTier(call.tool), call.minTier),
|
|
334
|
-
wait: {
|
|
337
|
+
wait: {
|
|
338
|
+
// A late answer on the card: the text of the turn that tells the model, and the release it earned.
|
|
339
|
+
late: (late) => {
|
|
340
|
+
const outcome = lateOutcome(call, late, watch);
|
|
341
|
+
if (outcome?.release) this.#lateApproved.push(outcome.release);
|
|
342
|
+
return outcome?.text;
|
|
343
|
+
},
|
|
344
|
+
},
|
|
335
345
|
details: approvalDetails(call, this.#fileRoot, needing.copy),
|
|
336
346
|
approvers: call.approverIds,
|
|
337
347
|
speakerOnly: needing.speakerOnly === true,
|
|
@@ -377,21 +387,12 @@ export class ConfirmationGate {
|
|
|
377
387
|
return held(answer === "unavailable" ? "held" : "expired");
|
|
378
388
|
}
|
|
379
389
|
|
|
380
|
-
/** A late answer on a call's card; see `lateOutcome`. Returns the text of the turn that tells the model. */
|
|
381
|
-
#late(
|
|
382
|
-
call: HeldCall,
|
|
383
|
-
late: LateAnswer,
|
|
384
|
-
watch: Watch | undefined,
|
|
385
|
-
): string | undefined {
|
|
386
|
-
const outcome = lateOutcome(call, late, watch);
|
|
387
|
-
if (outcome?.release) this.#lateApproved.push(outcome.release);
|
|
388
|
-
return outcome?.text;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
390
|
/** What a call needs when a rule holds it; undefined when none does. */
|
|
392
391
|
#needing(tool: string, input: Record<string, unknown>): Needing | undefined {
|
|
393
392
|
const action = this.#holds(tool, input, this.#context);
|
|
394
|
-
|
|
393
|
+
if (!action) return undefined;
|
|
394
|
+
const classes = this.#holds.classesOf?.(tool, input, this.#context);
|
|
395
|
+
return this.#unlessApproved(tool, input, { action, classes });
|
|
395
396
|
}
|
|
396
397
|
|
|
397
398
|
/**
|
|
@@ -431,17 +432,17 @@ export class ConfirmationGate {
|
|
|
431
432
|
action: judged.action,
|
|
432
433
|
...(minTier ? { minTier } : {}),
|
|
433
434
|
...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
|
|
435
|
+
...(judged.classes && judged.classes.length > 0
|
|
436
|
+
? { holdClasses: [...judged.classes] }
|
|
437
|
+
: {}),
|
|
434
438
|
};
|
|
435
439
|
// A plugin that already withdrew the call lets no approval of it release it.
|
|
436
|
-
|
|
437
|
-
?
|
|
438
|
-
: [this.#approved, this.#lateApproved])
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
this.#withdrawals.endCall(spent.call);
|
|
443
|
-
return { released: spent };
|
|
444
|
-
}
|
|
440
|
+
const spent = judged.supersede?.aborted
|
|
441
|
+
? undefined
|
|
442
|
+
: spendApproval([this.#approved, this.#lateApproved], call);
|
|
443
|
+
if (spent) {
|
|
444
|
+
this.#withdrawals.endCall(spent.call);
|
|
445
|
+
return { released: spent };
|
|
445
446
|
}
|
|
446
447
|
return {
|
|
447
448
|
held: call,
|