pi-roundtable 0.26.3 → 0.27.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 +48 -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 +24 -24
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
2
|
-
import type { ChannelKey } from "../domain/conversation.ts";
|
|
2
|
+
import type { ChannelKey, TurnResult } from "../domain/conversation.ts";
|
|
3
3
|
import { AgentError } from "../domain/errors.ts";
|
|
4
|
+
import type { RoundEnd, RoundProgress } from "../domain/round.ts";
|
|
4
5
|
import { messages } from "../i18n/index.ts";
|
|
5
6
|
import type { Speaker } from "../speakers.ts";
|
|
6
7
|
import { groupTurnText } from "./agent-prompt.ts";
|
|
@@ -24,14 +25,45 @@ import {
|
|
|
24
25
|
type TurnHost,
|
|
25
26
|
} from "./team-turn-types.ts";
|
|
26
27
|
|
|
28
|
+
/** A running round, as Stop reaches it: the flag it ends on, and the sessions whose turns it is running. */
|
|
29
|
+
interface RunningRound {
|
|
30
|
+
stopped: boolean;
|
|
31
|
+
sessions: Set<ChannelKey>;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
/** A group channel's rounds: scoring, then the chosen members answer one at a time. */
|
|
28
35
|
export class GroupTurns {
|
|
29
36
|
readonly #host: TurnHost;
|
|
37
|
+
/** The round each channel runs now; a channel runs one at a time, inside its queue. */
|
|
38
|
+
readonly #running = new Map<ChannelKey, RunningRound>();
|
|
30
39
|
|
|
31
40
|
constructor(host: TurnHost) {
|
|
32
41
|
this.#host = host;
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
/** Tells the channel's surface where the round stands; a surface that shows no rounds hears nothing, and a refusal only reaches the log. */
|
|
45
|
+
#tell(channel: ChannelKey, round: RoundProgress): void {
|
|
46
|
+
const { surfaces, logger } = this.#host.options;
|
|
47
|
+
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
48
|
+
surfaces
|
|
49
|
+
?.groupRound?.(channel, round)
|
|
50
|
+
.catch((error: unknown) =>
|
|
51
|
+
logger.warn({ channel, err: error }, "round not shown"),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ends the channel's running round: the member speaking is stopped, and the members still waiting
|
|
57
|
+
* do not speak. Returns the sessions whose turns the caller must stop, or undefined when no round runs
|
|
58
|
+
* in the channel.
|
|
59
|
+
*/
|
|
60
|
+
stopRound(channel: ChannelKey): ChannelKey[] | undefined {
|
|
61
|
+
const round = this.#running.get(channel);
|
|
62
|
+
if (!round) return undefined;
|
|
63
|
+
round.stopped = true;
|
|
64
|
+
return [...round.sessions];
|
|
65
|
+
}
|
|
66
|
+
|
|
35
67
|
/** The active group whose Discord channel or room the key is. */
|
|
36
68
|
#groupOf(channel: ChannelKey): AgentGroup {
|
|
37
69
|
const { store, surfaces } = this.#host.options;
|
|
@@ -63,7 +95,7 @@ export class GroupTurns {
|
|
|
63
95
|
.map((name) => store.agent(name))
|
|
64
96
|
.filter((m): m is Agent => m?.status === "active");
|
|
65
97
|
const recent = await store.recentGroupMessages(log, RECENT_FOR_SCORING);
|
|
66
|
-
await store.appendGroupMessage(log, {
|
|
98
|
+
const openedBy = await store.appendGroupMessage(log, {
|
|
67
99
|
author: speaker.tier === "owner" ? "owner" : `human:${speaker.id}`,
|
|
68
100
|
authorName:
|
|
69
101
|
speaker.tier === "owner" ? this.#host.options.owner.name : speaker.name,
|
|
@@ -103,48 +135,107 @@ export class GroupTurns {
|
|
|
103
135
|
const chain: Chain = { messages: 0, speaker };
|
|
104
136
|
const queued = [...order];
|
|
105
137
|
const spoken = new Set<string>();
|
|
138
|
+
const control: RunningRound = { stopped: false, sessions: new Set() };
|
|
139
|
+
this.#running.set(channel, control);
|
|
140
|
+
// The stop button belongs to the round, which ends whole: it is shown until the round ends.
|
|
141
|
+
const hideStop = this.#host.options.showStop(channel);
|
|
142
|
+
const share = (
|
|
143
|
+
state: RoundProgress["state"],
|
|
144
|
+
current: string | undefined,
|
|
145
|
+
ended?: RoundEnd,
|
|
146
|
+
) => {
|
|
147
|
+
// The owner's messages waiting in the channel's queue, not counting this round's own task.
|
|
148
|
+
const waiting = Math.max(this.#host.options.queue.size(channel) - 1, 0);
|
|
149
|
+
this.#tell(channel, {
|
|
150
|
+
id: openedBy,
|
|
151
|
+
state,
|
|
152
|
+
...(current === undefined ? {} : { current }),
|
|
153
|
+
next: [...queued],
|
|
154
|
+
spoken: [...spoken],
|
|
155
|
+
replies,
|
|
156
|
+
limit: MAX_ROUND_REPLIES,
|
|
157
|
+
...(ended === undefined ? {} : { ended }),
|
|
158
|
+
...(waiting > 0 ? { queued: waiting } : {}),
|
|
159
|
+
});
|
|
160
|
+
};
|
|
106
161
|
let last: Agent | undefined;
|
|
107
162
|
let replies = 0;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
163
|
+
let end: RoundEnd = "done";
|
|
164
|
+
try {
|
|
165
|
+
share("running", undefined);
|
|
166
|
+
while (queued.length > 0 && replies < MAX_ROUND_REPLIES) {
|
|
167
|
+
if (control.stopped) {
|
|
168
|
+
end = "stopped";
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
// A member may archive the group during the round.
|
|
172
|
+
if (store.group(group.name)?.status !== "active") {
|
|
173
|
+
queued.length = 0;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
const name = queued.shift() as string;
|
|
177
|
+
const member = store.agent(name);
|
|
178
|
+
if (member?.status !== "active") continue;
|
|
179
|
+
const first = !spoken.has(name);
|
|
180
|
+
const backlog = await store.backlog(log, name, BACKLOG_LIMIT);
|
|
181
|
+
// Delivered once the session has the text, so a message never comes twice.
|
|
182
|
+
if (backlog.lastId !== undefined)
|
|
183
|
+
await store.advanceCursor(log, name, backlog.lastId);
|
|
184
|
+
// Attachments and a confirmation belong to a member's first turn of the round only.
|
|
185
|
+
const extra: TurnExtra = { interactive: true, byMessage: true };
|
|
186
|
+
if (first) {
|
|
187
|
+
extra.attachments = attachments;
|
|
188
|
+
if (approvals.get(name)) extra.confirmed = true;
|
|
189
|
+
}
|
|
190
|
+
const scope = groupScope(member, group, channel);
|
|
191
|
+
control.sessions.add(scope.session);
|
|
192
|
+
share("running", name);
|
|
193
|
+
let result: TurnResult;
|
|
194
|
+
try {
|
|
195
|
+
result = await this.#host.turn(
|
|
196
|
+
member,
|
|
197
|
+
scope,
|
|
198
|
+
channel,
|
|
199
|
+
groupTurnText(group, member, backlog),
|
|
200
|
+
chain,
|
|
201
|
+
extra,
|
|
202
|
+
);
|
|
203
|
+
} finally {
|
|
204
|
+
control.sessions.delete(scope.session);
|
|
205
|
+
}
|
|
206
|
+
spoken.add(name);
|
|
207
|
+
replies += 1;
|
|
208
|
+
last = member;
|
|
209
|
+
if (control.stopped || (!result.ok && result.stopped)) {
|
|
210
|
+
end = "stopped";
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (result.ok) {
|
|
214
|
+
await store.appendGroupMessage(log, {
|
|
215
|
+
author: name,
|
|
216
|
+
authorName: this.#host.current(member).displayName,
|
|
217
|
+
text: result.text,
|
|
218
|
+
});
|
|
219
|
+
for (const handoff of mentionedMembers(result.text, members))
|
|
220
|
+
if (handoff !== name && !queued.includes(handoff))
|
|
221
|
+
queued.push(handoff);
|
|
222
|
+
}
|
|
223
|
+
share("running", undefined);
|
|
125
224
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
text: result.text,
|
|
141
|
-
});
|
|
142
|
-
for (const handoff of mentionedMembers(result.text, members))
|
|
143
|
-
if (handoff !== name && !queued.includes(handoff)) queued.push(handoff);
|
|
225
|
+
if (end !== "stopped" && queued.length > 0) end = "limit";
|
|
226
|
+
if (queued.length > 0 && last)
|
|
227
|
+
await this.#host.post(channel, this.#host.current(last), {
|
|
228
|
+
chunks: [
|
|
229
|
+
end === "stopped"
|
|
230
|
+
? messages().roundStoppedNotice
|
|
231
|
+
: messages().roundLimitNotice,
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
if (end === "stopped") queued.length = 0;
|
|
235
|
+
} finally {
|
|
236
|
+
hideStop();
|
|
237
|
+
this.#running.delete(channel);
|
|
238
|
+
share("ended", undefined, end);
|
|
144
239
|
}
|
|
145
|
-
if (queued.length > 0 && last)
|
|
146
|
-
await this.#host.post(channel, this.#host.current(last), {
|
|
147
|
-
chunks: [messages().roundLimitNotice],
|
|
148
|
-
});
|
|
149
240
|
}
|
|
150
241
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { CharacterInput } from "../../characters/index.ts";
|
|
2
|
+
import { helpersOf } from "../config/helpers.ts";
|
|
3
|
+
import { AgentError } from "../domain/errors.ts";
|
|
4
|
+
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
5
|
+
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
6
|
+
import type { Speaker } from "../speakers.ts";
|
|
7
|
+
import { toolTiers } from "../tool-tiers.ts";
|
|
8
|
+
import { isHelper } from "./agent-rules.ts";
|
|
9
|
+
import type { Agent } from "./agent-store.ts";
|
|
10
|
+
import { keysOf } from "./team-keys.ts";
|
|
11
|
+
import type { TeamContext } from "./team-options.ts";
|
|
12
|
+
|
|
13
|
+
/** The team's way of making an agent, which `createHelper` uses to make a temp knight. */
|
|
14
|
+
export type MakeAgent = (
|
|
15
|
+
who: {
|
|
16
|
+
speaker: Speaker;
|
|
17
|
+
by: string;
|
|
18
|
+
creator: Agent;
|
|
19
|
+
parent: Agent;
|
|
20
|
+
/** The conversation the temp knight is made from; only it and the owners may message the temp knight. */
|
|
21
|
+
origin?: string;
|
|
22
|
+
},
|
|
23
|
+
fields: {
|
|
24
|
+
name: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
prompt: string;
|
|
27
|
+
character?: CharacterInput;
|
|
28
|
+
skills?: string[];
|
|
29
|
+
},
|
|
30
|
+
settings: { model?: string | undefined; thinking?: string | undefined },
|
|
31
|
+
) => Promise<{ agent: Agent; text: string }>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The agent tool: a knight makes a temp knight for one task. It is a real agent with its own
|
|
35
|
+
* prompt, picture and place (a thread of the parent's channel on Discord, a room on the web),
|
|
36
|
+
* that runs the parent's model unless told otherwise. The task goes to it as a message from the
|
|
37
|
+
* caller, so the answer comes back to the caller's conversation as a new turn. At a cap the
|
|
38
|
+
* caller's oldest finished temp knight is archived to make room; with none finished, it refuses.
|
|
39
|
+
*/
|
|
40
|
+
export async function createHelper(
|
|
41
|
+
ctx: TeamContext,
|
|
42
|
+
make: MakeAgent,
|
|
43
|
+
caller: AgentTurnScope,
|
|
44
|
+
input: {
|
|
45
|
+
name?: string;
|
|
46
|
+
displayName: string;
|
|
47
|
+
prompt: string;
|
|
48
|
+
task: string;
|
|
49
|
+
character?: CharacterInput;
|
|
50
|
+
skills?: string[];
|
|
51
|
+
model?: string;
|
|
52
|
+
thinking?: string;
|
|
53
|
+
},
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const { store, logger } = ctx.options;
|
|
56
|
+
const limits = helpersOf(ctx.options.helpers);
|
|
57
|
+
if (!limits)
|
|
58
|
+
throw new AgentError("Temp knights are switched off on this host.");
|
|
59
|
+
const speaker = ctx.turns.speakerOf(caller);
|
|
60
|
+
if (!speaker)
|
|
61
|
+
throw new AgentError(
|
|
62
|
+
"helper_create can only be used during an agent turn.",
|
|
63
|
+
);
|
|
64
|
+
const parent = store.activeAgent(caller.name);
|
|
65
|
+
if (isHelper(parent))
|
|
66
|
+
throw new AgentError(
|
|
67
|
+
`You are a temp knight of "${parent.parent}" and cannot make temp knights; ask it to.`,
|
|
68
|
+
);
|
|
69
|
+
const { task, name: given, model, thinking, ...rest } = input;
|
|
70
|
+
// A temp knight never gets more than its parent unless the speaker could grant it by hand.
|
|
71
|
+
const tiers = ctx.options.toolTiers ?? toolTiers();
|
|
72
|
+
if (!tiers.allows(speaker.tier, "agent_update") && (model || thinking))
|
|
73
|
+
throw new AgentError(
|
|
74
|
+
"A temp knight runs your model and thinking level; choosing others needs a higher tier.",
|
|
75
|
+
);
|
|
76
|
+
if (rest.skills?.length && !tiers.allows(speaker.tier, "agent_skills")) {
|
|
77
|
+
const own = new Set(ctx.options.skills?.carriedNames(parent.name) ?? []);
|
|
78
|
+
const extra = rest.skills.filter((skill) => !own.has(skill));
|
|
79
|
+
if (extra.length > 0)
|
|
80
|
+
throw new AgentError(
|
|
81
|
+
`A temp knight can carry only skills you carry; not allowed: ${extra.join(", ")}.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const name = given ?? nextName(ctx, parent.name);
|
|
85
|
+
// Everything that can be refused is checked before anything is made or archived.
|
|
86
|
+
store.checkNewName(name);
|
|
87
|
+
const said = await makeRoom(ctx, parent, limits);
|
|
88
|
+
const made = await make(
|
|
89
|
+
{
|
|
90
|
+
speaker,
|
|
91
|
+
by: caller.name,
|
|
92
|
+
creator: parent,
|
|
93
|
+
parent,
|
|
94
|
+
origin: ctx.turns.turnChannel(caller),
|
|
95
|
+
},
|
|
96
|
+
{ ...rest, name },
|
|
97
|
+
{ model, thinking },
|
|
98
|
+
);
|
|
99
|
+
const helper = made.agent;
|
|
100
|
+
try {
|
|
101
|
+
await inheritLongRunning(ctx, caller, speaker, helper);
|
|
102
|
+
ctx.turns.message(caller, helper.name, task);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// The long-running mode or the task could not be handed over (a chain at its cap): the temp
|
|
105
|
+
// knight is not left idle, and its thread is closed.
|
|
106
|
+
await ctx.archiveHelpers([helper.name]);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
logger.info(
|
|
110
|
+
{ agent: helper.name, parent: parent.name },
|
|
111
|
+
"temp knight created",
|
|
112
|
+
);
|
|
113
|
+
return `Created "${helper.displayName}" (${helper.name}); it is working on the task and will answer here.${said}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The next `<parent>-<n>` no agent or group holds, with the parent cut to leave room for the number. */
|
|
117
|
+
function nextName(ctx: TeamContext, parent: string): string {
|
|
118
|
+
const { store } = ctx.options;
|
|
119
|
+
const base = parent.slice(0, 26);
|
|
120
|
+
for (let n = 1; n < 100_000; n += 1) {
|
|
121
|
+
const name = `${base}-${n}`;
|
|
122
|
+
if (!store.agent(name) && !store.group(name)) return name;
|
|
123
|
+
}
|
|
124
|
+
throw new AgentError(`No free name for a temp knight of "${parent}".`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Holds the caps: when the parent or the host is full, archives the parent's oldest finished temp
|
|
129
|
+
* knight and says so; with none finished, refuses and says what to wait for.
|
|
130
|
+
*/
|
|
131
|
+
async function makeRoom(
|
|
132
|
+
ctx: TeamContext,
|
|
133
|
+
parent: Agent,
|
|
134
|
+
limits: { perParent: number; perHost: number },
|
|
135
|
+
): Promise<string> {
|
|
136
|
+
const { store } = ctx.options;
|
|
137
|
+
const mine = store.helpersOf(parent.name, "active");
|
|
138
|
+
const onHost = store
|
|
139
|
+
.agents()
|
|
140
|
+
.filter((agent) => isHelper(agent) && agent.status === "active").length;
|
|
141
|
+
const full =
|
|
142
|
+
mine.length >= limits.perParent
|
|
143
|
+
? `${mine.length} temp knights of yours are still working`
|
|
144
|
+
: onHost >= limits.perHost
|
|
145
|
+
? `The host's ${onHost} temp knights are all in use`
|
|
146
|
+
: undefined;
|
|
147
|
+
if (!full) return "";
|
|
148
|
+
const oldest = mine
|
|
149
|
+
.filter((agent) => agent.finishedAt !== undefined)
|
|
150
|
+
.sort(
|
|
151
|
+
(a, b) =>
|
|
152
|
+
(a.finishedAt as Date).getTime() - (b.finishedAt as Date).getTime(),
|
|
153
|
+
)[0];
|
|
154
|
+
if (!oldest)
|
|
155
|
+
throw new AgentError(
|
|
156
|
+
`${full}, and none is finished; end one with helper_end or wait for one to finish.`,
|
|
157
|
+
);
|
|
158
|
+
await ctx.archiveHelpers([oldest.name]);
|
|
159
|
+
return ` To make room, "${oldest.displayName}" (${oldest.name}), your oldest finished temp knight, was archived.`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Turns long-running on for the temp knight's conversations when the conversation that asked for
|
|
164
|
+
* it has it on, so it works as long as its parent may. This adds no permission: an owner turned
|
|
165
|
+
* it on there. Only a turn of an owner can carry that over, as for any change of modes.
|
|
166
|
+
*/
|
|
167
|
+
async function inheritLongRunning(
|
|
168
|
+
ctx: TeamContext,
|
|
169
|
+
caller: AgentTurnScope,
|
|
170
|
+
speaker: Speaker,
|
|
171
|
+
helper: Agent,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const { store, surfaces } = ctx.options;
|
|
174
|
+
const modes = ctx.options.modes?.();
|
|
175
|
+
if (!modes?.peek(ctx.turns.turnChannel(caller)).longRunning) return;
|
|
176
|
+
if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL)
|
|
177
|
+
return;
|
|
178
|
+
for (const key of keysOf(store, helper.name, surfaces?.roomSurfaces() ?? []))
|
|
179
|
+
await modes.set(key, { longRunning: true }, speaker);
|
|
180
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Agent } from "./agent-rules.ts";
|
|
2
|
+
|
|
3
|
+
/** A knight cleared its own temp knights with `helper_end`. */
|
|
4
|
+
export interface HelpersEnded {
|
|
5
|
+
/** The knight that ended them. */
|
|
6
|
+
parent: string;
|
|
7
|
+
/** The temp knights archived, by name. */
|
|
8
|
+
names: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** What the agent team offers for temp knights: the helpers a knight made for a task. */
|
|
12
|
+
export interface HelperTeam {
|
|
13
|
+
/** The active temp knights a knight made, in the order they were made. */
|
|
14
|
+
helpersOf(parent: string): Agent[];
|
|
15
|
+
/**
|
|
16
|
+
* The active temp knights that are finished (nothing runs, queued or waits for the owner), of
|
|
17
|
+
* the named parents or, with none named, of every knight.
|
|
18
|
+
*/
|
|
19
|
+
finishedHelpers(parents?: readonly string[]): Agent[];
|
|
20
|
+
/**
|
|
21
|
+
* Archives temp knights like `archive`, each keeping its history, and closes their Discord
|
|
22
|
+
* threads; a name that is no active temp knight is passed over. `by` is who asks, for the log.
|
|
23
|
+
* Says which were archived.
|
|
24
|
+
*/
|
|
25
|
+
archiveHelpers(names: readonly string[], by: string): Promise<string[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Restores archived temp knights, the undo of `archiveHelpers`: only a name that is still an
|
|
28
|
+
* archived temp knight of an active parent is restored. Says which were.
|
|
29
|
+
*/
|
|
30
|
+
restoreHelpers(names: readonly string[]): Promise<string[]>;
|
|
31
|
+
/** Called when a knight clears its own temp knights with `helper_end`; not for an owner's or the host's clearing. */
|
|
32
|
+
onHelpersEnded(listener: (event: HelpersEnded) => void): void;
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { NO_ATTACHMENTS } from "../domain/attachment.ts";
|
|
2
|
+
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
3
|
+
import type { Speaker } from "../speakers.ts";
|
|
4
|
+
import { OWNER_SPEAKER } from "../testing/owner.ts";
|
|
5
|
+
import { agentChannel, fx, settle } from "./agent-team-fixture.ts";
|
|
6
|
+
|
|
7
|
+
export { settle };
|
|
8
|
+
|
|
9
|
+
export const SCOUT = {
|
|
10
|
+
displayName: "Scout One",
|
|
11
|
+
prompt: "You look one thing up.",
|
|
12
|
+
task: "Look up the first paper.",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** The scope of an agent's turn in its own Discord channel. */
|
|
16
|
+
export const scopeOf = (name: string): AgentTurnScope => ({
|
|
17
|
+
name,
|
|
18
|
+
session: agentChannel(name),
|
|
19
|
+
home: agentChannel(name),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** The owner's message to an agent, answered in its own place through the channel's queue. */
|
|
23
|
+
export const writeTo = (name: string, text: string) =>
|
|
24
|
+
fx.queue.run(agentChannel(name), () =>
|
|
25
|
+
fx.team.answerOwner(
|
|
26
|
+
agentChannel(name),
|
|
27
|
+
OWNER_SPEAKER,
|
|
28
|
+
text,
|
|
29
|
+
text,
|
|
30
|
+
NO_ATTACHMENTS,
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Runs `action` during the next turn of the agent `name` in its own place, as the model calls a
|
|
36
|
+
* tool, after the work already running has settled, and waits for what the action started.
|
|
37
|
+
*/
|
|
38
|
+
export async function inTurn<T>(
|
|
39
|
+
name: string,
|
|
40
|
+
action: (caller: AgentTurnScope) => Promise<T>,
|
|
41
|
+
speaker: Speaker = OWNER_SPEAKER,
|
|
42
|
+
via: string = name,
|
|
43
|
+
): Promise<T> {
|
|
44
|
+
await settle();
|
|
45
|
+
let outcome: { value: T } | { error: unknown } | undefined;
|
|
46
|
+
fx.runtime.during = async (request) => {
|
|
47
|
+
if (request.agent?.name !== name) return;
|
|
48
|
+
fx.runtime.during = undefined;
|
|
49
|
+
outcome = await action(scopeOf(name)).then(
|
|
50
|
+
(value) => ({ value }),
|
|
51
|
+
(error: unknown) => ({ error }),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
// Through the channel's queue, as the host runs it, so what the turn starts waits for its end.
|
|
55
|
+
await fx.queue.run(agentChannel(via), () =>
|
|
56
|
+
fx.team.answerOwner(agentChannel(via), speaker, "go", "go", NO_ATTACHMENTS),
|
|
57
|
+
);
|
|
58
|
+
fx.runtime.during = undefined;
|
|
59
|
+
await settle();
|
|
60
|
+
if (!outcome) throw new Error(`no turn of ${name} ran the action`);
|
|
61
|
+
if ("error" in outcome) throw outcome.error;
|
|
62
|
+
return outcome.value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Runs `action` inside the coordinator's turn. */
|
|
66
|
+
export const asCoordinator = <T>(
|
|
67
|
+
action: (caller: AgentTurnScope) => Promise<T>,
|
|
68
|
+
) => inTurn("coordinator", action);
|
|
69
|
+
|
|
70
|
+
/** The text of every turn the runtime ran, by the agent that ran it. */
|
|
71
|
+
export const turnsOf = (name: string) =>
|
|
72
|
+
fx.runtime.turns.filter((turn) => turn.agent?.name === name);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
3
|
+
import { THINKING_LEVELS } from "../models.ts";
|
|
4
|
+
import {
|
|
5
|
+
optionalString,
|
|
6
|
+
requiredString,
|
|
7
|
+
stringList,
|
|
8
|
+
type TextToolDef,
|
|
9
|
+
} from "../runtime/text-tools.ts";
|
|
10
|
+
import { CLASS, COLOR, DISPLAY, lookOf, PROMPT } from "./agent-tool-schema.ts";
|
|
11
|
+
import type { AgentOps } from "./agent-tools.ts";
|
|
12
|
+
|
|
13
|
+
/** The tools that make and end a knight's temp knights. */
|
|
14
|
+
export function helperToolDefs(
|
|
15
|
+
ops: AgentOps,
|
|
16
|
+
scope: AgentTurnScope,
|
|
17
|
+
skillsOn: boolean,
|
|
18
|
+
): TextToolDef<"helper_create" | "helper_end">[] {
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
name: "helper_create",
|
|
22
|
+
label: "Create temp knight",
|
|
23
|
+
description: `Make a temp knight (a helper knight) for one self-contained task that can run on its own, such as one of several things to look up or compare. It is a real agent with its own picture and place, listed under you; it starts at once and its answer comes back to you here as a new turn, like message_agent's. Make one per task, then end your turn; when the answers are in, merge them and clear the ones that are done with helper_end. It runs your model unless you give one, cannot make knights, temp knights or groups, and has no schedules. You may have a few at once, and when you are at the limit your oldest finished one is archived to make room.`,
|
|
24
|
+
parameters: Type.Object({
|
|
25
|
+
display_name: DISPLAY,
|
|
26
|
+
prompt: PROMPT,
|
|
27
|
+
task: Type.String({
|
|
28
|
+
minLength: 1,
|
|
29
|
+
maxLength: 4000,
|
|
30
|
+
description:
|
|
31
|
+
"The task, self-contained: what to do, what to read, and what to answer with.",
|
|
32
|
+
}),
|
|
33
|
+
name: Type.Optional(
|
|
34
|
+
Type.String({
|
|
35
|
+
pattern: "^[a-z0-9-]{1,32}$",
|
|
36
|
+
description:
|
|
37
|
+
"Permanent name: lowercase letters, digits, and dashes. Left out, it is <your name>-<number>.",
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
class: Type.Optional(CLASS),
|
|
41
|
+
color: Type.Optional(COLOR),
|
|
42
|
+
model: Type.Optional(
|
|
43
|
+
Type.String({
|
|
44
|
+
description:
|
|
45
|
+
"The model to run on, as <provider>/<model>; left out, yours. An unusable one is refused with the usable list.",
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
thinking: Type.Optional(
|
|
49
|
+
Type.Union(
|
|
50
|
+
[...THINKING_LEVELS, "default"].map((level) => Type.Literal(level)),
|
|
51
|
+
{ description: "Its thinking level; left out, yours." },
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
...(skillsOn
|
|
55
|
+
? {
|
|
56
|
+
skills: Type.Optional(
|
|
57
|
+
Type.Array(Type.String(), {
|
|
58
|
+
description:
|
|
59
|
+
"Skills it carries, from skill_list; pick those the task needs.",
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
}),
|
|
65
|
+
run: (i) => {
|
|
66
|
+
const name = optionalString(i, "name");
|
|
67
|
+
const model = optionalString(i, "model");
|
|
68
|
+
const thinking = optionalString(i, "thinking");
|
|
69
|
+
const skills = stringList(i, "skills");
|
|
70
|
+
const look = lookOf(i);
|
|
71
|
+
return ops.helperCreate(scope, {
|
|
72
|
+
displayName: requiredString(i, "display_name"),
|
|
73
|
+
prompt: requiredString(i, "prompt"),
|
|
74
|
+
task: requiredString(i, "task"),
|
|
75
|
+
...(name ? { name } : {}),
|
|
76
|
+
...(Object.keys(look).length > 0 ? { character: look } : {}),
|
|
77
|
+
...(model ? { model } : {}),
|
|
78
|
+
...(thinking ? { thinking } : {}),
|
|
79
|
+
...(skills && skills.length > 0 ? { skills } : {}),
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "helper_end",
|
|
85
|
+
label: "End temp knights",
|
|
86
|
+
description:
|
|
87
|
+
'Archive your own temp knights when you are done with them: the names you list, or "finished" for every one that has finished. Their history stays readable under the archive, and the owner can restore them. You cannot end anyone else\'s.',
|
|
88
|
+
parameters: Type.Object({
|
|
89
|
+
names: Type.Union(
|
|
90
|
+
[
|
|
91
|
+
Type.Array(Type.String(), { minItems: 1, maxItems: 50 }),
|
|
92
|
+
Type.Literal("finished"),
|
|
93
|
+
],
|
|
94
|
+
{ description: 'Their names, or "finished" for every finished one.' },
|
|
95
|
+
),
|
|
96
|
+
}),
|
|
97
|
+
run: (i) =>
|
|
98
|
+
ops.helperEnd(
|
|
99
|
+
scope,
|
|
100
|
+
i.names === "finished" ? "finished" : (stringList(i, "names") ?? []),
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|