pi-roundtable 0.16.0 → 0.17.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 +24 -0
- package/README.md +5 -0
- package/docs/plugins.md +69 -0
- package/package.json +1 -1
- package/src/cli/checks/configuration.ts +21 -1
- package/src/cli/project.ts +59 -8
- package/src/cli/runtime.ts +3 -0
- package/src/core/agents/agent-store.ts +4 -1
- package/src/core/builtin/discord.ts +5 -0
- package/src/core/config/adapters.ts +12 -0
- package/src/core/contract/runtime.ts +7 -0
- package/src/core/contract/surface.ts +7 -0
- package/src/core/db/check-database.ts +39 -0
- package/src/core/define-roundtable.ts +3 -0
- package/src/core/discord/discord-surface.ts +5 -0
- package/src/core/discord/formatting.ts +20 -0
- package/src/core/host-registration.ts +292 -0
- package/src/core/host.ts +45 -227
- package/src/core/identity/config-owner.ts +34 -0
- package/src/core/identity/identity-plugin.ts +7 -2
- package/src/core/identity/identity-service.ts +8 -1
- package/src/core/identity/plugin-identities.ts +19 -4
- package/src/core/modules/skills/skill-store.ts +3 -1
- package/src/core/runtime/extensions/surface-formatting.ts +22 -0
- package/src/core/runtime/pi-agent-runtime.ts +22 -21
- package/src/core/runtime/running-turn.ts +19 -0
- package/src/core/runtime/runtime-plugin.ts +4 -0
- package/src/core/runtime/runtime-types.ts +23 -2
- package/src/core/runtime/session-factory.ts +7 -0
- package/src/core/runtime/turn-budget.ts +112 -0
- package/src/core/testing/database.ts +20 -2
- package/src/core/testing/postgres.ts +160 -0
- package/src/core/testing/run-tool.ts +110 -0
- package/src/core/testing/test-host.ts +78 -30
- package/src/index.ts +1 -0
- package/src/testing.ts +25 -56
- package/templates/official/codex-images/plugin.test.ts.tmpl +29 -1
- package/templates/official/codex-images/plugin.ts +19 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isCheckDatabase } from "../db/check-database.ts";
|
|
1
2
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
2
3
|
import { IDENTITY, IDENTITY_LINKS } from "../services.ts";
|
|
3
4
|
import type { AccessRules } from "./access-policy.ts";
|
|
@@ -45,12 +46,16 @@ export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
|
|
|
45
46
|
provides: rules.selfLink ? [IDENTITY, IDENTITY_LINKS] : [IDENTITY],
|
|
46
47
|
setup: async ({ database, services, logger }) => {
|
|
47
48
|
if (summary) logger.info(backfillLine(summary));
|
|
49
|
+
const sql = database();
|
|
48
50
|
const identity = new PgIdentityService(
|
|
49
|
-
await PgPrincipalStore.attach(
|
|
51
|
+
await PgPrincipalStore.attach(sql),
|
|
50
52
|
rules,
|
|
51
53
|
{ logger, identities },
|
|
52
54
|
);
|
|
53
|
-
|
|
55
|
+
// `Roundtable.check()` has no database to write the configured principals to: it asks
|
|
56
|
+
// only what the configuration and the declared identities settle without one.
|
|
57
|
+
if (isCheckDatabase(sql)) identity.checkConfig();
|
|
58
|
+
else await identity.syncConfig();
|
|
54
59
|
services.provide(IDENTITY, identityView(identity));
|
|
55
60
|
if (rules.selfLink)
|
|
56
61
|
services.provide(
|
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
surfaceOf,
|
|
16
16
|
} from "./actor-facts.ts";
|
|
17
17
|
import { CachingPrincipalStore } from "./caching-principal-store.ts";
|
|
18
|
-
import { configuredOwnerId } from "./config-owner.ts";
|
|
18
|
+
import { configuredOwnerId, refuseOwnerDeclarations } from "./config-owner.ts";
|
|
19
19
|
import type { Contact, ContactAssessor } from "./contact.ts";
|
|
20
20
|
import {
|
|
21
21
|
type DeclaredIdentity,
|
|
22
|
+
refuseUnboundIdentities,
|
|
22
23
|
syncDeclaredIdentities,
|
|
23
24
|
} from "./plugin-identities.ts";
|
|
24
25
|
import { highest, lowest, publicOf, speakerOf } from "./principal-reads.ts";
|
|
@@ -141,6 +142,12 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
|
|
|
141
142
|
this.#logger = options.logger;
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** The refusals `syncConfig` makes from the configuration and the declarations alone, for `Roundtable.check()`. */
|
|
146
|
+
checkConfig(): void {
|
|
147
|
+
refuseUnboundIdentities(this.#declared, this.#rules.owners.length > 0);
|
|
148
|
+
refuseOwnerDeclarations(this.#rules.owners, this.#declared);
|
|
149
|
+
}
|
|
150
|
+
|
|
144
151
|
/**
|
|
145
152
|
* Makes the stored owners match the configuration: each configured owner's principal made or
|
|
146
153
|
* renamed, their identities linked, and the owner role granted, all as the configuration's;
|
|
@@ -57,6 +57,21 @@ export function declaredIdentities(
|
|
|
57
57
|
return declared;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The refusal `syncDeclaredIdentities` makes that needs no stored principal: an identity that names
|
|
62
|
+
* no principal when the configuration lists no primary owner to bind it to.
|
|
63
|
+
*/
|
|
64
|
+
export function refuseUnboundIdentities(
|
|
65
|
+
identities: readonly DeclaredIdentity[],
|
|
66
|
+
hasPrimaryOwner: boolean,
|
|
67
|
+
): void {
|
|
68
|
+
for (const { plugin, identity, principal } of identities)
|
|
69
|
+
if (principal === undefined && !hasPrimaryOwner)
|
|
70
|
+
throw new ConfigError(
|
|
71
|
+
`plugin ${plugin}: ${identity} names no principal, and access.owners lists no primary owner to bind it to. Give the plugin the principal it stands for.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
/**
|
|
61
76
|
* Links each identity a plugin declares to its principal, `primaryOwner` by default, as the
|
|
62
77
|
* plugin's: one the plugins bound to another principal at an earlier boot moves, one the
|
|
@@ -76,10 +91,10 @@ export async function syncDeclaredIdentities(
|
|
|
76
91
|
const ref = parseIdentity(identity);
|
|
77
92
|
if (!ref) throw new ConfigError(`plugin ${plugin}: ${identity}`);
|
|
78
93
|
const id = principal ?? primaryOwner;
|
|
79
|
-
if (id === undefined)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
if (id === undefined) {
|
|
95
|
+
refuseUnboundIdentities([{ plugin, identity, principal }], false);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
83
98
|
if (id === SYSTEM_PRINCIPAL || !(await store.get(id)))
|
|
84
99
|
throw new ConfigError(
|
|
85
100
|
`plugin ${plugin}: ${identity} is bound to principal ${id}, and there is no principal ${id}. roundtable principal list shows the principals, and roundtable principal create makes one.`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
+
import { isCheckDatabase } from "../../db/check-database.ts";
|
|
2
3
|
import { scopeToGuild } from "../../db/guild-scope.ts";
|
|
3
4
|
import type { Migration } from "../../db/migrations.ts";
|
|
4
5
|
import { AgentError } from "../../domain/errors.ts";
|
|
@@ -128,7 +129,8 @@ export class SkillStore {
|
|
|
128
129
|
/** The store over the host's migrated pool; the skills agents carry are those of one guild. */
|
|
129
130
|
static async attach(sql: SQL, guildId: string): Promise<SkillStore> {
|
|
130
131
|
const store = new SkillStore(sql, guildId);
|
|
131
|
-
|
|
132
|
+
// `Roundtable.check()` has no database to read the rows from: the store starts empty.
|
|
133
|
+
if (!isCheckDatabase(sql)) await store.#reload();
|
|
132
134
|
return store;
|
|
133
135
|
}
|
|
134
136
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Appends the formatting guidance of the surface the running turn replies on to the system
|
|
5
|
+
* prompt's appended part before each run, after the persona and what earlier handlers appended.
|
|
6
|
+
* Nothing is added when `guidance` returns undefined or only blank text: a turn whose channel has
|
|
7
|
+
* no surface, a surface without guidance, or no turn running.
|
|
8
|
+
*/
|
|
9
|
+
export function surfaceFormattingExtension(
|
|
10
|
+
guidance: () => string | undefined,
|
|
11
|
+
): ExtensionFactory {
|
|
12
|
+
return (pi) => {
|
|
13
|
+
pi.on("before_agent_start", (event) => {
|
|
14
|
+
const text = guidance();
|
|
15
|
+
if (text === undefined || text.trim() === "") return;
|
|
16
|
+
const appended = event.systemPromptOptions.appendSystemPrompt;
|
|
17
|
+
event.systemPromptOptions.appendSystemPrompt = appended
|
|
18
|
+
? `${appended}\n\n${text}`
|
|
19
|
+
: text;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -33,9 +33,9 @@ import { loadsMemory } from "./extensions/private-memory.ts";
|
|
|
33
33
|
import { missingToolsError } from "./extensions/self-compact-guard.ts";
|
|
34
34
|
import { interimPoster } from "./interim-text.ts";
|
|
35
35
|
import { preflightTools } from "./preflight-tools.ts";
|
|
36
|
-
import { PromptSlot
|
|
36
|
+
import { PromptSlot } from "./prompt-slot.ts";
|
|
37
37
|
import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
|
|
38
|
-
import type
|
|
38
|
+
import { type RunningTurn, runningTurn } from "./running-turn.ts";
|
|
39
39
|
import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
|
|
40
40
|
import { archiveSessions } from "./session-archive.ts";
|
|
41
41
|
import {
|
|
@@ -48,6 +48,7 @@ import { promptImages, SteerableRun } from "./steerable-run.ts";
|
|
|
48
48
|
import { thinkingLevel } from "./thinking-level.ts";
|
|
49
49
|
import { runningCalls } from "./tool-call-scope.ts";
|
|
50
50
|
import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
|
|
51
|
+
import { runtimeTurnBudget, type TurnBudget } from "./turn-budget.ts";
|
|
51
52
|
import { progressReporter } from "./turn-progress.ts";
|
|
52
53
|
import { wrapUpTurn } from "./turn-wrapup.ts";
|
|
53
54
|
import { workerReport } from "./worker-task.ts";
|
|
@@ -75,6 +76,8 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
75
76
|
readonly #turns = new Map<ChannelKey, RunningTurn>();
|
|
76
77
|
/** Each conversation's turn in progress, which the owner may steer or stop. */
|
|
77
78
|
readonly #running = new Map<ChannelKey, SteerableRun>();
|
|
79
|
+
/** Each running turn's time limit, which a steered message renews. */
|
|
80
|
+
readonly #budgets = new Map<ChannelKey, TurnBudget>();
|
|
78
81
|
readonly #memoryTurns = new Map<ChannelKey, () => void>();
|
|
79
82
|
|
|
80
83
|
/** Builds the runtime without starting a session; the preflight proves it can run. */
|
|
@@ -86,6 +89,10 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
86
89
|
speaker: (channel) => this.#turns.get(channel)?.speaker,
|
|
87
90
|
origin: (channel) => this.#turns.get(channel)?.origin,
|
|
88
91
|
client: (channel) => this.#turns.get(channel)?.client,
|
|
92
|
+
formatting: (channel) => {
|
|
93
|
+
const reply = this.#turns.get(channel)?.channel;
|
|
94
|
+
return reply === undefined ? undefined : options.formatting?.(reply);
|
|
95
|
+
},
|
|
89
96
|
withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
|
|
90
97
|
runTask: (scope, task) => this.#runTask(scope, task),
|
|
91
98
|
});
|
|
@@ -110,7 +117,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
110
117
|
|
|
111
118
|
// 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
|
|
112
119
|
async runTurn(request: TurnRequest): Promise<TurnResult> {
|
|
113
|
-
const { logger
|
|
120
|
+
const { logger } = this.#options;
|
|
114
121
|
if (!request.speaker) return unspokenTurn();
|
|
115
122
|
// A turn that is only asked for now, such as a group's next member, starts nothing.
|
|
116
123
|
if (this.#options.stopping?.())
|
|
@@ -195,13 +202,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
195
202
|
loadsMemory(registered),
|
|
196
203
|
);
|
|
197
204
|
this.#memoryTurns.set(key, markPrivateMemory);
|
|
198
|
-
this.#turns.set(key,
|
|
199
|
-
tier,
|
|
200
|
-
speaker: request.speaker,
|
|
201
|
-
...(request.origin === undefined ? {} : { origin: request.origin }),
|
|
202
|
-
...(request.client === undefined ? {} : { client: request.client }),
|
|
203
|
-
withheld,
|
|
204
|
-
});
|
|
205
|
+
this.#turns.set(key, runningTurn(request, tier, withheld));
|
|
205
206
|
const missing = wanted.filter((name) => !registered.has(name));
|
|
206
207
|
if (missing.length > 0) {
|
|
207
208
|
logger.warn(
|
|
@@ -274,17 +275,14 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
274
275
|
this.#sessions.drop(key, channelSession);
|
|
275
276
|
},
|
|
276
277
|
});
|
|
277
|
-
// Time spent waiting on the owner's cards does not count towards the timeout.
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
);
|
|
283
|
-
guard.abort();
|
|
284
|
-
});
|
|
278
|
+
// Time spent waiting on the owner's cards does not count towards the timeout or the cap.
|
|
279
|
+
const budget = runtimeTurnBudget(this.#options, slot, request.channel, () =>
|
|
280
|
+
guard.abort(),
|
|
281
|
+
);
|
|
282
|
+
this.#budgets.set(key, budget);
|
|
285
283
|
const running = new SteerableRun(
|
|
286
284
|
session,
|
|
287
|
-
() => request.steerable === true && !gate.pending(),
|
|
285
|
+
() => request.steerable === true && !gate.pending() && !budget.capped(),
|
|
288
286
|
() => guard.abort(),
|
|
289
287
|
);
|
|
290
288
|
this.#running.set(key, running);
|
|
@@ -315,9 +313,10 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
315
313
|
};
|
|
316
314
|
} finally {
|
|
317
315
|
this.#running.delete(key);
|
|
316
|
+
this.#budgets.delete(key);
|
|
318
317
|
this.#turns.delete(key);
|
|
319
318
|
this.#memoryTurns.delete(key);
|
|
320
|
-
|
|
319
|
+
budget.cancel();
|
|
321
320
|
guard.release();
|
|
322
321
|
slot.unbind();
|
|
323
322
|
unsubscribe();
|
|
@@ -360,8 +359,10 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
360
359
|
withAttachmentsBlock(text, attachments),
|
|
361
360
|
promptImages(attachments),
|
|
362
361
|
);
|
|
363
|
-
if (steered)
|
|
362
|
+
if (steered) {
|
|
364
363
|
this.#options.logger.info({ channel }, "message steered into the turn");
|
|
364
|
+
this.#budgets.get(channel)?.renew();
|
|
365
|
+
}
|
|
365
366
|
return steered;
|
|
366
367
|
} catch (error) {
|
|
367
368
|
this.#options.logger.warn({ channel, err: error }, "steer refused");
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { ClientPort } from "../domain/client.ts";
|
|
2
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
2
3
|
import type { Speaker, Tier } from "../speakers.ts";
|
|
3
4
|
|
|
4
5
|
/** What the runtime keeps about a conversation's running turn, for its tools to read. */
|
|
5
6
|
export interface RunningTurn {
|
|
7
|
+
/** The channel the turn replies in, as `TurnRequest.channel` says; its surface formats the reply. */
|
|
8
|
+
channel: TurnRequest["channel"];
|
|
6
9
|
tier: Tier;
|
|
7
10
|
speaker: Speaker;
|
|
8
11
|
/** Where the turn's request came from, as `TurnRequest.origin` says. */
|
|
@@ -12,3 +15,19 @@ export interface RunningTurn {
|
|
|
12
15
|
/** Whether the turn loads no memory. */
|
|
13
16
|
withheld: boolean;
|
|
14
17
|
}
|
|
18
|
+
|
|
19
|
+
/** The record of a turn that starts: what its tools read while it runs. */
|
|
20
|
+
export function runningTurn(
|
|
21
|
+
request: TurnRequest,
|
|
22
|
+
tier: Tier,
|
|
23
|
+
withheld: boolean,
|
|
24
|
+
): RunningTurn {
|
|
25
|
+
return {
|
|
26
|
+
channel: request.channel,
|
|
27
|
+
tier,
|
|
28
|
+
speaker: request.speaker,
|
|
29
|
+
...(request.origin === undefined ? {} : { origin: request.origin }),
|
|
30
|
+
...(request.client === undefined ? {} : { client: request.client }),
|
|
31
|
+
withheld,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -129,6 +129,8 @@ export function runtimePlugin(
|
|
|
129
129
|
const prompts = (channel: ChannelKey, scope?: PromptScope | Speaker) =>
|
|
130
130
|
context.surfaces.prompts(channel, scope);
|
|
131
131
|
const agents = () => options.agents?.current();
|
|
132
|
+
const formatting = (channel: ChannelKey) =>
|
|
133
|
+
context.surfaces.of(channel)?.formatting;
|
|
132
134
|
const running: AgentRuntime = providers.filled.has("runtime")
|
|
133
135
|
? providers.runtime(
|
|
134
136
|
runtimeDeps({
|
|
@@ -138,6 +140,7 @@ export function runtimePlugin(
|
|
|
138
140
|
sessions: context.sessions,
|
|
139
141
|
toolTiers: context.toolTiers,
|
|
140
142
|
prompts,
|
|
143
|
+
formatting,
|
|
141
144
|
agents,
|
|
142
145
|
confirmations: heldActions,
|
|
143
146
|
judge: providers.judge,
|
|
@@ -182,6 +185,7 @@ export function runtimePlugin(
|
|
|
182
185
|
return agents();
|
|
183
186
|
},
|
|
184
187
|
prompts,
|
|
188
|
+
formatting,
|
|
185
189
|
logger,
|
|
186
190
|
...(options.interimText
|
|
187
191
|
? { interimText: options.interimText }
|
|
@@ -79,10 +79,27 @@ export interface PiAgentRuntimeOptions {
|
|
|
79
79
|
* held action waits for a message that approves it.
|
|
80
80
|
*/
|
|
81
81
|
prompts?: (channel: ChannelKey, scope?: PromptScope) => Prompts | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* The formatting guidance of the chat surface that serves a channel, such as
|
|
84
|
+
* `ChatSurface.formatting`; undefined for a channel with no surface or no guidance. Read before
|
|
85
|
+
* each run for the channel the turn replies in, and appended to the system prompt after the
|
|
86
|
+
* persona.
|
|
87
|
+
*/
|
|
88
|
+
formatting?: (channel: ChannelKey) => string | undefined;
|
|
82
89
|
/** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
|
|
83
90
|
toolTiers?: ToolTiers;
|
|
84
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* A run that works longer, not counting time spent waiting on the owner's cards, is aborted and
|
|
93
|
+
* reported as failed; default 10 minutes. Each message steered into the turn gives it this
|
|
94
|
+
* budget again from that moment, up to `turnMaxMs`.
|
|
95
|
+
*/
|
|
85
96
|
turnTimeoutMs?: number;
|
|
97
|
+
/**
|
|
98
|
+
* The most one turn may work in all, however often it is steered, not counting time spent
|
|
99
|
+
* waiting on the owner's cards; default three times `turnTimeoutMs`. Once it is reached the turn
|
|
100
|
+
* takes no more steering (the message waits for the next turn) and is aborted as on its timeout.
|
|
101
|
+
*/
|
|
102
|
+
turnMaxMs?: number;
|
|
86
103
|
/**
|
|
87
104
|
* How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
|
|
88
105
|
* disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
|
|
@@ -124,13 +141,16 @@ export interface CoreExtensions {
|
|
|
124
141
|
askUser: ExtensionFactory;
|
|
125
142
|
selfCompactGuard: ExtensionFactory;
|
|
126
143
|
privateMemory: ExtensionFactory;
|
|
144
|
+
/** The formatting guidance of the surface the turn replies on, appended after the persona. */
|
|
145
|
+
surfaceFormatting: ExtensionFactory;
|
|
127
146
|
activeTools: ExtensionFactory;
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
/**
|
|
131
150
|
* A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
|
|
132
151
|
* and compact guard, the compactor, the MCP phase, the memory projection, so each request is
|
|
133
|
-
* projected after every other extension changed it,
|
|
152
|
+
* projected after every other extension changed it, the surface's formatting guidance, so it follows
|
|
153
|
+
* the persona and everything appended before it, and active-tools last, so its
|
|
134
154
|
* before_agent_start handler runs after every other extension's.
|
|
135
155
|
*/
|
|
136
156
|
export function sessionExtensions(
|
|
@@ -160,6 +180,7 @@ export function sessionExtensions(
|
|
|
160
180
|
...contributed(plan.compaction ? [plan.compaction] : []),
|
|
161
181
|
...contributed(plan.mcp),
|
|
162
182
|
{ name: "private-memory", factory: core.privateMemory },
|
|
183
|
+
{ name: "surface-formatting", factory: core.surfaceFormatting },
|
|
163
184
|
{ name: "active-tools", factory: core.activeTools },
|
|
164
185
|
];
|
|
165
186
|
}
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
COMPACT_TOOL,
|
|
59
59
|
selfCompactGuardExtension,
|
|
60
60
|
} from "./extensions/self-compact-guard.ts";
|
|
61
|
+
import { surfaceFormattingExtension } from "./extensions/surface-formatting.ts";
|
|
61
62
|
import type { PromptSlot } from "./prompt-slot.ts";
|
|
62
63
|
import type {
|
|
63
64
|
ChannelSession,
|
|
@@ -85,6 +86,8 @@ export interface SessionFactoryDeps {
|
|
|
85
86
|
origin(channel: ChannelKey): string | undefined;
|
|
86
87
|
/** What the client that started the conversation's running turn can do for its tools. */
|
|
87
88
|
client(channel: ChannelKey): ClientPort | undefined;
|
|
89
|
+
/** The formatting guidance of the surface the conversation's running turn replies on. */
|
|
90
|
+
formatting(channel: ChannelKey): string | undefined;
|
|
88
91
|
/** Whether the conversation's running turn loads no memory, as the agent server's option keeps a speaker out of it. */
|
|
89
92
|
withholdsMemory(channel: ChannelKey): boolean;
|
|
90
93
|
/**
|
|
@@ -416,6 +419,10 @@ export class SessionFactory {
|
|
|
416
419
|
: memoryReader(conversation, deps.speaker(turnKey)),
|
|
417
420
|
state.draws,
|
|
418
421
|
),
|
|
422
|
+
surfaceFormatting: surfaceFormattingExtension(() =>
|
|
423
|
+
// A worker writes a report for the turn, not a reply on its surface.
|
|
424
|
+
worker ? undefined : deps.formatting(turnKey),
|
|
425
|
+
),
|
|
419
426
|
activeTools: activeToolsExtension(() => state.tools),
|
|
420
427
|
}),
|
|
421
428
|
// An agent's prompt is set before each run by the agent-prompt extension.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
2
|
+
import type { PromptSlot } from "./prompt-slot.ts";
|
|
3
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
4
|
+
|
|
5
|
+
/** Which limit ended a turn: its budget since the start or the last steer, or its cap. */
|
|
6
|
+
export type TurnLimit = "turn" | "turnMax";
|
|
7
|
+
|
|
8
|
+
/** A turn's time limit, which a steered message renews up to a cap on the whole turn. */
|
|
9
|
+
export interface TurnBudget {
|
|
10
|
+
/**
|
|
11
|
+
* Gives the turn its full budget again from now, up to the cap; the work time left, in ms,
|
|
12
|
+
* or undefined when the cap is reached and the turn takes no more steering.
|
|
13
|
+
*/
|
|
14
|
+
renew(): number | undefined;
|
|
15
|
+
/** Whether the turn has worked up to its cap. */
|
|
16
|
+
capped(): boolean;
|
|
17
|
+
cancel(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The clock a turn budget reads; the real one by default, a fake one in tests. */
|
|
21
|
+
export interface BudgetClock {
|
|
22
|
+
now(): number;
|
|
23
|
+
schedule(run: () => void, ms: number): () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const REAL_CLOCK: BudgetClock = {
|
|
27
|
+
now: () => Date.now(),
|
|
28
|
+
schedule: (run, ms) => {
|
|
29
|
+
const timer = setTimeout(run, ms);
|
|
30
|
+
return () => clearTimeout(timer);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
|
|
36
|
+
* `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
|
|
37
|
+
* towards neither. `expire` is told which limit fired.
|
|
38
|
+
*/
|
|
39
|
+
export function turnBudget(
|
|
40
|
+
ms: number,
|
|
41
|
+
maxMs: number,
|
|
42
|
+
slot: Pick<PromptSlot, "waitedMs">,
|
|
43
|
+
expire: (limit: TurnLimit) => void,
|
|
44
|
+
clock: BudgetClock = REAL_CLOCK,
|
|
45
|
+
): TurnBudget {
|
|
46
|
+
const started = clock.now();
|
|
47
|
+
const worked = () => {
|
|
48
|
+
const now = clock.now();
|
|
49
|
+
return now - started - slot.waitedMs(now);
|
|
50
|
+
};
|
|
51
|
+
let deadline = Math.min(ms, maxMs);
|
|
52
|
+
let cancelTimer: (() => void) | undefined;
|
|
53
|
+
let expired = false;
|
|
54
|
+
const arm = (delay: number) => {
|
|
55
|
+
cancelTimer = clock.schedule(() => {
|
|
56
|
+
const left = deadline - worked();
|
|
57
|
+
if (left > 0) return arm(left);
|
|
58
|
+
expired = true;
|
|
59
|
+
expire(deadline >= maxMs ? "turnMax" : "turn");
|
|
60
|
+
}, delay);
|
|
61
|
+
};
|
|
62
|
+
arm(deadline);
|
|
63
|
+
return {
|
|
64
|
+
renew() {
|
|
65
|
+
const now = worked();
|
|
66
|
+
if (expired || now >= maxMs) return undefined;
|
|
67
|
+
deadline = Math.min(now + ms, maxMs);
|
|
68
|
+
cancelTimer?.();
|
|
69
|
+
arm(deadline - now);
|
|
70
|
+
return deadline - now;
|
|
71
|
+
},
|
|
72
|
+
capped: () => expired || worked() >= maxMs,
|
|
73
|
+
cancel: () => cancelTimer?.(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The time limit of one turn of the built-in runtime, from its options: `turnTimeoutMs` (default
|
|
79
|
+
* 10 minutes) renewed by each steer up to `turnMaxMs` (default three times that). It logs each
|
|
80
|
+
* renewal and, when a limit fires, which one before it calls `abort`.
|
|
81
|
+
*/
|
|
82
|
+
export function runtimeTurnBudget(
|
|
83
|
+
options: Pick<
|
|
84
|
+
PiAgentRuntimeOptions,
|
|
85
|
+
"turnTimeoutMs" | "turnMaxMs" | "logger"
|
|
86
|
+
>,
|
|
87
|
+
slot: Pick<PromptSlot, "waitedMs">,
|
|
88
|
+
channel: ChannelKey,
|
|
89
|
+
abort: () => void,
|
|
90
|
+
): TurnBudget {
|
|
91
|
+
const { logger, turnTimeoutMs = 10 * 60_000 } = options;
|
|
92
|
+
const turnMaxMs = options.turnMaxMs ?? 3 * turnTimeoutMs;
|
|
93
|
+
const budget = turnBudget(turnTimeoutMs, turnMaxMs, slot, (limit) => {
|
|
94
|
+
logger.warn(
|
|
95
|
+
{ channel, limit, turnTimeoutMs, turnMaxMs },
|
|
96
|
+
"turn timed out; aborting",
|
|
97
|
+
);
|
|
98
|
+
abort();
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
...budget,
|
|
102
|
+
renew() {
|
|
103
|
+
const deadlineMs = budget.renew();
|
|
104
|
+
if (deadlineMs !== undefined)
|
|
105
|
+
logger.info(
|
|
106
|
+
{ channel, deadlineMs },
|
|
107
|
+
"steer renewed the turn's time budget",
|
|
108
|
+
);
|
|
109
|
+
return deadlineMs;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -9,9 +9,27 @@ const url = process.env.ROUNDTABLE_TEST_DATABASE_URL;
|
|
|
9
9
|
/** The guild the stores of the tests serve. */
|
|
10
10
|
export const TEST_GUILD = "900000000000000001";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** The test database's URL, or "" when the run has none; `usePostgresForTests` sets it for the rest of the run. */
|
|
13
|
+
export let testDatabaseUrl = url ?? "";
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
/** `describe` when the run has a test database, `describe.skip` otherwise; `usePostgresForTests` switches it on. */
|
|
16
|
+
export let describeDb = url ? describe : describe.skip;
|
|
17
|
+
|
|
18
|
+
/** Makes `url` the run's test database, for the tests and suites that read the two exports above afterwards. */
|
|
19
|
+
export function adoptTestDatabase(url: string): void {
|
|
20
|
+
process.env.ROUNDTABLE_TEST_DATABASE_URL = url;
|
|
21
|
+
testDatabaseUrl = url;
|
|
22
|
+
describeDb = describe;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The test database's URL; throws, naming how to provide one, when the run has none. */
|
|
26
|
+
export function requireDatabase(): string {
|
|
27
|
+
if (!testDatabaseUrl)
|
|
28
|
+
throw new Error(
|
|
29
|
+
"this test needs PostgreSQL: set ROUNDTABLE_TEST_DATABASE_URL to a throwaway database, or start one for the run with usePostgresForTests() in a test preload (it needs initdb and postgres on PATH or in PG_BIN)",
|
|
30
|
+
);
|
|
31
|
+
return testDatabaseUrl;
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
/** A test pool with the migrations run, as the host runs them before any store attaches; the caller closes it. */
|
|
17
35
|
export async function migratedPool(...migrations: Migration[]): Promise<SQL> {
|