pi-roundtable 0.7.19 → 0.8.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 +38 -0
- package/README.md +44 -11
- package/README.zh-TW.md +44 -9
- package/docs/migrating-0.8.md +132 -0
- package/docs/plugins.md +267 -23
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +31 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/package.json +3 -2
- package/src/cli/cli.ts +15 -4
- package/src/cli/doctor.ts +21 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/project.ts +28 -2
- package/src/cli/templates.ts +46 -14
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +6 -1
- package/src/core/builtin/agent-server.ts +30 -95
- package/src/core/builtin/modules.ts +133 -34
- package/src/core/config/adapters.ts +57 -0
- package/src/core/config/config.ts +174 -77
- package/src/core/contract/channels.ts +4 -0
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +8 -4
- package/src/core/contract/surface.ts +13 -0
- package/src/core/conversations/conversation-registry.ts +42 -0
- package/src/core/conversations/conversation-store.ts +113 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +124 -54
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/owner-cards.ts +19 -8
- package/src/core/domain/conversation.ts +5 -0
- package/src/core/domain/owner-prompts.ts +1 -1
- package/src/core/domain/ports.ts +6 -0
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +6 -3
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +5 -7
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +14 -0
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +8 -0
- package/src/core/routing/conversation-turns.ts +55 -1
- package/src/core/routing/surface-port.ts +3 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +42 -4
- package/src/core/runtime/pi-agent-runtime.ts +13 -6
- package/src/core/runtime/prompt-slot.ts +2 -2
- package/src/core/runtime/runtime-plugin.ts +160 -0
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +15 -1
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/testing/modules.ts +47 -23
- package/src/core/testing/surface-contract.ts +383 -0
- package/src/core/testing/test-host.ts +27 -9
- package/src/discord/index.ts +2 -0
- package/src/index.ts +18 -0
- package/src/testing.ts +41 -5
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +15 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +55 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +2 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TurnProgress } from "../domain/progress.ts";
|
|
3
|
+
|
|
4
|
+
/** How long text is joined before it is sent, so a fast stream costs a few events a second. */
|
|
5
|
+
export const PROGRESS_INTERVAL_MS = 250;
|
|
6
|
+
/** The longest preview of a tool's arguments, ellipsis included. */
|
|
7
|
+
export const PROGRESS_PREVIEW_CHARS = 80;
|
|
8
|
+
|
|
9
|
+
/** One turn's progress as the runtime reports it; every method returns at once and never throws. */
|
|
10
|
+
export interface ProgressReporter {
|
|
11
|
+
text(delta: string): void;
|
|
12
|
+
toolStart(id: string, tool: string, args: unknown): void;
|
|
13
|
+
toolEnd(id: string, tool: string, isError: boolean): void;
|
|
14
|
+
/** Reports a session's event: its text deltas and its tools' starts and ends, never the thinking. */
|
|
15
|
+
observe(event: AgentSessionEvent): void;
|
|
16
|
+
/** Sends the text not sent yet; nothing is sent after it. */
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProgressReporterOptions {
|
|
21
|
+
intervalMs?: number;
|
|
22
|
+
/** Runs `run` after `ms`; returns a cancel. Replaceable in tests. */
|
|
23
|
+
setTimer?: (run: () => void, ms: number) => () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const realTimer = (run: () => void, ms: number) => {
|
|
27
|
+
const timer = setTimeout(run, ms);
|
|
28
|
+
timer.unref?.();
|
|
29
|
+
return () => clearTimeout(timer);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** One line of a tool's arguments, cut to the preview length; undefined for none. */
|
|
33
|
+
export function toolPreview(args: unknown): string | undefined {
|
|
34
|
+
let text: string | undefined;
|
|
35
|
+
try {
|
|
36
|
+
text = args === undefined ? undefined : JSON.stringify(args);
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (!text || text === "{}" || text === "[]") return undefined;
|
|
41
|
+
const line = text.replace(/\s+/g, " ");
|
|
42
|
+
return line.length > PROGRESS_PREVIEW_CHARS
|
|
43
|
+
? `${line.slice(0, PROGRESS_PREVIEW_CHARS - 1)}…`
|
|
44
|
+
: line;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Turns a session's text deltas and tool events into `TurnProgress`. Text is joined and sent at
|
|
49
|
+
* most once per interval, and always before a tool event, so the order the turn wrote in holds.
|
|
50
|
+
* A sink that throws loses that event only.
|
|
51
|
+
*/
|
|
52
|
+
export function progressReporter(
|
|
53
|
+
sink: (event: TurnProgress) => void,
|
|
54
|
+
options: ProgressReporterOptions = {},
|
|
55
|
+
): ProgressReporter {
|
|
56
|
+
const intervalMs = options.intervalMs ?? PROGRESS_INTERVAL_MS;
|
|
57
|
+
const setTimer = options.setTimer ?? realTimer;
|
|
58
|
+
let buffered = "";
|
|
59
|
+
let cancel: (() => void) | undefined;
|
|
60
|
+
let closed = false;
|
|
61
|
+
const send = (event: TurnProgress) => {
|
|
62
|
+
try {
|
|
63
|
+
sink(event);
|
|
64
|
+
} catch {
|
|
65
|
+
// The surface's failure is its own; the turn goes on without that event.
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const flush = () => {
|
|
69
|
+
cancel?.();
|
|
70
|
+
cancel = undefined;
|
|
71
|
+
if (buffered === "") return;
|
|
72
|
+
const delta = buffered;
|
|
73
|
+
buffered = "";
|
|
74
|
+
send({ type: "text", delta });
|
|
75
|
+
};
|
|
76
|
+
const reporter: ProgressReporter = {
|
|
77
|
+
observe(event) {
|
|
78
|
+
if (
|
|
79
|
+
event.type === "message_update" &&
|
|
80
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
81
|
+
)
|
|
82
|
+
reporter.text(event.assistantMessageEvent.delta);
|
|
83
|
+
else if (event.type === "tool_execution_start")
|
|
84
|
+
reporter.toolStart(event.toolCallId, event.toolName, event.args);
|
|
85
|
+
else if (event.type === "tool_execution_end")
|
|
86
|
+
reporter.toolEnd(event.toolCallId, event.toolName, event.isError);
|
|
87
|
+
},
|
|
88
|
+
text(delta) {
|
|
89
|
+
if (closed || delta === "") return;
|
|
90
|
+
buffered += delta;
|
|
91
|
+
cancel ??= setTimer(flush, intervalMs);
|
|
92
|
+
},
|
|
93
|
+
toolStart(id, tool, args) {
|
|
94
|
+
if (closed) return;
|
|
95
|
+
flush();
|
|
96
|
+
const preview = toolPreview(args);
|
|
97
|
+
send({
|
|
98
|
+
type: "tool_start",
|
|
99
|
+
id,
|
|
100
|
+
tool,
|
|
101
|
+
...(preview === undefined ? {} : { preview }),
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
toolEnd(id, tool, isError) {
|
|
105
|
+
if (closed) return;
|
|
106
|
+
flush();
|
|
107
|
+
send({ type: "tool_end", id, tool, ok: !isError });
|
|
108
|
+
},
|
|
109
|
+
close() {
|
|
110
|
+
if (closed) return;
|
|
111
|
+
flush();
|
|
112
|
+
closed = true;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
return reporter;
|
|
116
|
+
}
|
package/src/core/services.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { TeamStatus } from "./agents/team-status.ts";
|
|
|
4
4
|
import type { ScheduledOutcome } from "./contract/channels.ts";
|
|
5
5
|
import type { AgentRuntime } from "./contract/runtime.ts";
|
|
6
6
|
import { type ServiceKey, serviceKey } from "./contract/services.ts";
|
|
7
|
+
import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
|
|
7
8
|
import type { PendingConfirmation } from "./domain/conversation.ts";
|
|
8
9
|
import type { HttpRoute } from "./http/listeners.ts";
|
|
9
10
|
import type { ThinkingSetting } from "./models.ts";
|
|
@@ -35,6 +36,19 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
|
|
|
35
36
|
// interface, so an object with the same methods satisfies it without being one of the built-in
|
|
36
37
|
// classes; a plugin replaces a built-in by providing its own under the same key.
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The runtime every conversation turn runs on: the agent server's, and each turn run through
|
|
41
|
+
* `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
|
|
42
|
+
* by the `runtime` plugin, which every host registers, with or without the agent server.
|
|
43
|
+
*/
|
|
44
|
+
export const RUNTIME: ServiceKey<AgentRuntime> =
|
|
45
|
+
serviceKey<AgentRuntime>("roundtable.runtime");
|
|
46
|
+
/**
|
|
47
|
+
* The conversations run through `context.turns`: who each belongs to, its kind and visibility,
|
|
48
|
+
* and when it was last active, recorded at its first turn. Provided by the `conversations` plugin.
|
|
49
|
+
*/
|
|
50
|
+
export const CONVERSATIONS: ServiceKey<ConversationRegistry> =
|
|
51
|
+
serviceKey<ConversationRegistry>("roundtable.conversations");
|
|
38
52
|
/** The agent server: its team, its directory, the runtime that runs every agent turn. Provided by the agent-server plugin. */
|
|
39
53
|
export const AGENTS: ServiceKey<AgentServer> =
|
|
40
54
|
serviceKey<AgentServer>("roundtable.agents");
|
|
@@ -84,7 +98,7 @@ export interface AgentServer {
|
|
|
84
98
|
team: AgentTeam;
|
|
85
99
|
/** Read-only lookups of agents and groups; edits go through the team. */
|
|
86
100
|
directory: AgentDirectory;
|
|
87
|
-
/** The runtime every agent-server turn runs on: the `
|
|
101
|
+
/** The runtime every agent-server turn runs on: the same one `RUNTIME` provides. */
|
|
88
102
|
runtime: AgentRuntime;
|
|
89
103
|
/** Decides whether the owner's reply approves the actions the assistant held for them. */
|
|
90
104
|
approvals: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { rmSync } from "node:fs";
|
|
2
|
-
import type { Server } from "bun";
|
|
2
|
+
import type { Server, WebSocketHandler } from "bun";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
|
|
@@ -9,18 +9,48 @@ import type { Server } from "bun";
|
|
|
9
9
|
*/
|
|
10
10
|
export function serveUnix(
|
|
11
11
|
socketPath: string,
|
|
12
|
-
fetch: (
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
fetch: (
|
|
13
|
+
request: Request,
|
|
14
|
+
server: Server<undefined>,
|
|
15
|
+
) => Response | Promise<Response>,
|
|
16
|
+
options?: { error?: (error: Error) => Response | Promise<Response> },
|
|
17
|
+
): Server<undefined>;
|
|
18
|
+
/**
|
|
19
|
+
* Serves HTTP and WebSockets on a unix socket: `websocket` serves the sockets `fetch` upgrades
|
|
20
|
+
* with `server.upgrade`, and `fetch` answers nothing for a request it upgraded.
|
|
21
|
+
*/
|
|
22
|
+
export function serveUnix<WebSocketData>(
|
|
23
|
+
socketPath: string,
|
|
24
|
+
fetch: (
|
|
25
|
+
request: Request,
|
|
26
|
+
server: Server<WebSocketData>,
|
|
27
|
+
) => Response | undefined | Promise<Response | undefined>,
|
|
28
|
+
options: {
|
|
29
|
+
error?: (error: Error) => Response | Promise<Response>;
|
|
30
|
+
websocket: WebSocketHandler<WebSocketData>;
|
|
31
|
+
},
|
|
32
|
+
): Server<WebSocketData>;
|
|
33
|
+
export function serveUnix<WebSocketData>(
|
|
34
|
+
socketPath: string,
|
|
35
|
+
fetch: (
|
|
36
|
+
request: Request,
|
|
37
|
+
server: Server<WebSocketData>,
|
|
38
|
+
) => Response | undefined | Promise<Response | undefined>,
|
|
39
|
+
options: {
|
|
40
|
+
error?: (error: Error) => Response | Promise<Response>;
|
|
41
|
+
websocket?: WebSocketHandler<WebSocketData>;
|
|
42
|
+
} = {},
|
|
43
|
+
): Server<WebSocketData> {
|
|
15
44
|
rmSync(socketPath, { force: true });
|
|
16
45
|
const serveOptions = {
|
|
17
46
|
unix: socketPath,
|
|
18
47
|
idleTimeout: 0,
|
|
19
48
|
fetch,
|
|
20
49
|
...(options.error ? { error: options.error } : {}),
|
|
50
|
+
...(options.websocket ? { websocket: options.websocket } : {}),
|
|
21
51
|
};
|
|
22
52
|
// SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
|
|
23
53
|
return Bun.serve(
|
|
24
54
|
serveOptions as unknown as Parameters<typeof Bun.serve>[0],
|
|
25
|
-
) as Server<
|
|
55
|
+
) as unknown as Server<WebSocketData>;
|
|
26
56
|
}
|
|
@@ -3,7 +3,7 @@ import type { SQL } from "bun";
|
|
|
3
3
|
import { OWNER_TARGET } from "../agents/agent-claim.ts";
|
|
4
4
|
import { DISCORD, type DiscordServices } from "../builtin/discord.ts";
|
|
5
5
|
import { discordAdminPlugin } from "../builtin/discord-admin.ts";
|
|
6
|
-
import { modulesPlugin } from "../builtin/modules.ts";
|
|
6
|
+
import { type ModulesOptions, modulesPlugin } from "../builtin/modules.ts";
|
|
7
7
|
import { skillsPlugin } from "../builtin/skills.ts";
|
|
8
8
|
import { memoryPlugin } from "../builtin/stores.ts";
|
|
9
9
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
@@ -28,14 +28,28 @@ export interface ModuleRecord {
|
|
|
28
28
|
reportChannels: ChannelKey[];
|
|
29
29
|
/** How often a conversation without a chat channel fell back to the owner's messages. */
|
|
30
30
|
ownerChannelAsked: number;
|
|
31
|
+
/** What was posted on the surface, by channel. */
|
|
32
|
+
posted: { channel: ChannelKey; text: string }[];
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export const OWNER_CHANNEL: ChannelKey = "discord:owner-dm";
|
|
34
36
|
|
|
35
37
|
/** The modules plugin set up over stand-in stores and surface; returns its contribution and the record. */
|
|
36
38
|
export async function setUpModules(
|
|
37
|
-
options: {
|
|
39
|
+
options: {
|
|
40
|
+
agentChannelOf?: (name: string) => ChannelKey;
|
|
41
|
+
/** Whether Discord is there; default true. A host without it has no owner's messages. */
|
|
42
|
+
discord?: boolean;
|
|
43
|
+
/** Whether a plugin contributes the owner's background target, as the agent server does; default true. */
|
|
44
|
+
ownerTarget?: boolean;
|
|
45
|
+
/** Whether a claim owns a channel; by default the Discord channels are owned and no other. */
|
|
46
|
+
owns?: (channel: ChannelKey) => boolean;
|
|
47
|
+
/** Whether the claim owning a channel takes background turns; by default every owned channel's does. */
|
|
48
|
+
takesBackground?: (channel: ChannelKey) => boolean;
|
|
49
|
+
errorReporter?: ModulesOptions["errorReporter"];
|
|
50
|
+
} = {},
|
|
38
51
|
): Promise<{
|
|
52
|
+
plugin: RoundtablePlugin;
|
|
39
53
|
contribution: Contribution;
|
|
40
54
|
record: ModuleRecord;
|
|
41
55
|
services: ServiceRegistry;
|
|
@@ -44,6 +58,7 @@ export async function setUpModules(
|
|
|
44
58
|
threadOrigins: [],
|
|
45
59
|
reportChannels: [],
|
|
46
60
|
ownerChannelAsked: 0,
|
|
61
|
+
posted: [],
|
|
47
62
|
};
|
|
48
63
|
const plugin = modulesPlugin({
|
|
49
64
|
owner: {
|
|
@@ -61,40 +76,48 @@ export async function setUpModules(
|
|
|
61
76
|
thinking: "low",
|
|
62
77
|
worker: { run: async () => "found it" },
|
|
63
78
|
},
|
|
79
|
+
...(options.errorReporter ? { errorReporter: options.errorReporter } : {}),
|
|
64
80
|
});
|
|
65
81
|
const services = new ServiceRegistry([plugin]);
|
|
66
82
|
// SAFETY: the tools under test read no store; each stub is asked for nothing else.
|
|
67
83
|
services.preset(MEMORY, {} as MemoryStore);
|
|
68
84
|
services.preset(SCHEDULES, {} as ScheduleStore);
|
|
69
|
-
// SAFETY: the schedule tools ask the agent team for a channel and nothing else.
|
|
70
85
|
if (options.agentChannelOf)
|
|
86
|
+
// SAFETY: the schedule tools ask the agent team for a channel and nothing else.
|
|
71
87
|
services.preset(AGENTS, {
|
|
72
88
|
team: { channelOf: options.agentChannelOf },
|
|
73
89
|
} as unknown as AgentServer);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
if (options.discord !== false)
|
|
91
|
+
// SAFETY: the tools under test use the connection's owner channel and the threads' open only.
|
|
92
|
+
services.preset(DISCORD, {
|
|
93
|
+
connection: {
|
|
94
|
+
ownerChannel: async () => {
|
|
95
|
+
record.ownerChannelAsked += 1;
|
|
96
|
+
return OWNER_CHANNEL;
|
|
97
|
+
},
|
|
98
|
+
ownerOperations: () => ({}),
|
|
80
99
|
},
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return undefined;
|
|
100
|
+
threads: {
|
|
101
|
+
open: async (origin: ChannelKey | undefined) => {
|
|
102
|
+
record.threadOrigins.push(origin);
|
|
103
|
+
return undefined;
|
|
104
|
+
},
|
|
87
105
|
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
guard: {},
|
|
107
|
+
commands: { add: () => undefined },
|
|
108
|
+
} as unknown as DiscordServices);
|
|
109
|
+
const owns =
|
|
110
|
+
options.owns ?? ((channel: ChannelKey) => channel.startsWith("discord:"));
|
|
111
|
+
// SAFETY: setup and the preflight read only the logger, the conversations' background, targets and owners, the surfaces, and the services.
|
|
93
112
|
const context = {
|
|
94
113
|
logger: silentLogger(),
|
|
95
114
|
conversations: {
|
|
96
115
|
target: (name: string) =>
|
|
97
|
-
name === OWNER_TARGET.name
|
|
116
|
+
options.ownerTarget !== false && name === OWNER_TARGET.name
|
|
117
|
+
? OWNER_TARGET
|
|
118
|
+
: undefined,
|
|
119
|
+
owns,
|
|
120
|
+
takesBackground: options.takesBackground ?? owns,
|
|
98
121
|
background: async (turn: { channel: ChannelKey }) => {
|
|
99
122
|
record.reportChannels.push(turn.channel);
|
|
100
123
|
return { status: "ran" };
|
|
@@ -105,14 +128,15 @@ export async function setUpModules(
|
|
|
105
128
|
{
|
|
106
129
|
surface: "discord",
|
|
107
130
|
start: async () => undefined,
|
|
108
|
-
sendReply: async () =>
|
|
131
|
+
sendReply: async (channel, reply) =>
|
|
132
|
+
void record.posted.push({ channel, text: reply.chunks.join("") }),
|
|
109
133
|
},
|
|
110
134
|
]),
|
|
111
135
|
} as unknown as Omit<PluginContext, "services">;
|
|
112
136
|
const contribution = await services.setUp(plugin, () =>
|
|
113
137
|
plugin.setup(pluginContext(plugin, context, services.forPlugin(plugin))),
|
|
114
138
|
);
|
|
115
|
-
return { contribution, record, services };
|
|
139
|
+
return { plugin, contribution, record, services };
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
/** What each addon plugin contributes, set up over stand-ins as far as setup reads them. */
|