pi-roundtable 0.7.16 → 0.7.17
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 +13 -0
- package/docs/plugins.md +21 -1
- package/package.json +1 -1
- package/src/core/agents/agent-ports.ts +9 -0
- package/src/core/agents/team-turn-types.ts +1 -1
- package/src/core/agents/team-turns.ts +24 -0
- package/src/core/builtin/agent-server.ts +11 -0
- package/src/core/config/config.ts +16 -0
- package/src/core/contract/surface.ts +8 -0
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/discord/agent-discord.ts +33 -0
- package/src/core/discord/discord-surface.ts +20 -0
- package/src/core/domain/interim.ts +16 -0
- package/src/core/domain/ports.ts +6 -0
- package/src/core/routing/conversation-turns.ts +3 -0
- package/src/core/routing/surface-port.ts +1 -0
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/interim-text.ts +207 -0
- package/src/core/runtime/pi-agent-runtime.ts +13 -2
- package/src/core/runtime/prompt-slot.ts +13 -2
- package/src/core/runtime/runtime-types.ts +8 -0
- package/src/index.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.7.17] - 2026-10-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- A turn posts the text it writes before its final answer as it goes, so a proposal written before `ask_user` shows above its card instead of never reaching the channel. Primary text (400 characters or more, or with a Markdown heading, list, table or code fence) of a tool-calling assistant message is posted as ordinary messages when the message ends; shorter narration and the tools called (`-# bash ×3 · read`, names only) share one small-text progress message per run of tool calls, edited at most every 1.5 s, bounded to 2000 characters by dropping its oldest lines. Before any card the pending text is posted first. The final reply, its thinking line, and steered runs are unchanged; a failed interim post is logged and never fails the turn. See [interim text](docs/plugins.md#interim-text-what-a-turn-writes-before-its-final-answer).
|
|
13
|
+
- Config `interimText: "on" | "off"` (default `"on"`) and `interimPrimaryChars` (default 400), also on `PiAgentRuntimeOptions` and the agent server's options.
|
|
14
|
+
- `TurnRequest.interim`, `ChatSurface.interim(channel)` and `SurfacePort.interim(channel)` (Discord's surface implements it), optional `AgentChannels.interim(channelId, as)` (the agents' webhooks), and `PromptSlot.bind`'s optional `beforeCard`.
|
|
15
|
+
- Types: `InterimMessage`, `InterimPosts`, `InterimTextMode`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `ask_user`'s description asks for a question that reads on its own, with the context it needs.
|
|
20
|
+
|
|
8
21
|
## [0.7.16] - 2026-10-04
|
|
9
22
|
|
|
10
23
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -1056,7 +1056,7 @@ An `AgentRuntime` has these methods:
|
|
|
1056
1056
|
| `preflight?()` | Runs in the host's preflight, before anything starts; a throw stops the boot |
|
|
1057
1057
|
| `dispose?()` | Runs when the host stops the agent server's runtime service |
|
|
1058
1058
|
|
|
1059
|
-
A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`,
|
|
1059
|
+
A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`, flags (`steerable`, `interactive`, `confirmed`), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
|
|
1060
1060
|
An agent's turn also has `agent`, the agent's scope, whose `session` is the conversation's key.
|
|
1061
1061
|
Other turns use `kind` to name the conversation's persona, defaulting to `"owner"` when absent.
|
|
1062
1062
|
|
|
@@ -1882,6 +1882,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
|
|
|
1882
1882
|
| `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
|
|
1883
1883
|
| `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
|
|
1884
1884
|
| `prompts(channel, speaker?)` | The owner's way to approve a held action or answer `ask_user` inside a running turn, as `OwnerPrompts`: `confirm` and `ask` | the action is held until the owner's next message |
|
|
1885
|
+
| `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
|
|
1885
1886
|
|
|
1886
1887
|
The host starts each surface as `surface:<prefix>` at the contributing plugin's place in the order, before that plugin's own services.
|
|
1887
1888
|
It stops surfaces in reverse order, like other services.
|
|
@@ -1893,6 +1894,22 @@ If `prompts` is unavailable, it returns `undefined` and held actions wait for th
|
|
|
1893
1894
|
`of(channel)` returns the surface, or `undefined`.
|
|
1894
1895
|
The agent server asks the owner for approvals through `context.surfaces.prompts`, so a surface that gives `prompts` gets them in its own channels.
|
|
1895
1896
|
|
|
1897
|
+
#### Interim text: what a turn writes before its final answer
|
|
1898
|
+
|
|
1899
|
+
A model often writes text in an assistant message that then calls tools, such as a proposal before it asks `ask_user` "go with this version?".
|
|
1900
|
+
On a surface that gives `interim`, and in the agent server's channels through the agents' webhooks, the host posts that text as the turn goes, sorted in two:
|
|
1901
|
+
|
|
1902
|
+
- **Primary** text is posted as ordinary messages as soon as its assistant message ends: text of 400 characters or more, or written with a Markdown heading, list, table or code fence.
|
|
1903
|
+
- **Secondary** text, short narration between tool calls, goes to one progress message per run of tool-calling messages, in Discord's small text (`-# ` per line): each text as a line, then the tools called in the run, such as `-# bash ×3 · read · web_search` (names only). It is edited in place at most every 1.5 seconds, its last state always lands, and it stays inside 2000 characters by dropping its oldest lines behind `-# …`. A primary post or a card starts a new progress message.
|
|
1904
|
+
|
|
1905
|
+
Before any card, an `ask_user` question or an approval, the host posts the pending text and brings the progress message up to date, so what the model wrote before the card shows above it.
|
|
1906
|
+
The final reply is posted at the end as before, with its thinking line; an intermediate message is never the final one, so nothing is posted twice, and a steered run keeps its final text.
|
|
1907
|
+
A failed interim post or edit is logged and never fails the turn.
|
|
1908
|
+
Turns with nowhere to post, such as transient tasks, coding workers, and turns of a claim that passes its own `reply` to `context.turns.run`, post only their final reply.
|
|
1909
|
+
A runtime that fills [the `runtime` slot](#the-runtime-slot-replace-pi) receives the place to post as `TurnRequest.interim` and may use it or not.
|
|
1910
|
+
|
|
1911
|
+
The config's `interimText: "off"` posts only the final reply (default `"on"`), and `interimPrimaryChars` sets the length of primary text (default 400).
|
|
1912
|
+
|
|
1896
1913
|
The Discord plugin collects [slash commands](#slash-commands-commandsadd); the host doesn't compose them or pass them to surfaces on other networks.
|
|
1897
1914
|
|
|
1898
1915
|
This in-memory chat surface records everything the host asks of it.
|
|
@@ -2525,6 +2542,9 @@ Import from the entries listed below; source area files are internal.
|
|
|
2525
2542
|
| `HttpRoute` | `pi-roundtable` | type |
|
|
2526
2543
|
| `ImageDrawer` | `pi-roundtable` | type |
|
|
2527
2544
|
| `InboundMessage` | `pi-roundtable` | type |
|
|
2545
|
+
| `InterimMessage` | `pi-roundtable` | type |
|
|
2546
|
+
| `InterimPosts` | `pi-roundtable` | type |
|
|
2547
|
+
| `InterimTextMode` | `pi-roundtable` | type |
|
|
2528
2548
|
| `Judge` | `pi-roundtable` | type |
|
|
2529
2549
|
| `JudgeError` | `pi-roundtable` | value |
|
|
2530
2550
|
| `JudgeModel` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentRuntime, ContextUse } from "../contract/runtime.ts";
|
|
2
|
+
import type { InterimPosts } from "../domain/interim.ts";
|
|
2
3
|
import type { ThinkingSetting } from "../models.ts";
|
|
3
4
|
|
|
4
5
|
export type { ContextUse };
|
|
@@ -47,6 +48,14 @@ export interface ChannelMessage {
|
|
|
47
48
|
/** The agent server's channels as the agent team needs them. */
|
|
48
49
|
export interface AgentChannels {
|
|
49
50
|
post(channelId: string, post: AgentPost): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Where a running turn posts the text it writes before its final answer, under the same name
|
|
53
|
+
* and avatar as `post`; absent = only the final reply is posted.
|
|
54
|
+
*/
|
|
55
|
+
interim?(
|
|
56
|
+
channelId: string,
|
|
57
|
+
as: Omit<AgentPost, "thinking" | "chunks" | "files">,
|
|
58
|
+
): InterimPosts;
|
|
50
59
|
/** Creates a text channel under the category, made when missing; returns its id. */
|
|
51
60
|
createChannel(
|
|
52
61
|
name: string,
|
|
@@ -44,7 +44,7 @@ export interface TeamTurnsOptions {
|
|
|
44
44
|
/** Who the agents work for, as their prompts and group history name them. */
|
|
45
45
|
owner: OwnerIdentity;
|
|
46
46
|
store: PgAgentStore;
|
|
47
|
-
channels: Pick<AgentChannels, "post">;
|
|
47
|
+
channels: Pick<AgentChannels, "post" | "interim">;
|
|
48
48
|
studio: Pick<AvatarStudio, "url">;
|
|
49
49
|
/** Set once the runtime exists, which itself needs the team's tools. */
|
|
50
50
|
runtime: () => AgentTurnRunner;
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
TurnResult,
|
|
7
7
|
} from "../domain/conversation.ts";
|
|
8
8
|
import { AgentError } from "../domain/errors.ts";
|
|
9
|
+
import type { InterimPosts } from "../domain/interim.ts";
|
|
9
10
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
10
11
|
import { messages } from "../i18n/index.ts";
|
|
11
12
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
@@ -185,6 +186,7 @@ export class TeamTurns {
|
|
|
185
186
|
...(scope.group ? { group: scope.group } : {}),
|
|
186
187
|
};
|
|
187
188
|
this.#options.events?.turnStarted(turn);
|
|
189
|
+
const interim = this.#interim(postTo, agent);
|
|
188
190
|
let result: TurnResult;
|
|
189
191
|
try {
|
|
190
192
|
result = await settleTurn(
|
|
@@ -200,6 +202,7 @@ export class TeamTurns {
|
|
|
200
202
|
...(extra.interactive ? { interactive: true } : {}),
|
|
201
203
|
agent: scope,
|
|
202
204
|
speaker: chain.speaker,
|
|
205
|
+
...(interim ? { interim } : {}),
|
|
203
206
|
}),
|
|
204
207
|
),
|
|
205
208
|
"agent turn",
|
|
@@ -250,6 +253,27 @@ export class TeamTurns {
|
|
|
250
253
|
return this.#options.store.agent(agent.name) ?? agent;
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The turn's interim posts under the agent's name, read as each goes out so a name changed
|
|
258
|
+
* during the turn shows; none in a channel no agent or group owns any more.
|
|
259
|
+
*/
|
|
260
|
+
#interim(channel: ChannelKey, agent: Agent): InterimPosts | undefined {
|
|
261
|
+
const { store, channels, studio } = this.#options;
|
|
262
|
+
if (!channels.interim) return undefined;
|
|
263
|
+
const interim = channels.interim.bind(channels);
|
|
264
|
+
return {
|
|
265
|
+
post: async (text) => {
|
|
266
|
+
if (!channelOwner(store, channel))
|
|
267
|
+
throw new AgentError(`${channel} is archived`);
|
|
268
|
+
const as = this.#current(agent);
|
|
269
|
+
return interim(channelIdOf(channel), {
|
|
270
|
+
name: as.displayName,
|
|
271
|
+
avatarUrl: studio.url(as.avatarHash),
|
|
272
|
+
}).post(text);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
253
277
|
/**
|
|
254
278
|
* Posts under the agent's name. A channel no agent or group owns any more, archived while a
|
|
255
279
|
* turn ran, gets nothing, so its webhook is not made again.
|
|
@@ -14,6 +14,7 @@ import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
|
|
|
14
14
|
import type { AgentRuntime, AgentSessions } from "../contract/runtime.ts";
|
|
15
15
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
16
16
|
import { ConfigError } from "../domain/errors.ts";
|
|
17
|
+
import type { InterimTextMode } from "../domain/interim.ts";
|
|
17
18
|
import type { OwnerIdentity } from "../identity.ts";
|
|
18
19
|
import { ConfirmationJudge } from "../judging/confirmation-judge.ts";
|
|
19
20
|
import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
|
|
@@ -72,6 +73,10 @@ export interface AgentServerOptions {
|
|
|
72
73
|
avatarReference: string;
|
|
73
74
|
/** Reports the process's own errors to an agent; without one nothing is reported. */
|
|
74
75
|
errorReporter?: ErrorReporter;
|
|
76
|
+
/** Whether turns post the text they write before their final answer as they go; default "on". */
|
|
77
|
+
interimText?: InterimTextMode;
|
|
78
|
+
/** An intermediate text this long or longer is posted as an ordinary message; default 400. */
|
|
79
|
+
interimPrimaryChars?: number;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
/** The name of the agent server's plugin, as `serviceStarted` events name it. */
|
|
@@ -278,6 +283,12 @@ export function agentServerPlugin(
|
|
|
278
283
|
agents: agentSessions,
|
|
279
284
|
prompts,
|
|
280
285
|
logger,
|
|
286
|
+
...(options.interimText
|
|
287
|
+
? { interimText: options.interimText }
|
|
288
|
+
: {}),
|
|
289
|
+
...(options.interimPrimaryChars
|
|
290
|
+
? { interimPrimaryChars: options.interimPrimaryChars }
|
|
291
|
+
: {}),
|
|
281
292
|
});
|
|
282
293
|
runtime = running;
|
|
283
294
|
context.services.provide(AGENTS, {
|
|
@@ -2,6 +2,7 @@ import { tmpdir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { AgentSeed } from "../agents/agent-rules.ts";
|
|
4
4
|
import { ConfigError } from "../domain/errors.ts";
|
|
5
|
+
import type { InterimTextMode } from "../domain/interim.ts";
|
|
5
6
|
import { isLocale, type Locale } from "../i18n/index.ts";
|
|
6
7
|
import type { OwnerIdentity } from "../identity.ts";
|
|
7
8
|
import {
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
type ThinkingLevel,
|
|
12
13
|
} from "../models.ts";
|
|
13
14
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
15
|
+
import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
|
|
14
16
|
import type { Tier, TierMembers } from "../speakers.ts";
|
|
15
17
|
import {
|
|
16
18
|
bool,
|
|
@@ -123,6 +125,14 @@ export interface RoundtableConfig {
|
|
|
123
125
|
memory?: boolean;
|
|
124
126
|
/** The agent that investigates the process's own errors, by name. */
|
|
125
127
|
ops?: { agent: string };
|
|
128
|
+
/**
|
|
129
|
+
* Whether a turn posts the text it writes before its final answer as it goes: long or
|
|
130
|
+
* structured text as ordinary messages, short narration and the tools called in one small
|
|
131
|
+
* progress message. Default "on"; "off" posts only the final reply.
|
|
132
|
+
*/
|
|
133
|
+
interimText?: InterimTextMode;
|
|
134
|
+
/** An intermediate text this long or longer is posted as an ordinary message; default 400. */
|
|
135
|
+
interimPrimaryChars?: number;
|
|
126
136
|
plugins?: RoundtablePlugin[];
|
|
127
137
|
}
|
|
128
138
|
|
|
@@ -204,6 +214,8 @@ const schema = shape({
|
|
|
204
214
|
),
|
|
205
215
|
memory: optional(bool),
|
|
206
216
|
ops: optional(shape({ agent: text })),
|
|
217
|
+
interimText: optional(oneOf<InterimTextMode>("on", "off")),
|
|
218
|
+
interimPrimaryChars: optional(integer(1, 100_000)),
|
|
207
219
|
plugins: optional(
|
|
208
220
|
list(
|
|
209
221
|
guarded("a plugin, an object with a name and a setup function", isPlugin),
|
|
@@ -256,6 +268,8 @@ export interface ResolvedConfig {
|
|
|
256
268
|
skills: false | { builtinDir?: string; reposDir?: string };
|
|
257
269
|
memory: boolean;
|
|
258
270
|
ops?: { agent: string };
|
|
271
|
+
interimText: InterimTextMode;
|
|
272
|
+
interimPrimaryChars: number;
|
|
259
273
|
plugins: RoundtablePlugin[];
|
|
260
274
|
}
|
|
261
275
|
|
|
@@ -335,6 +349,8 @@ export function resolveConfig(input: unknown): ResolvedConfig {
|
|
|
335
349
|
skills: config.skills ?? {},
|
|
336
350
|
memory: config.memory ?? true,
|
|
337
351
|
...(config.ops ? { ops: config.ops } : {}),
|
|
352
|
+
interimText: config.interimText ?? "on",
|
|
353
|
+
interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
|
|
338
354
|
plugins: config.plugins ?? [],
|
|
339
355
|
};
|
|
340
356
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OutboundReply } from "../domain/conversation.ts";
|
|
2
|
+
import type { InterimPosts } from "../domain/interim.ts";
|
|
2
3
|
import type { OwnerPrompts } from "../domain/owner-prompts.ts";
|
|
3
4
|
import { PluginError } from "../errors.ts";
|
|
4
5
|
import type { ChannelKey } from "../sessions.ts";
|
|
@@ -68,6 +69,11 @@ export interface ChatSurface {
|
|
|
68
69
|
* absent, = the action is held until the owner's next message.
|
|
69
70
|
*/
|
|
70
71
|
prompts?(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Where a running turn posts the text it writes before its final answer: ordinary messages it
|
|
74
|
+
* may edit in place. Absent, or undefined, = only the final reply is posted.
|
|
75
|
+
*/
|
|
76
|
+
interim?(channel: ChannelKey): InterimPosts | undefined;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
/**
|
|
@@ -87,4 +93,6 @@ export interface SurfacePort {
|
|
|
87
93
|
unreact(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
|
|
88
94
|
/** The owner's prompts in the channel; undefined when its surface has none. */
|
|
89
95
|
prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
|
|
96
|
+
/** The channel's interim posts; undefined when its surface has none. */
|
|
97
|
+
interim(channel: ChannelKey): InterimPosts | undefined;
|
|
90
98
|
}
|
|
@@ -217,6 +217,8 @@ export async function defineRoundtable(
|
|
|
217
217
|
avatarListener: "public",
|
|
218
218
|
avatarUrl: config.http.publicUrl,
|
|
219
219
|
avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
|
|
220
|
+
interimText: config.interimText,
|
|
221
|
+
interimPrimaryChars: config.interimPrimaryChars,
|
|
220
222
|
...(errorReporter ? { errorReporter } : {}),
|
|
221
223
|
}),
|
|
222
224
|
seedsPlugin(config.agents),
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
DashboardBoard,
|
|
24
24
|
} from "../agents/agent-ports.ts";
|
|
25
25
|
import { AgentError } from "../domain/errors.ts";
|
|
26
|
+
import type { InterimPosts } from "../domain/interim.ts";
|
|
26
27
|
import { messages } from "../i18n/index.ts";
|
|
27
28
|
import type { Logger } from "../log.ts";
|
|
28
29
|
|
|
@@ -69,6 +70,38 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
interim(
|
|
74
|
+
channelId: string,
|
|
75
|
+
as: Omit<AgentPost, "thinking" | "chunks" | "files">,
|
|
76
|
+
): InterimPosts {
|
|
77
|
+
const threadId = as.threadId ? { threadId: as.threadId } : {};
|
|
78
|
+
return {
|
|
79
|
+
post: async (text) => {
|
|
80
|
+
const webhook = await this.#webhook(channelId);
|
|
81
|
+
try {
|
|
82
|
+
const message = await webhook.send({
|
|
83
|
+
username: as.name,
|
|
84
|
+
avatarURL: as.avatarUrl,
|
|
85
|
+
allowedMentions: { parse: ["users"] },
|
|
86
|
+
...threadId,
|
|
87
|
+
content: text,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
edit: async (change) => {
|
|
91
|
+
await webhook.editMessage(message, {
|
|
92
|
+
content: change,
|
|
93
|
+
...threadId,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.#webhooks.delete(channelId);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
async createChannel(
|
|
73
106
|
name: string,
|
|
74
107
|
topic: string,
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
InboundMessage,
|
|
18
18
|
OutboundReply,
|
|
19
19
|
} from "../domain/conversation.ts";
|
|
20
|
+
import type { InterimPosts } from "../domain/interim.ts";
|
|
20
21
|
import type { OwnerPrompts } from "../domain/owner-prompts.ts";
|
|
21
22
|
import type { OwnerNotifier } from "../domain/ports.ts";
|
|
22
23
|
import { messages } from "../i18n/index.ts";
|
|
@@ -159,6 +160,25 @@ export class DiscordSurface
|
|
|
159
160
|
return this.#options.prompts(channel, speaker);
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
/** The channel's interim posts: ordinary messages, edited in place for the progress line. */
|
|
164
|
+
interim(channel: ChannelKey): InterimPosts | undefined {
|
|
165
|
+
if (!channel.startsWith(PREFIX)) return undefined;
|
|
166
|
+
return {
|
|
167
|
+
post: async (text) => {
|
|
168
|
+
const target = await this.#sendable(channel);
|
|
169
|
+
const message = await target.send({
|
|
170
|
+
content: text,
|
|
171
|
+
allowedMentions: { parse: ["users"] },
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
edit: async (change) => {
|
|
175
|
+
await message.edit({ content: change });
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
async start(onMessage: (message: InboundMessage) => void): Promise<void> {
|
|
163
183
|
const { logger, token } = this.#options;
|
|
164
184
|
this.#client.on(Events.MessageCreate, (message) => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A message an interim post made, which the turn edits in place as it goes. */
|
|
2
|
+
export interface InterimMessage {
|
|
3
|
+
edit(text: string): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a running turn posts the text it writes before its final answer: the channel the
|
|
8
|
+
* final reply goes to, under the same name. Each call posts one ordinary message of at most
|
|
9
|
+
* Discord's 2000 characters.
|
|
10
|
+
*/
|
|
11
|
+
export interface InterimPosts {
|
|
12
|
+
post(text: string): Promise<InterimMessage>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether a turn posts its intermediate text as it goes; "on" by default. */
|
|
16
|
+
export type InterimTextMode = "on" | "off";
|
package/src/core/domain/ports.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { AgentTurnScope, TurnSelection } from "../sessions.ts";
|
|
|
2
2
|
import type { Speaker } from "../speakers.ts";
|
|
3
3
|
import type { TurnAttachments } from "./attachment.ts";
|
|
4
4
|
import type { ChannelKey } from "./conversation.ts";
|
|
5
|
+
import type { InterimPosts } from "./interim.ts";
|
|
5
6
|
|
|
6
7
|
export type { AgentTurnScope };
|
|
7
8
|
|
|
@@ -32,6 +33,11 @@ export interface TurnRequest {
|
|
|
32
33
|
* schedule's turn is not.
|
|
33
34
|
*/
|
|
34
35
|
interactive?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Where the turn posts the text it writes before its final answer, as it goes; the caller
|
|
38
|
+
* hands the channel its reply goes to. Absent = only the final reply is posted.
|
|
39
|
+
*/
|
|
40
|
+
interim?: InterimPosts;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/** The judge's question and answer shapes. */
|
|
@@ -82,6 +82,8 @@ export function conversationTurns(
|
|
|
82
82
|
const hideStop = surfaces.showStop(channel);
|
|
83
83
|
const turn = { kind, channel, speaker };
|
|
84
84
|
events.turnStarted(turn);
|
|
85
|
+
// A claim that posts its own reply formats its own text, so only the default reply posts as it goes.
|
|
86
|
+
const interim = input.reply ? undefined : surfaces.interim(channel);
|
|
85
87
|
let result: TurnResult;
|
|
86
88
|
try {
|
|
87
89
|
result = await settleTurn(
|
|
@@ -102,6 +104,7 @@ export function conversationTurns(
|
|
|
102
104
|
...(input.confirmed ? { confirmed: true } : {}),
|
|
103
105
|
...(input.steerable ? { steerable: true } : {}),
|
|
104
106
|
...(input.interactive ? { interactive: true } : {}),
|
|
107
|
+
...(interim ? { interim } : {}),
|
|
105
108
|
}),
|
|
106
109
|
),
|
|
107
110
|
"conversation turn",
|
|
@@ -41,5 +41,6 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
|
|
|
41
41
|
unreact: async (channel, messageId, emoji) =>
|
|
42
42
|
of(channel)?.unreact?.(channel, messageId, emoji),
|
|
43
43
|
prompts: (channel, speaker) => of(channel)?.prompts?.(channel, speaker),
|
|
44
|
+
interim: (channel) => of(channel)?.interim?.(channel),
|
|
44
45
|
};
|
|
45
46
|
}
|
|
@@ -37,7 +37,7 @@ export function askUserExtension(
|
|
|
37
37
|
pi.registerTool({
|
|
38
38
|
name: ASK_USER_TOOL,
|
|
39
39
|
label: `Ask ${o.name}`,
|
|
40
|
-
description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
|
|
40
|
+
description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
|
|
41
41
|
parameters: Type.Object(
|
|
42
42
|
{
|
|
43
43
|
question: Type.String({ minLength: 1, maxLength: 1500 }),
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { InterimMessage, InterimPosts } from "../domain/interim.ts";
|
|
2
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
3
|
+
import type { Logger } from "../log.ts";
|
|
4
|
+
import {
|
|
5
|
+
DISCORD_MESSAGE_LIMIT,
|
|
6
|
+
splitReply,
|
|
7
|
+
} from "../presentation/reply-splitter.ts";
|
|
8
|
+
import { textOf } from "../shared/session-messages.ts";
|
|
9
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
10
|
+
|
|
11
|
+
/** An intermediate text this long or longer is posted as an ordinary message. */
|
|
12
|
+
export const PRIMARY_CHARS = 400;
|
|
13
|
+
/** The progress message is edited at most this often; its last state always lands. */
|
|
14
|
+
export const PROGRESS_EDIT_MS = 1_500;
|
|
15
|
+
|
|
16
|
+
const STRUCTURE = /^\s{0,3}(#{1,6}\s|```|~~~|[-*+]\s|\d+[.)]\s|\|.*\|)/m;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether an intermediate text is content the owner must read, such as a proposal or findings:
|
|
20
|
+
* long, or written with a Markdown heading, list, table, or code fence. Shorter narration
|
|
21
|
+
* between tool calls goes to the progress message instead.
|
|
22
|
+
*/
|
|
23
|
+
export function isPrimaryText(
|
|
24
|
+
text: string,
|
|
25
|
+
primaryChars = PRIMARY_CHARS,
|
|
26
|
+
): boolean {
|
|
27
|
+
return text.length >= primaryChars || STRUCTURE.test(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A run's progress message: its narration lines, then the tools it called, all in Discord's
|
|
32
|
+
* small text and kept inside one message by dropping the oldest lines behind a "…" marker.
|
|
33
|
+
*/
|
|
34
|
+
export function progressText(
|
|
35
|
+
lines: readonly string[],
|
|
36
|
+
tools: ReadonlyMap<string, number>,
|
|
37
|
+
): string {
|
|
38
|
+
const small = (line: string) => `-# ${line}`;
|
|
39
|
+
const toolLine =
|
|
40
|
+
tools.size > 0
|
|
41
|
+
? small(
|
|
42
|
+
[...tools]
|
|
43
|
+
.map(([name, count]) => (count > 1 ? `${name} ×${count}` : name))
|
|
44
|
+
.join(" · "),
|
|
45
|
+
).slice(0, DISCORD_MESSAGE_LIMIT)
|
|
46
|
+
: undefined;
|
|
47
|
+
const body = lines
|
|
48
|
+
.flatMap((text) => text.split("\n"))
|
|
49
|
+
.map((line) => line.trim())
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.map(small);
|
|
52
|
+
const tail = toolLine ? [toolLine] : [];
|
|
53
|
+
const fits = (kept: string[]) =>
|
|
54
|
+
[...kept, ...tail].join("\n").length <= DISCORD_MESSAGE_LIMIT;
|
|
55
|
+
if (fits(body)) return [...body, ...tail].join("\n");
|
|
56
|
+
const marker = small("…");
|
|
57
|
+
let kept = body;
|
|
58
|
+
while (kept.length > 0 && !fits([marker, ...kept])) kept = kept.slice(1);
|
|
59
|
+
if (kept.length === 0 && body.length > 0) {
|
|
60
|
+
// One line alone is too long: keep its end.
|
|
61
|
+
const room =
|
|
62
|
+
DISCORD_MESSAGE_LIMIT - [marker, ...tail].join("\n").length - 4;
|
|
63
|
+
const last = body.at(-1) ?? "";
|
|
64
|
+
kept = room > 0 ? [small(`…${last.slice(-room)}`)] : [];
|
|
65
|
+
return [...kept, ...tail].join("\n");
|
|
66
|
+
}
|
|
67
|
+
return [marker, ...kept, ...tail].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Run {
|
|
71
|
+
lines: string[];
|
|
72
|
+
tools: Map<string, number>;
|
|
73
|
+
message?: InterimMessage;
|
|
74
|
+
shown?: string;
|
|
75
|
+
lastAt: number;
|
|
76
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface InterimPosterOptions {
|
|
80
|
+
logger: Logger;
|
|
81
|
+
channel: string;
|
|
82
|
+
primaryChars?: number;
|
|
83
|
+
editMs?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Posts a turn's intermediate text as it goes. A tool-calling assistant message whose text is
|
|
88
|
+
* primary is posted as ordinary messages; shorter text joins the run's progress message with
|
|
89
|
+
* the tools called, edited in place. A primary post or a card starts a new run. Posts go out
|
|
90
|
+
* in order, and a failed one is logged, never thrown.
|
|
91
|
+
*/
|
|
92
|
+
export class InterimPoster {
|
|
93
|
+
readonly #posts: InterimPosts;
|
|
94
|
+
readonly #options: InterimPosterOptions;
|
|
95
|
+
#queue: Promise<void> = Promise.resolve();
|
|
96
|
+
#run: Run = newRun();
|
|
97
|
+
|
|
98
|
+
constructor(posts: InterimPosts, options: InterimPosterOptions) {
|
|
99
|
+
this.#posts = posts;
|
|
100
|
+
this.#options = options;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A finished message of the turn; only a tool-calling assistant message is posted. */
|
|
104
|
+
messageEnd(message: {
|
|
105
|
+
role: string;
|
|
106
|
+
content?: unknown;
|
|
107
|
+
stopReason?: string;
|
|
108
|
+
}): void {
|
|
109
|
+
if (message.role !== "assistant" || message.stopReason !== "toolUse")
|
|
110
|
+
return;
|
|
111
|
+
const text = textOf(message.content).trim();
|
|
112
|
+
if (!text) return;
|
|
113
|
+
if (isPrimaryText(text, this.#options.primaryChars)) {
|
|
114
|
+
this.#endRun();
|
|
115
|
+
for (const chunk of splitReply(text))
|
|
116
|
+
this.#enqueue(async () => {
|
|
117
|
+
await this.#posts.post(chunk);
|
|
118
|
+
}, "interim text not posted");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.#run.lines.push(text);
|
|
122
|
+
this.#changed();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A tool the turn started, counted on the run's tool line. */
|
|
126
|
+
toolStart(name: string): void {
|
|
127
|
+
const { tools } = this.#run;
|
|
128
|
+
tools.set(name, (tools.get(name) ?? 0) + 1);
|
|
129
|
+
this.#changed();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Brings every post up to date and waits for them, then starts a new run: before a card, so
|
|
134
|
+
* the text written before it shows above it, and when the turn ends, before its final reply.
|
|
135
|
+
*/
|
|
136
|
+
async flush(): Promise<void> {
|
|
137
|
+
this.#endRun();
|
|
138
|
+
await this.#queue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#endRun(): void {
|
|
142
|
+
const run = this.#run;
|
|
143
|
+
this.#run = newRun();
|
|
144
|
+
clearTimeout(run.timer);
|
|
145
|
+
run.timer = undefined;
|
|
146
|
+
this.#render(run);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The run changed: shown now, or once the edit interval has passed. */
|
|
150
|
+
#changed(): void {
|
|
151
|
+
const run = this.#run;
|
|
152
|
+
if (run.timer) return;
|
|
153
|
+
const wait =
|
|
154
|
+
run.lastAt + (this.#options.editMs ?? PROGRESS_EDIT_MS) - now();
|
|
155
|
+
if (wait <= 0) {
|
|
156
|
+
this.#render(run);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
run.timer = setTimeout(() => {
|
|
160
|
+
run.timer = undefined;
|
|
161
|
+
this.#render(run);
|
|
162
|
+
}, wait);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#render(run: Run): void {
|
|
166
|
+
if (run.lines.length === 0 && run.tools.size === 0) return;
|
|
167
|
+
run.lastAt = now();
|
|
168
|
+
const text = progressText(run.lines, run.tools);
|
|
169
|
+
this.#enqueue(async () => {
|
|
170
|
+
if (text === run.shown) return;
|
|
171
|
+
if (run.message) await run.message.edit(text);
|
|
172
|
+
else run.message = await this.#posts.post(text);
|
|
173
|
+
run.shown = text;
|
|
174
|
+
}, "progress message not posted");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#enqueue(step: () => Promise<void>, failure: string): void {
|
|
178
|
+
const { logger, channel } = this.#options;
|
|
179
|
+
this.#queue = this.#queue.then(step).catch((error: unknown) => {
|
|
180
|
+
logger.warn({ channel, err: error }, failure);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The turn's poster, when its caller gave a place for interim posts and the host left them on. */
|
|
186
|
+
export function interimPoster(
|
|
187
|
+
request: Pick<TurnRequest, "interim" | "channel">,
|
|
188
|
+
options: Pick<
|
|
189
|
+
PiAgentRuntimeOptions,
|
|
190
|
+
"interimText" | "interimPrimaryChars" | "logger"
|
|
191
|
+
>,
|
|
192
|
+
): InterimPoster | undefined {
|
|
193
|
+
if (!request.interim || options.interimText === "off") return undefined;
|
|
194
|
+
return new InterimPoster(request.interim, {
|
|
195
|
+
logger: options.logger,
|
|
196
|
+
channel: request.channel,
|
|
197
|
+
...(options.interimPrimaryChars
|
|
198
|
+
? { primaryChars: options.interimPrimaryChars }
|
|
199
|
+
: {}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function newRun(): Run {
|
|
204
|
+
return { lines: [], tools: new Map(), lastAt: 0 };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const now = () => Date.now();
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
confirmedTurnText,
|
|
37
37
|
} from "./extensions/confirmation-gate.ts";
|
|
38
38
|
import { COMPACT_TOOL } from "./extensions/self-compact-guard.ts";
|
|
39
|
+
import { interimPoster } from "./interim-text.ts";
|
|
39
40
|
import { PromptSlot, workTimeout } from "./prompt-slot.ts";
|
|
40
41
|
import {
|
|
41
42
|
type PiAgentRuntimeOptions,
|
|
@@ -181,9 +182,17 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
181
182
|
// Collected as they end: a compaction during the turn shortens session.messages.
|
|
182
183
|
const turnMessages: TurnMessage[] = [];
|
|
183
184
|
const toolCalls: string[] = [];
|
|
185
|
+
// Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
|
|
186
|
+
const interim = interimPoster(request, this.#options);
|
|
184
187
|
const unsubscribe = session.subscribe((event) => {
|
|
185
|
-
if (event.type === "tool_execution_start")
|
|
186
|
-
|
|
188
|
+
if (event.type === "tool_execution_start") {
|
|
189
|
+
toolCalls.push(event.toolName);
|
|
190
|
+
interim?.toolStart(event.toolName);
|
|
191
|
+
}
|
|
192
|
+
if (event.type === "message_end") {
|
|
193
|
+
turnMessages.push(event.message);
|
|
194
|
+
interim?.messageEnd(event.message);
|
|
195
|
+
}
|
|
187
196
|
});
|
|
188
197
|
const slot = this.#sessions.slot(key);
|
|
189
198
|
slot.bind(
|
|
@@ -191,6 +200,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
191
200
|
? this.#options.prompts?.(request.channel, request.speaker)
|
|
192
201
|
: undefined,
|
|
193
202
|
request.agent?.name ?? assistantName(),
|
|
203
|
+
interim ? () => interim.flush() : undefined,
|
|
194
204
|
);
|
|
195
205
|
// Time spent waiting on the owner's cards does not count towards the timeout.
|
|
196
206
|
const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
|
|
@@ -237,6 +247,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
237
247
|
cancelTimeout();
|
|
238
248
|
slot.unbind();
|
|
239
249
|
unsubscribe();
|
|
250
|
+
await interim?.flush();
|
|
240
251
|
gate.endTurn();
|
|
241
252
|
const usage = session.getContextUsage();
|
|
242
253
|
if (usage)
|
|
@@ -10,14 +10,23 @@ import { assistantName } from "../i18n/index.ts";
|
|
|
10
10
|
*/
|
|
11
11
|
export class PromptSlot {
|
|
12
12
|
#prompts: OwnerPrompts | undefined;
|
|
13
|
+
#beforeCard: (() => Promise<void>) | undefined;
|
|
13
14
|
#asker = assistantName();
|
|
14
15
|
#open = 0;
|
|
15
16
|
#openSince = 0;
|
|
16
17
|
#waited = 0;
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Binds the turn's prompts; `asker` names who asks, the assistant or an agent. `beforeCard`
|
|
21
|
+
* runs before each card opens, such as posting the text the turn wrote before it.
|
|
22
|
+
*/
|
|
23
|
+
bind(
|
|
24
|
+
prompts: OwnerPrompts | undefined,
|
|
25
|
+
asker: string,
|
|
26
|
+
beforeCard?: () => Promise<void>,
|
|
27
|
+
): void {
|
|
20
28
|
this.#prompts = prompts;
|
|
29
|
+
this.#beforeCard = beforeCard;
|
|
21
30
|
this.#asker = asker;
|
|
22
31
|
this.#open = 0;
|
|
23
32
|
this.#waited = 0;
|
|
@@ -25,6 +34,7 @@ export class PromptSlot {
|
|
|
25
34
|
|
|
26
35
|
unbind(): void {
|
|
27
36
|
this.#prompts = undefined;
|
|
37
|
+
this.#beforeCard = undefined;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
get asker(): string {
|
|
@@ -49,6 +59,7 @@ export class PromptSlot {
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
async #waiting<T>(ask: () => Promise<T>): Promise<T> {
|
|
62
|
+
await this.#beforeCard?.();
|
|
52
63
|
if (this.#open++ === 0) this.#openSince = Date.now();
|
|
53
64
|
try {
|
|
54
65
|
return await ask();
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type { AgentSessions, LoadedSkill } from "../contract/runtime.ts";
|
|
8
8
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
9
|
+
import type { InterimTextMode } from "../domain/interim.ts";
|
|
9
10
|
import type { OwnerPrompts } from "../domain/owner-prompts.ts";
|
|
10
11
|
import type { OwnerIdentity } from "../identity.ts";
|
|
11
12
|
import type { Logger } from "../log.ts";
|
|
@@ -66,6 +67,13 @@ export interface PiAgentRuntimeOptions {
|
|
|
66
67
|
toolTiers?: ToolTiers;
|
|
67
68
|
/** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
|
|
68
69
|
turnTimeoutMs?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Whether a turn given a place for interim posts shows the text it writes before its final
|
|
72
|
+
* answer as it goes; default "on". "off" posts only the final reply.
|
|
73
|
+
*/
|
|
74
|
+
interimText?: InterimTextMode;
|
|
75
|
+
/** An intermediate text this long or longer is posted as an ordinary message; default 400. */
|
|
76
|
+
interimPrimaryChars?: number;
|
|
69
77
|
/** How long a new session waits for its MCP tools to register. */
|
|
70
78
|
mcpConnectTimeoutMs?: number;
|
|
71
79
|
}
|
package/src/index.ts
CHANGED