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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { QueuePort } from "../contract/channels.ts";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
|
+
import type { Logger } from "../log.ts";
|
|
4
|
+
import type { AgentTurnRunner } from "./agent-ports.ts";
|
|
5
|
+
import { isHelper } from "./agent-rules.ts";
|
|
6
|
+
import type { PgAgentStore } from "./agent-store.ts";
|
|
7
|
+
import { keysOf, scopeOf } from "./team-keys.ts";
|
|
8
|
+
|
|
9
|
+
export interface HelperWorkOptions {
|
|
10
|
+
store: Pick<PgAgentStore, "agent" | "group" | "setFinished">;
|
|
11
|
+
queue: Pick<QueuePort, "size">;
|
|
12
|
+
runtime: () => Pick<AgentTurnRunner, "heldActions">;
|
|
13
|
+
/** The surfaces that host rooms, which are places of a temp knight beside its Discord thread. */
|
|
14
|
+
roomSurfaces: () => readonly string[];
|
|
15
|
+
/** Called after a temp knight's state changed, so the pages and the dashboard redraw. */
|
|
16
|
+
changed: () => void;
|
|
17
|
+
logger: Logger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What keeps a temp knight working: a turn that runs, a message from its parent that has not been
|
|
22
|
+
* answered, a turn or message queued in one of its places, or a held action that waits for the
|
|
23
|
+
* owner. The moment none holds, the knight is *finished* and the store says so; any work that
|
|
24
|
+
* begins makes it working again, so no one reopens it.
|
|
25
|
+
*/
|
|
26
|
+
export class HelperWork {
|
|
27
|
+
readonly #options: HelperWorkOptions;
|
|
28
|
+
/** Turns and unanswered messages of each temp knight. */
|
|
29
|
+
readonly #running = new Map<string, number>();
|
|
30
|
+
|
|
31
|
+
constructor(options: HelperWorkOptions) {
|
|
32
|
+
this.#options = options;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A turn or a message to the agent begins; a finished temp knight is working again. */
|
|
36
|
+
begin(name: string): void {
|
|
37
|
+
if (!this.#helper(name)) return;
|
|
38
|
+
this.#running.set(name, (this.#running.get(name) ?? 0) + 1);
|
|
39
|
+
this.#stamp(name, false);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One piece of work of the agent ended; with nothing else holding it, a temp knight is finished. */
|
|
43
|
+
end(name: string): void {
|
|
44
|
+
const left = (this.#running.get(name) ?? 0) - 1;
|
|
45
|
+
if (left > 0) {
|
|
46
|
+
this.#running.set(name, left);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.#running.delete(name);
|
|
50
|
+
if (!this.#helper(name)) return;
|
|
51
|
+
void this.#settle(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether the agent is an active temp knight. */
|
|
55
|
+
#helper(name: string): boolean {
|
|
56
|
+
const agent = this.#options.store.agent(name);
|
|
57
|
+
return agent?.status === "active" && isHelper(agent);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async #settle(name: string): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
if (await this.#waiting(name)) return;
|
|
63
|
+
// Work begun while the held actions were read keeps it working.
|
|
64
|
+
if ((this.#running.get(name) ?? 0) > 0) return;
|
|
65
|
+
this.#stamp(name, true);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
this.#options.logger.warn(
|
|
68
|
+
{ agent: name, err: error },
|
|
69
|
+
"temp knight's state not settled",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether something else waits: a turn queued behind this one, or an action held for the owner. */
|
|
75
|
+
async #waiting(name: string): Promise<boolean> {
|
|
76
|
+
const { store, queue, roomSurfaces } = this.#options;
|
|
77
|
+
const agent = store.agent(name);
|
|
78
|
+
if (!agent) return false;
|
|
79
|
+
const keys: ChannelKey[] = keysOf(store, name, roomSurfaces());
|
|
80
|
+
// The task that just ended still counts in its channel's queue until it returns.
|
|
81
|
+
if (keys.some((key) => queue.size(key) > 1)) return true;
|
|
82
|
+
const runtime = this.#options.runtime();
|
|
83
|
+
for (const key of keys)
|
|
84
|
+
if (await runtime.heldActions(scopeOf(agent, key).session)) return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#stamp(name: string, finished: boolean): void {
|
|
89
|
+
const { store, changed, logger } = this.#options;
|
|
90
|
+
store.setFinished(name, finished).then(
|
|
91
|
+
(moved) => {
|
|
92
|
+
if (moved) changed();
|
|
93
|
+
},
|
|
94
|
+
(error: unknown) =>
|
|
95
|
+
logger.warn(
|
|
96
|
+
{ agent: name, err: error },
|
|
97
|
+
"temp knight's state not kept",
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -2,13 +2,8 @@ import type { ChannelKey } from "../domain/conversation.ts";
|
|
|
2
2
|
import { AgentError } from "../domain/errors.ts";
|
|
3
3
|
import type { AgentDirectory } from "../services.ts";
|
|
4
4
|
import type { Agent } from "./agent-store.ts";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
discordIdOf,
|
|
8
|
-
discordKey,
|
|
9
|
-
keysOf,
|
|
10
|
-
roomSurfaceOf,
|
|
11
|
-
} from "./team-keys.ts";
|
|
5
|
+
import { Routes } from "./routes.ts";
|
|
6
|
+
import { agentRoomKey, discordKey, keysOf } from "./team-keys.ts";
|
|
12
7
|
|
|
13
8
|
/** What the choice of a message's conversation reads. */
|
|
14
9
|
export interface MessageTargetContext {
|
|
@@ -19,6 +14,8 @@ export interface MessageTargetContext {
|
|
|
19
14
|
latest: ChannelKey | undefined;
|
|
20
15
|
/** The conversation the sender writes from. */
|
|
21
16
|
from: ChannelKey;
|
|
17
|
+
/** The host's routing table; without it the defaults every host had before `routing`. */
|
|
18
|
+
routes?: Pick<Routes, "of">;
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
/** The word for the web app's conversation, accepted when exactly one surface hosts rooms. */
|
|
@@ -27,8 +24,9 @@ const WEB = "web";
|
|
|
27
24
|
/**
|
|
28
25
|
* The conversation of `target` that a message is delivered into. With `ask` (a surface name such
|
|
29
26
|
* as "discord", "web", or the surface of a room, or one of the target's conversation keys) that
|
|
30
|
-
* one; otherwise the conversation the
|
|
31
|
-
*
|
|
27
|
+
* one; otherwise the conversation the routing table picks (`Routes.of`): by default the one the
|
|
28
|
+
* target used most recently, which a message from a room never follows out to Discord, and, when
|
|
29
|
+
* it has run none yet, the sender's own surface, so a message between rooms stays among the rooms.
|
|
32
30
|
*/
|
|
33
31
|
export function messageTarget(
|
|
34
32
|
ctx: MessageTargetContext,
|
|
@@ -38,20 +36,14 @@ export function messageTarget(
|
|
|
38
36
|
const keys = keysOf(ctx.store, target.name, ctx.roomSurfaces);
|
|
39
37
|
const has = keys.length > 0 ? keys.join(", ") : "none";
|
|
40
38
|
if (ask === undefined || ask.trim() === "") {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (via === undefined) {
|
|
50
|
-
if (!target.channelId)
|
|
51
|
-
throw new AgentError(`${target.name} has no channel yet.`);
|
|
52
|
-
return discordKey(target.channelId);
|
|
53
|
-
}
|
|
54
|
-
return agentRoomKey(via, target.name);
|
|
39
|
+
const routes =
|
|
40
|
+
ctx.routes ??
|
|
41
|
+
new Routes({
|
|
42
|
+
store: ctx.store,
|
|
43
|
+
roomSurfaces: () => ctx.roomSurfaces,
|
|
44
|
+
latest: () => ctx.latest,
|
|
45
|
+
});
|
|
46
|
+
return routes.of(target.name, "agentMessage", ctx.from);
|
|
55
47
|
}
|
|
56
48
|
const wanted = ask.trim();
|
|
57
49
|
const exact = keys.find((key) => key === wanted);
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ResolvedAgentRoutes,
|
|
3
|
+
ResolvedRouting,
|
|
4
|
+
} from "../config/routing.ts";
|
|
5
|
+
import { ROUTE_WORDS } from "../config/routing.ts";
|
|
6
|
+
import { parseChannelKey } from "../contract/surface.ts";
|
|
7
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
8
|
+
import { AgentError, ConfigError } from "../domain/errors.ts";
|
|
9
|
+
import type { AgentDirectory } from "../services.ts";
|
|
10
|
+
import {
|
|
11
|
+
agentRoomKey,
|
|
12
|
+
discordKey,
|
|
13
|
+
keysOf,
|
|
14
|
+
roomSurfaceOf,
|
|
15
|
+
} from "./team-keys.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The routing table: where an agent's work lands when no person is waiting on it. An agent's
|
|
19
|
+
* *place* on a surface is its Discord channel or its room there; its *primary place* is where
|
|
20
|
+
* unprompted work lands. Work a person started, or that waits on a person, is never routed.
|
|
21
|
+
* Read it as `AGENTS.routes`.
|
|
22
|
+
*/
|
|
23
|
+
export interface RoutingTable {
|
|
24
|
+
/**
|
|
25
|
+
* The agent's primary place: the surface its rules name; with `"auto"` (the default) its Discord
|
|
26
|
+
* channel when it has one, else its room on the first surface that hosts rooms. Undefined for
|
|
27
|
+
* an unknown agent.
|
|
28
|
+
*/
|
|
29
|
+
primary(agent: string): ChannelKey | undefined;
|
|
30
|
+
/** The agent's place on a surface (`"web"` for the one surface that hosts rooms); undefined when it has none there. */
|
|
31
|
+
placeOn(agent: string, surface: string): ChannelKey | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The conversation an event of `kind` for the agent lands in; `origin` is where it came in
|
|
34
|
+
* (a webhook post's conversation, the sender's turn channel, the conversation that asked for
|
|
35
|
+
* the agent). An `integration` event is never dropped: a vanished destination sends it to its
|
|
36
|
+
* origin. An `agentMessage` throws AgentError when the agent has no place; an `opening` task
|
|
37
|
+
* has none when the agent has no place at all.
|
|
38
|
+
*/
|
|
39
|
+
of(agent: string, kind: "integration", origin: ChannelKey): ChannelKey;
|
|
40
|
+
of(agent: string, kind: "agentMessage", origin?: ChannelKey): ChannelKey;
|
|
41
|
+
of(
|
|
42
|
+
agent: string,
|
|
43
|
+
kind: RouteKind,
|
|
44
|
+
origin?: ChannelKey,
|
|
45
|
+
): ChannelKey | undefined;
|
|
46
|
+
/** A named destination of the host, such as `ops`; undefined when none is set or its agent is not active. */
|
|
47
|
+
place(name: string): ChannelKey | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What starts agent work with no person waiting on the answer, and so may be routed. */
|
|
51
|
+
export type RouteKind = "integration" | "agentMessage" | "opening";
|
|
52
|
+
|
|
53
|
+
/** The word for the web app's surface, taken for it when exactly one surface hosts rooms. */
|
|
54
|
+
const WEB = "web";
|
|
55
|
+
|
|
56
|
+
/** What the table writes to: a warning with its fields. */
|
|
57
|
+
export interface RoutesLogger {
|
|
58
|
+
warn(fields: Record<string, unknown>, message: string): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RoutesOptions {
|
|
62
|
+
/** The checked `routing` block; without it every decision is what the host did before it existed. */
|
|
63
|
+
routing?: ResolvedRouting | undefined;
|
|
64
|
+
store: Pick<AgentDirectory, "agent" | "group">;
|
|
65
|
+
/** The surfaces that host agent rooms, read each time: they are only known once the plugins linked. */
|
|
66
|
+
roomSurfaces: () => readonly string[];
|
|
67
|
+
/** The conversation an agent last ran a turn in. */
|
|
68
|
+
latest?: (agent: string) => ChannelKey | undefined;
|
|
69
|
+
logger?: RoutesLogger;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the surfaces the host registered can hold, read when the agent server links. */
|
|
73
|
+
export interface LinkedSurfaces {
|
|
74
|
+
/** Every registered surface. */
|
|
75
|
+
names: readonly string[];
|
|
76
|
+
/** Whether the agents have Discord channels. */
|
|
77
|
+
discordChannels: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The one table that says where an agent's work lands. An agent's *place* on a surface is its
|
|
82
|
+
* Discord channel or its room there; its *primary place* is where work no person started lands.
|
|
83
|
+
* Work a person started, or that waits on a person (a message, a card, a schedule's firing, an
|
|
84
|
+
* exchange's answer), is never routed: it stays in the conversation they see. Every decision here
|
|
85
|
+
* is what the host did before `routing` existed until the configuration says otherwise.
|
|
86
|
+
*/
|
|
87
|
+
export class Routes implements RoutingTable {
|
|
88
|
+
readonly #routing: ResolvedRouting | undefined;
|
|
89
|
+
readonly #options: RoutesOptions;
|
|
90
|
+
readonly #warned = new Set<string>();
|
|
91
|
+
|
|
92
|
+
constructor(options: RoutesOptions) {
|
|
93
|
+
this.#options = options;
|
|
94
|
+
this.#routing = options.routing;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The rules of one agent: its own over the `"*"` entry, over the defaults. A temp knight has
|
|
99
|
+
* no row of its own and follows its parent's, so it lives where its parent does.
|
|
100
|
+
*/
|
|
101
|
+
rules(agent: string): ResolvedAgentRoutes {
|
|
102
|
+
const all = this.#routing?.agents;
|
|
103
|
+
const parent = this.#options.store.agent(agent)?.parent;
|
|
104
|
+
return {
|
|
105
|
+
...all?.["*"],
|
|
106
|
+
...(parent === undefined ? undefined : all?.[parent]),
|
|
107
|
+
...all?.[agent],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The agent's place on a surface (`"web"` for the one surface that hosts rooms); undefined when
|
|
113
|
+
* the agent is unknown or has none there.
|
|
114
|
+
*/
|
|
115
|
+
placeOn(name: string, surface: string): ChannelKey | undefined {
|
|
116
|
+
const agent = this.#options.store.agent(name);
|
|
117
|
+
if (!agent) return undefined;
|
|
118
|
+
const rooms = this.#options.roomSurfaces();
|
|
119
|
+
const wanted =
|
|
120
|
+
surface === WEB && !rooms.includes(WEB) && rooms.length === 1
|
|
121
|
+
? (rooms[0] as string)
|
|
122
|
+
: surface;
|
|
123
|
+
if (wanted === "discord")
|
|
124
|
+
return agent.channelId ? discordKey(agent.channelId) : undefined;
|
|
125
|
+
return rooms.includes(wanted) ? agentRoomKey(wanted, name) : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The agent's primary place, where its unprompted work lands: the surface its rules name, and
|
|
130
|
+
* with `"auto"` its Discord channel when it has one, else its room on the first surface that
|
|
131
|
+
* hosts rooms. An unknown agent has none. A named surface the agent has no place on is logged,
|
|
132
|
+
* and its auto place is used.
|
|
133
|
+
*/
|
|
134
|
+
primary(name: string): ChannelKey | undefined {
|
|
135
|
+
if (!this.#options.store.agent(name)) return undefined;
|
|
136
|
+
const wanted = this.rules(name).primary ?? "auto";
|
|
137
|
+
if (wanted !== "auto") {
|
|
138
|
+
const key = this.placeOn(name, wanted);
|
|
139
|
+
if (key) return key;
|
|
140
|
+
this.#warn(
|
|
141
|
+
`primary:${name}`,
|
|
142
|
+
{ agent: name, surface: wanted },
|
|
143
|
+
"the agent has no place on its primary surface; its auto place is used",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return keysOf(this.#options.store, name, this.#options.roomSurfaces())[0];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The conversation an event of `kind` for the agent lands in. `origin` is where it came in: the
|
|
151
|
+
* conversation of a webhook post (`integration`), the sender's turn channel (`agentMessage`), or
|
|
152
|
+
* the conversation that asked for the agent (`opening`). A destination that has vanished sends
|
|
153
|
+
* an `integration` event to its origin, with a warning, and never drops it. Throws AgentError
|
|
154
|
+
* when an `agentMessage` finds no place; undefined when an `opening` finds none.
|
|
155
|
+
*/
|
|
156
|
+
of(name: string, kind: "integration", origin: ChannelKey): ChannelKey;
|
|
157
|
+
of(name: string, kind: "agentMessage", origin?: ChannelKey): ChannelKey;
|
|
158
|
+
of(
|
|
159
|
+
name: string,
|
|
160
|
+
kind: RouteKind,
|
|
161
|
+
origin?: ChannelKey,
|
|
162
|
+
): ChannelKey | undefined;
|
|
163
|
+
of(
|
|
164
|
+
name: string,
|
|
165
|
+
kind: RouteKind,
|
|
166
|
+
origin?: ChannelKey,
|
|
167
|
+
): ChannelKey | undefined {
|
|
168
|
+
if (kind === "integration") return this.#integration(name, origin);
|
|
169
|
+
if (kind === "opening") return this.#opening(name, origin);
|
|
170
|
+
return this.#agentMessage(name, origin);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#integration(
|
|
174
|
+
name: string,
|
|
175
|
+
origin: ChannelKey | undefined,
|
|
176
|
+
): ChannelKey | undefined {
|
|
177
|
+
const to = this.rules(name).integration ?? "origin";
|
|
178
|
+
if (to === "origin" || origin === undefined) return origin;
|
|
179
|
+
const key = to === "primary" ? this.primary(name) : this.placeOn(name, to);
|
|
180
|
+
if (key) return key;
|
|
181
|
+
this.#warn(
|
|
182
|
+
`integration:${name}`,
|
|
183
|
+
{ agent: name, to },
|
|
184
|
+
"the agent has no place for its integrations; the report runs where the post was made",
|
|
185
|
+
);
|
|
186
|
+
return origin;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#opening(
|
|
190
|
+
name: string,
|
|
191
|
+
origin: ChannelKey | undefined,
|
|
192
|
+
): ChannelKey | undefined {
|
|
193
|
+
if (
|
|
194
|
+
(this.rules(name).opening ?? "origin-surface") === "origin-surface" &&
|
|
195
|
+
origin
|
|
196
|
+
) {
|
|
197
|
+
const key = this.placeOn(name, parseChannelKey(origin).surface);
|
|
198
|
+
if (key) return key;
|
|
199
|
+
}
|
|
200
|
+
return this.primary(name);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The conversation a message to the agent lands in when the sender names none: with `latest`
|
|
205
|
+
* the one it last ran a turn in, except that a message from a room never follows it out to
|
|
206
|
+
* Discord, since the room's text may be for the owners alone; with none yet, the sender's own
|
|
207
|
+
* surface when it hosts rooms, so a message between rooms stays among them; otherwise its
|
|
208
|
+
* primary place.
|
|
209
|
+
*/
|
|
210
|
+
#agentMessage(name: string, from: ChannelKey | undefined): ChannelKey {
|
|
211
|
+
const { store, roomSurfaces, latest } = this.#options;
|
|
212
|
+
const to = this.rules(name).agentMessage ?? "latest";
|
|
213
|
+
if (to !== "latest") {
|
|
214
|
+
const key =
|
|
215
|
+
to === "primary" ? this.primary(name) : this.placeOn(name, to);
|
|
216
|
+
if (key) return key;
|
|
217
|
+
this.#warn(
|
|
218
|
+
`message:${name}`,
|
|
219
|
+
{ agent: name, to },
|
|
220
|
+
"the agent has no place for messages to it; the conversation it last ran a turn in is used",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const via = from === undefined ? undefined : roomSurfaceOf(from);
|
|
224
|
+
const last = latest?.(name);
|
|
225
|
+
const keys = keysOf(store, name, roomSurfaces());
|
|
226
|
+
const outward =
|
|
227
|
+
via !== undefined &&
|
|
228
|
+
last !== undefined &&
|
|
229
|
+
roomSurfaceOf(last) === undefined;
|
|
230
|
+
if (last !== undefined && keys.includes(last) && !outward) return last;
|
|
231
|
+
if (via === undefined) {
|
|
232
|
+
const primary = this.primary(name);
|
|
233
|
+
if (!primary) throw new AgentError(`${name} has no channel yet.`);
|
|
234
|
+
return primary;
|
|
235
|
+
}
|
|
236
|
+
return agentRoomKey(via, name);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Another agent's place for `schedule_list`: its room on the surface `near` is on when that
|
|
241
|
+
* surface hosts rooms, otherwise its primary place. Throws AgentError when it has none.
|
|
242
|
+
*/
|
|
243
|
+
near(name: string, near?: ChannelKey): ChannelKey {
|
|
244
|
+
const surface = near === undefined ? undefined : roomSurfaceOf(near);
|
|
245
|
+
const room =
|
|
246
|
+
surface === undefined ? undefined : this.placeOn(name, surface);
|
|
247
|
+
const key = room ?? this.primary(name);
|
|
248
|
+
if (!key) throw new AgentError(`${name} has no channel yet`);
|
|
249
|
+
return key;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A named destination of the host (`ops`): its conversation, or the primary place of the agent
|
|
254
|
+
* it names while that agent is active; undefined when none is configured or it has none now.
|
|
255
|
+
*/
|
|
256
|
+
place(name: string): ChannelKey | undefined {
|
|
257
|
+
const target = this.#routing?.places[name];
|
|
258
|
+
if (target === undefined) return undefined;
|
|
259
|
+
if (typeof target === "string") return target;
|
|
260
|
+
return this.#options.store.agent(target.agent)?.status === "active"
|
|
261
|
+
? this.primary(target.agent)
|
|
262
|
+
: undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Refuses a surface the rules name that is not registered, or that cannot hold an agent's place,
|
|
267
|
+
* naming the key and the fix; called once every plugin has registered its surfaces.
|
|
268
|
+
*/
|
|
269
|
+
validate(linked: LinkedSurfaces): void {
|
|
270
|
+
if (!this.#routing) return;
|
|
271
|
+
const rooms = this.#options.roomSurfaces();
|
|
272
|
+
const holders = [...(linked.discordChannels ? ["discord"] : []), ...rooms];
|
|
273
|
+
for (const [agent, rules] of Object.entries(this.#routing?.agents ?? {}))
|
|
274
|
+
for (const key of ["primary", "integration", "agentMessage"] as const) {
|
|
275
|
+
const surface = rules[key];
|
|
276
|
+
if (
|
|
277
|
+
surface === undefined ||
|
|
278
|
+
(ROUTE_WORDS[key] as readonly string[]).includes(surface)
|
|
279
|
+
)
|
|
280
|
+
continue;
|
|
281
|
+
const path = `routing.agents.${agent}.${key}`;
|
|
282
|
+
if (surface === WEB && !linked.names.includes(WEB)) {
|
|
283
|
+
if (rooms.length !== 1)
|
|
284
|
+
throw new ConfigError(
|
|
285
|
+
`config ${path}: "web" stands for the one surface that hosts rooms, and ${rooms.length === 0 ? "none does" : `${rooms.length} do (${rooms.join(", ")})`}. Name the surface: ${holders.join(", ") || "none hosts agent places on this host"}.`,
|
|
286
|
+
);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!linked.names.includes(surface))
|
|
290
|
+
throw new ConfigError(
|
|
291
|
+
`config ${path}: no surface is registered as ${JSON.stringify(surface)}. The registered surfaces are ${linked.names.join(", ") || "none"}; name one of them, or add the plugin that brings ${JSON.stringify(surface)}.`,
|
|
292
|
+
);
|
|
293
|
+
if (!holders.includes(surface))
|
|
294
|
+
throw new ConfigError(
|
|
295
|
+
`config ${path}: the ${surface} surface holds no agent places, since it hosts no rooms${surface === "discord" ? " here (the agents have no Discord channels)" : ""}. Name one that does: ${holders.join(", ") || "none on this host"}.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Warns of the agents the rules and places name that the team does not know yet, which may be
|
|
302
|
+
* created later: their rules apply once they exist.
|
|
303
|
+
*/
|
|
304
|
+
warnUnknown(): void {
|
|
305
|
+
const names = [
|
|
306
|
+
...Object.keys(this.#routing?.agents ?? {}).filter((n) => n !== "*"),
|
|
307
|
+
...Object.values(this.#routing?.places ?? {}).flatMap((t) =>
|
|
308
|
+
typeof t === "object" ? [t.agent] : [],
|
|
309
|
+
),
|
|
310
|
+
];
|
|
311
|
+
const unknown = [...new Set(names)].filter(
|
|
312
|
+
(n) => !this.#options.store.agent(n),
|
|
313
|
+
);
|
|
314
|
+
if (unknown.length > 0)
|
|
315
|
+
this.#options.logger?.warn(
|
|
316
|
+
{ agents: unknown },
|
|
317
|
+
"routing names agents the team does not have yet; their rules apply once they exist",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Warns once per key, so a repeated event does not flood the log. */
|
|
322
|
+
#warn(key: string, fields: Record<string, unknown>, message: string): void {
|
|
323
|
+
if (this.#warned.has(key)) return;
|
|
324
|
+
this.#warned.add(key);
|
|
325
|
+
this.#options.logger?.warn(fields, message);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The routing table of a team's options: the host's own, or, without one, the defaults. */
|
|
330
|
+
export function routesOf(options: {
|
|
331
|
+
routes?: Routes | undefined;
|
|
332
|
+
store: RoutesOptions["store"] & {
|
|
333
|
+
conversations: { latest(agent: string): ChannelKey | undefined };
|
|
334
|
+
};
|
|
335
|
+
surfaces?: { roomSurfaces(): readonly string[] } | undefined;
|
|
336
|
+
logger: RoutesLogger;
|
|
337
|
+
}): Routes {
|
|
338
|
+
return (
|
|
339
|
+
options.routes ??
|
|
340
|
+
new Routes({
|
|
341
|
+
store: options.store,
|
|
342
|
+
roomSurfaces: () => options.surfaces?.roomSurfaces() ?? [],
|
|
343
|
+
latest: (agent) => options.store.conversations.latest(agent),
|
|
344
|
+
logger: options.logger,
|
|
345
|
+
})
|
|
346
|
+
);
|
|
347
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PendingConfirmation } from "../domain/conversation.ts";
|
|
2
|
+
import { mayApproveNamed } from "../runtime/extensions/held-calls.ts";
|
|
3
|
+
import { type Speaker, tierAtLeast } from "../speakers.ts";
|
|
4
|
+
import type { ToolTiers } from "../tool-tiers.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the speaker may approve the held actions by a message, call by call: an owner, as the
|
|
8
|
+
* agents' channels are shared, or, for a call whose tool named its approvers, one of those
|
|
9
|
+
* principals, or, for any other call, the principal whose turn held them (by their id, for
|
|
10
|
+
* actions held before 0.9). Their tier must now hold every call. Nobody else in the channel
|
|
11
|
+
* may, so a call that named approvers is not its speaker's to approve unless named. The gate
|
|
12
|
+
* checks the named approvers again when the turn begins, whoever asked for the release.
|
|
13
|
+
*/
|
|
14
|
+
export function mayApprove(
|
|
15
|
+
tiers: Pick<ToolTiers, "allows">,
|
|
16
|
+
speaker: Speaker,
|
|
17
|
+
pending: PendingConfirmation,
|
|
18
|
+
): boolean {
|
|
19
|
+
const theirs =
|
|
20
|
+
pending.principalId !== undefined
|
|
21
|
+
? speaker.principalId === pending.principalId
|
|
22
|
+
: speaker.id === pending.speakerId;
|
|
23
|
+
// Nothing held names an approver to go by: only the owners and the speaker whose turn held it.
|
|
24
|
+
if (pending.calls.length === 0) return speaker.tier === "owner" || theirs;
|
|
25
|
+
return pending.calls.every(
|
|
26
|
+
(call) =>
|
|
27
|
+
(speaker.tier === "owner" ||
|
|
28
|
+
(call.approverIds ? mayApproveNamed(speaker, call) : theirs)) &&
|
|
29
|
+
tiers.allows(speaker.tier, call.tool) &&
|
|
30
|
+
(!call.minTier || tierAtLeast(speaker.tier, call.minTier)),
|
|
31
|
+
);
|
|
32
|
+
}
|