pi-roundtable 0.9.0 → 0.9.2
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 +20 -0
- package/docs/plugins.md +46 -10
- package/package.json +1 -1
- package/src/core/attachments/attachment-port.ts +34 -0
- package/src/core/attachments/attachment-store.ts +248 -0
- package/src/core/attachments/model-images.ts +39 -0
- package/src/core/attachments/tool-attachment.ts +53 -0
- package/src/core/attachments/turn-attachments.ts +4 -21
- package/src/core/builtin/modules.ts +10 -1
- package/src/core/config/config.ts +9 -0
- package/src/core/contract/attachments.ts +71 -0
- package/src/core/contract/channels.ts +5 -0
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/define.ts +25 -0
- package/src/core/drain.ts +56 -5
- package/src/core/errors.ts +13 -0
- package/src/core/host-background.ts +56 -0
- package/src/core/host.ts +33 -60
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/modules/schedules/scheduler.ts +13 -2
- package/src/core/plugin.ts +6 -0
- package/src/core/routing/channel-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +59 -7
- package/src/core/routing/conversation-turns.ts +7 -2
- package/src/core/runtime/abort-guard.ts +58 -0
- package/src/core/runtime/conversation-sessions.ts +15 -0
- package/src/core/runtime/pi-agent-runtime.ts +61 -78
- package/src/core/runtime/runtime-plugin.ts +1 -0
- package/src/core/runtime/runtime-types.ts +8 -0
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/runtime/steerable-run.ts +12 -3
- package/src/core/runtime/thinking-level.ts +47 -0
- package/src/core/runtime/turn-wrapup.ts +63 -0
- package/src/core/sessions.ts +2 -0
- package/src/index.ts +8 -0
- package/src/testing.ts +9 -1
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
} from "../contract/channels.ts";
|
|
13
13
|
import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
|
|
14
14
|
import { IdentityError } from "../domain/errors.ts";
|
|
15
|
-
import { PluginError } from "../errors.ts";
|
|
15
|
+
import { HostStoppingError, PluginError } from "../errors.ts";
|
|
16
|
+
import { messages } from "../i18n/index.ts";
|
|
16
17
|
import type { ActorFacts } from "../identity/actor-facts.ts";
|
|
17
18
|
import type { Contact, ContactAssessor } from "../identity/contact.ts";
|
|
18
19
|
import {
|
|
@@ -37,8 +38,11 @@ export interface ChannelRouterOptions {
|
|
|
37
38
|
contacts?: ContactAssessor;
|
|
38
39
|
/** Whom a background turn runs as; without it, only the host's own turns run. */
|
|
39
40
|
principals?: Pick<IdentityService, "speakerFor">;
|
|
40
|
-
/**
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The surfaces, so a conversation started over is marked in a channel that shows one, and a
|
|
43
|
+
* message that arrives while the host shuts down is told so; without it, neither is.
|
|
44
|
+
*/
|
|
45
|
+
surfaces?: Pick<SurfacePort, "of"> & Partial<Pick<SurfacePort, "sendReply">>;
|
|
42
46
|
/** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
|
|
43
47
|
forwardJoinMs?: number;
|
|
44
48
|
}
|
|
@@ -56,6 +60,12 @@ export function orderClaims(
|
|
|
56
60
|
return claims.toSorted((a, b) => b.priority - a.priority);
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/** The outcome of a background turn the host did not start because it is shutting down. */
|
|
64
|
+
const stopping = (): ScheduledOutcome => ({
|
|
65
|
+
status: "skipped",
|
|
66
|
+
reason: new HostStoppingError().message,
|
|
67
|
+
});
|
|
68
|
+
|
|
59
69
|
/**
|
|
60
70
|
* Routes every conversation operation to the claim that owns its channel, each inside the
|
|
61
71
|
* channel's queue: messages after joining a bare forward to its text, background turns,
|
|
@@ -69,6 +79,8 @@ export class ChannelRouter implements ConversationPort {
|
|
|
69
79
|
readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
|
|
70
80
|
/** The surfaces already warned that their messages carry no actor. */
|
|
71
81
|
readonly #warned = new Set<string>();
|
|
82
|
+
/** The channels already told that the host is restarting, so a burst of messages gets one notice. */
|
|
83
|
+
readonly #told = new Set<ChannelKey>();
|
|
72
84
|
|
|
73
85
|
constructor(options: ChannelRouterOptions) {
|
|
74
86
|
this.#options = options;
|
|
@@ -100,7 +112,13 @@ export class ChannelRouter implements ConversationPort {
|
|
|
100
112
|
if (!claim) return;
|
|
101
113
|
const started = await this.#inOrder(message.channel, async () => {
|
|
102
114
|
const admission = await this.#admit(claim, message);
|
|
103
|
-
|
|
115
|
+
if (!admission) return undefined;
|
|
116
|
+
// Once the host shuts down only the turns already running finish: this one starts nothing.
|
|
117
|
+
if (this.#options.queue.closed) {
|
|
118
|
+
await this.#refuse(message, admission);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return this.#start(message, admission);
|
|
104
122
|
});
|
|
105
123
|
await started?.done;
|
|
106
124
|
}
|
|
@@ -209,6 +227,33 @@ export class ChannelRouter implements ConversationPort {
|
|
|
209
227
|
};
|
|
210
228
|
}
|
|
211
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Drops a message the host no longer starts a turn for because it is shutting down, and tells
|
|
232
|
+
* its channel once that it restarts; no one is told for a bot's or an integration's message, such
|
|
233
|
+
* as a report, since a notice there would only start the next one.
|
|
234
|
+
*/
|
|
235
|
+
async #refuse(message: InboundMessage, admission: Admission): Promise<void> {
|
|
236
|
+
const { logger, surfaces } = this.#options;
|
|
237
|
+
const { channel } = message;
|
|
238
|
+
if (admission.kind === "turn") admission.dropped?.();
|
|
239
|
+
else
|
|
240
|
+
admission.unanswered({
|
|
241
|
+
status: "skipped",
|
|
242
|
+
reason: new HostStoppingError().message,
|
|
243
|
+
});
|
|
244
|
+
logger.info({ channel }, "message not run: the host is shutting down");
|
|
245
|
+
if (message.authorIsBot || message.integration || this.#told.has(channel))
|
|
246
|
+
return;
|
|
247
|
+
this.#told.add(channel);
|
|
248
|
+
try {
|
|
249
|
+
await surfaces?.sendReply?.(channel, {
|
|
250
|
+
chunks: [messages().restartingNotice],
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
logger.warn({ channel, err: error }, "restart notice not posted");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
212
257
|
/**
|
|
213
258
|
* Starts what the claim admitted, in the channel's queue, and returns while it runs: a message
|
|
214
259
|
* during a turn is first offered to it, then marked as waiting.
|
|
@@ -238,12 +283,17 @@ export class ChannelRouter implements ConversationPort {
|
|
|
238
283
|
return admission.run();
|
|
239
284
|
})
|
|
240
285
|
// pi-lens-ignore: no-unknown-parameters
|
|
241
|
-
.catch((error: unknown) =>
|
|
286
|
+
.catch(async (error: unknown) => {
|
|
287
|
+
// The host began shutting down while this message waited behind a running turn.
|
|
288
|
+
if (error instanceof HostStoppingError) {
|
|
289
|
+
if (queued) void busy?.unreact(QUEUED_MARK);
|
|
290
|
+
return this.#refuse(message, admission);
|
|
291
|
+
}
|
|
242
292
|
logger.error(
|
|
243
293
|
{ channel: message.channel, err: error },
|
|
244
294
|
admission.failure,
|
|
245
|
-
)
|
|
246
|
-
);
|
|
295
|
+
);
|
|
296
|
+
});
|
|
247
297
|
return { done };
|
|
248
298
|
}
|
|
249
299
|
|
|
@@ -257,6 +307,7 @@ export class ChannelRouter implements ConversationPort {
|
|
|
257
307
|
* handed to another target's claim, and so is one whose author may not run it now.
|
|
258
308
|
*/
|
|
259
309
|
async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
|
|
310
|
+
if (this.#options.queue.closed) return stopping();
|
|
260
311
|
if (!this.#options.targets(turn.target))
|
|
261
312
|
return {
|
|
262
313
|
status: "skipped",
|
|
@@ -277,6 +328,7 @@ export class ChannelRouter implements ConversationPort {
|
|
|
277
328
|
return claim.background({ ...turn, speaker: runs.speaker });
|
|
278
329
|
});
|
|
279
330
|
} catch (error) {
|
|
331
|
+
if (error instanceof HostStoppingError) return stopping();
|
|
280
332
|
return { status: "failed", error: String(error) };
|
|
281
333
|
}
|
|
282
334
|
}
|
|
@@ -8,7 +8,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
|
|
|
8
8
|
import type { TurnResult } from "../domain/conversation.ts";
|
|
9
9
|
import type { TurnConversation } from "../domain/ports.ts";
|
|
10
10
|
import type { TurnProgress } from "../domain/progress.ts";
|
|
11
|
-
import { PluginError } from "../errors.ts";
|
|
11
|
+
import { HostStoppingError, PluginError } from "../errors.ts";
|
|
12
12
|
import { messages } from "../i18n/index.ts";
|
|
13
13
|
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
14
14
|
import type { Logger } from "../log.ts";
|
|
@@ -61,7 +61,8 @@ export interface ConversationTurns {
|
|
|
61
61
|
* task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
|
|
62
62
|
* `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
|
|
63
63
|
* posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
|
|
64
|
-
* It rejects during setup (NotLinkedError),
|
|
64
|
+
* It rejects during setup (NotLinkedError), once the host is shutting down (HostStoppingError),
|
|
65
|
+
* when the host has no runtime to run the turn on,
|
|
65
66
|
* when the conversation cannot be recorded in the host's registry (a database error, or a
|
|
66
67
|
* `private` conversation without a speaker), and when the conversation is recorded private to a
|
|
67
68
|
* principal other than the speaker's, unless the speaker is the host's own (`SYSTEM_PRINCIPAL`).
|
|
@@ -75,6 +76,8 @@ export interface ConversationTurns {
|
|
|
75
76
|
export interface ConversationTurnsOptions {
|
|
76
77
|
/** Throws NotLinkedError until every plugin is set up and linked. */
|
|
77
78
|
linked: () => void;
|
|
79
|
+
/** Whether the host is shutting down, so no turn starts; undefined or false while it serves. */
|
|
80
|
+
stopping?: () => boolean;
|
|
78
81
|
/** The runtime that runs the turn; throws PluginError when the host has none yet. */
|
|
79
82
|
runtime: () => AgentRuntime;
|
|
80
83
|
/** Where each turn records its conversation; absent, or undefined, records none. */
|
|
@@ -143,6 +146,8 @@ export function conversationTurns(
|
|
|
143
146
|
async run(input) {
|
|
144
147
|
const { channel, kind, speaker } = input;
|
|
145
148
|
options.linked();
|
|
149
|
+
// Refused before anything is shown or recorded, like a turn the host cannot run.
|
|
150
|
+
if (options.stopping?.()) throw new HostStoppingError();
|
|
146
151
|
// A host without a runtime is a setup mistake, so it is refused before anything is shown.
|
|
147
152
|
const runtime = options.runtime();
|
|
148
153
|
// A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { AgentRunError } from "../domain/errors.ts";
|
|
3
|
+
|
|
4
|
+
/** How long a run told to abort gets to end before its session is disposed of; default for the guard. */
|
|
5
|
+
const ABORT_GRACE_MS = 15_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Aborts a Pi session's run and does not trust the abort to work. A model behind a subprocess that
|
|
9
|
+
* ignores the abort would leave the run, and the queue behind it, waiting for ever, so a run still
|
|
10
|
+
* going once the grace is over has its session disposed of, which drops its model connection, and
|
|
11
|
+
* `gaveUp` rejects so the caller can end the turn as failed.
|
|
12
|
+
*/
|
|
13
|
+
export class AbortGuard {
|
|
14
|
+
/** Rejects when the abort did not end the run in time; never resolves. */
|
|
15
|
+
readonly gaveUp: Promise<never>;
|
|
16
|
+
readonly #session: Pick<AgentSession, "abort" | "dispose">;
|
|
17
|
+
readonly #graceMs: number;
|
|
18
|
+
readonly #onGiveUp: () => void;
|
|
19
|
+
#reject: (error: Error) => void = () => undefined;
|
|
20
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
session: Pick<AgentSession, "abort" | "dispose">,
|
|
24
|
+
options: { graceMs?: number; onGiveUp?: () => void } = {},
|
|
25
|
+
) {
|
|
26
|
+
this.#session = session;
|
|
27
|
+
this.#graceMs = options.graceMs ?? ABORT_GRACE_MS;
|
|
28
|
+
this.#onGiveUp = options.onGiveUp ?? (() => undefined);
|
|
29
|
+
this.gaveUp = new Promise<never>((_resolve, reject) => {
|
|
30
|
+
this.#reject = reject;
|
|
31
|
+
});
|
|
32
|
+
// Nobody waits on it unless the abort fails.
|
|
33
|
+
this.gaveUp.catch(() => undefined);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Aborts the run, and starts the grace the first time. */
|
|
37
|
+
abort(): void {
|
|
38
|
+
void this.#session.abort();
|
|
39
|
+
this.#timer ??= setTimeout(() => {
|
|
40
|
+
this.#onGiveUp();
|
|
41
|
+
try {
|
|
42
|
+
this.#session.dispose();
|
|
43
|
+
} catch {
|
|
44
|
+
// A session that cannot be disposed of is dropped all the same.
|
|
45
|
+
}
|
|
46
|
+
this.#reject(
|
|
47
|
+
new AgentRunError(
|
|
48
|
+
"the run did not stop after it was aborted, so its session was disposed of",
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}, this.#graceMs);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The run ended: no session is disposed of any more. */
|
|
55
|
+
release(): void {
|
|
56
|
+
clearTimeout(this.#timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -262,6 +262,21 @@ export class ConversationSessions {
|
|
|
262
262
|
);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Forgets a session that was disposed of while its turn still ran, so the conversation's next
|
|
267
|
+
* turn opens a new one from its files; a newer session of the conversation stays.
|
|
268
|
+
*/
|
|
269
|
+
drop(key: ChannelKey, disposed: ChannelSession): void {
|
|
270
|
+
const open = this.#sessions.get(key);
|
|
271
|
+
if (!open) return;
|
|
272
|
+
void open
|
|
273
|
+
.then((current) => {
|
|
274
|
+
if (current === disposed && this.#sessions.get(key) === open)
|
|
275
|
+
this.#sessions.delete(key);
|
|
276
|
+
})
|
|
277
|
+
.catch(() => undefined);
|
|
278
|
+
}
|
|
279
|
+
|
|
265
280
|
/** Drops the open session and everything held for the conversation, leaving its files. */
|
|
266
281
|
// pi-lens-ignore: mixed-async-styles — disposal failures of a dropped session are swallowed in a chain on purpose
|
|
267
282
|
async forget(channel: ChannelKey): Promise<void> {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { rmSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
|
-
type AgentSession,
|
|
4
3
|
type ModelRuntime,
|
|
5
4
|
SessionManager,
|
|
6
5
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -16,20 +15,16 @@ import type {
|
|
|
16
15
|
} from "../domain/conversation.ts";
|
|
17
16
|
import { AgentRunError } from "../domain/errors.ts";
|
|
18
17
|
import type { TurnRequest } from "../domain/ports.ts";
|
|
18
|
+
import { HostStoppingError } from "../errors.ts";
|
|
19
19
|
import { assistantName } from "../i18n/index.ts";
|
|
20
20
|
import { promptScopeOf } from "../interactions/prompts.ts";
|
|
21
|
-
import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
|
|
22
21
|
import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
|
|
23
22
|
import { withoutReplyFiles } from "../reply-files.ts";
|
|
24
23
|
import type { TransientTask } from "../sessions.ts";
|
|
25
24
|
import type { Speaker, Tier } from "../speakers.ts";
|
|
26
25
|
import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
bridgeTurnRefusal,
|
|
30
|
-
chosenAgentModel,
|
|
31
|
-
useChosenAgentModel,
|
|
32
|
-
} from "./bridge-guard.ts";
|
|
26
|
+
import { AbortGuard } from "./abort-guard.ts";
|
|
27
|
+
import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
|
|
33
28
|
import { ConversationSessions } from "./conversation-sessions.ts";
|
|
34
29
|
import { confirmedTurnText } from "./extensions/confirmation-gate.ts";
|
|
35
30
|
import { loadsMemory } from "./extensions/private-memory.ts";
|
|
@@ -47,14 +42,11 @@ import {
|
|
|
47
42
|
} from "./session-conversation.ts";
|
|
48
43
|
import { SessionFactory } from "./session-factory.ts";
|
|
49
44
|
import { promptImages, SteerableRun } from "./steerable-run.ts";
|
|
45
|
+
import { thinkingLevel } from "./thinking-level.ts";
|
|
50
46
|
import { runningCalls } from "./tool-call-scope.ts";
|
|
51
|
-
import {
|
|
52
|
-
lastReply,
|
|
53
|
-
transcriptOf,
|
|
54
|
-
turnAnswer,
|
|
55
|
-
unspokenTurn,
|
|
56
|
-
} from "./turn-answer.ts";
|
|
47
|
+
import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
|
|
57
48
|
import { progressReporter } from "./turn-progress.ts";
|
|
49
|
+
import { wrapUpTurn } from "./turn-wrapup.ts";
|
|
58
50
|
import { workerReport } from "./worker-task.ts";
|
|
59
51
|
|
|
60
52
|
export {
|
|
@@ -118,6 +110,12 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
118
110
|
async runTurn(request: TurnRequest): Promise<TurnResult> {
|
|
119
111
|
const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
|
|
120
112
|
if (!request.speaker) return unspokenTurn();
|
|
113
|
+
// A turn that is only asked for now, such as a group's next member, starts nothing.
|
|
114
|
+
if (this.#options.stopping?.())
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: new AgentRunError(new HostStoppingError().message),
|
|
118
|
+
};
|
|
121
119
|
const key = request.agent?.session ?? request.channel;
|
|
122
120
|
// Resolve current scope; refuse a foreign private speaker before touching its session.
|
|
123
121
|
const conversation = await this.#sessions.conversation(key, request);
|
|
@@ -147,7 +145,17 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
147
145
|
: undefined;
|
|
148
146
|
if (bridgeRefused)
|
|
149
147
|
return { ok: false, error: new AgentRunError(bridgeRefused) };
|
|
150
|
-
const level = await
|
|
148
|
+
const level = await thinkingLevel(
|
|
149
|
+
{
|
|
150
|
+
modelRuntime: this.#modelRuntime,
|
|
151
|
+
effort: this.#options.effort,
|
|
152
|
+
judged: this.#sessions.judged,
|
|
153
|
+
},
|
|
154
|
+
key,
|
|
155
|
+
session,
|
|
156
|
+
request,
|
|
157
|
+
agentModel,
|
|
158
|
+
);
|
|
151
159
|
if (session.thinkingLevel !== level) session.setThinkingLevel(level);
|
|
152
160
|
const gate = await this.#sessions.gate(key, request.agent !== undefined);
|
|
153
161
|
const pending = gate.pending();
|
|
@@ -240,17 +248,29 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
240
248
|
request.agent?.name ?? assistantName(),
|
|
241
249
|
interim ? () => interim.flush() : undefined,
|
|
242
250
|
);
|
|
251
|
+
// A model that ignores the abort must not hold the turn: its session is disposed of instead.
|
|
252
|
+
const guard = new AbortGuard(session, {
|
|
253
|
+
graceMs: this.#options.turnAbortGraceMs,
|
|
254
|
+
onGiveUp: () => {
|
|
255
|
+
logger.error(
|
|
256
|
+
{ channel: request.channel },
|
|
257
|
+
"turn did not stop after its abort; disposing of its session",
|
|
258
|
+
);
|
|
259
|
+
this.#sessions.drop(key, channelSession);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
243
262
|
// Time spent waiting on the owner's cards does not count towards the timeout.
|
|
244
263
|
const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
|
|
245
264
|
logger.warn(
|
|
246
265
|
{ channel: request.channel, turnTimeoutMs },
|
|
247
266
|
"turn timed out; aborting",
|
|
248
267
|
);
|
|
249
|
-
|
|
268
|
+
guard.abort();
|
|
250
269
|
});
|
|
251
270
|
const running = new SteerableRun(
|
|
252
271
|
session,
|
|
253
272
|
() => request.steerable === true && !gate.pending(),
|
|
273
|
+
() => guard.abort(),
|
|
254
274
|
);
|
|
255
275
|
this.#running.set(key, running);
|
|
256
276
|
|
|
@@ -260,7 +280,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
260
280
|
request.confirmed && pending
|
|
261
281
|
? confirmedTurnText(pending, request.text, who)
|
|
262
282
|
: request.text;
|
|
263
|
-
|
|
283
|
+
const prompted = running.run(() =>
|
|
264
284
|
session.prompt(
|
|
265
285
|
withAttachmentsBlock(text, attachments),
|
|
266
286
|
attachments.images.length > 0
|
|
@@ -268,6 +288,9 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
268
288
|
: undefined,
|
|
269
289
|
),
|
|
270
290
|
);
|
|
291
|
+
// A run abandoned by the guard may still reject later; nobody waits for it then.
|
|
292
|
+
prompted.catch(() => undefined);
|
|
293
|
+
await Promise.race([prompted, guard.gaveUp]);
|
|
271
294
|
} catch (error) {
|
|
272
295
|
if (running.stopped)
|
|
273
296
|
return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
|
|
@@ -280,42 +303,20 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
280
303
|
this.#turns.delete(key);
|
|
281
304
|
this.#memoryTurns.delete(key);
|
|
282
305
|
cancelTimeout();
|
|
306
|
+
guard.release();
|
|
283
307
|
slot.unbind();
|
|
284
308
|
unsubscribe();
|
|
285
309
|
progress?.close();
|
|
286
310
|
await interim?.flush();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
.save(key, held)
|
|
297
|
-
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
298
|
-
.catch((error: unknown) =>
|
|
299
|
-
logger.error({ channel: key, err: error }, "held actions not saved"),
|
|
300
|
-
);
|
|
301
|
-
if (held)
|
|
302
|
-
logger.info(
|
|
303
|
-
{ channel: key, held: held.calls.map((call) => call.action) },
|
|
304
|
-
"actions held for confirmation",
|
|
305
|
-
);
|
|
306
|
-
logger.info(
|
|
307
|
-
{
|
|
308
|
-
channel: key,
|
|
309
|
-
selection: request.selection.id,
|
|
310
|
-
toolCalls,
|
|
311
|
-
thinking: session.thinkingLevel,
|
|
312
|
-
// pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
|
|
313
|
-
...(request.agent
|
|
314
|
-
? { model: `${session.model?.provider}/${session.model?.id}` }
|
|
315
|
-
: {}),
|
|
316
|
-
},
|
|
317
|
-
"turn finished",
|
|
318
|
-
);
|
|
311
|
+
await wrapUpTurn({
|
|
312
|
+
key,
|
|
313
|
+
request,
|
|
314
|
+
session,
|
|
315
|
+
gate,
|
|
316
|
+
toolCalls,
|
|
317
|
+
options: this.#options,
|
|
318
|
+
recordUsage: (usage) => this.#sessions.usage.set(key, usage),
|
|
319
|
+
});
|
|
319
320
|
}
|
|
320
321
|
|
|
321
322
|
if (running.stopped)
|
|
@@ -368,6 +369,8 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
368
369
|
): Promise<string> {
|
|
369
370
|
const { dataDir } = this.#options;
|
|
370
371
|
const { signal } = task;
|
|
372
|
+
if (this.#options.stopping?.())
|
|
373
|
+
throw new AgentRunError(new HostStoppingError().message);
|
|
371
374
|
// A task works at the tier of the turn that started it; without one there is no tier to take.
|
|
372
375
|
const turn = this.#turns.get(scope.turn);
|
|
373
376
|
if (!turn)
|
|
@@ -393,7 +396,11 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
393
396
|
const unsubscribe = session.subscribe((event) => {
|
|
394
397
|
if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
|
|
395
398
|
});
|
|
396
|
-
|
|
399
|
+
// A worker whose model ignores the abort is given up on, like a turn's.
|
|
400
|
+
const guard = new AbortGuard(session, {
|
|
401
|
+
graceMs: this.#options.turnAbortGraceMs,
|
|
402
|
+
});
|
|
403
|
+
const abort = () => guard.abort();
|
|
397
404
|
signal?.addEventListener("abort", abort, { once: true });
|
|
398
405
|
const timer = setTimeout(abort, task.timeoutMs);
|
|
399
406
|
try {
|
|
@@ -411,13 +418,16 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
411
418
|
(name) => registered.has(name) && this.#tiers.allows(tier, name),
|
|
412
419
|
);
|
|
413
420
|
session.setActiveToolsByName([...worker.tools]);
|
|
414
|
-
|
|
421
|
+
const prompted = withoutReplyFiles(() => session.prompt(task.text));
|
|
422
|
+
prompted.catch(() => undefined);
|
|
423
|
+
await Promise.race([prompted, guard.gaveUp]);
|
|
415
424
|
return workerReport(
|
|
416
425
|
session.messages,
|
|
417
426
|
"the worker stopped before it reported",
|
|
418
427
|
);
|
|
419
428
|
} finally {
|
|
420
429
|
clearTimeout(timer);
|
|
430
|
+
guard.release();
|
|
421
431
|
signal?.removeEventListener("abort", abort);
|
|
422
432
|
unsubscribe();
|
|
423
433
|
session.dispose();
|
|
@@ -467,33 +477,6 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
467
477
|
return transcriptOf(await this.#sessions.messages(channel), limit);
|
|
468
478
|
}
|
|
469
479
|
|
|
470
|
-
/** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
|
|
471
|
-
async #thinkingLevel(
|
|
472
|
-
key: ChannelKey,
|
|
473
|
-
session: AgentSession,
|
|
474
|
-
request: TurnRequest,
|
|
475
|
-
agentModel?: AgentModelSettings,
|
|
476
|
-
): Promise<ThinkingLevel> {
|
|
477
|
-
const setting = request.agent
|
|
478
|
-
? await useChosenAgentModel(
|
|
479
|
-
session,
|
|
480
|
-
this.#modelRuntime,
|
|
481
|
-
request.agent.name,
|
|
482
|
-
agentModel,
|
|
483
|
-
)
|
|
484
|
-
: AUTO_THINKING;
|
|
485
|
-
if (setting !== AUTO_THINKING) {
|
|
486
|
-
this.#sessions.judged.delete(key);
|
|
487
|
-
return setting;
|
|
488
|
-
}
|
|
489
|
-
const level = await this.#options.effort.judge(request.text, {
|
|
490
|
-
reply: lastReply(session.messages),
|
|
491
|
-
level: this.#sessions.judged.get(key),
|
|
492
|
-
});
|
|
493
|
-
this.#sessions.judged.set(key, level);
|
|
494
|
-
return level;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
480
|
dispose(): void {
|
|
498
481
|
this.#sessions.dispose();
|
|
499
482
|
}
|
|
@@ -80,6 +80,14 @@ export interface PiAgentRuntimeOptions {
|
|
|
80
80
|
toolTiers?: ToolTiers;
|
|
81
81
|
/** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
|
|
82
82
|
turnTimeoutMs?: number;
|
|
83
|
+
/**
|
|
84
|
+
* How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
|
|
85
|
+
* disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
|
|
86
|
+
* abort would otherwise hold the turn, and the shutdown drain, for ever.
|
|
87
|
+
*/
|
|
88
|
+
turnAbortGraceMs?: number;
|
|
89
|
+
/** Whether the host is shutting down: a turn or a task is then refused before it starts. */
|
|
90
|
+
stopping?: () => boolean;
|
|
83
91
|
/**
|
|
84
92
|
* Whether a turn given a place for interim posts shows the text it writes before its final
|
|
85
93
|
* answer as it goes; default "on". "off" posts only the final reply.
|
|
@@ -33,13 +33,22 @@ export class SteerableRun {
|
|
|
33
33
|
readonly #canSteer: () => boolean;
|
|
34
34
|
/** Steered prompts in arrival order, each with its images. */
|
|
35
35
|
readonly #steers: { text: string; images: PromptImages }[] = [];
|
|
36
|
+
readonly #abort: () => void;
|
|
36
37
|
#stopped = false;
|
|
37
38
|
#done = false;
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `canSteer` is asked at each steer: the turn is the owner's and holds no actions. `abort`
|
|
42
|
+
* ends the session's run when the turn is stopped; by default the session's own abort.
|
|
43
|
+
*/
|
|
44
|
+
constructor(
|
|
45
|
+
session: SteeringSession,
|
|
46
|
+
canSteer: () => boolean,
|
|
47
|
+
abort: () => void = () => void session.abort(),
|
|
48
|
+
) {
|
|
41
49
|
this.#session = session;
|
|
42
50
|
this.#canSteer = canSteer;
|
|
51
|
+
this.#abort = abort;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
get stopped(): boolean {
|
|
@@ -106,7 +115,7 @@ export class SteerableRun {
|
|
|
106
115
|
if (this.#done) return false;
|
|
107
116
|
this.#stopped = true;
|
|
108
117
|
this.#session.clearQueue();
|
|
109
|
-
|
|
118
|
+
this.#abort();
|
|
110
119
|
return true;
|
|
111
120
|
}
|
|
112
121
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSession,
|
|
3
|
+
ModelRuntime,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
6
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
7
|
+
import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
|
|
8
|
+
import {
|
|
9
|
+
type AgentModelSettings,
|
|
10
|
+
useChosenAgentModel,
|
|
11
|
+
} from "./bridge-guard.ts";
|
|
12
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
13
|
+
import { lastReply } from "./turn-answer.ts";
|
|
14
|
+
|
|
15
|
+
/** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
|
|
16
|
+
export async function thinkingLevel(
|
|
17
|
+
parts: {
|
|
18
|
+
modelRuntime: ModelRuntime;
|
|
19
|
+
effort: PiAgentRuntimeOptions["effort"];
|
|
20
|
+
/** The level last judged for each conversation. */
|
|
21
|
+
judged: Map<ChannelKey, ThinkingLevel>;
|
|
22
|
+
},
|
|
23
|
+
key: ChannelKey,
|
|
24
|
+
session: AgentSession,
|
|
25
|
+
request: TurnRequest,
|
|
26
|
+
agentModel?: AgentModelSettings,
|
|
27
|
+
): Promise<ThinkingLevel> {
|
|
28
|
+
const { modelRuntime, effort, judged } = parts;
|
|
29
|
+
const setting = request.agent
|
|
30
|
+
? await useChosenAgentModel(
|
|
31
|
+
session,
|
|
32
|
+
modelRuntime,
|
|
33
|
+
request.agent.name,
|
|
34
|
+
agentModel,
|
|
35
|
+
)
|
|
36
|
+
: AUTO_THINKING;
|
|
37
|
+
if (setting !== AUTO_THINKING) {
|
|
38
|
+
judged.delete(key);
|
|
39
|
+
return setting;
|
|
40
|
+
}
|
|
41
|
+
const level = await effort.judge(request.text, {
|
|
42
|
+
reply: lastReply(session.messages),
|
|
43
|
+
level: judged.get(key),
|
|
44
|
+
});
|
|
45
|
+
judged.set(key, level);
|
|
46
|
+
return level;
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
4
|
+
import type { ConfirmationGate } from "./extensions/confirmation-gate.ts";
|
|
5
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a turn leaves behind when it ends, whatever way it ended: the gate stops counting the turn,
|
|
9
|
+
* the conversation's context usage and held actions are recorded, and the turn is logged. A
|
|
10
|
+
* session disposed of under a turn that would not stop has no usage to read.
|
|
11
|
+
*/
|
|
12
|
+
export async function wrapUpTurn(parts: {
|
|
13
|
+
key: ChannelKey;
|
|
14
|
+
request: TurnRequest;
|
|
15
|
+
session: AgentSession;
|
|
16
|
+
gate: ConfirmationGate;
|
|
17
|
+
toolCalls: readonly string[];
|
|
18
|
+
options: Pick<PiAgentRuntimeOptions, "confirmations" | "logger">;
|
|
19
|
+
recordUsage: (usage: {
|
|
20
|
+
tokens: number | null;
|
|
21
|
+
contextWindow: number;
|
|
22
|
+
}) => void;
|
|
23
|
+
}): Promise<void> {
|
|
24
|
+
const { key, request, session, gate, toolCalls, options, recordUsage } =
|
|
25
|
+
parts;
|
|
26
|
+
const { logger } = options;
|
|
27
|
+
gate.endTurn();
|
|
28
|
+
try {
|
|
29
|
+
const usage = session.getContextUsage();
|
|
30
|
+
if (usage)
|
|
31
|
+
recordUsage({
|
|
32
|
+
tokens: usage.tokens,
|
|
33
|
+
contextWindow: usage.contextWindow,
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logger.warn({ channel: key, err: error }, "context usage not read");
|
|
37
|
+
}
|
|
38
|
+
const held = gate.pending();
|
|
39
|
+
await options.confirmations
|
|
40
|
+
.save(key, held)
|
|
41
|
+
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
42
|
+
.catch((error: unknown) =>
|
|
43
|
+
logger.error({ channel: key, err: error }, "held actions not saved"),
|
|
44
|
+
);
|
|
45
|
+
if (held)
|
|
46
|
+
logger.info(
|
|
47
|
+
{ channel: key, held: held.calls.map((call) => call.action) },
|
|
48
|
+
"actions held for confirmation",
|
|
49
|
+
);
|
|
50
|
+
logger.info(
|
|
51
|
+
{
|
|
52
|
+
channel: key,
|
|
53
|
+
selection: request.selection.id,
|
|
54
|
+
toolCalls,
|
|
55
|
+
thinking: session.thinkingLevel,
|
|
56
|
+
// pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
|
|
57
|
+
...(request.agent
|
|
58
|
+
? { model: `${session.model?.provider}/${session.model?.id}` }
|
|
59
|
+
: {}),
|
|
60
|
+
},
|
|
61
|
+
"turn finished",
|
|
62
|
+
);
|
|
63
|
+
}
|