pi-roundtable 0.28.0 → 0.29.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 +25 -0
- package/docs/plugins.md +32 -9
- package/package.json +1 -1
- package/src/core/agents/agent-messages.ts +5 -7
- package/src/core/agents/agent-rules.ts +27 -0
- package/src/core/agents/agent-team.ts +5 -0
- package/src/core/agents/agent-tools.ts +18 -10
- package/src/core/agents/message-target.ts +20 -18
- package/src/core/agents/routes.ts +42 -24
- package/src/core/agents/team-editing.ts +5 -5
- package/src/core/agents/team-helpers.ts +76 -5
- package/src/core/agents/team-keys.ts +14 -0
- package/src/core/agents/team-turns.ts +2 -4
- package/src/core/builtin/modules.ts +15 -15
- package/src/core/config/routing.ts +5 -4
- package/src/core/contract/channels.ts +55 -2
- package/src/core/discord/direct-channel.ts +1 -0
- package/src/core/host-registration.ts +4 -0
- package/src/core/modules/notify/notify.ts +3 -2
- package/src/core/modules/notify/session-notify.ts +21 -4
- package/src/core/modules/skills/skill-contract.ts +11 -0
- package/src/core/modules/skills/skill-registry.ts +3 -1
- package/src/core/modules/skills/skill-store.ts +4 -3
- package/src/core/presence/delivery-router.ts +165 -0
- package/src/core/presence/direct-channels.ts +39 -68
- package/src/core/routing/actor-of.ts +40 -0
- package/src/core/routing/background-speaker.ts +50 -0
- package/src/core/routing/channel-router.ts +73 -97
- package/src/core/routing/queued-messages.ts +151 -0
- package/src/index.ts +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.29.1] - 2026-10-31
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Queued messages can be listed, edited and cancelled. A message sent while a turn runs that the turn did not take waits in the channel's queue (`QUEUED_MARK`); until its own turn starts, `ConversationPort.queued(channel)` lists it (`QueuedMessage`: `messageId`, `principalId`, `text`, `queuedAt`), `cancelQueued(channel, messageId, by)` makes its turn never run (the claim's `Admission.dropped` is called and the mark comes off), and `editQueued(channel, messageId, by, text)` admits it again with the new text, so the turn runs on that. `by` is the author's principal (`QueuedBy`); anyone else gets `forbidden`, and a message that started, was steered or was cancelled is `unknown` (`QueuedChange`). The three are optional on the port type, as `messageDeleted` is. A surface that reports a deleted queued message cancels it, and one that reports an edited one changes its text, through `SurfaceMessageEvents` as before: on Discord, deleting a queued message cancels it and editing it changes what its turn uses. `QUEUED_MARK` and `STEERED_MARK` are exported from the main entry.
|
|
13
|
+
|
|
14
|
+
## [0.29.0] - 2026-10-31
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `SkillRegistry.groups()`, `setGroup(name, description, skills)` and `deleteGroup(name)` are part of the skill registry interface (`SkillGroup`: `name`, `description`, `skills`), so a host surface can manage skill groups. A group stays a filter and a list to apply once: an agent carries skills, not groups. `SkillRegistry` has three new required methods, so a hand-built stand-in must set them.
|
|
19
|
+
- `helperSurface` tells the surface a temp knight was made on, read off its stored origin. `AgentOps.reach` readies a message's place before `message_agent` delivers it.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Delivery follows the conversation the work came from. One delivery router (`src/core/presence/delivery-router.ts`) decides where a notice or run for a person goes from its origin: a provider on the origin's surface first, another surface only when none there reaches the person, logged with both surfaces. A schedule made in a web room that calls `notify` now notices in the web app (Home notice and push) instead of a Discord direct message. The web app's push mirror of a notice Discord delivered stays, to the same person only.
|
|
24
|
+
- Breaking: `DirectChannels.reach`, `known`, and `notify` take the origin `ChannelKey` first (`reach(origin, principalId)`, `notify(origin, principalId, text)`), and `DirectChannels` no longer extends `Notifier`. `DirectChannelProvider.surface` (optional, default `name`) names the surface a provider reaches; the Discord, web, and webchat providers set it.
|
|
25
|
+
- `message_agent` without `conversation` reaches the target's place on the sender's surface (`routing.agents.<name>.agentMessage` default `origin-surface`), never the conversation it used most recently on another surface; with no place there it lands in the primary place, logged. `latest` now follows only on the sender's surface. `RoutingTable.of(agent, "agentMessage", origin)` requires the origin.
|
|
26
|
+
- The notify tool's description says it goes out on the conversation's own surface.
|
|
27
|
+
- A temp knight lives only on the surface of the conversation that made it. One made in the web app gets no Discord thread at creation; the thread is made once, when a `message_agent` call from a turn that may message it names `conversation: "discord"`, or names no conversation from a Discord turn (where an unnamed message goes), and restoring it reopens the thread it had but never makes a new one. Archiving one without a thread works as before. A temp knight made before its origin was kept is placed as before. See pi-roundtable-web for the rooms of temp knights made on Discord.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- A group seat outside its running turn, such as after a start-over, stays in the group's room on its own surface instead of falling out to the group's Discord channel.
|
|
32
|
+
|
|
8
33
|
## [0.28.0] - 2026-10-31
|
|
9
34
|
|
|
10
35
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -182,7 +182,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
|
|
|
182
182
|
| `surfaces` | Every contributed [chat surface](#surfaces-a-chat-network-of-your-own), chosen by the prefix of a channel key: `of`, `sendReply`, `startTyping`, `showStop`, `react`, `unreact`, `prompts` |
|
|
183
183
|
| `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
|
|
184
184
|
| `observe(filter, observer)` | Hears inbound messages without owning or admitting them: [`observe`](#observe-hearing-messages-without-owning-them). Returns a function that stops it; may be called during `setup` |
|
|
185
|
-
| `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(principalId)`, `known(principalId)`, and `notify(principalId, text)
|
|
185
|
+
| `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(origin, principalId)`, `known(origin, principalId)`, and `notify(origin, principalId, text)`, each through the [delivery router](#where-things-are-delivered) |
|
|
186
186
|
| `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
|
|
187
187
|
|
|
188
188
|
`sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, and `dashboard()` are available from a service's `start`, an event handler, a session tool, or a claim's turn.
|
|
@@ -258,7 +258,7 @@ The built-in plugins provide these, from the main entry:
|
|
|
258
258
|
| `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
|
|
259
259
|
| `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), `avatars` (`AvatarStudio`), `workspace` (`AgentWorkspace`: the agents' `workDir` and, when set, `scratchDir`), and `routes` (`RoutingTable`: where work no person started lands; see [Routing](#routing-where-work-no-person-started-lands)) |
|
|
260
260
|
| `PROMPTS` | `PromptDocuments` | `prompts` | The prompt texts the owner edits in the app, each with a file version, an override and a history: `register`, `text`, `list`, `get`, `set`, `revert`, `history`. Always on; read it with `get`. See [`PROMPTS`: prompt texts the owner edits](#prompts-prompt-texts-the-owner-edits) |
|
|
261
|
-
| `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory` |
|
|
261
|
+
| `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory`, and the groups `groups`, `setGroup`, `deleteGroup` |
|
|
262
262
|
| `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `createWithin`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
|
|
263
263
|
| `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
|
|
264
264
|
| `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(principalId)` gives that principal's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
|
|
@@ -2436,6 +2436,8 @@ When that record finds the author someone else than the claim admitted, such as
|
|
|
2436
2436
|
|
|
2437
2437
|
A claim may also hear that a message it owns changed, through `onMessageDeleted(channel, messageId)` and `onMessageEdited(message)`, which a surface reports with the second argument of its `start` (`{ deleted, edited }`, a `SurfaceMessageEvents`). Discord reports both, including for a message the bot had not cached; a surface that cannot tell never calls them. The router delivers each as it comes, to the claim that owns the channel and no other, outside the channel's queue, so a running turn does not hold it up; a throw or rejection is logged and never fails the router. A delete carries only ids, and Discord's delete event does not say who wrote the message, so the claim hears the deletion of the bot's own messages too and ignores an id it does not know. An edit carries the message as it reads now, as an `InboundMessage`, without `speaker` or `peerBot` because nobody is resolved or admitted for it; the bot's own edits are not reported, nor is a change that leaves the text, the embeds and the attachments as they were, such as a pin. A bulk delete is reported as one delete per message.
|
|
2438
2438
|
|
|
2439
|
+
A message sent while a turn runs is first offered to that turn (`busy.steer`); one the turn does not take waits in the channel's queue with `QUEUED_MARK`, and until its own turn starts the conversation port can list, cancel and edit it: `queued(channel)` returns its waiting messages oldest first (`messageId`, the author's `principalId`, the `text` its turn will use, `queuedAt`); `cancelQueued(channel, messageId, { principalId })` makes its turn never run, calling the admission's `dropped` and removing the mark; `editQueued(channel, messageId, { principalId }, text)` admits the message again with the new text through the claim's `admit`, drops the old admission, and the turn runs on the new one. Only the message's author may (a different principal gets `forbidden`, and a message with no resolved author can be changed by no one). A message whose turn has started, was steered into the running turn, or was cancelled is `unknown`; an edit the claim would no longer admit is `refused` and the old text stays. The decision is made in one synchronous step, so a cancel or an edit and the start of the turn never both win. The surface's own copy of the message is the caller's to change. When a surface reports a queued message deleted or edited (`onMessageDeleted`, `onMessageEdited` above), the router cancels it or changes its text itself, without the author check since the surface says it happened: that is how deleting a queued Discord message cancels it. The three methods are optional on `ConversationPort`, as `messageDeleted` is.
|
|
2440
|
+
|
|
2439
2441
|
A claim may have `stop(channel)`, which stops the channel's running turn and returns whether one was running; the Stop button, `conversations.stop`, and every other stop go through it.
|
|
2440
2442
|
The router calls only the owning claim's `stop`, returning `false` when that method is absent.
|
|
2441
2443
|
Give `stop` to a claim whose conversations run turns that can be interrupted.
|
|
@@ -2999,6 +3001,7 @@ setup: ({ services }) => ({
|
|
|
2999
3001
|
directChannels: [
|
|
3000
3002
|
{
|
|
3001
3003
|
name: "inbox",
|
|
3004
|
+
surface: "chat",
|
|
3002
3005
|
label: "a notice in the web chat's inbox",
|
|
3003
3006
|
reaches: async (principalId) => inboxOf(principalId),
|
|
3004
3007
|
deliver: async (principalId, text) => postNotice(principalId, text),
|
|
@@ -3010,23 +3013,36 @@ setup: ({ services }) => ({
|
|
|
3010
3013
|
| Field | What it is |
|
|
3011
3014
|
|---|---|
|
|
3012
3015
|
| `name` | Unique across every plugin; a second provider of one name is a `PluginError` naming both plugins |
|
|
3016
|
+
| `surface` | Optional: the surface whose conversations it reaches, the key prefix of the channels `reaches` names, such as `discord` or the web app's `app`. Work from a conversation on that surface reaches the person here first. Without it, `name` |
|
|
3013
3017
|
| `label` | How the `notify` tool names a notice sent through it, completing "Send Ada …", such as `a direct message on Discord` |
|
|
3014
3018
|
| `reaches(principalId)` | The person's own conversation here, as a channel key, or `undefined` when they have none here. It may reach the network and throw |
|
|
3015
3019
|
| `knows(principalId)` | Optional: whether the person has a conversation here, from the host's own records such as their linked identities, without the network. A session asks it when it decides whether to offer `notify` or the schedule and delegation tools, so an outage then does not take them away; without it the host asks `reaches` |
|
|
3016
3020
|
| `deliver(principalId, text)` | Optional: sends the notice itself. Without it the host posts the text in the channel `reaches` names, through the surface that serves it |
|
|
3017
|
-
| `mirror(principalId, text)` | Optional: echoes a notice another provider delivered, such as a web app pushing to a phone that Discord's direct message already reached. `notify` calls it on every provider but the one that delivered, only after a delivery; a failure is logged and never changes the result |
|
|
3021
|
+
| `mirror(principalId, text)` | Optional: echoes a notice another provider delivered to the same person, such as a web app pushing to a phone that Discord's direct message already reached. `notify` calls it on every provider but the one that delivered, only after a delivery; a failure is logged and never changes the result |
|
|
3018
3022
|
|
|
3019
|
-
The host reaches a person through the
|
|
3020
|
-
`context.directChannels.reach(principalId)` resolves that provider and channel, or `undefined`; `known(principalId)` resolves the
|
|
3023
|
+
Every lookup takes the *origin*: the conversation the work came from. The host reaches a person through a provider on the origin's surface, and through another only when none there reaches them, as [Where things are delivered](#where-things-are-delivered) describes.
|
|
3024
|
+
`context.directChannels.reach(origin, principalId)` resolves that provider and channel, or `undefined`; `known(origin, principalId)` resolves the provider that knows the person without the network, in the same order; `notify(origin, principalId, text)` sends a notice there and resolves `false` when no provider reaches the person.
|
|
3021
3025
|
The Discord plugin contributes one named `discord`: it finds a principal's Discord identities through `IDENTITY` and reaches the first one's direct messages, the primary owner's configured identity first, so a single owner is reached in the same direct messages as in 0.8.
|
|
3022
3026
|
|
|
3023
3027
|
The `notify` tool, 0.8's `notify_owner`, sends such a notice. A session has it only when a provider is contributed, and a private conversation only when a provider knows its person; the channel itself is reached when a notice is sent.
|
|
3024
3028
|
It notifies the conversation's person in a private conversation and the turn's speaker in a shared one, and refuses when no provider reaches them or when the speaker is not the private conversation's person; the host's own turns, such as a report's, notify the conversation's person in a private conversation and the primary owner elsewhere, as `notify_owner` did.
|
|
3025
|
-
Its description names the
|
|
3029
|
+
It sends on the conversation's own surface: from a web room the web app's notice and push, from Discord a Discord direct message. Its description names the channel on that surface that can reach the person, and says it goes out on the conversation's own surface, such as `Send Ada a direct message on Discord. It goes out on this conversation's own surface. …`.
|
|
3026
3030
|
In a shared conversation it names the primary owner while only they can receive a notice (the host has no other owner, and only owners may use it), and "the speaker" otherwise; this is checked again before each turn, so an open session follows an owner granted or revoked meanwhile.
|
|
3027
3031
|
Until 1.0 the name `notify_owner` still selects it in a selection or a profile and names it in a task's `exclude` and a plugin's `requiredTools`, and an operator's or a plugin's `toolTiers` entry for `notify_owner` applies to `notify` unless `notify` has its own.
|
|
3028
3032
|
Using the old name logs a deprecation warning once per logger, whether in a selection or in `toolTiers`.
|
|
3029
3033
|
|
|
3034
|
+
### Where things are delivered
|
|
3035
|
+
|
|
3036
|
+
Everything the host sends goes back where the work came from. Every turn knows its *origin*, the conversation it runs in (a `ChannelKey` whose prefix is its surface), and so does everything a turn leaves behind: a schedule keeps the conversation it was made in, a delegated or coding job its channel and origin, a kept agent message its target's conversation and the sender's, a dispatch thread its parent. Nothing chooses a destination from a global default: not the first provider contributed, not the conversation an agent used most recently, not an agent's Discord channel.
|
|
3037
|
+
|
|
3038
|
+
- **Replies, cards, and threads** go through `SurfacePort`, which picks the surface by the channel key's prefix: an `ask_user` card, a timeout card, a message's request and answer cards, a job's thread and report all land in the origin's conversation, on its surface.
|
|
3039
|
+
- **The delivery router** (`src/core/presence/delivery-router.ts`) is the one place that decides where a notice or run for a *person* goes. Given the origin, it tries the direct channels on the origin's surface first: a web conversation reaches the web app's channel (a Home notice and a push), a Discord conversation a Discord direct message. Only when no provider on that surface reaches the person does it try another surface, and it logs that fallback with both surfaces (`from`, `to`). `notify`, `context.directChannels`, and the run channel of a schedule or delegated task made in a conversation no chat surface carries (`runChannelFor`) all go through it.
|
|
3040
|
+
- **Mirroring** is the provider's own declaration: after a delivery, each other provider that implements `mirror` echoes the notice to the *same person*, such as the web app pushing to their phone a notice Discord delivered. It never sends to anyone else and never posts in a conversation.
|
|
3041
|
+
- **`message_agent`** without `conversation` reaches the target's place on the *sender's* surface: from a web room, the target's room on that surface; from Discord, the target's Discord channel. When the target has no place there, it lands in the target's primary place, and the crossing is logged. A routing rule may pin it elsewhere; `latest` follows the conversation the target last used only on the sender's surface.
|
|
3042
|
+
- **A group seat** outside its running turn, such as after a start-over, posts in the group's room on its own surface and never falls out to the group's Discord channel.
|
|
3043
|
+
|
|
3044
|
+
`scripts/delivery-scan.test.ts` fails the build when code outside the router asks a provider to reach someone, walks the providers to pick one, or reads an agent's Discord channel id outside the files listed there; `src/core/presence/cross-surface.test.ts` starts each outbound feature from a web and from a Discord conversation and checks the other surface received nothing.
|
|
3045
|
+
|
|
3030
3046
|
### `surfaces`: a chat network of your own
|
|
3031
3047
|
|
|
3032
3048
|
A chat surface connects the host to one chat network: it reports what people write, and it posts what the conversations answer.
|
|
@@ -3087,7 +3103,7 @@ An agent has a *place* on each surface that holds one: its Discord channel, and
|
|
|
3087
3103
|
| `primary(agent)` | The primary place: the surface the rules name, and with `auto` the agent's Discord channel when it has one, else its room on the first surface that hosts rooms. Undefined for an unknown agent. |
|
|
3088
3104
|
| `placeOn(agent, surface)` | The agent's place on a surface (`web` for the one surface that hosts rooms); undefined when it has none there. |
|
|
3089
3105
|
| `of(agent, "integration", origin)` | Where the report turn of a foreign webhook's post made in `origin` runs; `origin` itself by default, and also when the destination has vanished. |
|
|
3090
|
-
| `of(agent, "agentMessage", from)` | The conversation a `message_agent` call that names none reaches: by default the agent's
|
|
3106
|
+
| `of(agent, "agentMessage", from)` | The conversation a `message_agent` call that names none reaches: by default (`origin-surface`) the agent's place on the surface `from` is on, else its primary place, logged; it never crosses surfaces by recency. Throws `AgentError` when the agent has no place. |
|
|
3091
3107
|
| `of(agent, "opening", origin?)` | Where an opening task runs: by default the agent's place on the surface `origin` is on, else its primary place; undefined when it has no place at all. |
|
|
3092
3108
|
| `place("ops")` | A named place of the host: the conversation, or the primary place of the agent it names while that agent is active. |
|
|
3093
3109
|
|
|
@@ -3109,13 +3125,13 @@ It follows its knight's routing rules, and its yolo and long-running modes follo
|
|
|
3109
3125
|
|
|
3110
3126
|
| Rule | What happens |
|
|
3111
3127
|
|---|---|
|
|
3112
|
-
| Place |
|
|
3128
|
+
| Place | A temp knight lives on the surface of the conversation that made it, kept in `Agent.origin`; its place on the other surface is made only when someone uses it there. Made on Discord, it lives in a thread under its knight's channel, whose posts go through the channel's webhook under the temp knight's own name and picture; a surface's room list leaves its room out until the room has a message or someone opens it by its address. Made in a room, it gets no thread: the thread is made, once, when a `message_agent` call from a turn that may message it names `conversation: "discord"`, or names none from a Discord conversation, where an unnamed message goes. A knight with no Discord channel gives it a room on a surface that hosts rooms. Archiving a temp knight archives and locks its thread when it has one; restoring it unarchives the thread, and only one made on Discord gets a new thread when the old one is gone. A temp knight made before its origin was kept is placed as one made on Discord. |
|
|
3113
3129
|
| Finished | Derived, never set by the knight: nothing runs, nothing is queued, no card waits for the owner and no message from its knight is still unanswered. `Agent.finishedAt` carries the stamp. |
|
|
3114
3130
|
| Limits | `helpers.perParent` (default 4) and `helpers.perHost` (default 12) count active temp knights; depth is one, so a temp knight cannot make one. At a limit the knight's oldest finished temp knight is archived to make room, and the call is refused only when every one of them is busy. |
|
|
3115
3131
|
| Cleanup | `helper_end`, the owner's clearing in a surface, a knight's own archive (which archives its temp knights first), and, after `helpers.archiveFinishedAfterHours` (default 24), an hourly sweep. An archived temp knight keeps its history and comes back with `restoreHelpers`. Deleting an archived knight deletes its temp knights. |
|
|
3116
3132
|
|
|
3117
3133
|
`AgentTeam` offers `helpersOf(parent)`, `finishedHelpers(parents?)`, `archiveHelpers(names, by)`, `restoreHelpers(names)` (only a name that is still an archived temp knight of an active knight is restored) and `onHelpersEnded(listener)`, which hears a knight's own `helper_end` and nothing else, so a surface can offer the owner an undo.
|
|
3118
|
-
`isHelper(agent)` tells a temp knight, for a surface that lists agents; a group never takes one.
|
|
3134
|
+
`isHelper(agent)` tells a temp knight, for a surface that lists agents, and `helperSurface(agent)` the surface it was made on (undefined for one made before its origin was kept); a group never takes one.
|
|
3119
3135
|
The `helpers` block of the configuration turns the limits and the sweep; `helpers: false` offers neither tool, and a value out of its range, or a `perParent` above `perHost`, stops the start with a `ConfigError`:
|
|
3120
3136
|
|
|
3121
3137
|
```ts
|
|
@@ -4403,6 +4419,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4403
4419
|
| `AvatarStudio` | `pi-roundtable` | type |
|
|
4404
4420
|
| `PROMPTS` | `pi-roundtable` | value |
|
|
4405
4421
|
| `characterOf` | `pi-roundtable` | value |
|
|
4422
|
+
| `helperSurface` | `pi-roundtable` | value |
|
|
4406
4423
|
| `isHelper` | `pi-roundtable` | value |
|
|
4407
4424
|
| `BACKGROUND_TURNS` | `pi-roundtable` | value |
|
|
4408
4425
|
| `CONVERSATIONS` | `pi-roundtable` | value |
|
|
@@ -4551,6 +4568,11 @@ Import from the entries listed below; source area files are internal.
|
|
|
4551
4568
|
| `Provisioning` | `pi-roundtable` | type |
|
|
4552
4569
|
| `ProvisioningRule` | `pi-roundtable` | type |
|
|
4553
4570
|
| `QueuePort` | `pi-roundtable` | type |
|
|
4571
|
+
| `QueuedBy` | `pi-roundtable` | type |
|
|
4572
|
+
| `QueuedChange` | `pi-roundtable` | type |
|
|
4573
|
+
| `QueuedMessage` | `pi-roundtable` | type |
|
|
4574
|
+
| `QUEUED_MARK` | `pi-roundtable` | value |
|
|
4575
|
+
| `STEERED_MARK` | `pi-roundtable` | value |
|
|
4554
4576
|
| `QUESTIONNAIRE_LIMITS` | `pi-roundtable` | value |
|
|
4555
4577
|
| `Questionnaire` | `pi-roundtable` | type |
|
|
4556
4578
|
| `QuestionnaireAnswer` | `pi-roundtable` | type |
|
|
@@ -4594,6 +4616,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4594
4616
|
| `PromptConflictError` | `pi-roundtable` | value |
|
|
4595
4617
|
| `SkillConflictError` | `pi-roundtable` | value |
|
|
4596
4618
|
| `SkillDocument` | `pi-roundtable` | type |
|
|
4619
|
+
| `SkillGroup` | `pi-roundtable` | type |
|
|
4597
4620
|
| `SKILLS` | `pi-roundtable` | value |
|
|
4598
4621
|
| `SINGLE_QUESTION` | `pi-roundtable` | value |
|
|
4599
4622
|
| `Schedule` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
chainLimitReason,
|
|
16
16
|
deliveredMessagePost,
|
|
17
17
|
} from "./agent-prompt.ts";
|
|
18
|
+
import { answersTo } from "./agent-rules.ts";
|
|
18
19
|
import type { Agent } from "./agent-store.ts";
|
|
19
20
|
import { conversationLabel, messageTarget } from "./message-target.ts";
|
|
20
21
|
import { discordIdOf, discordKey, scopeOf } from "./team-keys.ts";
|
|
@@ -78,13 +79,10 @@ export class AgentMessages {
|
|
|
78
79
|
);
|
|
79
80
|
// A temp knight talks only to the conversation it was made from (and to the owners), so one
|
|
80
81
|
// person's helper session is not open to another person's turn.
|
|
81
|
-
if (target.parent
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`"${target.name}" is a temp knight of "${target.parent}" and answers only the conversation that made it; ask "${target.parent}" there.`,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
82
|
+
if (!answersTo(target, caller.name, parent, chain.speaker))
|
|
83
|
+
throw new AgentError(
|
|
84
|
+
`"${target.name}" is a temp knight of "${target.parent}" and answers only the conversation that made it; ask "${target.parent}" there.`,
|
|
85
|
+
);
|
|
88
86
|
chain.messages += 1;
|
|
89
87
|
// A temp knight is working from the moment a message is handed to it until it has answered.
|
|
90
88
|
this.#host.helpers.begin(target.name);
|
|
@@ -42,6 +42,33 @@ export interface Agent {
|
|
|
42
42
|
export const isHelper = (agent: Pick<Agent, "parent"> | undefined): boolean =>
|
|
43
43
|
agent?.parent !== undefined;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Whether `sender`, writing from `from` in a run `speaker` started, may message `target`: a temp
|
|
47
|
+
* knight answers only its knight in the conversation that made it, and the owners.
|
|
48
|
+
*/
|
|
49
|
+
export const answersTo = (
|
|
50
|
+
target: Pick<Agent, "parent" | "origin">,
|
|
51
|
+
sender: string,
|
|
52
|
+
from: string,
|
|
53
|
+
speaker: { tier: string },
|
|
54
|
+
): boolean =>
|
|
55
|
+
target.parent === undefined ||
|
|
56
|
+
speaker.tier === "owner" ||
|
|
57
|
+
(sender === target.parent && target.origin === from);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The surface a temp knight was made on, such as "discord" or the web app's, read off its origin;
|
|
61
|
+
* undefined for a permanent knight and for a temp knight made before the origin was kept. A temp
|
|
62
|
+
* knight lives on that surface; its place on another is made only when someone uses it there.
|
|
63
|
+
*/
|
|
64
|
+
export const helperSurface = (
|
|
65
|
+
agent: Pick<Agent, "parent" | "origin"> | undefined,
|
|
66
|
+
): string | undefined => {
|
|
67
|
+
const origin = agent?.parent === undefined ? undefined : agent.origin;
|
|
68
|
+
const colon = origin?.indexOf(":") ?? -1;
|
|
69
|
+
return origin && colon > 0 ? origin.slice(0, colon) : undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
45
72
|
export interface AgentGroup {
|
|
46
73
|
name: string;
|
|
47
74
|
displayName: string;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import type { HelperTeam } from "./helper-ops.ts";
|
|
20
20
|
import { type Routes, routesOf } from "./routes.ts";
|
|
21
21
|
import { TeamEditing } from "./team-editing.ts";
|
|
22
|
+
import type { TeamHelpers } from "./team-helpers.ts";
|
|
22
23
|
import {
|
|
23
24
|
channelOwner,
|
|
24
25
|
coordinatorOf,
|
|
@@ -321,6 +322,10 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
|
|
|
321
322
|
return this.#turns.message(...args);
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
reach(...args: Parameters<TeamHelpers["reach"]>): Promise<void> {
|
|
326
|
+
return this.#lifecycle.helpers.reach(...args);
|
|
327
|
+
}
|
|
328
|
+
|
|
324
329
|
async read(
|
|
325
330
|
channel: string,
|
|
326
331
|
around: string | undefined,
|
|
@@ -123,7 +123,7 @@ export interface AgentOps {
|
|
|
123
123
|
avatar(name: string, look: CharacterInput): Promise<string>;
|
|
124
124
|
/** Queues the message and returns at once. */
|
|
125
125
|
/**
|
|
126
|
-
* Hands a request to another agent, in
|
|
126
|
+
* Hands a request to another agent, in its conversation on the sender's surface, or in the
|
|
127
127
|
* `conversation` named: "discord", "web" (or a room surface's name), or one of its conversation keys.
|
|
128
128
|
*/
|
|
129
129
|
message(
|
|
@@ -132,6 +132,15 @@ export interface AgentOps {
|
|
|
132
132
|
text: string,
|
|
133
133
|
conversation?: string,
|
|
134
134
|
): string;
|
|
135
|
+
/**
|
|
136
|
+
* Readies the place a message is about to reach: a temp knight made in the web app gets its
|
|
137
|
+
* Discord thread when the caller may message it and names "discord". Never rejects.
|
|
138
|
+
*/
|
|
139
|
+
reach?(
|
|
140
|
+
caller: AgentTurnScope,
|
|
141
|
+
to: string,
|
|
142
|
+
conversation?: string,
|
|
143
|
+
): Promise<void>;
|
|
135
144
|
createGroup(input: {
|
|
136
145
|
name: string;
|
|
137
146
|
displayName: string;
|
|
@@ -425,24 +434,23 @@ function toolDefs(
|
|
|
425
434
|
name: MESSAGE_AGENT_TOOL,
|
|
426
435
|
label: "Message agent",
|
|
427
436
|
description:
|
|
428
|
-
'Send a self-contained request to another agent. It returns at once; the agent works in its own conversation and its answer comes back to you as a new turn, in the conversation you are writing from. An agent has one conversation on each surface (Discord, the web app); by default the request lands in the
|
|
437
|
+
'Send a self-contained request to another agent. It returns at once; the agent works in its own conversation and its answer comes back to you as a new turn, in the conversation you are writing from. An agent has one conversation on each surface (Discord, the web app); by default the request lands in its conversation on the surface you are writing from (its room from a web room, its Discord channel from Discord), so nothing crosses surfaces unless you name it. Give conversation to choose another: "discord", "web", or one of the conversation keys agent_get lists for it. The request shows in its conversation, and the request and the answer show as small cards in yours.',
|
|
429
438
|
parameters: Type.Object({
|
|
430
439
|
to: NAME,
|
|
431
440
|
text: Type.String({ minLength: 1, maxLength: 4000 }),
|
|
432
441
|
conversation: Type.Optional(
|
|
433
442
|
Type.String({
|
|
434
443
|
description:
|
|
435
|
-
'Which of the agent\'s conversations to deliver into: "discord", "web" (or the name of another surface that hosts rooms), or a key from agent_get. Leave it out to reach the
|
|
444
|
+
'Which of the agent\'s conversations to deliver into: "discord", "web" (or the name of another surface that hosts rooms), or a key from agent_get. Leave it out to reach its conversation on the surface you are writing from.',
|
|
436
445
|
}),
|
|
437
446
|
),
|
|
438
447
|
}),
|
|
439
|
-
run: (i) =>
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
),
|
|
448
|
+
run: async (i) => {
|
|
449
|
+
const to = requiredString(i, "to");
|
|
450
|
+
const conversation = optionalString(i, "conversation");
|
|
451
|
+
await ops.reach?.(scope, to, conversation);
|
|
452
|
+
return ops.message(scope, to, requiredString(i, "text"), conversation);
|
|
453
|
+
},
|
|
446
454
|
});
|
|
447
455
|
return defs;
|
|
448
456
|
}
|
|
@@ -3,19 +3,22 @@ import { AgentError } from "../domain/errors.ts";
|
|
|
3
3
|
import type { AgentDirectory } from "../services.ts";
|
|
4
4
|
import type { Agent } from "./agent-store.ts";
|
|
5
5
|
import { Routes } from "./routes.ts";
|
|
6
|
-
import { agentRoomKey,
|
|
6
|
+
import { agentRoomKey, keysOf } from "./team-keys.ts";
|
|
7
7
|
|
|
8
8
|
/** What the choice of a message's conversation reads. */
|
|
9
9
|
export interface MessageTargetContext {
|
|
10
10
|
store: Pick<AgentDirectory, "agent" | "group">;
|
|
11
11
|
/** The surfaces that host agent rooms. */
|
|
12
12
|
roomSurfaces: readonly string[];
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The conversation the target last ran a turn in, if it ran one since the host started; read
|
|
15
|
+
* only under the `"latest"` routing rule, and only on the sender's surface.
|
|
16
|
+
*/
|
|
14
17
|
latest: ChannelKey | undefined;
|
|
15
|
-
/** The conversation the sender writes from. */
|
|
18
|
+
/** The conversation the sender writes from: the origin whose surface the message stays on. */
|
|
16
19
|
from: ChannelKey;
|
|
17
20
|
/** The host's routing table; without it the defaults every host had before `routing`. */
|
|
18
|
-
routes?: Pick<Routes, "of">;
|
|
21
|
+
routes?: Pick<Routes, "of" | "placeOn">;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/** The word for the web app's conversation, accepted when exactly one surface hosts rooms. */
|
|
@@ -24,9 +27,8 @@ const WEB = "web";
|
|
|
24
27
|
/**
|
|
25
28
|
* The conversation of `target` that a message is delivered into. With `ask` (a surface name such
|
|
26
29
|
* as "discord", "web", or the surface of a room, or one of the target's conversation keys) that
|
|
27
|
-
* one; otherwise the conversation the routing table picks (`Routes.of`): by default the
|
|
28
|
-
*
|
|
29
|
-
* it has run none yet, the sender's own surface, so a message between rooms stays among the rooms.
|
|
30
|
+
* one; otherwise the conversation the routing table picks (`Routes.of`): by default the target's
|
|
31
|
+
* place on the sender's own surface, so a message never crosses surfaces unless named.
|
|
30
32
|
*/
|
|
31
33
|
export function messageTarget(
|
|
32
34
|
ctx: MessageTargetContext,
|
|
@@ -35,25 +37,25 @@ export function messageTarget(
|
|
|
35
37
|
): ChannelKey {
|
|
36
38
|
const keys = keysOf(ctx.store, target.name, ctx.roomSurfaces);
|
|
37
39
|
const has = keys.length > 0 ? keys.join(", ") : "none";
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
const routes =
|
|
41
|
+
ctx.routes ??
|
|
42
|
+
new Routes({
|
|
43
|
+
store: ctx.store,
|
|
44
|
+
roomSurfaces: () => ctx.roomSurfaces,
|
|
45
|
+
latest: () => ctx.latest,
|
|
46
|
+
});
|
|
47
|
+
if (ask === undefined || ask.trim() === "")
|
|
46
48
|
return routes.of(target.name, "agentMessage", ctx.from);
|
|
47
|
-
}
|
|
48
49
|
const wanted = ask.trim();
|
|
49
50
|
const exact = keys.find((key) => key === wanted);
|
|
50
51
|
if (exact !== undefined) return exact;
|
|
51
52
|
if (wanted === "discord") {
|
|
52
|
-
|
|
53
|
+
const place = routes.placeOn(target.name, "discord");
|
|
54
|
+
if (!place)
|
|
53
55
|
throw new AgentError(
|
|
54
56
|
`${target.name} has no Discord channel; its conversations: ${has}.`,
|
|
55
57
|
);
|
|
56
|
-
return
|
|
58
|
+
return place;
|
|
57
59
|
}
|
|
58
60
|
const surface = ctx.roomSurfaces.includes(wanted)
|
|
59
61
|
? wanted
|
|
@@ -37,7 +37,7 @@ export interface RoutingTable {
|
|
|
37
37
|
* has none when the agent has no place at all.
|
|
38
38
|
*/
|
|
39
39
|
of(agent: string, kind: "integration", origin: ChannelKey): ChannelKey;
|
|
40
|
-
of(agent: string, kind: "agentMessage", origin
|
|
40
|
+
of(agent: string, kind: "agentMessage", origin: ChannelKey): ChannelKey;
|
|
41
41
|
of(
|
|
42
42
|
agent: string,
|
|
43
43
|
kind: RouteKind,
|
|
@@ -154,7 +154,7 @@ export class Routes implements RoutingTable {
|
|
|
154
154
|
* when an `agentMessage` finds no place; undefined when an `opening` finds none.
|
|
155
155
|
*/
|
|
156
156
|
of(name: string, kind: "integration", origin: ChannelKey): ChannelKey;
|
|
157
|
-
of(name: string, kind: "agentMessage", origin
|
|
157
|
+
of(name: string, kind: "agentMessage", origin: ChannelKey): ChannelKey;
|
|
158
158
|
of(
|
|
159
159
|
name: string,
|
|
160
160
|
kind: RouteKind,
|
|
@@ -201,39 +201,57 @@ export class Routes implements RoutingTable {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
|
-
* The conversation a message to the agent lands in when the sender names none
|
|
205
|
-
*
|
|
206
|
-
* Discord,
|
|
207
|
-
*
|
|
208
|
-
*
|
|
204
|
+
* The conversation a message to the agent lands in when the sender names none. By default
|
|
205
|
+
* (`"origin-surface"`) its place on the sender's own surface: its Discord channel for a message
|
|
206
|
+
* from Discord, its room for one from a room. `"latest"` is the conversation it last ran a turn
|
|
207
|
+
* in, but only on the sender's surface. A message never crosses surfaces by recency: when the
|
|
208
|
+
* agent has no place on the sender's surface, or no sender is known, it lands in the agent's
|
|
209
|
+
* primary place, and a crossing is logged with both surfaces.
|
|
209
210
|
*/
|
|
210
211
|
#agentMessage(name: string, from: ChannelKey | undefined): ChannelKey {
|
|
211
|
-
const {
|
|
212
|
-
const to = this.rules(name).agentMessage ?? "
|
|
213
|
-
if (to !== "latest") {
|
|
212
|
+
const { latest } = this.#options;
|
|
213
|
+
const to = this.rules(name).agentMessage ?? "origin-surface";
|
|
214
|
+
if (to !== "latest" && to !== "origin-surface") {
|
|
214
215
|
const key =
|
|
215
216
|
to === "primary" ? this.primary(name) : this.placeOn(name, to);
|
|
216
217
|
if (key) return key;
|
|
217
218
|
this.#warn(
|
|
218
219
|
`message:${name}`,
|
|
219
220
|
{ agent: name, to },
|
|
220
|
-
"the agent has no place for messages to it;
|
|
221
|
+
"the agent has no place for messages to it; its place on the sender's surface is used",
|
|
221
222
|
);
|
|
222
223
|
}
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
224
|
+
const surface =
|
|
225
|
+
from === undefined ? undefined : parseChannelKey(from).surface;
|
|
226
|
+
if (to === "latest" && surface !== undefined) {
|
|
227
|
+
const last = latest?.(name);
|
|
228
|
+
const keys = keysOf(
|
|
229
|
+
this.#options.store,
|
|
230
|
+
name,
|
|
231
|
+
this.#options.roomSurfaces(),
|
|
232
|
+
);
|
|
233
|
+
if (
|
|
234
|
+
last !== undefined &&
|
|
235
|
+
keys.includes(last) &&
|
|
236
|
+
parseChannelKey(last).surface === surface
|
|
237
|
+
)
|
|
238
|
+
return last;
|
|
235
239
|
}
|
|
236
|
-
|
|
240
|
+
const own = surface === undefined ? undefined : this.placeOn(name, surface);
|
|
241
|
+
if (own) return own;
|
|
242
|
+
const primary = this.primary(name);
|
|
243
|
+
if (!primary) throw new AgentError(`${name} has no channel yet.`);
|
|
244
|
+
if (surface !== undefined)
|
|
245
|
+
this.#warn(
|
|
246
|
+
`message-cross:${name}:${surface}`,
|
|
247
|
+
{
|
|
248
|
+
agent: name,
|
|
249
|
+
from: surface,
|
|
250
|
+
to: parseChannelKey(primary).surface,
|
|
251
|
+
},
|
|
252
|
+
"the agent has no place on the sender's surface; the message lands in its primary place",
|
|
253
|
+
);
|
|
254
|
+
return primary;
|
|
237
255
|
}
|
|
238
256
|
|
|
239
257
|
/**
|
|
@@ -220,9 +220,9 @@ export class TeamEditing {
|
|
|
220
220
|
creator: Agent | undefined;
|
|
221
221
|
/** The surface whose room runs the opening task. */
|
|
222
222
|
openIn?: string | undefined;
|
|
223
|
-
/** Makes the agent a temp knight of this knight
|
|
223
|
+
/** Makes the agent a temp knight of this knight, which settles on the parent's model. */
|
|
224
224
|
parent?: Agent | undefined;
|
|
225
|
-
/** The conversation a temp knight is made from. */
|
|
225
|
+
/** The conversation a temp knight is made from: made on Discord, it lives in a thread of the parent's channel, made elsewhere in its rooms alone. */
|
|
226
226
|
origin?: string | undefined;
|
|
227
227
|
},
|
|
228
228
|
input: {
|
|
@@ -279,10 +279,10 @@ export class TeamEditing {
|
|
|
279
279
|
const suggested = suggestCharacter(input, taken);
|
|
280
280
|
const character = this.#resolve(wanted ?? {}, suggested);
|
|
281
281
|
const avatarHash = await studio.render(character);
|
|
282
|
-
// Without Discord channels the agent lives in its rooms, which exist as soon as it does.
|
|
283
|
-
|
|
282
|
+
// Without Discord channels the agent lives in its rooms, which exist as soon as it does.
|
|
283
|
+
const onDiscord = who.origin?.startsWith("discord:") !== false;
|
|
284
284
|
const channelId = who.parent
|
|
285
|
-
? who.parent.channelId && channels?.createThread
|
|
285
|
+
? onDiscord && who.parent.channelId && channels?.createThread
|
|
286
286
|
? await channels.createThread(who.parent.channelId, input.displayName)
|
|
287
287
|
: undefined
|
|
288
288
|
: await channels?.createChannel(
|