pi-roundtable 0.26.2 → 0.27.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 +53 -0
- package/README.md +23 -0
- package/README.zh-TW.md +31 -8
- package/docs/plugins.md +176 -7
- package/package.json +1 -1
- package/src/core/agents/agent-book.ts +34 -1
- package/src/core/agents/agent-claim.ts +25 -5
- package/src/core/agents/agent-dashboard.ts +23 -2
- package/src/core/agents/agent-messages.ts +28 -10
- package/src/core/agents/agent-ports.ts +10 -0
- package/src/core/agents/agent-prompt.ts +17 -1
- package/src/core/agents/agent-rules.ts +11 -1
- package/src/core/agents/agent-schema.ts +22 -0
- package/src/core/agents/agent-statements.ts +16 -2
- package/src/core/agents/agent-store.ts +30 -20
- package/src/core/agents/agent-team-fake-runtime.ts +48 -0
- package/src/core/agents/agent-team-fixture.ts +32 -44
- package/src/core/agents/agent-team.ts +69 -74
- package/src/core/agents/agent-tool-schema.ts +84 -0
- package/src/core/agents/agent-tools.ts +50 -85
- package/src/core/agents/group-turns.ts +132 -41
- package/src/core/agents/helper-create.ts +180 -0
- package/src/core/agents/helper-ops.ts +33 -0
- package/src/core/agents/helper-test-kit.ts +72 -0
- package/src/core/agents/helper-tools.ts +104 -0
- package/src/core/agents/helper-work.ts +101 -0
- package/src/core/agents/message-target.ts +15 -23
- package/src/core/agents/routes.ts +347 -0
- package/src/core/agents/team-approval.ts +32 -0
- package/src/core/agents/team-editing.ts +97 -32
- package/src/core/agents/team-helpers.ts +216 -0
- package/src/core/agents/team-lifecycle.ts +26 -1
- package/src/core/agents/team-options.ts +8 -0
- package/src/core/agents/team-prompt.ts +75 -0
- package/src/core/agents/team-selection.ts +31 -13
- package/src/core/agents/team-status.ts +6 -0
- package/src/core/agents/team-text.ts +20 -4
- package/src/core/agents/team-turn-types.ts +12 -1
- package/src/core/agents/team-turns.ts +25 -31
- package/src/core/builtin/agent-server.ts +37 -2
- package/src/core/builtin/modules.ts +4 -4
- package/src/core/config/adapters.ts +12 -0
- package/src/core/config/agent-team.ts +2 -0
- package/src/core/config/config-schema.ts +4 -0
- package/src/core/config/config.ts +48 -2
- package/src/core/config/helpers.ts +60 -0
- package/src/core/config/ops.ts +8 -0
- package/src/core/config/routing.ts +296 -0
- package/src/core/contract/channels.ts +73 -2
- package/src/core/contract/surface.ts +31 -1
- package/src/core/define-roundtable.ts +8 -1
- package/src/core/discord/agent-discord.ts +67 -6
- package/src/core/discord/discord-surface.ts +14 -16
- package/src/core/discord/message-changes.ts +66 -0
- package/src/core/discord/poster/poster-checks.ts +62 -8
- package/src/core/discord/poster/poster.ts +41 -0
- package/src/core/discord/poster/rest-poster-read.ts +130 -0
- package/src/core/discord/poster/rest-poster.ts +38 -120
- package/src/core/domain/round.ts +31 -0
- package/src/core/host-registration.ts +15 -0
- package/src/core/host.ts +6 -0
- package/src/core/i18n/conversation.ts +3 -0
- package/src/core/i18n/dashboard.ts +4 -0
- package/src/core/ops/error-reporter.ts +4 -0
- package/src/core/ops/ops-alerts.ts +102 -6
- package/src/core/plugin.ts +17 -0
- package/src/core/registry/contributions.ts +1 -26
- package/src/core/registry/surface-service.ts +41 -0
- package/src/core/routing/channel-router.ts +54 -3
- package/src/core/routing/claim-events.ts +42 -0
- package/src/core/routing/message-observers.ts +86 -0
- package/src/core/routing/peer-bots.ts +149 -0
- package/src/core/routing/surface-port.ts +4 -0
- package/src/core/services.ts +7 -6
- package/src/core/testing/fake-poster-guild.ts +1 -1
- package/src/core/testing/fake-poster-reads.ts +96 -0
- package/src/core/testing/fake-poster.ts +62 -94
- package/src/core/tool-set.snapshot.json +8 -0
- package/src/discord/index.ts +2 -0
- package/src/index.ts +18 -1
- package/src/kit/channels.ts +1 -0
- package/src/kit/index.ts +5 -0
- package/src/kit/ops-alerts.ts +4 -0
- package/src/testing.ts +18 -1
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import type { Agent, AgentGroup } from "./agent-rules.ts";
|
|
|
3
3
|
import {
|
|
4
4
|
checkAgentName,
|
|
5
5
|
checkDisplayName,
|
|
6
|
+
isHelper,
|
|
6
7
|
MAX_GROUP_MEMBERS,
|
|
7
8
|
MIN_GROUP_MEMBERS,
|
|
8
9
|
} from "./agent-rules.ts";
|
|
@@ -53,6 +54,34 @@ export abstract class AgentBook {
|
|
|
53
54
|
return undefined;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/** The temp knights `parent` made, active and archived, or only those of one `status`. */
|
|
58
|
+
helpersOf(parent: string, status?: Agent["status"]): Agent[] {
|
|
59
|
+
return this.agents().filter(
|
|
60
|
+
(agent) =>
|
|
61
|
+
agent.parent === parent &&
|
|
62
|
+
(status === undefined || agent.status === status),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether marking the agent finished (or working, with `false`) changes an active temp knight's stamp. */
|
|
67
|
+
protected stampChanges(name: string, finished: boolean): boolean {
|
|
68
|
+
const agent = this.agentMap.get(name);
|
|
69
|
+
return (
|
|
70
|
+
isHelper(agent) &&
|
|
71
|
+
agent?.status === "active" &&
|
|
72
|
+
(agent.finishedAt !== undefined) !== finished
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A temp knight's parent must be an active permanent knight: depth is one. */
|
|
77
|
+
protected checkParent(parent: string): void {
|
|
78
|
+
const agent = this.activeAgent(parent);
|
|
79
|
+
if (isHelper(agent))
|
|
80
|
+
throw new AgentError(
|
|
81
|
+
`"${parent}" is a temp knight, and a temp knight cannot make temp knights.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
groups(): AgentGroup[] {
|
|
57
86
|
return [...this.groupMap.values()];
|
|
58
87
|
}
|
|
@@ -118,7 +147,11 @@ export abstract class AgentBook {
|
|
|
118
147
|
throw new AgentError(
|
|
119
148
|
`A group has ${MIN_GROUP_MEMBERS} to ${MAX_GROUP_MEMBERS} different members.`,
|
|
120
149
|
);
|
|
121
|
-
for (const member of members)
|
|
150
|
+
for (const member of members)
|
|
151
|
+
if (isHelper(this.activeAgent(member)))
|
|
152
|
+
throw new AgentError(
|
|
153
|
+
`"${member}" is a temp knight and sits at no table; a group takes permanent knights only.`,
|
|
154
|
+
);
|
|
122
155
|
if (!members.includes(host))
|
|
123
156
|
throw new AgentError(`The host "${host}" must be one of the members.`);
|
|
124
157
|
}
|
|
@@ -12,12 +12,13 @@ import type {
|
|
|
12
12
|
InboundMessage,
|
|
13
13
|
} from "../contract/channels.ts";
|
|
14
14
|
import type { AgentRuntime } from "../contract/runtime.ts";
|
|
15
|
-
import type
|
|
15
|
+
import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
|
|
16
16
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
17
17
|
import type { OwnerIdentity } from "../identity.ts";
|
|
18
18
|
import type { Logger } from "../log.ts";
|
|
19
19
|
import { PERSONAL_TARGET } from "../modules/background/personal-target.ts";
|
|
20
20
|
import { withReference } from "../routing/message-text.ts";
|
|
21
|
+
import { answersAuthor } from "../routing/peer-bots.ts";
|
|
21
22
|
import { outcome } from "../routing/settle-turn.ts";
|
|
22
23
|
import { systemTurn } from "../routing/system-turns.ts";
|
|
23
24
|
import { addressee, attributed, type Speaker } from "../speakers.ts";
|
|
@@ -55,6 +56,7 @@ export interface AgentClaimOptions {
|
|
|
55
56
|
| "owns"
|
|
56
57
|
| "answerOwner"
|
|
57
58
|
| "answerGroup"
|
|
59
|
+
| "stopRound"
|
|
58
60
|
| "answerBackground"
|
|
59
61
|
| "startFresh"
|
|
60
62
|
>;
|
|
@@ -65,6 +67,11 @@ export interface AgentClaimOptions {
|
|
|
65
67
|
*/
|
|
66
68
|
surface: Pick<SurfacePort, "react" | "unreact"> &
|
|
67
69
|
Partial<Pick<SurfacePort, "roomSurfaces" | "of">>;
|
|
70
|
+
/**
|
|
71
|
+
* Where the report turn of a webhook's post in an agent's channel runs, given the channel it was
|
|
72
|
+
* posted in: the routing table's `integration` rule. Without it, in that channel.
|
|
73
|
+
*/
|
|
74
|
+
integrationChannel?: (origin: ChannelKey) => ChannelKey;
|
|
68
75
|
/** Where a channel's attachments are saved. */
|
|
69
76
|
attachmentDir: (channel: ChannelKey) => string;
|
|
70
77
|
/** The downloads' limits: all of a message's files together (default 60 s), and one file without a byte (default 10 s). */
|
|
@@ -137,16 +144,23 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
|
|
|
137
144
|
const webhookReport = (message: InboundMessage): Admission | undefined => {
|
|
138
145
|
const text = message.text.trim();
|
|
139
146
|
if (!text) return undefined;
|
|
147
|
+
const channel =
|
|
148
|
+
options.integrationChannel?.(message.channel) ?? message.channel;
|
|
149
|
+
// Away from where it was posted, the turn says where, so the agent can find the raw post.
|
|
150
|
+
const where =
|
|
151
|
+
channel === message.channel
|
|
152
|
+
? "your channel"
|
|
153
|
+
: `your ${parseChannelKey(message.channel).surface} place (${message.channel})`;
|
|
140
154
|
return {
|
|
141
155
|
kind: "background",
|
|
142
156
|
// The host's own turn, at the owner tier as in 0.8; what the webhook posted is untrusted input.
|
|
143
157
|
turn: systemTurn({
|
|
144
|
-
channel
|
|
158
|
+
channel,
|
|
145
159
|
target: PERSONAL_TARGET.name,
|
|
146
160
|
author: { id: message.authorId, name: message.authorName },
|
|
147
161
|
tier: "owner",
|
|
148
162
|
turnId: `webhook-${message.messageId}`,
|
|
149
|
-
text: `Webhook "${message.authorName}" posted in
|
|
163
|
+
text: `Webhook "${message.authorName}" posted in ${where}:\n${text}`,
|
|
150
164
|
report: true,
|
|
151
165
|
}),
|
|
152
166
|
unanswered: (result) =>
|
|
@@ -180,7 +194,7 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
|
|
|
180
194
|
return webhookReport(message);
|
|
181
195
|
// Who may talk to the agents, and at which tier, the router resolved through the identity service.
|
|
182
196
|
const { speaker } = message;
|
|
183
|
-
if (message
|
|
197
|
+
if (!answersAuthor(message) || !speaker) return undefined;
|
|
184
198
|
if (options.speakers === "owners" && speaker.tier !== "owner")
|
|
185
199
|
return undefined;
|
|
186
200
|
const { channel, messageId } = message;
|
|
@@ -256,7 +270,13 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
|
|
|
256
270
|
),
|
|
257
271
|
);
|
|
258
272
|
},
|
|
259
|
-
stop: (channel) =>
|
|
273
|
+
stop: (channel) => {
|
|
274
|
+
// A group's round ends whole: the member speaking is stopped, and the ones waiting do not speak.
|
|
275
|
+
const sessions = team.stopRound(channel);
|
|
276
|
+
if (!sessions) return runtime.stop(channel);
|
|
277
|
+
for (const session of sessions) runtime.stop(session);
|
|
278
|
+
return true;
|
|
279
|
+
},
|
|
260
280
|
startFresh: async (channel) => {
|
|
261
281
|
await team.startFresh(channel);
|
|
262
282
|
return "agent";
|
|
@@ -40,7 +40,11 @@ function agentState(agent: AgentStatus): string {
|
|
|
40
40
|
return agent.waiting > 0 ? text.dashQueued(agent.waiting) : text.dashIdle;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function agentBlock(
|
|
43
|
+
function agentBlock(
|
|
44
|
+
agent: AgentStatus,
|
|
45
|
+
now: Date,
|
|
46
|
+
helpers: readonly AgentStatus[],
|
|
47
|
+
): string {
|
|
44
48
|
const channel = agent.channelId ? ` <#${agent.channelId}>` : "";
|
|
45
49
|
const text = messages();
|
|
46
50
|
const last = agent.lastActive
|
|
@@ -51,6 +55,15 @@ function agentBlock(agent: AgentStatus, now: Date): string {
|
|
|
51
55
|
`\`${agent.model}\` · thinking \`${thinkingLabel(agent.thinking)}\``,
|
|
52
56
|
`${agentState(agent)} · ${contextText(agent.context)}`,
|
|
53
57
|
text.dashAgentFooter(agent.schedules, last),
|
|
58
|
+
// Temp knights are counted under their parent, not listed one by one.
|
|
59
|
+
...(helpers.length > 0
|
|
60
|
+
? [
|
|
61
|
+
text.dashHelpers(
|
|
62
|
+
helpers.length,
|
|
63
|
+
helpers.filter((helper) => !helper.finished).length,
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
: []),
|
|
54
67
|
].join("\n");
|
|
55
68
|
}
|
|
56
69
|
|
|
@@ -100,7 +113,15 @@ export function dashboardSections(
|
|
|
100
113
|
header,
|
|
101
114
|
listed(
|
|
102
115
|
"### Agents",
|
|
103
|
-
status.agents
|
|
116
|
+
status.agents
|
|
117
|
+
.filter((agent) => agent.parent === undefined)
|
|
118
|
+
.map((agent) =>
|
|
119
|
+
agentBlock(
|
|
120
|
+
agent,
|
|
121
|
+
now,
|
|
122
|
+
status.agents.filter((other) => other.parent === agent.name),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
104
125
|
budget,
|
|
105
126
|
),
|
|
106
127
|
listed(
|
|
@@ -62,6 +62,7 @@ export class AgentMessages {
|
|
|
62
62
|
roomSurfaces: surfaces?.roomSurfaces() ?? [],
|
|
63
63
|
latest: store.conversations.latest(target.name),
|
|
64
64
|
from: parent,
|
|
65
|
+
routes: this.#host.routes,
|
|
65
66
|
},
|
|
66
67
|
target,
|
|
67
68
|
conversation,
|
|
@@ -77,7 +78,18 @@ export class AgentMessages {
|
|
|
77
78
|
throw new AgentError(
|
|
78
79
|
chainLimitReason(addressee(chain.speaker, this.#host.options.owner)),
|
|
79
80
|
);
|
|
81
|
+
// A temp knight talks only to the conversation it was made from (and to the owners), so one
|
|
82
|
+
// person's helper session is not open to another person's turn.
|
|
83
|
+
if (target.parent !== undefined && chain.speaker.tier !== "owner") {
|
|
84
|
+
const own = sender.name === target.parent && target.origin === parent;
|
|
85
|
+
if (!own)
|
|
86
|
+
throw new AgentError(
|
|
87
|
+
`"${target.name}" is a temp knight of "${target.parent}" and answers only the conversation that made it; ask "${target.parent}" there.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
80
90
|
chain.messages += 1;
|
|
91
|
+
// A temp knight is working from the moment a message is handed to it until it has answered.
|
|
92
|
+
this.#host.helpers.begin(target.name);
|
|
81
93
|
// The thread opens in the sender's turn channel, on its surface when that hosts threads; a
|
|
82
94
|
// surface that hosts none leaves the answer returning to the sender's conversation.
|
|
83
95
|
const exchange = this.#openExchange(parent, sender, target, text);
|
|
@@ -113,7 +125,8 @@ export class AgentMessages {
|
|
|
113
125
|
{ from: sender.name, to: target.name, err: error },
|
|
114
126
|
"agent message not delivered",
|
|
115
127
|
);
|
|
116
|
-
})
|
|
128
|
+
})
|
|
129
|
+
.finally(() => this.#host.helpers.end(target.name));
|
|
117
130
|
logger.info(
|
|
118
131
|
{
|
|
119
132
|
from: sender.name,
|
|
@@ -395,14 +408,19 @@ export class AgentMessages {
|
|
|
395
408
|
return;
|
|
396
409
|
}
|
|
397
410
|
// Its thread was closed when the host refused it, so the answer returns to the sender's conversation.
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
411
|
+
this.#host.helpers.begin(target.name);
|
|
412
|
+
try {
|
|
413
|
+
await this.#exchange(
|
|
414
|
+
sender,
|
|
415
|
+
target,
|
|
416
|
+
entry.text,
|
|
417
|
+
chain,
|
|
418
|
+
undefined,
|
|
419
|
+
entry.channel,
|
|
420
|
+
origin,
|
|
421
|
+
);
|
|
422
|
+
} finally {
|
|
423
|
+
this.#host.helpers.end(target.name);
|
|
424
|
+
}
|
|
407
425
|
}
|
|
408
426
|
}
|
|
@@ -66,6 +66,16 @@ export interface AgentChannels {
|
|
|
66
66
|
topic: string,
|
|
67
67
|
category: AgentCategory,
|
|
68
68
|
): Promise<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Opens a thread under a channel to be a temp knight's place, with no start message; returns its
|
|
71
|
+
* id. Posts to that id speak through the parent channel's webhook into the thread. Absent: a
|
|
72
|
+
* temp knight lives in rooms only.
|
|
73
|
+
*/
|
|
74
|
+
createThread?(parentChannelId: string, name: string): Promise<string>;
|
|
75
|
+
/** Archives and locks a temp knight's thread (locked only when permitted); its history stays. */
|
|
76
|
+
closeThread?(threadId: string): Promise<void>;
|
|
77
|
+
/** Opens a temp knight's archived thread again, unlocked. */
|
|
78
|
+
openThread?(threadId: string): Promise<void>;
|
|
69
79
|
/** Moves a channel into the category when it sits elsewhere; true when it moved. */
|
|
70
80
|
placeIn(channelId: string, category: AgentCategory): Promise<boolean>;
|
|
71
81
|
/** Every category of the server in order, each with its channels in order. */
|
|
@@ -32,6 +32,10 @@ export function agentSystemPrompt(input: {
|
|
|
32
32
|
agentTools?: AgentTools;
|
|
33
33
|
/** Where this conversation is, and the other surfaces the agent has places on. */
|
|
34
34
|
surface?: { label: string; key: string; others: readonly string[] };
|
|
35
|
+
/** Whether the agent has the temp knight tools. */
|
|
36
|
+
helpers?: boolean;
|
|
37
|
+
/** The knight that made this agent, when it is a temp knight. */
|
|
38
|
+
parent?: Agent;
|
|
35
39
|
}): string {
|
|
36
40
|
const { agent, coordinator, shared, workDir, owner, shellUser, group } =
|
|
37
41
|
input;
|
|
@@ -48,6 +52,14 @@ export function agentSystemPrompt(input: {
|
|
|
48
52
|
? `You have no shell or file tools and no workspace on ${o.his} machine: you work through the conversation and the tools listed to you.${input.images ? " To make a new picture, describe it to generate_image, which saves it and attaches it to your reply." : ""}`
|
|
49
53
|
: `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}.${input.scratchDir ? ` Write temporary files under the scratch dir ${input.scratchDir} ($TMPDIR), not elsewhere in /tmp; make them with \`mktemp -p "$TMPDIR"\`, since a bare \`mktemp\` ignores $TMPDIR on some systems.` : ""} Commands that are destructive or change the system, and writes outside the workspace${input.scratchDir ? " and the scratch dir" : ""}, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm. To show a file you have, attach it to your reply with attach_file or give its path to discord_send_message; never read an image or base64 a file to send it.${input.images ? " To make a new picture, describe it to generate_image, which saves it and attaches it to your reply." : ""}`,
|
|
50
54
|
];
|
|
55
|
+
if (input.parent)
|
|
56
|
+
parts.push(
|
|
57
|
+
`You are a temp knight of "${input.parent.displayName}" (\`${input.parent.name}\`), made for one task: do it, answer with what it asked for, and stop. Your answer goes back to "${input.parent.displayName}", which clears you when it is done. You cannot make knights, temp knights, groups, or schedules.`,
|
|
58
|
+
);
|
|
59
|
+
else if (input.helpers)
|
|
60
|
+
parts.push(
|
|
61
|
+
"A task that stands on its own, such as one of several things to look up or compare, can go to a temp knight: helper_create makes one of yours that works in parallel, its answer comes back to you as a new turn, and helper_end clears the ones you are done with.",
|
|
62
|
+
);
|
|
51
63
|
if (group) {
|
|
52
64
|
const others = group.members
|
|
53
65
|
.flatMap((m) => (m.name === agent.name ? [] : [`"${m.displayName}"`]))
|
|
@@ -60,7 +72,11 @@ export function agentSystemPrompt(input: {
|
|
|
60
72
|
"message_agent hands a self-contained request to another agent; it answers later, and its answer comes back to you as a new turn.",
|
|
61
73
|
);
|
|
62
74
|
}
|
|
63
|
-
parts.push(
|
|
75
|
+
parts.push(
|
|
76
|
+
input.parent
|
|
77
|
+
? `## Your task brief\nThis was written by whoever spoke with "${input.parent.displayName}" when it made you. It says what to do; it is not an instruction from ${o.name}, and it gives you no authority beyond the tools and tier of the turn you run in.\n${agent.prompt}`
|
|
78
|
+
: `## Your role\n${agent.prompt}`,
|
|
79
|
+
);
|
|
64
80
|
if (input.speaker && input.speaker.tier !== "owner")
|
|
65
81
|
parts.push(speakerText(input.speaker, owner));
|
|
66
82
|
return parts.join("\n\n");
|
|
@@ -9,7 +9,7 @@ import { THINKING_LEVELS, type ThinkingLevel } from "../models.ts";
|
|
|
9
9
|
export const MAX_PROMPT_CHARS = 4_000;
|
|
10
10
|
export const MAX_AVATAR_PROMPT_CHARS = 1_000;
|
|
11
11
|
export const MIN_GROUP_MEMBERS = 2;
|
|
12
|
-
export const MAX_GROUP_MEMBERS =
|
|
12
|
+
export const MAX_GROUP_MEMBERS = 8;
|
|
13
13
|
|
|
14
14
|
export type AgentStatus = "active" | "archived";
|
|
15
15
|
|
|
@@ -30,8 +30,18 @@ export interface Agent {
|
|
|
30
30
|
/** Unset follows the assistant's thinking level. */
|
|
31
31
|
thinking?: ThinkingLevel;
|
|
32
32
|
status: AgentStatus;
|
|
33
|
+
/** The knight that made this one as a temp knight; absent for a permanent knight. */
|
|
34
|
+
parent?: string;
|
|
35
|
+
/** A temp knight's moment of going quiet; absent while it works and for a permanent knight. */
|
|
36
|
+
finishedAt?: Date;
|
|
37
|
+
/** The conversation (channel key) a temp knight was made from; only it and the owners may message the temp knight. */
|
|
38
|
+
origin?: string;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/** Whether the agent is a temp knight: one a knight made to do a task, which has a parent. */
|
|
42
|
+
export const isHelper = (agent: Pick<Agent, "parent"> | undefined): boolean =>
|
|
43
|
+
agent?.parent !== undefined;
|
|
44
|
+
|
|
35
45
|
export interface AgentGroup {
|
|
36
46
|
name: string;
|
|
37
47
|
displayName: string;
|
|
@@ -19,6 +19,9 @@ export interface AgentRow {
|
|
|
19
19
|
model: string | null;
|
|
20
20
|
thinking: ThinkingLevel | null;
|
|
21
21
|
status: AgentStatus;
|
|
22
|
+
parent: string | null;
|
|
23
|
+
finished_at: Date | null;
|
|
24
|
+
origin: string | null;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface GroupRow {
|
|
@@ -43,6 +46,9 @@ export const toAgent = (row: AgentRow): Agent => {
|
|
|
43
46
|
...(row.model ? { model: row.model } : {}),
|
|
44
47
|
...(row.thinking ? { thinking: row.thinking } : {}),
|
|
45
48
|
status: row.status,
|
|
49
|
+
...(row.parent ? { parent: row.parent } : {}),
|
|
50
|
+
...(row.finished_at ? { finishedAt: row.finished_at } : {}),
|
|
51
|
+
...(row.origin ? { origin: row.origin } : {}),
|
|
46
52
|
};
|
|
47
53
|
};
|
|
48
54
|
|
|
@@ -136,6 +142,22 @@ export function agentMigrations(guildId: string): Migration[] {
|
|
|
136
142
|
await sql`ALTER TABLE agents ADD COLUMN IF NOT EXISTS character jsonb`;
|
|
137
143
|
},
|
|
138
144
|
},
|
|
145
|
+
// Temp knights: who made an agent, and when a temp knight last went quiet. Both are null for a permanent knight.
|
|
146
|
+
{
|
|
147
|
+
name: "agents-helpers",
|
|
148
|
+
up: async (sql) => {
|
|
149
|
+
await sql`ALTER TABLE agents ADD COLUMN IF NOT EXISTS parent text`;
|
|
150
|
+
await sql`ALTER TABLE agents ADD COLUMN IF NOT EXISTS finished_at timestamptz`;
|
|
151
|
+
await sql`CREATE INDEX IF NOT EXISTS agents_parent ON agents (guild_id, parent)`;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
// The conversation a temp knight was made from, which alone may message it.
|
|
155
|
+
{
|
|
156
|
+
name: "agents-helper-origin",
|
|
157
|
+
up: async (sql) => {
|
|
158
|
+
await sql`ALTER TABLE agents ADD COLUMN IF NOT EXISTS origin text`;
|
|
159
|
+
},
|
|
160
|
+
},
|
|
139
161
|
// The change history of the agents' prompts; the prompts plugin creates the same table for its documents.
|
|
140
162
|
promptHistoryMigration("agent-prompt-history"),
|
|
141
163
|
// Agent messages the host refused while it stopped, replayed at the next start.
|
|
@@ -40,12 +40,24 @@ export class AgentStatements {
|
|
|
40
40
|
channelId: string | null;
|
|
41
41
|
avatarHash: string | null;
|
|
42
42
|
character: CharacterSpec | null;
|
|
43
|
+
/** The knight that made it as a temp knight, which starts out working. */
|
|
44
|
+
parent: string | null;
|
|
45
|
+
/** The conversation a temp knight was made from. */
|
|
46
|
+
origin: string | null;
|
|
43
47
|
}): Promise<AgentRow[]> {
|
|
44
48
|
return this.#sql`
|
|
45
|
-
INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id, avatar_hash, character)
|
|
49
|
+
INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id, avatar_hash, character, parent, origin)
|
|
46
50
|
VALUES (${this.#guild}, ${agent.name}, ${agent.displayName}, ${agent.prompt}, ${agent.avatarPrompt},
|
|
47
51
|
${agent.channelId}, ${agent.avatarHash},
|
|
48
|
-
${agent.character}::jsonb)
|
|
52
|
+
${agent.character}::jsonb, ${agent.parent}, ${agent.origin})
|
|
53
|
+
RETURNING *`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stamps a temp knight finished at `at`, or clears the stamp with null; a permanent knight is left alone. */
|
|
57
|
+
setFinished(name: string, at: Date | null): Promise<AgentRow[]> {
|
|
58
|
+
return this.#sql`
|
|
59
|
+
UPDATE agents SET finished_at = ${at}
|
|
60
|
+
WHERE guild_id = ${this.#guild} AND name = ${name} AND parent IS NOT NULL
|
|
49
61
|
RETURNING *`;
|
|
50
62
|
}
|
|
51
63
|
|
|
@@ -85,9 +97,11 @@ export class AgentStatements {
|
|
|
85
97
|
WHERE guild_id = ${this.#guild} AND name = ${name} RETURNING *`;
|
|
86
98
|
}
|
|
87
99
|
|
|
100
|
+
/** A restored temp knight has nothing running, so it comes back finished. */
|
|
88
101
|
restoreAgent(name: string, channelId: string | null): Promise<AgentRow[]> {
|
|
89
102
|
return this.#sql`
|
|
90
103
|
UPDATE agents SET status = 'active', channel_id = ${channelId},
|
|
104
|
+
finished_at = CASE WHEN parent IS NULL THEN NULL ELSE now() END,
|
|
91
105
|
updated_at = now()
|
|
92
106
|
WHERE guild_id = ${this.#guild} AND name = ${name}
|
|
93
107
|
RETURNING *`;
|
|
@@ -159,9 +159,13 @@ export class PgAgentStore extends AgentBook implements AgentDirectory {
|
|
|
159
159
|
avatarHash?: string;
|
|
160
160
|
/** Absent in a team without Discord, whose agents live in rooms. */
|
|
161
161
|
channelId?: string;
|
|
162
|
+
/** Makes it a temp knight of this active, permanent knight. */
|
|
163
|
+
parent?: string;
|
|
164
|
+
origin?: string; // the conversation a temp knight was made from
|
|
162
165
|
}): Promise<Agent> {
|
|
163
166
|
return this.#serial(async () => {
|
|
164
167
|
checkAgentName(agent.name);
|
|
168
|
+
if (agent.parent !== undefined) this.checkParent(agent.parent);
|
|
165
169
|
checkDisplayName(agent.displayName);
|
|
166
170
|
checkPrompt(agent.prompt);
|
|
167
171
|
const avatarPrompt = agent.avatarPrompt ?? "";
|
|
@@ -175,11 +179,22 @@ export class PgAgentStore extends AgentBook implements AgentDirectory {
|
|
|
175
179
|
channelId: agent.channelId ?? null,
|
|
176
180
|
avatarHash: agent.avatarHash ?? null,
|
|
177
181
|
character: agent.character ?? null,
|
|
182
|
+
parent: agent.parent ?? null,
|
|
183
|
+
origin: agent.origin ?? null,
|
|
178
184
|
});
|
|
179
185
|
return this.keepAgent(rows[0]);
|
|
180
186
|
});
|
|
181
187
|
}
|
|
182
188
|
|
|
189
|
+
/** Marks an active temp knight finished now, or working again with `false`; says whether the stamp changed. */
|
|
190
|
+
setFinished(name: string, finished: boolean): Promise<boolean> {
|
|
191
|
+
return this.#writeOne(() =>
|
|
192
|
+
this.stampChanges(name, finished)
|
|
193
|
+
? this.#statements.setFinished(name, finished ? new Date() : null)
|
|
194
|
+
: Promise.resolve([]),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
183
198
|
async updateAgent(
|
|
184
199
|
name: string,
|
|
185
200
|
change: {
|
|
@@ -323,21 +338,14 @@ export class PgAgentStore extends AgentBook implements AgentDirectory {
|
|
|
323
338
|
* Stores a character and picture only while the agent still has none, so a look chosen in the
|
|
324
339
|
* meantime wins. Returns whether it was written.
|
|
325
340
|
*/
|
|
326
|
-
|
|
341
|
+
dressIfUndressed(
|
|
327
342
|
name: string,
|
|
328
343
|
character: CharacterSpec,
|
|
329
344
|
avatarHash: string,
|
|
330
345
|
): Promise<boolean> {
|
|
331
|
-
return this.#
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
character,
|
|
335
|
-
avatarHash,
|
|
336
|
-
);
|
|
337
|
-
if (!rows[0]) return false;
|
|
338
|
-
this.keepAgentRow(rows[0]);
|
|
339
|
-
return true;
|
|
340
|
-
});
|
|
346
|
+
return this.#writeOne(() =>
|
|
347
|
+
this.#statements.dressIfUndressed(name, character, avatarHash),
|
|
348
|
+
);
|
|
341
349
|
}
|
|
342
350
|
|
|
343
351
|
/**
|
|
@@ -345,20 +353,22 @@ export class PgAgentStore extends AgentBook implements AgentDirectory {
|
|
|
345
353
|
* store's write queue, so a look saved meanwhile keeps its own picture. Returns whether it was
|
|
346
354
|
* written.
|
|
347
355
|
*/
|
|
348
|
-
|
|
356
|
+
setAvatarIfLook(
|
|
349
357
|
name: string,
|
|
350
358
|
character: CharacterSpec,
|
|
351
359
|
avatarHash: string,
|
|
352
360
|
): Promise<boolean> {
|
|
361
|
+
return this.#writeOne(() =>
|
|
362
|
+
this.#statements.setAvatarIfLook(name, character, avatarHash),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Runs a conditional row write in the queue; true when it wrote a row, which joins the map. */
|
|
367
|
+
#writeOne(write: () => Promise<AgentRow[]>): Promise<boolean> {
|
|
353
368
|
return this.#serial(async () => {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
avatarHash,
|
|
358
|
-
);
|
|
359
|
-
if (!rows[0]) return false;
|
|
360
|
-
this.keepAgentRow(rows[0]);
|
|
361
|
-
return true;
|
|
369
|
+
const [row] = await write();
|
|
370
|
+
if (row) this.keepAgentRow(row);
|
|
371
|
+
return row !== undefined;
|
|
362
372
|
});
|
|
363
373
|
}
|
|
364
374
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ContextUse } from "../contract/runtime.ts";
|
|
2
|
+
import type {
|
|
3
|
+
ChannelKey,
|
|
4
|
+
PendingConfirmation,
|
|
5
|
+
TurnResult,
|
|
6
|
+
} from "../domain/conversation.ts";
|
|
7
|
+
import { AgentRunError } from "../domain/errors.ts";
|
|
8
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
9
|
+
import type { AgentTurnRunner } from "./agent-ports.ts";
|
|
10
|
+
|
|
11
|
+
/** Answers every turn with a scripted reply; records what each turn received. */
|
|
12
|
+
export class FakeRuntime implements AgentTurnRunner {
|
|
13
|
+
turns: TurnRequest[] = [];
|
|
14
|
+
reply: (request: TurnRequest) => string = (r) => `${r.agent?.name} ok`;
|
|
15
|
+
held = new Map<ChannelKey, PendingConfirmation>();
|
|
16
|
+
fresh: ChannelKey[] = [];
|
|
17
|
+
/** Runs inside a turn, like a tool call the model makes. */
|
|
18
|
+
during?: (request: TurnRequest) => unknown;
|
|
19
|
+
/** The next turn ends as if the owner pressed stop. */
|
|
20
|
+
stopNext = false;
|
|
21
|
+
async runTurn(request: TurnRequest): Promise<TurnResult> {
|
|
22
|
+
this.turns.push(request);
|
|
23
|
+
await this.during?.(request);
|
|
24
|
+
if (this.stopNext) {
|
|
25
|
+
this.stopNext = false;
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
error: new AgentRunError("stopped by the owner"),
|
|
29
|
+
stopped: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, text: this.reply(request) };
|
|
33
|
+
}
|
|
34
|
+
async heldActions(session: ChannelKey) {
|
|
35
|
+
return this.held.get(session);
|
|
36
|
+
}
|
|
37
|
+
async startFresh(session: ChannelKey) {
|
|
38
|
+
this.fresh.push(session);
|
|
39
|
+
}
|
|
40
|
+
deleted: ChannelKey[] = [];
|
|
41
|
+
async deleteConversation(session: ChannelKey) {
|
|
42
|
+
this.deleted.push(session);
|
|
43
|
+
}
|
|
44
|
+
usage = new Map<ChannelKey, ContextUse>();
|
|
45
|
+
contextUsage(session: ChannelKey) {
|
|
46
|
+
return this.usage.get(session);
|
|
47
|
+
}
|
|
48
|
+
}
|