pi-roundtable 0.1.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { archiveSessions } from "./session-archive.ts";
|
|
6
|
+
|
|
7
|
+
describe("archiveSessions", () => {
|
|
8
|
+
test("moves the conversations out of the top level, keeping them", () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "roundtable-archive-"));
|
|
10
|
+
writeFileSync(join(dir, "a.jsonl"), "{}\n");
|
|
11
|
+
writeFileSync(join(dir, "notes.txt"), "stay");
|
|
12
|
+
expect(archiveSessions(dir, new Date("2026-09-27T01:02:03Z"))).toBe(1);
|
|
13
|
+
expect(readdirSync(dir).sort()).toEqual(["archive", "notes.txt"]);
|
|
14
|
+
expect(
|
|
15
|
+
readdirSync(join(dir, "archive", "2026-09-27T01-02-03.000Z")),
|
|
16
|
+
).toEqual(["a.jsonl"]);
|
|
17
|
+
expect(archiveSessions(dir)).toBe(0);
|
|
18
|
+
expect(archiveSessions(join(dir, "missing"))).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Moves a session directory's conversations into `archive/<time>/`, so the next session
|
|
6
|
+
* opened there starts with no history. Pi only reads `.jsonl` files at the directory's top
|
|
7
|
+
* level. Returns how many were moved.
|
|
8
|
+
*/
|
|
9
|
+
export function archiveSessions(dir: string, now = new Date()): number {
|
|
10
|
+
if (!existsSync(dir)) return 0;
|
|
11
|
+
const files = readdirSync(dir).filter((file) => file.endsWith(".jsonl"));
|
|
12
|
+
if (files.length === 0) return 0;
|
|
13
|
+
const target = join(dir, "archive", now.toISOString().replaceAll(":", "-"));
|
|
14
|
+
mkdirSync(target, { recursive: true });
|
|
15
|
+
for (const file of files) renameSync(join(dir, file), join(target, file));
|
|
16
|
+
return files.length;
|
|
17
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type AgentSession,
|
|
5
|
+
createAgentSession,
|
|
6
|
+
DefaultResourceLoader,
|
|
7
|
+
type ModelRuntime,
|
|
8
|
+
type SessionManager,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { channelSegment } from "../attachments/attachment-dir.ts";
|
|
11
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
12
|
+
import { ConfigError } from "../domain/errors.ts";
|
|
13
|
+
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
14
|
+
import type { LinkedSessions } from "../plugin.ts";
|
|
15
|
+
import {
|
|
16
|
+
planOrder,
|
|
17
|
+
type SessionContext,
|
|
18
|
+
type SessionPlan,
|
|
19
|
+
type ToolSelection,
|
|
20
|
+
type TransientTask,
|
|
21
|
+
} from "../sessions.ts";
|
|
22
|
+
import { packageDir } from "../shared/package-dir.ts";
|
|
23
|
+
import { profileToolsExtension } from "../shared/profile-tools.ts";
|
|
24
|
+
import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
|
|
25
|
+
import { addressee, type Speaker, THE_SPEAKER } from "../speakers.ts";
|
|
26
|
+
import {
|
|
27
|
+
CompactionTiers,
|
|
28
|
+
compactionEngine,
|
|
29
|
+
HARD_COMPACT_TOKENS,
|
|
30
|
+
} from "./compaction-tiers.ts";
|
|
31
|
+
import { ASK_USER_TOOL, askUserExtension } from "./extensions/ask-user.ts";
|
|
32
|
+
import {
|
|
33
|
+
type ConfirmationGate,
|
|
34
|
+
confirmationGateExtension,
|
|
35
|
+
} from "./extensions/confirmation-gate.ts";
|
|
36
|
+
import {
|
|
37
|
+
COMPACT_TOOL,
|
|
38
|
+
selfCompactGuardExtension,
|
|
39
|
+
} from "./extensions/self-compact-guard.ts";
|
|
40
|
+
import type { PromptSlot } from "./prompt-slot.ts";
|
|
41
|
+
import type {
|
|
42
|
+
ChannelSession,
|
|
43
|
+
CompactionEnd,
|
|
44
|
+
PiAgentRuntimeOptions,
|
|
45
|
+
} from "./runtime-types.ts";
|
|
46
|
+
import {
|
|
47
|
+
type LoadedSkill,
|
|
48
|
+
revisionsKey,
|
|
49
|
+
sessionExtensions,
|
|
50
|
+
skillsKey,
|
|
51
|
+
} from "./runtime-types.ts";
|
|
52
|
+
|
|
53
|
+
/** What a session factory asks of the runtime that owns the turns. */
|
|
54
|
+
export interface SessionFactoryDeps {
|
|
55
|
+
/** The person the conversation's running turn is for. */
|
|
56
|
+
speaker(channel: ChannelKey): Speaker | undefined;
|
|
57
|
+
/** Runs a task beside the conversation, under its gate. */
|
|
58
|
+
runTask(channel: ChannelKey, task: TransientTask): Promise<string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Builds Pi sessions for the runtime: one resource loader per session, its extensions in order. */
|
|
62
|
+
export class SessionFactory {
|
|
63
|
+
readonly #options: PiAgentRuntimeOptions;
|
|
64
|
+
readonly #modelRuntime: ModelRuntime;
|
|
65
|
+
readonly #deps: SessionFactoryDeps;
|
|
66
|
+
/** The linked session parts, with each package's directory; resolved on first use. */
|
|
67
|
+
#linked: (LinkedSessions & { extensionPaths: string[] }) | undefined;
|
|
68
|
+
|
|
69
|
+
constructor(options: PiAgentRuntimeOptions, deps: SessionFactoryDeps) {
|
|
70
|
+
this.#options = options;
|
|
71
|
+
this.#modelRuntime = options.modelRuntime;
|
|
72
|
+
this.#deps = deps;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
link(): LinkedSessions & { extensionPaths: string[] } {
|
|
76
|
+
if (!this.#linked) {
|
|
77
|
+
const linked = this.#options.sessions();
|
|
78
|
+
this.#linked = {
|
|
79
|
+
...linked,
|
|
80
|
+
extensionPaths: linked.piPackages.map(packageDir),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return this.#linked;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get plan(): SessionPlan {
|
|
87
|
+
return this.link().plan;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
toolsFor(selection: ToolSelection): string[] {
|
|
91
|
+
const groups = planOrder(this.plan).flatMap(
|
|
92
|
+
(tool) => tool.snapshot().groups ?? [],
|
|
93
|
+
);
|
|
94
|
+
return [
|
|
95
|
+
...selection.tools,
|
|
96
|
+
COMPACT_TOOL,
|
|
97
|
+
ASK_USER_TOOL,
|
|
98
|
+
...groups
|
|
99
|
+
.filter((group) => selection.groups.includes(group.name))
|
|
100
|
+
.flatMap((group) => group.tools),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Waits for the tools the session tools register late, such as pi-mcp-adapter's after its eager connection. */
|
|
105
|
+
async #awaitTools(
|
|
106
|
+
session: AgentSession,
|
|
107
|
+
expected: readonly string[],
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const deadline = Date.now() + (this.#options.mcpConnectTimeoutMs ?? 30_000);
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
const registered = new Set(
|
|
112
|
+
session.getAllTools().map((tool) => tool.name),
|
|
113
|
+
);
|
|
114
|
+
if (expected.every((name) => registered.has(name))) return;
|
|
115
|
+
await Bun.sleep(250);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
workDir(): string {
|
|
120
|
+
const dir = join(this.#options.dataDir, "work");
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
122
|
+
return dir;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sessionDir(channel: ChannelKey): string {
|
|
126
|
+
return join(this.#options.dataDir, "sessions", channelSegment(channel));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An agent's carried skills; owner sessions carry none. */
|
|
130
|
+
skillsOf(agent: AgentTurnScope | undefined): readonly LoadedSkill[] {
|
|
131
|
+
const agents = this.#options.agents;
|
|
132
|
+
return agent && agents ? agents.skills(agent.name) : [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Agents work in their shared workspace; owner sessions in the assistant's own. */
|
|
136
|
+
cwd(agent: AgentTurnScope | undefined): string {
|
|
137
|
+
const agents = this.#options.agents;
|
|
138
|
+
if (!agent || !agents) return this.workDir();
|
|
139
|
+
mkdirSync(agents.workDir, { recursive: true });
|
|
140
|
+
return agents.workDir;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Logs who compacted a conversation, the compaction extension or Pi's summary, by how much, and the context size
|
|
145
|
+
* that compacts it next, so a move to the hard ceiling shows.
|
|
146
|
+
*/
|
|
147
|
+
#logCompaction(
|
|
148
|
+
channel: ChannelKey,
|
|
149
|
+
event: CompactionEnd,
|
|
150
|
+
session: AgentSession,
|
|
151
|
+
): void {
|
|
152
|
+
const { logger } = this.#options;
|
|
153
|
+
const trigger = event.reason === "manual" ? "self" : event.reason;
|
|
154
|
+
if (!event.result) {
|
|
155
|
+
logger.warn(
|
|
156
|
+
{
|
|
157
|
+
channel,
|
|
158
|
+
trigger,
|
|
159
|
+
aborted: event.aborted,
|
|
160
|
+
error: event.errorMessage,
|
|
161
|
+
},
|
|
162
|
+
"compaction failed",
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const { model, settingsManager } = session;
|
|
167
|
+
logger.info(
|
|
168
|
+
{
|
|
169
|
+
channel,
|
|
170
|
+
trigger,
|
|
171
|
+
engine: compactionEngine(
|
|
172
|
+
event.result.details,
|
|
173
|
+
this.plan.compaction?.engine,
|
|
174
|
+
),
|
|
175
|
+
tokensBefore: event.result.tokensBefore,
|
|
176
|
+
tokensAfter: event.result.estimatedTokensAfter,
|
|
177
|
+
nextCompactionAt:
|
|
178
|
+
model &&
|
|
179
|
+
model.contextWindow -
|
|
180
|
+
settingsManager.getCompactionSettings(model).reserveTokens,
|
|
181
|
+
},
|
|
182
|
+
"conversation compacted",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Each channel gets its own resource loader: Pi binds extension actions such as
|
|
188
|
+
* setActiveTools to the loader's shared runtime, so sessions sharing a loader would
|
|
189
|
+
* act on whichever session bound last.
|
|
190
|
+
*/
|
|
191
|
+
// pi-lens-ignore: long-parameter-list, high-fan-out — the build inputs of one session; its SessionContext is derived here from them
|
|
192
|
+
async create(
|
|
193
|
+
channel: ChannelKey,
|
|
194
|
+
sessionManager: SessionManager,
|
|
195
|
+
gate: ConfirmationGate,
|
|
196
|
+
slot: PromptSlot,
|
|
197
|
+
attachmentDir: string,
|
|
198
|
+
agent: AgentTurnScope | undefined,
|
|
199
|
+
): Promise<ChannelSession> {
|
|
200
|
+
const { agentDir, model, thinking, persona, logger } = this.#options;
|
|
201
|
+
const skills = this.skillsOf(agent);
|
|
202
|
+
const state = {
|
|
203
|
+
tools: [] as readonly string[],
|
|
204
|
+
revisions: revisionsKey(this.plan),
|
|
205
|
+
skills: skillsKey(skills),
|
|
206
|
+
};
|
|
207
|
+
const awaited = planOrder(this.plan).flatMap(
|
|
208
|
+
(tool) => tool.snapshot().awaitTools ?? [],
|
|
209
|
+
);
|
|
210
|
+
const cwd = this.cwd(agent);
|
|
211
|
+
const agents = agent ? this.#options.agents : undefined;
|
|
212
|
+
if (agent && !agents)
|
|
213
|
+
throw new ConfigError("agent turns need the runtime's agents option");
|
|
214
|
+
const tiers = new CompactionTiers(
|
|
215
|
+
sessionManager,
|
|
216
|
+
(provider, id) =>
|
|
217
|
+
this.#modelRuntime.getModel(provider, id)?.contextWindow,
|
|
218
|
+
this.plan.compaction?.engine,
|
|
219
|
+
);
|
|
220
|
+
const context: SessionContext = {
|
|
221
|
+
kind: agent ? "agent" : "owner",
|
|
222
|
+
homeChannel: channel,
|
|
223
|
+
turnChannel: agent && agents ? agents.turnChannel(agent) : channel,
|
|
224
|
+
compaction: {
|
|
225
|
+
wrap: (compactor) =>
|
|
226
|
+
tiers.wrapCompactor(compactor, (bypass) =>
|
|
227
|
+
logger.info(
|
|
228
|
+
{ channel, ...bypass, ceiling: HARD_COMPACT_TOKENS },
|
|
229
|
+
"compaction skips the extension for Pi's summary",
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
},
|
|
233
|
+
speaker: () => this.#deps.speaker(channel),
|
|
234
|
+
runTask: (task) => this.#deps.runTask(channel, task),
|
|
235
|
+
};
|
|
236
|
+
if (agent) context.agent = agent;
|
|
237
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
238
|
+
cwd,
|
|
239
|
+
agentDir,
|
|
240
|
+
noExtensions: true,
|
|
241
|
+
noSkills: true,
|
|
242
|
+
additionalSkillPaths: skills.map((skill) => skill.file),
|
|
243
|
+
noPromptTemplates: true,
|
|
244
|
+
noThemes: true,
|
|
245
|
+
noContextFiles: true,
|
|
246
|
+
additionalExtensionPaths: this.link().extensionPaths,
|
|
247
|
+
extensionFactories: sessionExtensions(this.plan, context, {
|
|
248
|
+
readAttachment: readAttachmentExtension(attachmentDir),
|
|
249
|
+
confirmationGate: confirmationGateExtension(gate, slot),
|
|
250
|
+
askUser: askUserExtension(
|
|
251
|
+
slot,
|
|
252
|
+
agent ? THE_SPEAKER : this.#options.owner,
|
|
253
|
+
() => addressee(this.#deps.speaker(channel), this.#options.owner),
|
|
254
|
+
),
|
|
255
|
+
selfCompactGuard: selfCompactGuardExtension(),
|
|
256
|
+
profileTools: profileToolsExtension(() => state.tools),
|
|
257
|
+
}),
|
|
258
|
+
// An agent's prompt is set before each run by the agent-prompt extension.
|
|
259
|
+
appendSystemPrompt: agent ? [] : [persona],
|
|
260
|
+
});
|
|
261
|
+
await resourceLoader.reload();
|
|
262
|
+
|
|
263
|
+
const { session } = await createAgentSession({
|
|
264
|
+
cwd,
|
|
265
|
+
agentDir,
|
|
266
|
+
thinkingLevel: thinking,
|
|
267
|
+
modelRuntime: this.#modelRuntime,
|
|
268
|
+
resourceLoader,
|
|
269
|
+
sessionManager,
|
|
270
|
+
// Large windows compact through the extension at SOFT_COMPACT_TOKENS, and through pi's summary
|
|
271
|
+
// past HARD_COMPACT_TOKENS.
|
|
272
|
+
settingsManager: tiers.settings(),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Extension providers such as claude-bridge exist only after the session loads its extensions.
|
|
276
|
+
const resolved = this.#modelRuntime.getModel(model.provider, model.id);
|
|
277
|
+
if (!resolved) {
|
|
278
|
+
session.dispose();
|
|
279
|
+
throw new ConfigError(
|
|
280
|
+
`model ${model.provider}/${model.id} is not available`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
await session.setModel(resolved);
|
|
284
|
+
await this.#awaitTools(session, awaited);
|
|
285
|
+
session.subscribe((event) => {
|
|
286
|
+
if (event.type === "compaction_end")
|
|
287
|
+
this.#logCompaction(channel, event, session);
|
|
288
|
+
});
|
|
289
|
+
return Object.assign(state, { session });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type AssistantMessage,
|
|
7
|
+
createFauxCore,
|
|
8
|
+
type FauxResponseStep,
|
|
9
|
+
fauxAssistantMessage,
|
|
10
|
+
type TranscriptContext,
|
|
11
|
+
} from "@earendil-works/pi-ai";
|
|
12
|
+
import {
|
|
13
|
+
type AgentSession,
|
|
14
|
+
createAgentSession,
|
|
15
|
+
DefaultResourceLoader,
|
|
16
|
+
ModelRuntime,
|
|
17
|
+
SessionManager,
|
|
18
|
+
SettingsManager,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { SteerableRun, type SteeringSession } from "./steerable-run.ts";
|
|
21
|
+
|
|
22
|
+
/** A real Pi session on the faux provider, whose scripted answers may wait on the test. */
|
|
23
|
+
async function fauxSession(steps: FauxResponseStep[]) {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "roundtable-steer-"));
|
|
25
|
+
const core = createFauxCore({ provider: "faux", models: [{ id: "faux-1" }] });
|
|
26
|
+
core.setResponses(steps);
|
|
27
|
+
const modelRuntime = await ModelRuntime.create({
|
|
28
|
+
authPath: join(dir, "auth.json"),
|
|
29
|
+
modelsPath: null,
|
|
30
|
+
allowModelNetwork: false,
|
|
31
|
+
refreshOnCreate: false,
|
|
32
|
+
});
|
|
33
|
+
modelRuntime.registerProvider("faux", {
|
|
34
|
+
api: core.api,
|
|
35
|
+
apiKey: "test",
|
|
36
|
+
baseUrl: "http://faux.invalid",
|
|
37
|
+
streamSimple: core.streamSimple,
|
|
38
|
+
models: [
|
|
39
|
+
{
|
|
40
|
+
id: "faux-1",
|
|
41
|
+
name: "Faux",
|
|
42
|
+
reasoning: false,
|
|
43
|
+
input: ["text", "image"],
|
|
44
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
45
|
+
contextWindow: 100_000,
|
|
46
|
+
maxTokens: 1_000,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
const model = modelRuntime.getModel("faux", "faux-1");
|
|
51
|
+
if (!model) throw new Error("faux model not registered");
|
|
52
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
53
|
+
cwd: dir,
|
|
54
|
+
agentDir: dir,
|
|
55
|
+
noExtensions: true,
|
|
56
|
+
noSkills: true,
|
|
57
|
+
noPromptTemplates: true,
|
|
58
|
+
noThemes: true,
|
|
59
|
+
noContextFiles: true,
|
|
60
|
+
});
|
|
61
|
+
await resourceLoader.reload();
|
|
62
|
+
const { session } = await createAgentSession({
|
|
63
|
+
cwd: dir,
|
|
64
|
+
agentDir: dir,
|
|
65
|
+
modelRuntime,
|
|
66
|
+
model,
|
|
67
|
+
resourceLoader,
|
|
68
|
+
sessionManager: SessionManager.inMemory(),
|
|
69
|
+
settingsManager: SettingsManager.inMemory({}),
|
|
70
|
+
noTools: "all",
|
|
71
|
+
});
|
|
72
|
+
return { session, core };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A promise the test resolves by hand. */
|
|
76
|
+
function gate() {
|
|
77
|
+
let open = () => {};
|
|
78
|
+
const opened = new Promise<void>((resolve) => {
|
|
79
|
+
open = resolve;
|
|
80
|
+
});
|
|
81
|
+
return { opened, open };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const userTexts = (context: TranscriptContext) =>
|
|
85
|
+
context.messages
|
|
86
|
+
.filter((m) => m.role === "user")
|
|
87
|
+
.map((m) =>
|
|
88
|
+
typeof m.content === "string"
|
|
89
|
+
? m.content
|
|
90
|
+
: m.content.map((c) => (c.type === "text" ? c.text : "")).join(""),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const answers = (session: AgentSession) =>
|
|
94
|
+
session.messages
|
|
95
|
+
.filter((m): m is AssistantMessage => m.role === "assistant")
|
|
96
|
+
.map((m) =>
|
|
97
|
+
m.content.map((c) => (c.type === "text" ? c.text : "")).join(""),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
describe("SteerableRun on a Pi session", () => {
|
|
101
|
+
let seen: string[][];
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
seen = [];
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a steer during the run is answered in the same turn", async () => {
|
|
107
|
+
const thinking = gate();
|
|
108
|
+
const started = gate();
|
|
109
|
+
const { session } = await fauxSession([
|
|
110
|
+
async (context) => {
|
|
111
|
+
seen.push(userTexts(context));
|
|
112
|
+
started.open();
|
|
113
|
+
await thinking.opened;
|
|
114
|
+
return fauxAssistantMessage("first answer");
|
|
115
|
+
},
|
|
116
|
+
(context) => {
|
|
117
|
+
seen.push(userTexts(context));
|
|
118
|
+
return fauxAssistantMessage("answer after the addition");
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
const run = new SteerableRun(session, () => true);
|
|
122
|
+
const done = run.run(() => session.prompt("look this up for me"));
|
|
123
|
+
await started.opened;
|
|
124
|
+
expect(await run.steer("also check journald", [])).toBe(true);
|
|
125
|
+
thinking.open();
|
|
126
|
+
await done;
|
|
127
|
+
expect(run.steered).toBe(true);
|
|
128
|
+
expect(seen[1]).toEqual(["look this up for me", "also check journald"]);
|
|
129
|
+
expect(answers(session)).toEqual([
|
|
130
|
+
"first answer",
|
|
131
|
+
"answer after the addition",
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a steer at agent_end is answered in the same turn by Pi itself", async () => {
|
|
136
|
+
const { session } = await fauxSession([
|
|
137
|
+
fauxAssistantMessage("first answer"),
|
|
138
|
+
(context) => {
|
|
139
|
+
seen.push(userTexts(context));
|
|
140
|
+
return fauxAssistantMessage("answer after the addition");
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
const run = new SteerableRun(session, () => true);
|
|
144
|
+
let late: Promise<boolean> | undefined;
|
|
145
|
+
// agent_end fires after Pi's last look at its steering queue.
|
|
146
|
+
session.subscribe((event) => {
|
|
147
|
+
if (event.type === "agent_end" && !late)
|
|
148
|
+
late = run.steer("a late addition", []);
|
|
149
|
+
});
|
|
150
|
+
await run.run(() => session.prompt("look this up for me"));
|
|
151
|
+
expect(await late).toBe(true);
|
|
152
|
+
expect(seen.at(-1)).toContain("a late addition");
|
|
153
|
+
expect(session.getSteeringMessages()).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("stop aborts the run and drops its steers", async () => {
|
|
157
|
+
const started = gate();
|
|
158
|
+
const { session, core } = await fauxSession([
|
|
159
|
+
(_context, options) => {
|
|
160
|
+
started.open();
|
|
161
|
+
return new Promise<AssistantMessage>((resolve) =>
|
|
162
|
+
options?.signal?.addEventListener("abort", () =>
|
|
163
|
+
resolve(fauxAssistantMessage("", { stopReason: "aborted" })),
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
fauxAssistantMessage("must not appear"),
|
|
168
|
+
]);
|
|
169
|
+
const run = new SteerableRun(session, () => true);
|
|
170
|
+
const done = run.run(() => session.prompt("a long-running job"));
|
|
171
|
+
await started.opened;
|
|
172
|
+
expect(await run.steer("this line is dropped", [])).toBe(true);
|
|
173
|
+
expect(run.stop()).toBe(true);
|
|
174
|
+
await done;
|
|
175
|
+
expect(run.stopped).toBe(true);
|
|
176
|
+
expect(core.state.callCount).toBe(1);
|
|
177
|
+
expect(session.getSteeringMessages()).toEqual([]);
|
|
178
|
+
expect(run.stop()).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a turn that may not be steered, or has ended, takes nothing", async () => {
|
|
182
|
+
const started = gate();
|
|
183
|
+
const finish = gate();
|
|
184
|
+
const { session } = await fauxSession([
|
|
185
|
+
async () => {
|
|
186
|
+
started.open();
|
|
187
|
+
await finish.opened;
|
|
188
|
+
return fauxAssistantMessage("done");
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
const run = new SteerableRun(session, () => false);
|
|
192
|
+
const done = run.run(() => session.prompt("a scheduled job"));
|
|
193
|
+
await started.opened;
|
|
194
|
+
expect(await run.steer("cannot be inserted", [])).toBe(false);
|
|
195
|
+
finish.open();
|
|
196
|
+
await done;
|
|
197
|
+
expect(await run.steer("too late", [])).toBe(false);
|
|
198
|
+
expect(session.getSteeringMessages()).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The window Pi leaves open: its input handling ends after the session settled, so the steer
|
|
204
|
+
* sits in the queue with no run to take it.
|
|
205
|
+
*/
|
|
206
|
+
class SettledSession {
|
|
207
|
+
isStreaming = true;
|
|
208
|
+
queue: string[] = [];
|
|
209
|
+
prompts: string[] = [];
|
|
210
|
+
async steer(text: string) {
|
|
211
|
+
this.queue.push(text);
|
|
212
|
+
}
|
|
213
|
+
getSteeringMessages() {
|
|
214
|
+
return this.queue;
|
|
215
|
+
}
|
|
216
|
+
clearQueue() {
|
|
217
|
+
const steering = this.queue;
|
|
218
|
+
this.queue = [];
|
|
219
|
+
return { steering, followUp: [] };
|
|
220
|
+
}
|
|
221
|
+
async abort() {}
|
|
222
|
+
async waitForIdle() {}
|
|
223
|
+
async prompt(text: string) {
|
|
224
|
+
this.prompts.push(text);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
describe("SteerableRun leftovers", () => {
|
|
229
|
+
test("a steer Pi left queued as the run ended is prompted in the same turn", async () => {
|
|
230
|
+
const session = new SettledSession();
|
|
231
|
+
const run = new SteerableRun(
|
|
232
|
+
session as unknown as SteeringSession,
|
|
233
|
+
() => true,
|
|
234
|
+
);
|
|
235
|
+
await run.run(async () => {
|
|
236
|
+
expect(await run.steer("a late addition", [])).toBe(true);
|
|
237
|
+
session.isStreaming = false;
|
|
238
|
+
});
|
|
239
|
+
expect(session.prompts).toEqual(["a late addition"]);
|
|
240
|
+
expect(session.queue).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("a steer queued after the turn ended is taken back", async () => {
|
|
244
|
+
const session = new SettledSession();
|
|
245
|
+
const run = new SteerableRun(
|
|
246
|
+
session as unknown as SteeringSession,
|
|
247
|
+
() => true,
|
|
248
|
+
);
|
|
249
|
+
const settled = gate();
|
|
250
|
+
let late: Promise<boolean> | undefined;
|
|
251
|
+
session.steer = async (text: string) => {
|
|
252
|
+
// Pi's input handling finishes only after the turn is over.
|
|
253
|
+
await settled.opened;
|
|
254
|
+
session.queue.push(text);
|
|
255
|
+
};
|
|
256
|
+
await run.run(async () => {
|
|
257
|
+
late = run.steer("a late addition", []);
|
|
258
|
+
});
|
|
259
|
+
settled.open();
|
|
260
|
+
expect(await late).toBe(false);
|
|
261
|
+
expect(session.queue).toEqual([]);
|
|
262
|
+
expect(session.prompts).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type PromptImages = NonNullable<Parameters<AgentSession["steer"]>[1]>;
|
|
4
|
+
|
|
5
|
+
/** The part of a Pi session a steerable run drives. */
|
|
6
|
+
export type SteeringSession = Pick<
|
|
7
|
+
AgentSession,
|
|
8
|
+
| "isStreaming"
|
|
9
|
+
| "steer"
|
|
10
|
+
| "getSteeringMessages"
|
|
11
|
+
| "clearQueue"
|
|
12
|
+
| "abort"
|
|
13
|
+
| "prompt"
|
|
14
|
+
| "waitForIdle"
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One turn of a Pi session that the owner may steer or stop. Pi checks its queue once more
|
|
19
|
+
* after a run, but a steer whose input handling ends after the session settled stays queued,
|
|
20
|
+
* where the next run would pick it up out of order; `run` answers it in the same turn instead.
|
|
21
|
+
*/
|
|
22
|
+
export class SteerableRun {
|
|
23
|
+
readonly #session: SteeringSession;
|
|
24
|
+
readonly #canSteer: () => boolean;
|
|
25
|
+
/** Steered prompts in arrival order, each with its images. */
|
|
26
|
+
readonly #steers: { text: string; images: PromptImages }[] = [];
|
|
27
|
+
#stopped = false;
|
|
28
|
+
#done = false;
|
|
29
|
+
|
|
30
|
+
/** `canSteer` is asked at each steer: the turn is the owner's and holds no actions. */
|
|
31
|
+
constructor(session: SteeringSession, canSteer: () => boolean) {
|
|
32
|
+
this.#session = session;
|
|
33
|
+
this.#canSteer = canSteer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get stopped(): boolean {
|
|
37
|
+
return this.#stopped;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether any message was steered into the turn. */
|
|
41
|
+
get steered(): boolean {
|
|
42
|
+
return this.#steers.length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Runs the turn's first prompt, then any steer Pi left queued as the run ended. */
|
|
46
|
+
async run(first: () => Promise<void>): Promise<void> {
|
|
47
|
+
try {
|
|
48
|
+
await first();
|
|
49
|
+
for (
|
|
50
|
+
let left = this.#leftovers();
|
|
51
|
+
left.length > 0 && !this.#stopped;
|
|
52
|
+
left = this.#leftovers()
|
|
53
|
+
) {
|
|
54
|
+
this.#session.clearQueue();
|
|
55
|
+
await this.#session.waitForIdle();
|
|
56
|
+
const images = left.flatMap((steer) => steer.images);
|
|
57
|
+
await this.#session.prompt(
|
|
58
|
+
left.map((steer) => steer.text).join("\n\n"),
|
|
59
|
+
images.length > 0 ? { images } : undefined,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
// Synchronous with the last leftover check, so no steer slips between the two.
|
|
64
|
+
this.#done = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Adds the text to the running turn; false when it must wait for a turn of its own. */
|
|
69
|
+
async steer(text: string, images: PromptImages): Promise<boolean> {
|
|
70
|
+
if (
|
|
71
|
+
this.#stopped ||
|
|
72
|
+
this.#done ||
|
|
73
|
+
!this.#session.isStreaming ||
|
|
74
|
+
!this.#canSteer()
|
|
75
|
+
)
|
|
76
|
+
return false;
|
|
77
|
+
const steer = { text, images };
|
|
78
|
+
// Recorded first, so a run ending meanwhile finds it among the leftovers.
|
|
79
|
+
this.#steers.push(steer);
|
|
80
|
+
try {
|
|
81
|
+
await this.#session.steer(text, images.length > 0 ? images : undefined);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
this.#steers.splice(this.#steers.indexOf(steer), 1);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
// Queued after the turn stopped taking steers: taken back to wait for its own turn.
|
|
87
|
+
if (this.#done && this.#session.getSteeringMessages().includes(text)) {
|
|
88
|
+
this.#session.clearQueue();
|
|
89
|
+
this.#steers.splice(this.#steers.indexOf(steer), 1);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Aborts the turn and drops what was steered into it; false once it has ended. */
|
|
96
|
+
stop(): boolean {
|
|
97
|
+
if (this.#done) return false;
|
|
98
|
+
this.#stopped = true;
|
|
99
|
+
this.#session.clearQueue();
|
|
100
|
+
void this.#session.abort();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Steers still in Pi's queue: sent as the run ended, so the model never saw them. */
|
|
105
|
+
#leftovers() {
|
|
106
|
+
const queued = this.#session.getSteeringMessages();
|
|
107
|
+
return this.#steers.filter((steer) => queued.includes(steer.text));
|
|
108
|
+
}
|
|
109
|
+
}
|