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
|
@@ -19,20 +19,28 @@ const OWN_KEYS = new Set(["level", "time", "pid", "hostname", "msg", "err"]);
|
|
|
19
19
|
const HEAD_KEYS = ["app", "plugin", "module"];
|
|
20
20
|
const APP_FRAME = /((?:src|shared|worker)\/[^\s():]+\.ts):(\d+)/;
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** Who investigates the errors: an agent, in its channel, or the claim of one conversation. */
|
|
23
|
+
export type ErrorReportDestination =
|
|
24
|
+
| { agent: string }
|
|
25
|
+
| { conversation: ChannelKey };
|
|
26
|
+
|
|
27
|
+
/** How the reports are delivered, known once the surfaces, and for an agent the team, are up. */
|
|
23
28
|
export interface ErrorReportDelivery {
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The agent's channel while it is active; undefined for an archived or unknown agent. Asked
|
|
31
|
+
* only for an agent's reports.
|
|
32
|
+
*/
|
|
33
|
+
channelOf?(agent: string): ChannelKey | undefined;
|
|
34
|
+
/** The visible message in the channel. */
|
|
27
35
|
post(channel: ChannelKey, text: string): Promise<void>;
|
|
28
|
-
/** The
|
|
36
|
+
/** The report turn; resolves when it has run. */
|
|
29
37
|
turn(channel: ChannelKey, text: string): Promise<ScheduledOutcome>;
|
|
30
38
|
logger: Logger;
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
export interface ErrorReporterOptions {
|
|
34
|
-
/**
|
|
35
|
-
|
|
42
|
+
/** Who investigates the errors. */
|
|
43
|
+
destination: ErrorReportDestination;
|
|
36
44
|
/** The name the reports give the process, such as "Roundtable". */
|
|
37
45
|
app: string;
|
|
38
46
|
ignored?: readonly RegExp[];
|
|
@@ -43,14 +51,14 @@ export interface ErrorReporterOptions {
|
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
/**
|
|
46
|
-
* The process's own `error` and `fatal` log entries, each reported to the ops agent
|
|
47
|
-
* message and a report turn in its channel. The same error goes at most once an hour, and at
|
|
54
|
+
* The process's own `error` and `fatal` log entries, each reported to the ops agent or the ops
|
|
55
|
+
* conversation as a visible message and a report turn in its channel. The same error goes at most once an hour, and at
|
|
48
56
|
* most five reports an hour overall; the rest are counted and summed up in one report when the
|
|
49
57
|
* hour allows. Errors from the reporting itself are only logged; errors logged elsewhere while
|
|
50
58
|
* an error-report turn runs are counted and summed up once it ends.
|
|
51
59
|
*/
|
|
52
60
|
export class ErrorReporter {
|
|
53
|
-
readonly
|
|
61
|
+
readonly destination: ErrorReportDestination;
|
|
54
62
|
readonly #app: string;
|
|
55
63
|
readonly #ignored: readonly RegExp[];
|
|
56
64
|
readonly #now: () => number;
|
|
@@ -68,7 +76,7 @@ export class ErrorReporter {
|
|
|
68
76
|
#summaryDue = false;
|
|
69
77
|
|
|
70
78
|
constructor(options: ErrorReporterOptions) {
|
|
71
|
-
this
|
|
79
|
+
this.destination = options.destination;
|
|
72
80
|
this.#app = options.app;
|
|
73
81
|
this.#ignored = options.ignored ?? IGNORED_ERRORS;
|
|
74
82
|
this.#now = options.now ?? Date.now;
|
|
@@ -177,7 +185,11 @@ export class ErrorReporter {
|
|
|
177
185
|
const delivery = this.#delivery;
|
|
178
186
|
if (!delivery) return Promise.resolve();
|
|
179
187
|
return this.#reporting.run(true, async () => {
|
|
180
|
-
const
|
|
188
|
+
const { destination } = this;
|
|
189
|
+
const channel =
|
|
190
|
+
"conversation" in destination
|
|
191
|
+
? destination.conversation
|
|
192
|
+
: delivery.channelOf?.(destination.agent);
|
|
181
193
|
if (!channel) return;
|
|
182
194
|
try {
|
|
183
195
|
await delivery.post(channel, text);
|
package/src/core/plugin.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ServiceKey, Services } from "./contract/services.ts";
|
|
|
11
11
|
import type { ChatSurface, SurfacePort } from "./contract/surface.ts";
|
|
12
12
|
import type { Migration } from "./db/migrations.ts";
|
|
13
13
|
import type { ToolContribution } from "./define.ts";
|
|
14
|
+
import type { TurnProgress } from "./domain/progress.ts";
|
|
14
15
|
import { PluginError } from "./errors.ts";
|
|
15
16
|
import type { HoldCheck, HoldRule } from "./holds.ts";
|
|
16
17
|
import type { HttpRoute } from "./http/listeners.ts";
|
|
@@ -75,6 +76,11 @@ export interface TurnEndEvent extends TurnEvent {
|
|
|
75
76
|
result: "ok" | "failed" | "stopped";
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/** What a running turn wrote or ran, as a handler hears of it, between its start and its end. */
|
|
80
|
+
export interface TurnProgressEvent extends TurnEvent {
|
|
81
|
+
progress: TurnProgress;
|
|
82
|
+
}
|
|
83
|
+
|
|
78
84
|
/** What a plugin may react to; a handler that throws is logged and never stops the others. */
|
|
79
85
|
export interface EventHandlers {
|
|
80
86
|
/**
|
|
@@ -87,6 +93,12 @@ export interface EventHandlers {
|
|
|
87
93
|
turnStarted?(turn: TurnEvent): Promise<void> | void;
|
|
88
94
|
/** A turn that began ended, however it ended. */
|
|
89
95
|
turnEnded?(turn: TurnEndEvent): Promise<void> | void;
|
|
96
|
+
/**
|
|
97
|
+
* A turn run through `context.turns` wrote text or ran a tool, as it goes: text joined over a
|
|
98
|
+
* short interval, and each tool's name with a short preview of its arguments. Turns on a runtime
|
|
99
|
+
* without live progress report none.
|
|
100
|
+
*/
|
|
101
|
+
turnProgress?(event: TurnProgressEvent): Promise<void> | void;
|
|
90
102
|
/** The team changed: an agent or group was created, edited, arranged, archived, or started over. */
|
|
91
103
|
changed?(): Promise<void> | void;
|
|
92
104
|
/** The shutdown drain ended, before any service stops; `left` is the work it gave up on. */
|
|
@@ -97,6 +109,8 @@ export interface EventHandlers {
|
|
|
97
109
|
export interface EventSink {
|
|
98
110
|
turnStarted(turn: TurnEvent): void;
|
|
99
111
|
turnEnded(turn: TurnEndEvent): void;
|
|
112
|
+
/** Optional, so a sink of your own written before it still fits. */
|
|
113
|
+
turnProgress?(event: TurnProgressEvent): void;
|
|
100
114
|
changed(): void;
|
|
101
115
|
}
|
|
102
116
|
|
|
@@ -35,7 +35,7 @@ function defaults(judgeModel: JudgeModel | undefined): Providers {
|
|
|
35
35
|
},
|
|
36
36
|
runtime: () => {
|
|
37
37
|
throw new ProviderError(
|
|
38
|
-
"no runtime provider is configured: the
|
|
38
|
+
"no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)",
|
|
39
39
|
);
|
|
40
40
|
},
|
|
41
41
|
};
|
|
@@ -168,4 +168,12 @@ export class ChannelRouter implements ConversationPort {
|
|
|
168
168
|
postsInPlace(channel: ChannelKey): boolean {
|
|
169
169
|
return this.#owner(channel)?.postsInPlace === true;
|
|
170
170
|
}
|
|
171
|
+
|
|
172
|
+
owns(channel: ChannelKey): boolean {
|
|
173
|
+
return this.#owner(channel) !== undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
takesBackground(channel: ChannelKey): boolean {
|
|
177
|
+
return this.#owner(channel)?.background !== undefined;
|
|
178
|
+
}
|
|
171
179
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { AgentRuntime } from "../contract/runtime.ts";
|
|
2
2
|
import type { SurfacePort } from "../contract/surface.ts";
|
|
3
|
+
import type {
|
|
4
|
+
ConversationRegistry,
|
|
5
|
+
ConversationVisibility,
|
|
6
|
+
} from "../conversations/conversation-registry.ts";
|
|
3
7
|
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
4
8
|
import type { TurnResult } from "../domain/conversation.ts";
|
|
9
|
+
import type { TurnProgress } from "../domain/progress.ts";
|
|
10
|
+
import { PluginError } from "../errors.ts";
|
|
5
11
|
import { messages } from "../i18n/index.ts";
|
|
6
12
|
import type { Logger } from "../log.ts";
|
|
7
13
|
import type { EventSink } from "../plugin.ts";
|
|
@@ -36,6 +42,11 @@ export interface ConversationTurnInput {
|
|
|
36
42
|
* notice); the claim formats its own. A throw is logged, never rethrown.
|
|
37
43
|
*/
|
|
38
44
|
reply?(result: TurnResult): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* How the conversation is recorded at its first turn: `private` to the speaker, or `shared`
|
|
47
|
+
* (the default). Later turns keep what the first recorded, and give a title only then.
|
|
48
|
+
*/
|
|
49
|
+
conversation?: { visibility: ConversationVisibility; title?: string };
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/**
|
|
@@ -48,7 +59,11 @@ export interface ConversationTurns {
|
|
|
48
59
|
* task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
|
|
49
60
|
* `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
|
|
50
61
|
* posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
|
|
51
|
-
* It rejects during setup (NotLinkedError)
|
|
62
|
+
* It rejects during setup (NotLinkedError), when the host has no runtime to run the turn on,
|
|
63
|
+
* and when the conversation cannot be recorded in the host's registry (a database error, or a
|
|
64
|
+
* `private` conversation without a speaker). Each of these rejects before the turn starts:
|
|
65
|
+
* nothing is shown, no event is emitted, and `reply` is not called, so a claim that answers
|
|
66
|
+
* its person tells them itself.
|
|
52
67
|
*/
|
|
53
68
|
run(input: ConversationTurnInput): Promise<TurnResult>;
|
|
54
69
|
}
|
|
@@ -58,6 +73,8 @@ export interface ConversationTurnsOptions {
|
|
|
58
73
|
linked: () => void;
|
|
59
74
|
/** The runtime that runs the turn; throws PluginError when the host has none yet. */
|
|
60
75
|
runtime: () => AgentRuntime;
|
|
76
|
+
/** Where each turn records its conversation; absent, or undefined, records none. */
|
|
77
|
+
registry?: () => Pick<ConversationRegistry, "register"> | undefined;
|
|
61
78
|
surfaces: SurfacePort;
|
|
62
79
|
events: EventSink;
|
|
63
80
|
/** The plugins' agent selection, read at each turn that gives none of its own. */
|
|
@@ -68,6 +85,27 @@ export interface ConversationTurnsOptions {
|
|
|
68
85
|
/** The selection of a turn that gives none: the plugins' `agentSelection`. */
|
|
69
86
|
const DEFAULT_SELECTION = "turns";
|
|
70
87
|
|
|
88
|
+
/** Records the conversation of a turn: private to the speaker when asked, else shared. */
|
|
89
|
+
async function record(
|
|
90
|
+
registry: Pick<ConversationRegistry, "register"> | undefined,
|
|
91
|
+
input: ConversationTurnInput,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
if (!registry) return;
|
|
94
|
+
const visibility = input.conversation?.visibility ?? "shared";
|
|
95
|
+
if (visibility === "private" && !input.speaker)
|
|
96
|
+
throw new PluginError(
|
|
97
|
+
`a private conversation needs the speaker it belongs to; ${input.channel} was run without one.`,
|
|
98
|
+
);
|
|
99
|
+
const title = input.conversation?.title;
|
|
100
|
+
await registry.register({
|
|
101
|
+
key: input.channel,
|
|
102
|
+
kind: input.kind,
|
|
103
|
+
visibility,
|
|
104
|
+
...(visibility === "private" ? { principalId: input.speaker.id } : {}),
|
|
105
|
+
...(title === undefined ? {} : { title }),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
71
109
|
export function conversationTurns(
|
|
72
110
|
options: ConversationTurnsOptions,
|
|
73
111
|
): ConversationTurns {
|
|
@@ -78,12 +116,26 @@ export function conversationTurns(
|
|
|
78
116
|
options.linked();
|
|
79
117
|
// A host without a runtime is a setup mistake, so it is refused before anything is shown.
|
|
80
118
|
const runtime = options.runtime();
|
|
119
|
+
// A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
|
|
120
|
+
await record(options.registry?.(), input);
|
|
81
121
|
const stopTyping = surfaces.startTyping(channel);
|
|
82
122
|
const hideStop = surfaces.showStop(channel);
|
|
83
123
|
const turn = { kind, channel, speaker };
|
|
84
124
|
events.turnStarted(turn);
|
|
85
125
|
// A claim that posts its own reply formats its own text, so only the default reply posts as it goes.
|
|
86
126
|
const interim = input.reply ? undefined : surfaces.interim(channel);
|
|
127
|
+
// What the runtime reports as it goes reaches the surface and the handlers until the turn ends.
|
|
128
|
+
let live = true;
|
|
129
|
+
const progress = (event: TurnProgress) => {
|
|
130
|
+
if (!live) return;
|
|
131
|
+
events.turnProgress?.({ ...turn, progress: event });
|
|
132
|
+
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
133
|
+
surfaces
|
|
134
|
+
.progress?.(channel, event)
|
|
135
|
+
?.catch((error: unknown) =>
|
|
136
|
+
logger.warn({ channel, kind, err: error }, "progress not shown"),
|
|
137
|
+
);
|
|
138
|
+
};
|
|
87
139
|
let result: TurnResult;
|
|
88
140
|
try {
|
|
89
141
|
result = await settleTurn(
|
|
@@ -105,11 +157,13 @@ export function conversationTurns(
|
|
|
105
157
|
...(input.steerable ? { steerable: true } : {}),
|
|
106
158
|
...(input.interactive ? { interactive: true } : {}),
|
|
107
159
|
...(interim ? { interim } : {}),
|
|
160
|
+
progress,
|
|
108
161
|
}),
|
|
109
162
|
),
|
|
110
163
|
"conversation turn",
|
|
111
164
|
);
|
|
112
165
|
} finally {
|
|
166
|
+
live = false;
|
|
113
167
|
hideStop();
|
|
114
168
|
}
|
|
115
169
|
events.turnEnded({
|
|
@@ -42,5 +42,8 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
|
|
|
42
42
|
of(channel)?.unreact?.(channel, messageId, emoji),
|
|
43
43
|
prompts: (channel, speaker) => of(channel)?.prompts?.(channel, speaker),
|
|
44
44
|
interim: (channel) => of(channel)?.interim?.(channel),
|
|
45
|
+
progress: async (channel, event) => {
|
|
46
|
+
await of(channel)?.progress?.(channel, event);
|
|
47
|
+
},
|
|
45
48
|
};
|
|
46
49
|
}
|
|
@@ -54,6 +54,8 @@ function byCodeUnit(a: string, b: string): number {
|
|
|
54
54
|
// pi-lens-ignore: large-class — one gate per conversation; its state is the held calls and their approvals
|
|
55
55
|
export class ConfirmationGate {
|
|
56
56
|
#selectionId: string | undefined;
|
|
57
|
+
/** The running turn's speaker, whom the actions it holds belong to. */
|
|
58
|
+
#speakerId: string | undefined;
|
|
57
59
|
#pending: PendingConfirmation | undefined;
|
|
58
60
|
#approved: HeldCall[] = [];
|
|
59
61
|
readonly #holds: HoldCheck;
|
|
@@ -91,13 +93,18 @@ export class ConfirmationGate {
|
|
|
91
93
|
: pending;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* `addressee` is who the turn is for; without one, the owner. `speakerId` is who spoke the turn,
|
|
98
|
+
* recorded with what it holds; without one, only the owner may approve them.
|
|
99
|
+
*/
|
|
95
100
|
beginTurn(
|
|
96
101
|
selectionId: string,
|
|
97
102
|
confirmed: boolean,
|
|
98
103
|
addressee?: OwnerIdentity,
|
|
104
|
+
speakerId?: string,
|
|
99
105
|
): void {
|
|
100
106
|
this.#selectionId = selectionId;
|
|
107
|
+
this.#speakerId = speakerId;
|
|
101
108
|
this.#addressee = addressee ?? this.#owner;
|
|
102
109
|
this.#approved = confirmed ? [...(this.pending()?.calls ?? [])] : [];
|
|
103
110
|
this.#pending = undefined;
|
|
@@ -180,10 +187,11 @@ export class ConfirmationGate {
|
|
|
180
187
|
const selectionId = this.#selectionId;
|
|
181
188
|
if (selectionId === undefined)
|
|
182
189
|
throw new Error("a call was held outside a turn");
|
|
183
|
-
const pending = this.#pending ?? {
|
|
190
|
+
const pending: PendingConfirmation = this.#pending ?? {
|
|
184
191
|
selectionId,
|
|
185
192
|
heldAt: new Date(),
|
|
186
193
|
calls: [],
|
|
194
|
+
...(this.#speakerId === undefined ? {} : { speakerId: this.#speakerId }),
|
|
187
195
|
};
|
|
188
196
|
if (
|
|
189
197
|
!pending.calls.some((c) => c.tool === call.tool && c.input === call.input)
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ConfigError } from "../../domain/errors.ts";
|
|
2
3
|
|
|
3
4
|
/** pi-self-compact's tool, active in every session. */
|
|
4
5
|
export const COMPACT_TOOL = "compact_session";
|
|
5
6
|
|
|
7
|
+
/** The preflight's refusal of a session without the tools it requires, saying where compaction's comes from. */
|
|
8
|
+
export function missingToolsError(missing: readonly string[]): ConfigError {
|
|
9
|
+
const source = missing.includes(COMPACT_TOOL)
|
|
10
|
+
? ` ${COMPACT_TOOL} comes from the Pi package pi-self-compact: install it (bun add pi-self-compact) and load it from a plugin with piPackages: ["pi-self-compact"], as the plugins/self-compact.ts of a project roundtable init creates does.`
|
|
11
|
+
: "";
|
|
12
|
+
return new ConfigError(
|
|
13
|
+
`required tools are not registered: ${missing.join(", ")}.${source}`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
const RESUME_REFUSED =
|
|
7
18
|
"compact_session cannot take resume here: a resumed run would start outside this conversation and its reply would reach no one. Finish this turn's work, then call compact_session again without resume; the next message starts from the compacted context.";
|
|
8
19
|
|
|
@@ -10,6 +10,7 @@ interface Row {
|
|
|
10
10
|
selection_id: string;
|
|
11
11
|
held_at: Date;
|
|
12
12
|
calls: string;
|
|
13
|
+
speaker_id: string | null;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/** Each owner channel's held actions, in the database, so a restart keeps them. */
|
|
@@ -34,14 +35,46 @@ export class PendingConfirmationStore {
|
|
|
34
35
|
},
|
|
35
36
|
};
|
|
36
37
|
|
|
38
|
+
/** Who spoke the turn that held the actions, so only they and the owner approve them. */
|
|
39
|
+
static readonly speakerMigration: Migration = {
|
|
40
|
+
name: "held-actions-speaker",
|
|
41
|
+
up: async (sql) => {
|
|
42
|
+
await sql`ALTER TABLE held_actions ADD COLUMN IF NOT EXISTS speaker_id text`;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The hold the speaker belongs to, by its `held_at`. 0.7 replaces a row's held actions and
|
|
48
|
+
* leaves `speaker_id` as it was, so after a rollback and an upgrade a speaker counts only while
|
|
49
|
+
* the row still holds the actions it was written with.
|
|
50
|
+
*/
|
|
51
|
+
static readonly speakerHoldMigration: Migration = {
|
|
52
|
+
name: "held-actions-speaker-hold",
|
|
53
|
+
up: async (sql) => {
|
|
54
|
+
await sql`ALTER TABLE held_actions ADD COLUMN IF NOT EXISTS speaker_held_at timestamptz`;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The store's migrations in order. */
|
|
59
|
+
static migrations(): Migration[] {
|
|
60
|
+
return [
|
|
61
|
+
PendingConfirmationStore.migration,
|
|
62
|
+
PendingConfirmationStore.speakerMigration,
|
|
63
|
+
PendingConfirmationStore.speakerHoldMigration,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
37
67
|
/** The store over the host's migrated pool. */
|
|
38
68
|
static async attach(sql: SQL): Promise<PendingConfirmationStore> {
|
|
39
69
|
return new PendingConfirmationStore(sql);
|
|
40
70
|
}
|
|
41
71
|
|
|
42
72
|
async load(channel: ChannelKey): Promise<PendingConfirmation | undefined> {
|
|
73
|
+
// A speaker written with other held actions than the row's, or without its hold, is none.
|
|
43
74
|
const rows: Row[] = await this.#sql`
|
|
44
|
-
SELECT selection_id, held_at, calls
|
|
75
|
+
SELECT selection_id, held_at, calls,
|
|
76
|
+
CASE WHEN speaker_held_at = held_at THEN speaker_id END AS speaker_id
|
|
77
|
+
FROM held_actions
|
|
45
78
|
WHERE channel_key = ${channel}`;
|
|
46
79
|
const row = rows[0];
|
|
47
80
|
return row
|
|
@@ -50,6 +83,7 @@ export class PendingConfirmationStore {
|
|
|
50
83
|
heldAt: row.held_at,
|
|
51
84
|
// pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
|
|
52
85
|
calls: JSON.parse(row.calls) as HeldCall[],
|
|
86
|
+
...(row.speaker_id === null ? {} : { speakerId: row.speaker_id }),
|
|
53
87
|
}
|
|
54
88
|
: undefined;
|
|
55
89
|
}
|
|
@@ -63,11 +97,15 @@ export class PendingConfirmationStore {
|
|
|
63
97
|
await this.#sql`DELETE FROM held_actions WHERE channel_key = ${channel}`;
|
|
64
98
|
return;
|
|
65
99
|
}
|
|
100
|
+
const speaker = pending.speakerId ?? null;
|
|
66
101
|
await this.#sql`
|
|
67
|
-
INSERT INTO held_actions
|
|
102
|
+
INSERT INTO held_actions
|
|
103
|
+
(channel_key, selection_id, held_at, calls, speaker_id, speaker_held_at)
|
|
68
104
|
VALUES (${channel}, ${pending.selectionId}, ${pending.heldAt},
|
|
69
|
-
${JSON.stringify(pending.calls)}
|
|
105
|
+
${JSON.stringify(pending.calls)}, ${speaker},
|
|
106
|
+
${speaker === null ? null : pending.heldAt})
|
|
70
107
|
ON CONFLICT (channel_key) DO UPDATE SET selection_id = EXCLUDED.selection_id,
|
|
71
|
-
held_at = EXCLUDED.held_at, calls = EXCLUDED.calls
|
|
108
|
+
held_at = EXCLUDED.held_at, calls = EXCLUDED.calls,
|
|
109
|
+
speaker_id = EXCLUDED.speaker_id, speaker_held_at = EXCLUDED.speaker_held_at`;
|
|
72
110
|
}
|
|
73
111
|
}
|
|
@@ -35,7 +35,10 @@ import {
|
|
|
35
35
|
ConfirmationGate,
|
|
36
36
|
confirmedTurnText,
|
|
37
37
|
} from "./extensions/confirmation-gate.ts";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
COMPACT_TOOL,
|
|
40
|
+
missingToolsError,
|
|
41
|
+
} from "./extensions/self-compact-guard.ts";
|
|
39
42
|
import { interimPoster } from "./interim-text.ts";
|
|
40
43
|
import { PromptSlot, workTimeout } from "./prompt-slot.ts";
|
|
41
44
|
import {
|
|
@@ -47,6 +50,7 @@ import { archiveSessions } from "./session-archive.ts";
|
|
|
47
50
|
import { SessionFactory } from "./session-factory.ts";
|
|
48
51
|
import { type PromptImages, SteerableRun } from "./steerable-run.ts";
|
|
49
52
|
import { lastReply, turnAnswer } from "./turn-answer.ts";
|
|
53
|
+
import { progressReporter } from "./turn-progress.ts";
|
|
50
54
|
import { workerReport } from "./worker-task.ts";
|
|
51
55
|
|
|
52
56
|
export {
|
|
@@ -117,11 +121,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
117
121
|
),
|
|
118
122
|
];
|
|
119
123
|
const missing = expected.filter((name) => !registered.has(name));
|
|
120
|
-
if (missing.length > 0)
|
|
121
|
-
throw new ConfigError(
|
|
122
|
-
`required tools are not registered: ${missing.join(", ")}`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
124
|
+
if (missing.length > 0) throw missingToolsError(missing);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// pi-lens-ignore: high-complexity, high-fan-out, mixed-async-styles — one turn's lifecycle end to end; batch 4 splits it with the channel router
|
|
@@ -168,6 +168,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
168
168
|
request.selection.id,
|
|
169
169
|
request.confirmed === true,
|
|
170
170
|
addressee(request.speaker, this.#options.owner),
|
|
171
|
+
request.speaker?.id,
|
|
171
172
|
);
|
|
172
173
|
if (request.confirmed && pending) {
|
|
173
174
|
logger.info(
|
|
@@ -184,7 +185,12 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
184
185
|
const toolCalls: string[] = [];
|
|
185
186
|
// Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
|
|
186
187
|
const interim = interimPoster(request, this.#options);
|
|
188
|
+
// The caller's live view: the text as it streams and each tool, never the thinking.
|
|
189
|
+
const progress = request.progress
|
|
190
|
+
? progressReporter(request.progress)
|
|
191
|
+
: undefined;
|
|
187
192
|
const unsubscribe = session.subscribe((event) => {
|
|
193
|
+
progress?.observe(event);
|
|
188
194
|
if (event.type === "tool_execution_start") {
|
|
189
195
|
toolCalls.push(event.toolName);
|
|
190
196
|
interim?.toolStart(event.toolName);
|
|
@@ -247,6 +253,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
247
253
|
cancelTimeout();
|
|
248
254
|
slot.unbind();
|
|
249
255
|
unsubscribe();
|
|
256
|
+
progress?.close();
|
|
250
257
|
await interim?.flush();
|
|
251
258
|
gate.endTurn();
|
|
252
259
|
const usage = session.getContextUsage();
|
|
@@ -46,8 +46,8 @@ export class PromptSlot {
|
|
|
46
46
|
const prompts = this.#prompts;
|
|
47
47
|
if (!prompts) return undefined;
|
|
48
48
|
return {
|
|
49
|
-
confirm: (title, message, signal) =>
|
|
50
|
-
this.#waiting(() => prompts.confirm(title, message, signal)),
|
|
49
|
+
confirm: (title, message, signal, minTier) =>
|
|
50
|
+
this.#waiting(() => prompts.confirm(title, message, signal, minTier)),
|
|
51
51
|
ask: (title, question: OwnerQuestion, signal) =>
|
|
52
52
|
this.#waiting(() => prompts.ask(title, question, signal)),
|
|
53
53
|
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
AgentRuntime,
|
|
4
|
+
AgentSessions,
|
|
5
|
+
HeldActionStore,
|
|
6
|
+
RuntimeDeps,
|
|
7
|
+
} from "../contract/runtime.ts";
|
|
8
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
9
|
+
import type { InterimTextMode } from "../domain/interim.ts";
|
|
10
|
+
import type { OwnerIdentity } from "../identity.ts";
|
|
11
|
+
import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
|
|
12
|
+
import type { ModelRef, ThinkingLevel } from "../models.ts";
|
|
13
|
+
import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
|
|
14
|
+
import { RUNTIME } from "../services.ts";
|
|
15
|
+
import type { Speaker } from "../speakers.ts";
|
|
16
|
+
import { PendingConfirmationStore } from "./pending-confirmation-store.ts";
|
|
17
|
+
import { PiAgentRuntime } from "./pi-agent-runtime.ts";
|
|
18
|
+
|
|
19
|
+
/** The name of the runtime plugin, as `serviceStarted` events and the migration ledger name it. */
|
|
20
|
+
export const RUNTIME_PLUGIN = "runtime";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where the agent server hands the runtime its per-agent settings. The runtime is built before the
|
|
24
|
+
* agent server sets up, so it reads them at each turn; on a host without the agent server they stay
|
|
25
|
+
* unbound and no agent turn can run.
|
|
26
|
+
*/
|
|
27
|
+
export interface AgentSessionsSlot {
|
|
28
|
+
/** The agent server's settings, or undefined while none are bound. */
|
|
29
|
+
current(): AgentSessions | undefined;
|
|
30
|
+
/** Called once, by the agent server's setup. */
|
|
31
|
+
bind(sessions: AgentSessions): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function agentSessionsSlot(): AgentSessionsSlot {
|
|
35
|
+
let bound: AgentSessions | undefined;
|
|
36
|
+
return {
|
|
37
|
+
current: () => bound,
|
|
38
|
+
bind: (sessions) => {
|
|
39
|
+
if (bound)
|
|
40
|
+
throw new Error(
|
|
41
|
+
"the agent sessions are bound once, by the agent server",
|
|
42
|
+
);
|
|
43
|
+
bound = sessions;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RuntimePluginOptions {
|
|
49
|
+
/** Who the conversations serve, as prompts and tool results name them. */
|
|
50
|
+
owner: OwnerIdentity & { id: string };
|
|
51
|
+
/** Shared with the rest of the host, so logins refresh in one place. */
|
|
52
|
+
modelRuntime: ModelRuntime;
|
|
53
|
+
agentDir: string;
|
|
54
|
+
dataDir: string;
|
|
55
|
+
/** The assistant's model, and the thinking level the effort judge falls back to. */
|
|
56
|
+
model: ModelRef;
|
|
57
|
+
thinking: ThinkingLevel;
|
|
58
|
+
/** How sure the effort judge must be before its answer is used. */
|
|
59
|
+
judgeThreshold: number;
|
|
60
|
+
/** The agent server's settings, bound when it sets up; without a slot no agent turn runs. */
|
|
61
|
+
agents?: AgentSessionsSlot;
|
|
62
|
+
/** Whether turns post the text they write before their final answer as they go; default "on". */
|
|
63
|
+
interimText?: InterimTextMode;
|
|
64
|
+
/** An intermediate text this long or longer is posted as an ordinary message; default 400. */
|
|
65
|
+
interimPrimaryChars?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The runtime every conversation turn runs on, built once at setup and provided as `RUNTIME`: the
|
|
70
|
+
* `runtime` provider slot's when a plugin fills it, Pi's otherwise. It keeps the held actions'
|
|
71
|
+
* table, so a host without the agent server still holds and restores actions across a restart.
|
|
72
|
+
*/
|
|
73
|
+
export function runtimePlugin(
|
|
74
|
+
options: RuntimePluginOptions,
|
|
75
|
+
/** Opens the held actions' store; a test hands a stand-in, so the plugin sets up without a database. */
|
|
76
|
+
openHeldActions: (
|
|
77
|
+
context: Pick<PluginContext, "database">,
|
|
78
|
+
) => Promise<HeldActionStore> = (context) =>
|
|
79
|
+
PendingConfirmationStore.attach(context.database()),
|
|
80
|
+
): RoundtablePlugin {
|
|
81
|
+
let runtime: AgentRuntime | undefined;
|
|
82
|
+
return {
|
|
83
|
+
name: RUNTIME_PLUGIN,
|
|
84
|
+
migrations: PendingConfirmationStore.migrations(),
|
|
85
|
+
provides: [RUNTIME],
|
|
86
|
+
preflight: async () => {
|
|
87
|
+
await runtime?.preflight?.();
|
|
88
|
+
},
|
|
89
|
+
setup: async (context) => {
|
|
90
|
+
const { logger, providers } = context;
|
|
91
|
+
const heldActions = await openHeldActions(context);
|
|
92
|
+
const prompts = (channel: ChannelKey, speaker?: Speaker) =>
|
|
93
|
+
context.surfaces.prompts(channel, speaker);
|
|
94
|
+
const agents = () => options.agents?.current();
|
|
95
|
+
const running: AgentRuntime = providers.filled.has("runtime")
|
|
96
|
+
? providers.runtime(
|
|
97
|
+
runtimeDeps({
|
|
98
|
+
logger,
|
|
99
|
+
env: context.env,
|
|
100
|
+
owner: { id: options.owner.id, name: options.owner.name },
|
|
101
|
+
sessions: context.sessions,
|
|
102
|
+
toolTiers: context.toolTiers,
|
|
103
|
+
prompts,
|
|
104
|
+
agents,
|
|
105
|
+
confirmations: heldActions,
|
|
106
|
+
judge: providers.judge,
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
: new PiAgentRuntime({
|
|
110
|
+
owner: options.owner,
|
|
111
|
+
sessions: context.sessions,
|
|
112
|
+
agentDir: options.agentDir,
|
|
113
|
+
modelRuntime: options.modelRuntime,
|
|
114
|
+
dataDir: options.dataDir,
|
|
115
|
+
model: options.model,
|
|
116
|
+
thinking: options.thinking,
|
|
117
|
+
effort: new EffortJudge({
|
|
118
|
+
judge: providers.judge,
|
|
119
|
+
brief: AGENT_BRIEF,
|
|
120
|
+
fallback: options.thinking,
|
|
121
|
+
threshold: options.judgeThreshold,
|
|
122
|
+
logger,
|
|
123
|
+
}),
|
|
124
|
+
confirmations: heldActions,
|
|
125
|
+
toolTiers: context.toolTiers,
|
|
126
|
+
get agents() {
|
|
127
|
+
return agents();
|
|
128
|
+
},
|
|
129
|
+
prompts,
|
|
130
|
+
logger,
|
|
131
|
+
...(options.interimText
|
|
132
|
+
? { interimText: options.interimText }
|
|
133
|
+
: {}),
|
|
134
|
+
...(options.interimPrimaryChars
|
|
135
|
+
? { interimPrimaryChars: options.interimPrimaryChars }
|
|
136
|
+
: {}),
|
|
137
|
+
});
|
|
138
|
+
runtime = running;
|
|
139
|
+
context.services.provide(RUNTIME, running);
|
|
140
|
+
return {
|
|
141
|
+
services: [{ name: "runtime", stop: () => running.dispose?.() }],
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The deps a runtime provider gets, with `agents` read when asked, after the agent server bound it. */
|
|
148
|
+
function runtimeDeps(
|
|
149
|
+
deps: Omit<RuntimeDeps, "agents"> & {
|
|
150
|
+
agents: () => AgentSessions | undefined;
|
|
151
|
+
},
|
|
152
|
+
): RuntimeDeps {
|
|
153
|
+
const { agents, ...rest } = deps;
|
|
154
|
+
return {
|
|
155
|
+
...rest,
|
|
156
|
+
get agents() {
|
|
157
|
+
return agents();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|