pi-roundtable 0.8.0 → 0.9.1
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 +115 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +618 -86
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +5 -2
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +150 -86
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +87 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +82 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +42 -11
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- 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 +52 -68
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +56 -4
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +274 -21
- package/src/core/routing/conversation-turns.ts +45 -10
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/abort-guard.ts +58 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +139 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +174 -190
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +51 -5
- package/src/core/runtime/runtime-types.ts +48 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +21 -3
- package/src/core/runtime/thinking-level.ts +47 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/runtime/turn-wrapup.ts +63 -0
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +8 -0
- package/src/index.ts +48 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
2
|
import {
|
|
4
|
-
type AgentSession,
|
|
5
3
|
type ModelRuntime,
|
|
6
4
|
SessionManager,
|
|
7
5
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -15,42 +13,40 @@ import type {
|
|
|
15
13
|
TranscriptEntry,
|
|
16
14
|
TurnResult,
|
|
17
15
|
} from "../domain/conversation.ts";
|
|
18
|
-
import { AgentRunError
|
|
16
|
+
import { AgentRunError } from "../domain/errors.ts";
|
|
19
17
|
import type { TurnRequest } from "../domain/ports.ts";
|
|
18
|
+
import { HostStoppingError } from "../errors.ts";
|
|
20
19
|
import { assistantName } from "../i18n/index.ts";
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
parseModelRef,
|
|
24
|
-
type ThinkingLevel,
|
|
25
|
-
type ThinkingSetting,
|
|
26
|
-
} from "../models.ts";
|
|
20
|
+
import { promptScopeOf } from "../interactions/prompts.ts";
|
|
21
|
+
import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
|
|
27
22
|
import { withoutReplyFiles } from "../reply-files.ts";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import { addressee, type Speaker, type Tier } from "../speakers.ts";
|
|
23
|
+
import type { TransientTask } from "../sessions.ts";
|
|
24
|
+
import type { Speaker, Tier } from "../speakers.ts";
|
|
31
25
|
import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
|
|
26
|
+
import { AbortGuard } from "./abort-guard.ts";
|
|
27
|
+
import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
|
|
32
28
|
import { ConversationSessions } from "./conversation-sessions.ts";
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
confirmedTurnText,
|
|
37
|
-
} from "./extensions/confirmation-gate.ts";
|
|
38
|
-
import {
|
|
39
|
-
COMPACT_TOOL,
|
|
40
|
-
missingToolsError,
|
|
41
|
-
} from "./extensions/self-compact-guard.ts";
|
|
29
|
+
import { confirmedTurnText } from "./extensions/confirmation-gate.ts";
|
|
30
|
+
import { loadsMemory } from "./extensions/private-memory.ts";
|
|
31
|
+
import { missingToolsError } from "./extensions/self-compact-guard.ts";
|
|
42
32
|
import { interimPoster } from "./interim-text.ts";
|
|
33
|
+
import { preflightTools } from "./preflight-tools.ts";
|
|
43
34
|
import { PromptSlot, workTimeout } from "./prompt-slot.ts";
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
TRANSCRIPT_ENTRY_CHARS,
|
|
47
|
-
type TurnMessage,
|
|
48
|
-
} from "./runtime-types.ts";
|
|
35
|
+
import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
|
|
36
|
+
import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
|
|
49
37
|
import { archiveSessions } from "./session-archive.ts";
|
|
38
|
+
import {
|
|
39
|
+
memoryReader,
|
|
40
|
+
refusedSpeaker,
|
|
41
|
+
turnAddressee,
|
|
42
|
+
} from "./session-conversation.ts";
|
|
50
43
|
import { SessionFactory } from "./session-factory.ts";
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
44
|
+
import { promptImages, SteerableRun } from "./steerable-run.ts";
|
|
45
|
+
import { thinkingLevel } from "./thinking-level.ts";
|
|
46
|
+
import { runningCalls } from "./tool-call-scope.ts";
|
|
47
|
+
import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
|
|
53
48
|
import { progressReporter } from "./turn-progress.ts";
|
|
49
|
+
import { wrapUpTurn } from "./turn-wrapup.ts";
|
|
54
50
|
import { workerReport } from "./worker-task.ts";
|
|
55
51
|
|
|
56
52
|
export {
|
|
@@ -60,6 +56,8 @@ export {
|
|
|
60
56
|
sessionExtensions,
|
|
61
57
|
} from "./runtime-types.ts";
|
|
62
58
|
|
|
59
|
+
const MEMORY = new Set<string>(MEMORY_TOOLS);
|
|
60
|
+
|
|
63
61
|
/** AgentRuntime over the Pi SDK: one persistent session per channel, each with its own loader. */
|
|
64
62
|
const STOPPED = "stopped by the owner";
|
|
65
63
|
|
|
@@ -73,10 +71,11 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
73
71
|
/** The tier and speaker of each conversation's running turn. */
|
|
74
72
|
readonly #turns = new Map<
|
|
75
73
|
ChannelKey,
|
|
76
|
-
{ tier: Tier; speaker: Speaker
|
|
74
|
+
{ tier: Tier; speaker: Speaker; withheld: boolean }
|
|
77
75
|
>();
|
|
78
76
|
/** Each conversation's turn in progress, which the owner may steer or stop. */
|
|
79
77
|
readonly #running = new Map<ChannelKey, SteerableRun>();
|
|
78
|
+
readonly #memoryTurns = new Map<ChannelKey, () => void>();
|
|
80
79
|
|
|
81
80
|
/** Builds the runtime without starting a session; the preflight proves it can run. */
|
|
82
81
|
constructor(options: PiAgentRuntimeOptions) {
|
|
@@ -85,7 +84,8 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
85
84
|
this.#tiers = options.toolTiers ?? toolTiers();
|
|
86
85
|
this.#factory = new SessionFactory(options, {
|
|
87
86
|
speaker: (channel) => this.#turns.get(channel)?.speaker,
|
|
88
|
-
|
|
87
|
+
withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
|
|
88
|
+
runTask: (scope, task) => this.#runTask(scope, task),
|
|
89
89
|
});
|
|
90
90
|
this.#sessions = new ConversationSessions(
|
|
91
91
|
options,
|
|
@@ -94,50 +94,80 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/**
|
|
98
|
-
* Proves the model and every required tool resolve, in a throwaway session that runs no turn;
|
|
99
|
-
* startup stops here before anything connects.
|
|
100
|
-
*/
|
|
97
|
+
/** Resolves the model and required tools in throwaway shared and private sessions, without a turn. */
|
|
101
98
|
async preflight(): Promise<void> {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
join(this.#factory.workDir(), "probe-attachments"),
|
|
108
|
-
undefined,
|
|
109
|
-
"owner",
|
|
110
|
-
);
|
|
111
|
-
const registered = new Set(
|
|
112
|
-
probe.session.getAllTools().map((tool) => tool.name),
|
|
99
|
+
const required = this.#factory.requiredTools();
|
|
100
|
+
const registered = await preflightTools(
|
|
101
|
+
this.#factory,
|
|
102
|
+
this.#options.owner,
|
|
103
|
+
required,
|
|
113
104
|
);
|
|
114
|
-
|
|
115
|
-
const expected = [
|
|
116
|
-
...this.#factory.link().requiredTools,
|
|
117
|
-
COMPACT_TOOL,
|
|
118
|
-
ASK_USER_TOOL,
|
|
119
|
-
...planOrder(this.#factory.plan).flatMap(
|
|
120
|
-
(tool) => tool.snapshot().requiredTools ?? [],
|
|
121
|
-
),
|
|
122
|
-
];
|
|
123
|
-
const missing = expected.filter((name) => !registered.has(name));
|
|
105
|
+
const missing = required.filter((name) => !registered.has(name));
|
|
124
106
|
if (missing.length > 0) throw missingToolsError(missing);
|
|
125
107
|
}
|
|
126
108
|
|
|
127
109
|
// pi-lens-ignore: high-complexity, high-fan-out, mixed-async-styles — one turn's lifecycle end to end; batch 4 splits it with the channel router
|
|
128
110
|
async runTurn(request: TurnRequest): Promise<TurnResult> {
|
|
129
111
|
const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
|
|
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
|
+
};
|
|
130
119
|
const key = request.agent?.session ?? request.channel;
|
|
131
|
-
|
|
120
|
+
// Resolve current scope; refuse a foreign private speaker before touching its session.
|
|
121
|
+
const conversation = await this.#sessions.conversation(key, request);
|
|
122
|
+
const refused = refusedSpeaker(conversation, request.speaker);
|
|
123
|
+
if (refused) return { ok: false, error: new AgentRunError(refused) };
|
|
124
|
+
const scoped = { ...request, conversation };
|
|
125
|
+
const channelSession = await this.#sessions.freshSession(key, scoped);
|
|
126
|
+
const who = turnAddressee(
|
|
127
|
+
request.speaker,
|
|
128
|
+
channelSession,
|
|
129
|
+
this.#options.owner,
|
|
130
|
+
);
|
|
132
131
|
const { session } = channelSession;
|
|
133
|
-
const
|
|
132
|
+
const agentModel = chosenAgentModel(request.agent?.name, this.#options);
|
|
133
|
+
const bridgeRefused =
|
|
134
|
+
conversation.visibility === "shared"
|
|
135
|
+
? await bridgeTurnRefusal(
|
|
136
|
+
request.agent?.name,
|
|
137
|
+
this.#options,
|
|
138
|
+
{
|
|
139
|
+
messages: session.messages,
|
|
140
|
+
sessionManager: session.sessionManager,
|
|
141
|
+
reader: request.speaker.principalId,
|
|
142
|
+
},
|
|
143
|
+
agentModel,
|
|
144
|
+
)
|
|
145
|
+
: undefined;
|
|
146
|
+
if (bridgeRefused)
|
|
147
|
+
return { ok: false, error: new AgentRunError(bridgeRefused) };
|
|
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
|
+
);
|
|
134
159
|
if (session.thinkingLevel !== level) session.setThinkingLevel(level);
|
|
135
160
|
const gate = await this.#sessions.gate(key, request.agent !== undefined);
|
|
136
161
|
const pending = gate.pending();
|
|
137
162
|
const registered = new Set(session.getAllTools().map((tool) => tool.name));
|
|
138
|
-
// The speaker's tier limits the tools
|
|
139
|
-
const tier = request.speaker
|
|
163
|
+
// The speaker's tier limits the tools.
|
|
164
|
+
const { tier } = request.speaker;
|
|
140
165
|
// An approving turn also has the held calls' tools, which a dispatched worker may have used.
|
|
166
|
+
// The agent server may keep a speaker below the owner tier out of memory: no tools, no block.
|
|
167
|
+
const withheld =
|
|
168
|
+
request.agent !== undefined &&
|
|
169
|
+
this.#options.agents?.memory === "owners" &&
|
|
170
|
+
tier !== "owner";
|
|
141
171
|
const wanted = toolsForTier(
|
|
142
172
|
[
|
|
143
173
|
...new Set([
|
|
@@ -149,8 +179,17 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
149
179
|
],
|
|
150
180
|
tier,
|
|
151
181
|
this.#tiers,
|
|
182
|
+
).filter((name) => !withheld || !MEMORY.has(name));
|
|
183
|
+
const markPrivateMemory = recordMemoryTurn(
|
|
184
|
+
session.sessionManager,
|
|
185
|
+
request.speaker.principalId,
|
|
186
|
+
!withheld &&
|
|
187
|
+
channelSession.memory !== "none" &&
|
|
188
|
+
memoryReader(conversation, request.speaker) !== undefined &&
|
|
189
|
+
loadsMemory(registered),
|
|
152
190
|
);
|
|
153
|
-
this.#
|
|
191
|
+
this.#memoryTurns.set(key, markPrivateMemory);
|
|
192
|
+
this.#turns.set(key, { tier, speaker: request.speaker, withheld });
|
|
154
193
|
const missing = wanted.filter((name) => !registered.has(name));
|
|
155
194
|
if (missing.length > 0) {
|
|
156
195
|
logger.warn(
|
|
@@ -167,8 +206,8 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
167
206
|
gate.beginTurn(
|
|
168
207
|
request.selection.id,
|
|
169
208
|
request.confirmed === true,
|
|
170
|
-
|
|
171
|
-
request.speaker
|
|
209
|
+
who,
|
|
210
|
+
request.speaker,
|
|
172
211
|
);
|
|
173
212
|
if (request.confirmed && pending) {
|
|
174
213
|
logger.info(
|
|
@@ -196,6 +235,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
196
235
|
interim?.toolStart(event.toolName);
|
|
197
236
|
}
|
|
198
237
|
if (event.type === "message_end") {
|
|
238
|
+
if (carriesMemory(event.message)) markPrivateMemory();
|
|
199
239
|
turnMessages.push(event.message);
|
|
200
240
|
interim?.messageEnd(event.message);
|
|
201
241
|
}
|
|
@@ -203,22 +243,34 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
203
243
|
const slot = this.#sessions.slot(key);
|
|
204
244
|
slot.bind(
|
|
205
245
|
request.interactive
|
|
206
|
-
? this.#options.prompts?.(request.channel,
|
|
246
|
+
? this.#options.prompts?.(request.channel, promptScopeOf(scoped))
|
|
207
247
|
: undefined,
|
|
208
248
|
request.agent?.name ?? assistantName(),
|
|
209
249
|
interim ? () => interim.flush() : undefined,
|
|
210
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
|
+
});
|
|
211
262
|
// Time spent waiting on the owner's cards does not count towards the timeout.
|
|
212
263
|
const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
|
|
213
264
|
logger.warn(
|
|
214
265
|
{ channel: request.channel, turnTimeoutMs },
|
|
215
266
|
"turn timed out; aborting",
|
|
216
267
|
);
|
|
217
|
-
|
|
268
|
+
guard.abort();
|
|
218
269
|
});
|
|
219
270
|
const running = new SteerableRun(
|
|
220
271
|
session,
|
|
221
272
|
() => request.steerable === true && !gate.pending(),
|
|
273
|
+
() => guard.abort(),
|
|
222
274
|
);
|
|
223
275
|
this.#running.set(key, running);
|
|
224
276
|
|
|
@@ -226,13 +278,9 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
226
278
|
const attachments = request.attachments ?? NO_ATTACHMENTS;
|
|
227
279
|
const text =
|
|
228
280
|
request.confirmed && pending
|
|
229
|
-
? confirmedTurnText(
|
|
230
|
-
pending,
|
|
231
|
-
request.text,
|
|
232
|
-
addressee(request.speaker, this.#options.owner),
|
|
233
|
-
)
|
|
281
|
+
? confirmedTurnText(pending, request.text, who)
|
|
234
282
|
: request.text;
|
|
235
|
-
|
|
283
|
+
const prompted = running.run(() =>
|
|
236
284
|
session.prompt(
|
|
237
285
|
withAttachmentsBlock(text, attachments),
|
|
238
286
|
attachments.images.length > 0
|
|
@@ -240,6 +288,9 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
240
288
|
: undefined,
|
|
241
289
|
),
|
|
242
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]);
|
|
243
294
|
} catch (error) {
|
|
244
295
|
if (running.stopped)
|
|
245
296
|
return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
|
|
@@ -250,43 +301,22 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
250
301
|
} finally {
|
|
251
302
|
this.#running.delete(key);
|
|
252
303
|
this.#turns.delete(key);
|
|
304
|
+
this.#memoryTurns.delete(key);
|
|
253
305
|
cancelTimeout();
|
|
306
|
+
guard.release();
|
|
254
307
|
slot.unbind();
|
|
255
308
|
unsubscribe();
|
|
256
309
|
progress?.close();
|
|
257
310
|
await interim?.flush();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
.save(key, held)
|
|
268
|
-
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
269
|
-
.catch((error: unknown) =>
|
|
270
|
-
logger.error({ channel: key, err: error }, "held actions not saved"),
|
|
271
|
-
);
|
|
272
|
-
if (held)
|
|
273
|
-
logger.info(
|
|
274
|
-
{ channel: key, held: held.calls.map((call) => call.action) },
|
|
275
|
-
"actions held for confirmation",
|
|
276
|
-
);
|
|
277
|
-
logger.info(
|
|
278
|
-
{
|
|
279
|
-
channel: key,
|
|
280
|
-
selection: request.selection.id,
|
|
281
|
-
toolCalls,
|
|
282
|
-
thinking: session.thinkingLevel,
|
|
283
|
-
// pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
|
|
284
|
-
...(request.agent
|
|
285
|
-
? { model: `${session.model?.provider}/${session.model?.id}` }
|
|
286
|
-
: {}),
|
|
287
|
-
},
|
|
288
|
-
"turn finished",
|
|
289
|
-
);
|
|
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
|
+
});
|
|
290
320
|
}
|
|
291
321
|
|
|
292
322
|
if (running.stopped)
|
|
@@ -331,53 +361,73 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
331
361
|
return stopped;
|
|
332
362
|
}
|
|
333
363
|
|
|
334
|
-
/**
|
|
335
|
-
* Runs a task in a fresh, unsaved session with the selected tools and the channel's
|
|
336
|
-
* confirmation gate, and returns its final text.
|
|
337
|
-
*/
|
|
364
|
+
/** Runs a task in an unsaved session with the selected tools and conversation's gate. */
|
|
338
365
|
// pi-lens-ignore: high-fan-out — builds, runs, and disposes one transient session; batch 4 turns it into the core's child-run API
|
|
339
|
-
async #runTask(
|
|
366
|
+
async #runTask(
|
|
367
|
+
scope: { turn: ChannelKey; home: ChannelKey },
|
|
368
|
+
task: TransientTask,
|
|
369
|
+
): Promise<string> {
|
|
340
370
|
const { dataDir } = this.#options;
|
|
341
371
|
const { signal } = task;
|
|
342
|
-
|
|
372
|
+
if (this.#options.stopping?.())
|
|
373
|
+
throw new AgentRunError(new HostStoppingError().message);
|
|
374
|
+
// A task works at the tier of the turn that started it; without one there is no tier to take.
|
|
375
|
+
const turn = this.#turns.get(scope.turn);
|
|
376
|
+
if (!turn)
|
|
377
|
+
throw new AgentRunError(
|
|
378
|
+
`a task runs beside a turn of its conversation, at that turn's tier, and ${scope.turn} has no turn running`,
|
|
379
|
+
);
|
|
380
|
+
// A worker inherits the starting turn's conversation and memory policy, without asking.
|
|
381
|
+
const parent = await this.#sessions.session(scope.turn);
|
|
382
|
+
const { conversation, memory } = parent;
|
|
343
383
|
const worker = await this.#factory.create(
|
|
344
|
-
|
|
384
|
+
scope.home,
|
|
345
385
|
SessionManager.inMemory(this.#factory.workDir()),
|
|
346
|
-
await this.#sessions.gate(
|
|
386
|
+
await this.#sessions.gate(scope.home, false),
|
|
347
387
|
new PromptSlot(),
|
|
348
|
-
ownerAttachmentDir(dataDir,
|
|
388
|
+
ownerAttachmentDir(dataDir, scope.home),
|
|
349
389
|
undefined,
|
|
350
390
|
"owner",
|
|
391
|
+
conversation,
|
|
392
|
+
{ turn: scope.turn, memory },
|
|
351
393
|
);
|
|
352
394
|
const { session } = worker;
|
|
353
395
|
const toolCalls: string[] = [];
|
|
354
396
|
const unsubscribe = session.subscribe((event) => {
|
|
355
397
|
if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
|
|
356
398
|
});
|
|
357
|
-
|
|
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();
|
|
358
404
|
signal?.addEventListener("abort", abort, { once: true });
|
|
359
405
|
const timer = setTimeout(abort, task.timeoutMs);
|
|
360
406
|
try {
|
|
361
407
|
const registered = new Set(session.getAllTools().map((t) => t.name));
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
|
|
408
|
+
// A worker that loaded the reader's memory may report it: the call that started it, and the
|
|
409
|
+
// calls that one runs within, then record their results as the reader's.
|
|
410
|
+
if (memoryReader(conversation, turn.speaker) && loadsMemory(registered)) {
|
|
411
|
+
parent.draws.drawn(runningCalls());
|
|
412
|
+
this.#memoryTurns.get(scope.turn)?.();
|
|
413
|
+
}
|
|
414
|
+
const { tier } = turn;
|
|
365
415
|
worker.tools = this.#factory
|
|
366
|
-
.
|
|
416
|
+
.taskTools(task.selection, task.exclude)
|
|
367
417
|
.filter(
|
|
368
|
-
(name) =>
|
|
369
|
-
!excluded.has(name) &&
|
|
370
|
-
registered.has(name) &&
|
|
371
|
-
this.#tiers.allows(tier, name),
|
|
418
|
+
(name) => registered.has(name) && this.#tiers.allows(tier, name),
|
|
372
419
|
);
|
|
373
420
|
session.setActiveToolsByName([...worker.tools]);
|
|
374
|
-
|
|
421
|
+
const prompted = withoutReplyFiles(() => session.prompt(task.text));
|
|
422
|
+
prompted.catch(() => undefined);
|
|
423
|
+
await Promise.race([prompted, guard.gaveUp]);
|
|
375
424
|
return workerReport(
|
|
376
425
|
session.messages,
|
|
377
426
|
"the worker stopped before it reported",
|
|
378
427
|
);
|
|
379
428
|
} finally {
|
|
380
429
|
clearTimeout(timer);
|
|
430
|
+
guard.release();
|
|
381
431
|
signal?.removeEventListener("abort", abort);
|
|
382
432
|
unsubscribe();
|
|
383
433
|
session.dispose();
|
|
@@ -416,84 +466,18 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
416
466
|
async heldActions(
|
|
417
467
|
session: ChannelKey,
|
|
418
468
|
): Promise<PendingConfirmation | undefined> {
|
|
419
|
-
|
|
420
|
-
return gate.pending();
|
|
469
|
+
return this.#sessions.held(session);
|
|
421
470
|
}
|
|
422
471
|
|
|
423
472
|
async recentTranscript(
|
|
424
473
|
channel: ChannelKey,
|
|
425
474
|
limit: number,
|
|
426
475
|
): Promise<TranscriptEntry[]> {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
for (const message of session.messages) {
|
|
430
|
-
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
431
|
-
const text = textOf(message.content).trim();
|
|
432
|
-
if (text)
|
|
433
|
-
entries.push({
|
|
434
|
-
role: message.role,
|
|
435
|
-
text: text.slice(0, TRANSCRIPT_ENTRY_CHARS),
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
return entries.slice(-limit);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* The turn's thinking level: an agent's fixed setting, or the judge's pick, which is kept for the
|
|
443
|
-
* next turn while the judge is unsure. An agent session also switches to the agent's model here.
|
|
444
|
-
*/
|
|
445
|
-
async #thinkingLevel(
|
|
446
|
-
key: ChannelKey,
|
|
447
|
-
session: AgentSession,
|
|
448
|
-
request: TurnRequest,
|
|
449
|
-
): Promise<ThinkingLevel> {
|
|
450
|
-
const setting = request.agent
|
|
451
|
-
? await this.#useAgentModel(session, request.agent.name)
|
|
452
|
-
: AUTO_THINKING;
|
|
453
|
-
if (setting !== AUTO_THINKING) {
|
|
454
|
-
this.#sessions.judged.delete(key);
|
|
455
|
-
return setting;
|
|
456
|
-
}
|
|
457
|
-
const level = await this.#options.effort.judge(request.text, {
|
|
458
|
-
reply: lastReply(session.messages),
|
|
459
|
-
level: this.#sessions.judged.get(key),
|
|
460
|
-
});
|
|
461
|
-
this.#sessions.judged.set(key, level);
|
|
462
|
-
return level;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** Switches an agent session to the agent's current model; returns its thinking setting. */
|
|
466
|
-
async #useAgentModel(
|
|
467
|
-
session: AgentSession,
|
|
468
|
-
name: string,
|
|
469
|
-
): Promise<ThinkingSetting> {
|
|
470
|
-
const agents = this.#options.agents;
|
|
471
|
-
if (!agents)
|
|
472
|
-
throw new ConfigError("agent turns need the runtime's agents option");
|
|
473
|
-
const { model, thinking } = agents.modelOf(name);
|
|
474
|
-
const ref = parseModelRef(model);
|
|
475
|
-
if (
|
|
476
|
-
session.model?.provider !== ref?.provider ||
|
|
477
|
-
session.model?.id !== ref?.id
|
|
478
|
-
) {
|
|
479
|
-
const resolved = ref && this.#modelRuntime.getModel(ref.provider, ref.id);
|
|
480
|
-
if (!resolved)
|
|
481
|
-
throw new AgentRunError(
|
|
482
|
-
`${name}'s model ${model} is not available on this host`,
|
|
483
|
-
);
|
|
484
|
-
await session.setModel(resolved);
|
|
485
|
-
}
|
|
486
|
-
return thinking;
|
|
476
|
+
await this.#sessions.held(channel);
|
|
477
|
+
return transcriptOf(await this.#sessions.messages(channel), limit);
|
|
487
478
|
}
|
|
488
479
|
|
|
489
480
|
dispose(): void {
|
|
490
481
|
this.#sessions.dispose();
|
|
491
482
|
}
|
|
492
483
|
}
|
|
493
|
-
|
|
494
|
-
function promptImages(attachments: TurnAttachments): PromptImages {
|
|
495
|
-
return attachments.images.map((image) => ({
|
|
496
|
-
type: "image" as const,
|
|
497
|
-
...image,
|
|
498
|
-
}));
|
|
499
|
-
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ConfirmationGate } from "./extensions/confirmation-gate.ts";
|
|
4
|
+
import { PromptSlot } from "./prompt-slot.ts";
|
|
5
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
6
|
+
import type { SessionFactory } from "./session-factory.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tools available in either startup scope, without running a turn or retaining a session.
|
|
10
|
+
* The private probe is built only when the shared one lacks a required tool, so a host
|
|
11
|
+
* without scoped tools starts exactly as before.
|
|
12
|
+
*/
|
|
13
|
+
export async function preflightTools(
|
|
14
|
+
factory: SessionFactory,
|
|
15
|
+
owner: PiAgentRuntimeOptions["owner"],
|
|
16
|
+
required: readonly string[],
|
|
17
|
+
): Promise<Set<string>> {
|
|
18
|
+
const registered = new Set<string>();
|
|
19
|
+
for (const conversation of [
|
|
20
|
+
{ visibility: "shared" },
|
|
21
|
+
{ visibility: "private", principalId: owner.id },
|
|
22
|
+
] as const) {
|
|
23
|
+
if (
|
|
24
|
+
conversation.visibility === "private" &&
|
|
25
|
+
required.every((name) => registered.has(name))
|
|
26
|
+
)
|
|
27
|
+
break;
|
|
28
|
+
const probe = await factory.create(
|
|
29
|
+
conversation.visibility === "shared"
|
|
30
|
+
? "probe:startup"
|
|
31
|
+
: "probe:startup-private",
|
|
32
|
+
SessionManager.inMemory(factory.workDir()),
|
|
33
|
+
new ConfirmationGate(factory.link().holds, owner),
|
|
34
|
+
new PromptSlot(),
|
|
35
|
+
join(factory.workDir(), "probe-attachments"),
|
|
36
|
+
undefined,
|
|
37
|
+
"owner",
|
|
38
|
+
conversation,
|
|
39
|
+
);
|
|
40
|
+
try {
|
|
41
|
+
for (const tool of probe.session.getAllTools()) registered.add(tool.name);
|
|
42
|
+
} finally {
|
|
43
|
+
probe.session.dispose();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return registered;
|
|
47
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { OwnerPrompts, OwnerQuestion } from "../domain/owner-prompts.ts";
|
|
2
1
|
import { assistantName } from "../i18n/index.ts";
|
|
2
|
+
import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A run's way to ask the owner while it works. A turn they started or a report turn binds the
|
|
@@ -9,7 +9,7 @@ import { assistantName } from "../i18n/index.ts";
|
|
|
9
9
|
* leaves out.
|
|
10
10
|
*/
|
|
11
11
|
export class PromptSlot {
|
|
12
|
-
#prompts:
|
|
12
|
+
#prompts: Prompts | undefined;
|
|
13
13
|
#beforeCard: (() => Promise<void>) | undefined;
|
|
14
14
|
#asker = assistantName();
|
|
15
15
|
#open = 0;
|
|
@@ -21,7 +21,7 @@ export class PromptSlot {
|
|
|
21
21
|
* runs before each card opens, such as posting the text the turn wrote before it.
|
|
22
22
|
*/
|
|
23
23
|
bind(
|
|
24
|
-
prompts:
|
|
24
|
+
prompts: Prompts | undefined,
|
|
25
25
|
asker: string,
|
|
26
26
|
beforeCard?: () => Promise<void>,
|
|
27
27
|
): void {
|
|
@@ -42,7 +42,7 @@ export class PromptSlot {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** The turn's prompts, each open card counted as waiting; undefined when none is bound. */
|
|
45
|
-
get prompts():
|
|
45
|
+
get prompts(): Prompts | undefined {
|
|
46
46
|
const prompts = this.#prompts;
|
|
47
47
|
if (!prompts) return undefined;
|
|
48
48
|
return {
|