pi-roundtable 0.27.1 → 0.28.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 +20 -0
- package/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/docs/plugins.md +29 -13
- package/package.json +1 -1
- package/src/characters/compose.ts +11 -0
- package/src/characters/parts-face.ts +23 -2
- package/src/core/agents/agent-messages.ts +32 -85
- package/src/core/agents/agent-ports.ts +3 -0
- package/src/core/agents/agent-prompt.ts +2 -16
- package/src/core/agents/agent-tools.ts +1 -1
- package/src/core/agents/helper-create.ts +3 -24
- package/src/core/agents/helper-modes.ts +64 -0
- package/src/core/agents/team-turn-types.ts +7 -13
- package/src/core/agents/team-turns.ts +7 -2
- package/src/core/builtin/agent-server.ts +3 -4
- package/src/core/builtin/discord.ts +35 -8
- package/src/core/config/adapters.ts +2 -1
- package/src/core/config/limits.ts +35 -2
- package/src/core/discord/agent-discord.ts +12 -1
- package/src/core/discord/card-copies.ts +7 -2
- package/src/core/discord/discord-surface.ts +13 -30
- package/src/core/discord/intercom-cards.ts +121 -0
- package/src/core/discord/owner-card-asks.ts +9 -0
- package/src/core/discord/owner-card-types.ts +2 -0
- package/src/core/discord/owner-cards.ts +2 -1
- package/src/core/discord/stop-button.ts +35 -0
- package/src/core/domain/conversation.ts +18 -0
- package/src/core/domain/interim.ts +7 -0
- package/src/core/domain/turn-timeout.ts +5 -0
- package/src/core/i18n/conversation.ts +16 -4
- package/src/core/i18n/turn-timeout.ts +10 -2
- package/src/core/interactions/prompts.ts +5 -0
- package/src/core/modes/autonomy.ts +22 -1
- package/src/core/modes/conversation-modes.ts +135 -23
- package/src/core/modes/mode-inheritance.ts +78 -0
- package/src/core/modes/modes-plugin.ts +1 -1
- package/src/core/modules/skills/skill-contract.ts +2 -0
- package/src/core/presentation/intercom.ts +44 -0
- package/src/core/runtime/ask-questions.ts +21 -4
- package/src/core/runtime/extensions/ask-user-text.ts +36 -3
- package/src/core/runtime/extensions/ask-user.ts +21 -1
- package/src/core/runtime/interim-text.ts +7 -4
- package/src/core/runtime/pi-agent-runtime.ts +2 -2
- package/src/core/runtime/prompt-slot.ts +11 -1
- package/src/core/runtime/session-factory.ts +11 -6
- package/src/core/runtime/turn-budget.ts +34 -3
- package/src/core/runtime/turn-limits.ts +13 -3
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.28.0] - 2026-10-31
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- The web console's Connectors page manages the owner's MCP connectors (list, add with a write-only token, change a purpose, remove) and the MCP channel authorizations (bundles with their channels, operations and recent audit; authorize, rename, revoke, and a new bundle URL shown once). See pi-roundtable-web and pi-roundtable-mcp.
|
|
13
|
+
- Yolo mode: an `ask_user` card holds the turn only `autonomy.yolo.questionGraceSeconds` (default 30 s); unanswered, the agent goes on with its recommended option, says what it assumed, and ends the turn only for a truly blocking question, while the card stays open for a late answer. The timeout card waits `autonomy.yolo.timeoutCardSeconds` (default 60 s) and an unanswered one continues the turn up to the long-running cap (`TurnTimeout.autoContinue`). `PromptWait.graceMs` carries a turn's own wait to surfaces. A host that is stopping still posts no card.
|
|
14
|
+
- Temp knights follow the yolo and long-running modes of the conversation that made them, live: their own modes cannot be switched, an origin that is gone reads off, and only an owner's turn makes a temp knight follow (`ConversationModes.follow`, `forget`, `trackOrigins`, `ConversationMode.inheritedFrom`, migration `conversation-modes-follows`).
|
|
15
|
+
- Discord keeps intercom cards' full texts in `intercom-texts.json` (newest 300, following `discord.cardCopies`), opened ephemerally with **Show all**.
|
|
16
|
+
- The character kit's `composeSvg` takes `mouth: "open" | "closed"` for the frames of a talking figure.
|
|
17
|
+
- `SkillRegistry.delete(name)` is part of the skill registry interface, so a host surface can remove an agent-written skill as `skill_delete` does.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `message_agent` opens no thread: the exchange shows in the sender's conversation as small intercom cards (`IntercomCard`, `OutboundReply.intercom`, `AgentPost.intercom`) that open to the whole text. `TeamTurnsOptions.threads` is gone.
|
|
22
|
+
- `InterimPosts.tools: false` leaves the tool line out of the progress message; the web app's rooms set it, Discord keeps the line.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `ask_user` posts no card while the host stops: open cards live in the surface's memory and do not survive the restart, so the tool now tells the turn to ask in its reply instead. A card still waiting when the drain begins is reported lost rather than "stays open".
|
|
27
|
+
|
|
8
28
|
## [0.27.1] - 2026-10-31
|
|
9
29
|
|
|
10
30
|
### Fixed
|
package/README.md
CHANGED
|
@@ -192,6 +192,8 @@ A turn works 10 minutes by default before it asks you whether to go on, and 30 m
|
|
|
192
192
|
| `turnLimits.cardSeconds` | `300` | How long that card waits for an answer, 30 to 3600; with none, the turn stops |
|
|
193
193
|
| `autonomy.longRunning.maxSeconds` | `14400` (4 h) | The most a long-running conversation's turn works before it asks or stops, 600 to 604800 |
|
|
194
194
|
| `autonomy.yolo.allow`, `autonomy.yolo.card` | none | Hold classes yolo mode may approve beyond its defaults (`shell.rm`, say), and rule names whose calls it approves although the rule reports no classes (`tool:repo_push`, say), and classes or rule names it never approves (`shell.push`, say). By default yolo approves a plain `git push` and writes outside the workspace and cards the other destructive shell classes; see the [plugin guide](docs/plugins.md#hold-classes-and-yolo-mode) |
|
|
195
|
+
| `autonomy.yolo.questionGraceSeconds` | `30` | In a yolo conversation, how long an `ask_user` card holds the turn, 5 to 600; unanswered, the turn goes on with its own best choice and the card stays open |
|
|
196
|
+
| `autonomy.yolo.timeoutCardSeconds` | `60` | In a yolo conversation, how long the timeout card waits, 10 to 3600; unanswered, the turn goes on as if you pressed Continue, up to `autonomy.longRunning.maxSeconds` |
|
|
195
197
|
|
|
196
198
|
Without `discord` the agents still run when `agents` lists them, in the rooms of a surface that hosts them, such as pi-roundtable-web; the host refuses to start when none does:
|
|
197
199
|
|
package/README.zh-TW.md
CHANGED
|
@@ -187,6 +187,8 @@ export default {
|
|
|
187
187
|
| `turnLimits.cardSeconds` | `300` | 那張卡片等你回答多久,30 到 3600;時間到沒回答,這一輪就停止 |
|
|
188
188
|
| `autonomy.longRunning.maxSeconds` | `14400`(4 小時) | 長時間執行對話的一輪,工作到這個上限才會詢問或停止,600 到 604800 |
|
|
189
189
|
| `autonomy.yolo.allow`、`autonomy.yolo.card` | 無 | yolo 模式在預設之外可以核准的 hold 類別(例如 `shell.rm`),以及它也核准其呼叫的規則名稱(該規則沒有回報類別,例如 `tool:repo_push`),以及它永遠不核准的類別或規則名稱(例如 `shell.push`)。預設下 yolo 會核准一般的 `git push` 和工作區以外的寫入,其餘破壞性的 shell 類別仍然出卡;見[外掛指南](docs/plugins.md#hold-classes-and-yolo-mode) |
|
|
190
|
+
| `autonomy.yolo.questionGraceSeconds` | `30` | yolo 對話裡,`ask_user` 卡片讓這一輪等多久,5 到 600;沒回答時,這一輪照它自己的最佳選擇繼續,卡片仍然開著 |
|
|
191
|
+
| `autonomy.yolo.timeoutCardSeconds` | `60` | yolo 對話裡,逾時卡片等多久,10 到 3600;沒回答時,這一輪就像按了「繼續」一樣接著做,最多到 `autonomy.longRunning.maxSeconds` |
|
|
190
192
|
|
|
191
193
|
沒有 `discord` 時,只要 `agents` 列了 agent,它們就在支援房間的 surface(例如 pi-roundtable-web)裡運作;沒有任何 surface 支援房間時 host 拒絕啟動:
|
|
192
194
|
|
package/docs/plugins.md
CHANGED
|
@@ -269,7 +269,7 @@ The built-in plugins provide these, from the main entry:
|
|
|
269
269
|
| `ChannelContextSourceAttachment` | `pi-roundtable` | type |
|
|
270
270
|
| `ChannelContextVerdict` | `pi-roundtable` | type |
|
|
271
271
|
| `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
|
|
272
|
-
| `THREADS` | `DispatchThreads` | `threads` | The threads of background dispatches (coding tasks, delegated tasks
|
|
272
|
+
| `THREADS` | `DispatchThreads` | `threads` | The threads of background dispatches (coding tasks, delegated tasks), on any surface that hosts them: `open(origin, title, intro?)` opens the thread under the channel `origin` through the `threads` of the surface that serves its prefix and resolves to a `DispatchThread`, or `undefined` when that surface hosts none or the channel cannot. See [Dispatch threads on any surface](#dispatch-threads-on-any-surface) |
|
|
273
273
|
| `CHANNEL_CONTEXT` | `ChannelContexts` | `channel-context` | `read(message, options?)` gives the context of one addressed message, from the surface whose key prefix its channel carries: the messages posted there since the assistant last posted, or `undefined` for a surface without `channelContext`, a channel that is not shared, and when nothing new was said. See [Channel context](#channel-context-what-was-said-around-an-addressed-message) |
|
|
274
274
|
| `WORKER_SESSIONS` | `WorkerSessions` | `worker-sessions` | Fresh Pi sessions for a plugin's own workers, on the host's model login: `delegation` is the host's delegation `model` and `thinking`, and `run({ task, signal, prompt, extensions, toolNames, model?, thinking?, aborted? })` runs the task in a session that has only the tools `extensions` register and `toolNames` names (no built-in tool, skill, context file, memory or agent tool) and returns the worker's final answer; see [Workers of your own](#workers-of-your-own) |
|
|
275
275
|
|
|
@@ -459,7 +459,7 @@ A conversation can run with less supervision when its owner switches a mode on.
|
|
|
459
459
|
| Mode | Effect |
|
|
460
460
|
|---|---|
|
|
461
461
|
| `longRunning` | A turn that reaches its time limit goes on by itself, up to `autonomy.longRunning.maxSeconds` of work, instead of asking on a card. See [long-running conversations](#long-running-conversations) |
|
|
462
|
-
| `yolo` | The server approves the held calls whose [hold classes](#hold-classes-and-yolo-mode) it allows itself, before a card is posted, and logs each one. What it allows is `autonomy.yolo.allow` and `autonomy.yolo.card` in the config |
|
|
462
|
+
| `yolo` | The server approves the held calls whose [hold classes](#hold-classes-and-yolo-mode) it allows itself, before a card is posted, and logs each one. What it allows is `autonomy.yolo.allow` and `autonomy.yolo.card` in the config. The cards it still posts do not stop the work: see [cards in a yolo conversation](#cards-in-a-yolo-conversation) |
|
|
463
463
|
|
|
464
464
|
Both are off for a conversation nobody has set. `MODES` stores them in the `conversation_modes` table, one row per conversation key, so they survive a restart, and works for any key; a surface decides who may toggle them. Read a mode where you act on it, with `peek`, which never waits on the database. Change it with `set`, which writes first and updates the memory afterwards:
|
|
465
465
|
|
|
@@ -473,6 +473,12 @@ modes.peek(channel).longRunning; // true
|
|
|
473
473
|
|
|
474
474
|
`set` throws `PluginError` for anyone but an owner, and for the host's own principal (`SYSTEM_PRINCIPAL`), so a webhook or a report turn cannot switch a conversation to run unattended. A change names only the modes it sets. It logs `conversation modes changed` with the channel, both modes and who changed them. `onChange(listener)` hears every change until the function it returns is called; a listener that throws is logged and the rest still hear it.
|
|
475
475
|
|
|
476
|
+
#### Modes a temp knight inherits
|
|
477
|
+
|
|
478
|
+
A temp knight's conversations (its Discord thread and its rooms) follow the yolo and long-running modes of the conversation that made it, live: `helper_create` records them with `follow(channels, origin, by)`, and `peek` on each returns the origin's modes with `ConversationMode.inheritedFrom` (`{ channel, name?, gone? }`, `name` the knight's display name). Switching a mode in the knight's conversation applies to its temp knights at once, and `onChange` tells each of their conversations too. `set` refuses a conversation that follows another (`PluginError`), so its modes are switched only where they come from; the web room's banner says "Inherited from <knight>" in place of its switches, and a switch in its menu is refused with that reason. When the origin's agent or group is archived or deleted, the temp knight's modes read off (`gone: true`). The follow is stored in the `follows` column of `conversation_modes` (the migration `conversation-modes-follows`), so it survives a restart; the agent server tells the modes which conversations still follow theirs with `trackOrigins`, so a name an agent takes later follows nothing. A yolo approval in a temp knight's turn names its own conversation in `autoApproved`.
|
|
479
|
+
|
|
480
|
+
Inheriting adds no permission: an owner turned the modes on in the origin. `follow` takes an owner, as `set` does, so only an owner's turn makes a temp knight follow; a temp knight that a member's turn, or the host's own, made follows nothing and starts with both modes off (`forget(channel)` drops a conversation's modes and what it follows, and needs no speaker since it only turns modes off). A temp knight made before 0.28 keeps the long-running mode it was given.
|
|
481
|
+
|
|
476
482
|
When a held call is approved by yolo mode, `ApprovedBy.auto` is `"yolo"` (a person's answer never sets it; `ToolTurn.approvedBy` shows it to the tool), and the approver reports it with `autoApproved(entry)`, which logs `held call approved by yolo mode` and tells the `onAutoApproved` listeners, so a surface can show it. What yolo approves, and what it never does, is under [hold classes and yolo mode](#hold-classes-and-yolo-mode).
|
|
477
483
|
|
|
478
484
|
## Principals and access
|
|
@@ -1116,6 +1122,15 @@ export default {
|
|
|
1116
1122
|
|
|
1117
1123
|
An approved call runs in the same turn. It is reported as approved by the speaker with `ApprovedBy.auto: "yolo"`, logged as `held call approved by yolo mode` (channel, tool, classes, principal) and told to `MODES.onAutoApproved` listeners. A message that confirms a held call works as before. A turn with no card surface is decided the same way, so an owner's own turn there goes on instead of holding the call.
|
|
1118
1124
|
|
|
1125
|
+
#### Cards in a yolo conversation
|
|
1126
|
+
|
|
1127
|
+
Yolo mode still posts the two kinds of cards meant for a person, but neither stops autonomous work when no one answers:
|
|
1128
|
+
|
|
1129
|
+
- An `ask_user` card holds the turn `autonomy.yolo.questionGraceSeconds` at most (`5` to `600`, default `30`; `Autonomy.yolo.questionGraceMs`), passed to the surface as `PromptWait.graceMs`, so Discord and the web app wait that long instead of their own grace. Unanswered, the tool tells the model to keep working: go on with its recommended option, or its best judgment, and say in its reply what it assumed. The card stays open, and a later answer starts a new turn to correct course. Only a question that truly blocks (no safe choice without the answer, such as an irreversible or outward-facing step whose direction only the person can pick) may end the turn, after the work the answer does not affect is done. The turn's system prompt carries the same rule, so the model does not end a yolo turn with a question either.
|
|
1130
|
+
- The timeout card waits `autonomy.yolo.timeoutCardSeconds` (`10` to `3600`, default `60`; `Autonomy.yolo.timeoutCardGraceMs`), says that with no answer the turn keeps going, and, unanswered, extends the turn as **Continue** would; its closed card says it continued automatically, and the log says `yolo mode continued the turn; the timeout card went unanswered`. The extensions stop at `autonomy.longRunning.maxSeconds` of work: there the card is the usual one and no answer stops the turn. **Cancel** still stops it. Long-running mode, which extends without a card, is unchanged.
|
|
1131
|
+
|
|
1132
|
+
The mode is read when the card is asked for, so a toggle applies to the next card. While the host stops, `ask_user` posts no card and reports a waiting one lost, in yolo as outside it. Outside yolo nothing changes.
|
|
1133
|
+
|
|
1119
1134
|
**Plain `git push` and writes outside the workspace are approved under yolo**, because they are the non-destructive shell classes (`shell.push`, `shell.write`). A plain push to `main` deploys wherever a push to `main` deploys. To keep it behind a card, put `shell.push` in `autonomy.yolo.card`; to keep every write outside the roots behind one, put `shell.write` there.
|
|
1120
1135
|
|
|
1121
1136
|
#### The shell rule
|
|
@@ -3027,7 +3042,7 @@ The agent server claims `discord:` keys, and, only on a surface that declares `a
|
|
|
3027
3042
|
|---|---|---|
|
|
3028
3043
|
| `surface` | The key prefix | (required) |
|
|
3029
3044
|
| `start(deliver)` | Connects, and calls `deliver(message)` for every incoming message; the host passes its conversation router | (required) |
|
|
3030
|
-
| `sendReply(channel, reply)` | Posts an `OutboundReply`: a thinking line, a card, text chunks, then files | (required) |
|
|
3045
|
+
| `sendReply(channel, reply)` | Posts an `OutboundReply`: a thinking line, a card, text chunks, then files; a reply with `intercom` is a message between agents, which a surface may draw as a small card in place of its chunks | (required) |
|
|
3031
3046
|
| `stop()` | Disconnects when the host stops | nothing to stop |
|
|
3032
3047
|
| `agentRooms` | `true` makes the surface host [agent rooms](#agent-rooms-on-any-surface): a conversation with each agent and group of the team, as `<surface>:agent.<name>` and `<surface>:group.<name>` | no rooms; the agent team answers only in Discord |
|
|
3033
3048
|
| `threads` | A `SurfaceThreads` that hosts [dispatch threads](#dispatch-threads-on-any-surface): a background dispatch started in one of the surface's channels opens its thread there, and the thread is the conversation `<surface>:<id>` | the dispatch posts in the channel as before |
|
|
@@ -3038,7 +3053,7 @@ The agent server claims `discord:` keys, and, only on a surface that declares `a
|
|
|
3038
3053
|
| `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)`. A group's round shows it for the whole round, and stopping ends the round: the member speaking is stopped and the members still waiting do not speak | none is shown |
|
|
3039
3054
|
| `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
|
|
3040
3055
|
| `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`, and, where the surface declares `asks`, `questionnaire` ([several questions on one card](#several-questions-on-one-card-questionnaire-and-asksupport)); without a scope, the owners | the action is held until a message approves it |
|
|
3041
|
-
| `interim(channel, as?)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place. `as` is a `PostAuthor` (`{ name, agent? }`) when an agent of the team speaks, as `OutboundReply.author` is for the final reply | only the final reply is posted |
|
|
3056
|
+
| `interim(channel, as?)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place. `tools: false` leaves the tool line out of the progress message, for a surface that shows tool use elsewhere. `as` is a `PostAuthor` (`{ name, agent? }`) when an agent of the team speaks, as `OutboundReply.author` is for the final reply | only the final reply is posted |
|
|
3042
3057
|
| `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok, refused? }` (`refused`, only on a failed end that a hold refused, says how: `declined` on its approval card, `expired` when a card was shown and went unanswered, `pending` while the card stays open, or `held` when no card could be shown (no one may approve it, or it could not be posted); it is absent for a success and for a real failure). The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
|
|
3043
3058
|
| `groupRound(channel, round)` | Shows where a group room's round stands: a `RoundProgress`, `{ id, state, current?, next, spoken, replies, limit, ended?, queued? }`, with `id` the group message that opened the round, `state` `running` or `ended`, `current` the member speaking, `next` those still to speak in order (a handoff appended), `spoken` those who replied, `ended` (once it ended) `done`, `limit` or `stopped`, and `queued` the owner's messages waiting for the next round. Called when the round is planned, before and after each member's turn, and when it ends; each call carries the whole state. Called for the room keys of a surface that hosts rooms, never for a Discord channel; a rejection is logged and the round goes on | the surface shows no rounds |
|
|
3044
3059
|
|
|
@@ -3049,7 +3064,7 @@ A room is a conversation of its own, apart from the agent's or group's Discord c
|
|
|
3049
3064
|
A room has its own session and held actions, so a card or an approving message in the web room does not touch the Discord channel's.
|
|
3050
3065
|
A group's room keeps its own log, `<group>@<surface>`, with the seats `agentgroup:<surface>.group.<group>.<agent>`; the Discord channel's log and seats are untouched.
|
|
3051
3066
|
A schedule an agent sets from a room belongs to the room, and the agent's own `schedule_list`, `schedule_update` and `schedule_cancel` reach it from every other conversation of that agent (its Discord channel and each room, `AGENTS.team.roomsOf(name)`); `schedule_list` says where each one fires. Only the agent's own schedules: another agent's are still read by name with `agent`, never changed.
|
|
3052
|
-
`message_agent` delivers into the target's most recent conversation, the one it last ran a turn in (kept in the table `agent_latest_conversation`, so a restart keeps it), whether that is its Discord channel or a room. An agent that has run no turn yet is reached on the sender's surface: a room's message stays among the rooms. The optional `conversation` chooses another: `"discord"`, `"web"` (the one surface that hosts rooms, or that surface's name), or one of the conversation keys `agent_get` lists. The answer, and the follow-up turn it wakes, return to the conversation the sender wrote from, wherever the target answered.
|
|
3067
|
+
`message_agent` delivers into the target's most recent conversation, the one it last ran a turn in (kept in the table `agent_latest_conversation`, so a restart keeps it), whether that is its Discord channel or a room. An agent that has run no turn yet is reached on the sender's surface: a room's message stays among the rooms. The optional `conversation` chooses another: `"discord"`, `"web"` (the one surface that hosts rooms, or that surface's name), or one of the conversation keys `agent_get` lists. The answer, and the follow-up turn it wakes, return to the conversation the sender wrote from, wherever the target answered. The sender's conversation shows the exchange as cards, through `sendReply` with `OutboundReply.intercom` (an `IntercomCard`: `kind` `sent`, `answered` or `failed`, the agent the request went to as `peer`, and the whole `text`): the request under the sender's name when it is sent, then the answer, or why none came, under the target's. A surface that shows cards draws a small card that opens to the text; `chunks` carry the card's heading and the text for one that does not. An empty answer posts no card, and no thread is opened.
|
|
3053
3068
|
Starting a room over resets that room's session (or a group room's seats) alone.
|
|
3054
3069
|
Archiving an agent or group clears the schedules and sessions of every key `AGENTS.team.roomsOf(name)` lists: its Discord channel, when it has one, then its room on each surface, and the room stops being owned: `AGENTS.team.owns(room)` is `undefined`, the agent server's claim no longer takes its messages, and a surface should refuse them (the web disables the composer) instead of delivering them to a room nobody answers in.
|
|
3055
3070
|
|
|
@@ -3090,7 +3105,7 @@ Both tools are on the `member` tier and are offered to every agent that is not i
|
|
|
3090
3105
|
A temp knight is a real agent: an ordinary row of the agent table with a nullable `parent` and `finished_at` (the migration `agents-helpers`, additive), its own prompt, picture and sessions, and a name that defaults to `<parent>-<n>`.
|
|
3091
3106
|
Its model and thinking level are its knight's unless the call names others.
|
|
3092
3107
|
It has no group seat, and no agent, group, archive, channel-arrange or schedule tools.
|
|
3093
|
-
It follows its knight's routing rules, and
|
|
3108
|
+
It follows its knight's routing rules, and its yolo and long-running modes follow the conversation that made it, live (see [modes a temp knight inherits](#modes-a-temp-knight-inherits)).
|
|
3094
3109
|
|
|
3095
3110
|
| Rule | What happens |
|
|
3096
3111
|
|---|---|
|
|
@@ -3111,7 +3126,7 @@ defineRoundtable({
|
|
|
3111
3126
|
|
|
3112
3127
|
#### Dispatch threads on any surface
|
|
3113
3128
|
|
|
3114
|
-
A background dispatch (a coding task, a delegated task
|
|
3129
|
+
A background dispatch (a coding task, a delegated task) gets a thread beside the channel that started it: one start line in the channel, then the dispatch's progress and report inside the thread.
|
|
3115
3130
|
A surface hosts them with `threads`, a `SurfaceThreads`:
|
|
3116
3131
|
|
|
3117
3132
|
| Method | What the surface does |
|
|
@@ -3192,12 +3207,12 @@ Because anyone who can see the card can copy the text, it holds nothing they may
|
|
|
3192
3207
|
#### A prompt that outlives its turn: `PromptWait`
|
|
3193
3208
|
|
|
3194
3209
|
`confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy?, prompt? }`, `approvedBy` naming the approver where the surface knows them and `prompt` the id of the card that was answered, `{ kind: "question", answer, by }`, or `{ kind: "questionnaire", answer: QuestionnaireAnswer, by }`, `by` naming who answered; only a `questionnaire` call can produce the last) and returns the text of the turn that tells the conversation, or undefined for none.
|
|
3195
|
-
Given one, a surface may stop the turn's wait after a grace period: `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
|
|
3210
|
+
Given one, a surface may stop the turn's wait after a grace period (`wait.graceMs` when the turn gives one, as a yolo turn's `ask_user` does, else the surface's own): `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
|
|
3196
3211
|
Without one, a prompt waits for its answer, or for its turn's stop; a surface that cannot start a turn ignores `wait` and waits too.
|
|
3197
3212
|
A stop cancels a prompt only while its turn still waits on it; after that the prompt belongs to the conversation.
|
|
3198
3213
|
|
|
3199
3214
|
The runtime gives both of its prompts a `wait`.
|
|
3200
|
-
`ask_user` then tells the model the person has not answered yet and to end its turn saying it is waiting, without asking again, and the late answer's turn carries the question and the answer.
|
|
3215
|
+
`ask_user` then tells the model the person has not answered yet and to end its turn saying it is waiting, without asking again (in a yolo conversation, to keep working instead; see [cards in a yolo conversation](#cards-in-a-yolo-conversation)), and the late answer's turn carries the question and the answer.
|
|
3201
3216
|
A held action's call is blocked as awaiting the approval, and is not held for a later message; a late approval lets that exact call (same tool, same canonical input) run once, and its turn tells the model to make it now and report the result, so the call runs under the gate and tier rules every call does. The release is the approval of one call, so it is spent only by that call: in a turn whose speaker is the person who approved on the card (or an owner; a surface that does not say who approved leaves it to any speaker), and only when that person's message started the turn (`TurnRequest.byMessage`, set by the core's message paths: an owner's message in an agent channel, a group round, a web chat or remote MCP message). A schedule's, agent-message (follow-ups included), report, webhook or error-report turn never spends it, whoever its speaker is and whatever their tier, and the system principal never does; they show the card again, within an hour of the answer (`LATE_RELEASE_TTL_MS`), and not after the identical call has run without a hold, which is the run the person approved, so it is dropped then. A release nobody spends in that time is gone, and the same call is held again for a card; a late refusal starts a turn that says so.
|
|
3202
3217
|
A coding worker's cards give no `wait`, since nothing could resume a worker: it waits for the answer, and the waiting does not count against its time.
|
|
3203
3218
|
|
|
@@ -3231,7 +3246,7 @@ A model often writes text in an assistant message that then calls tools, such as
|
|
|
3231
3246
|
On a surface that gives `interim`, and in the agent server's channels through the agents' webhooks, the host posts that text as the turn goes, sorted in two:
|
|
3232
3247
|
|
|
3233
3248
|
- **Primary** text is posted as ordinary messages as soon as its assistant message ends: text of 400 characters or more, or written with a Markdown heading, list, table or code fence.
|
|
3234
|
-
- **Secondary** text, short narration between tool calls, goes to one progress message per run of tool-calling messages, in Discord's small text (`-# ` per line): each text as a line, then the tools called in the run, such as `-# bash ×3 · read · web_search` (names only). It is edited in place at most every 1.5 seconds, its last state always lands, and it stays inside 2000 characters by dropping its oldest lines behind `-# …`. A primary post or a card starts a new progress message.
|
|
3249
|
+
- **Secondary** text, short narration between tool calls, goes to one progress message per run of tool-calling messages, in Discord's small text (`-# ` per line): each text as a line, then the tools called in the run, such as `-# bash ×3 · read · web_search` (names only). It is edited in place at most every 1.5 seconds, its last state always lands, and it stays inside 2000 characters by dropping its oldest lines behind `-# …`. A primary post or a card starts a new progress message. Posts that set `InterimPosts.tools` to `false`, as the web app's rooms do because their progress pane shows the tools, get the narration without the tool line, and a run that wrote no narration posts nothing; Discord keeps the line.
|
|
3235
3250
|
|
|
3236
3251
|
Before any card, an `ask_user` question or an approval, the host posts the pending text and brings the progress message up to date, so what the model wrote before the card shows above it.
|
|
3237
3252
|
The final reply is posted at the end as before, with its thinking line; an intermediate message is never the final one, so nothing is posted twice, and a steered run keeps its final text.
|
|
@@ -3740,7 +3755,7 @@ The parts are medieval: the clothes, hats and gear of an agent are what a late-m
|
|
|
3740
3755
|
|
|
3741
3756
|
### The picture: `composeSvg`
|
|
3742
3757
|
|
|
3743
|
-
`ComposeOptions` are all optional: `expr` (one of the 13 `EXPRESSIONS`, by default the one `STATE_EXPRESSION` gives the state), `state` (one of the seven `AVATAR_STATES`: `idle`, `thinking`, `working`, `waiting`, `done`, `error`, `sleepy`), `look` (`modern-light` by default, `modern-dark`, `camelot-light` or `camelot-dark`, which change only the background and ring), `frame` (`disc` or `shield`), `bare` (no frame or background), `still`, `uid`, `size`, `label` and `skins` (six `#rrggbb` skin tones that `CharacterSpec.skin` indexes, in place of `CATALOG.skins`; anything but six valid colors throws a `CharacterError` whose `field` is `skins` or `skins[3]`).
|
|
3758
|
+
`ComposeOptions` are all optional: `expr` (one of the 13 `EXPRESSIONS`, by default the one `STATE_EXPRESSION` gives the state), `state` (one of the seven `AVATAR_STATES`: `idle`, `thinking`, `working`, `waiting`, `done`, `error`, `sleepy`), `look` (`modern-light` by default, `modern-dark`, `camelot-light` or `camelot-dark`, which change only the background and ring), `frame` (`disc` or `shield`), `bare` (no frame or background), `mouth` (`open` or `closed`, drawn in place of the expression's own mouth, for the two frames of a talking figure), `still`, `uid`, `size`, `label` and `skins` (six `#rrggbb` skin tones that `CharacterSpec.skin` indexes, in place of `CATALOG.skins`; anything but six valid colors throws a `CharacterError` whose `field` is `skins` or `skins[3]`).
|
|
3744
3759
|
|
|
3745
3760
|
- The output has literal colors only. It has no `style` attribute, variable, `<style>`, script, text, image, link, event handler or `<foreignObject>`, so a page whose content security policy forbids inline styles can inject it, and `canvas` rasterizes it as it is. It is at most 8 KB and the same for the same input. The root is `<svg class="av" data-expr>` and the layers are `<g class="NAME" data-part="NAME">` in a fixed order (`frame`, `ch` holding `back`, `body`, `head`, `hat`, `eyes-brows`, `mouth`, `fx`, `acc`, `prop`, then `state`); the prop group carries `data-prop`.
|
|
3746
3761
|
- `composeSvg` checks the spec again with `parseCharacter`, so a spec cast from untrusted data throws instead of reaching the markup. `uid` (`/^[a-z0-9]{1,16}$/`, default `a`) ends every clip path id, so each avatar on a page needs its own `uid`: two with the same one share clip path ids and one clips the other. The server-side renders pass an explicit one. `size` (an integer up to 4096) adds `width` and `height`, which `canvas` needs. `label` (at most 200 characters) becomes `aria-label` with `<>"'=()&` written as entities; without it the figure is `aria-hidden`.
|
|
@@ -4137,7 +4152,7 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
|
|
|
4137
4152
|
Every call shares the same shutdown.
|
|
4138
4153
|
Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
|
|
4139
4154
|
The built-in runtime limits a turn to 10 minutes of work by default (`turnLimits.timeoutSeconds`), not counting time spent waiting on the owner's cards. Each message steered into the running turn gives it the full budget again from that moment, up to a cap of 30 minutes of work for the whole turn by default (`turnLimits.maxSeconds`); once the cap is reached the turn takes no more steering, so a message waits for the next turn.
|
|
4140
|
-
When a limit fires on a turn that can show a card (an owner's turn or a report turn on a surface with cards, the same turns that can ask with `ask_user` or an approval card), the turn is not aborted: it keeps working and a card asks the owner whether to go on. The card names the limit, how long the turn has worked, what it is doing now (the tool running and for how long, or waiting on the model, or compacting the conversation) and the tools it called so far, by name and count, never their inputs. **Continue** gives the turn another full budget from the answer; at the cap it moves the cap too, to a full budget from the answer, so the owner can keep going in steps. **Cancel** aborts the turn. With no answer within `turnLimits.cardSeconds` of wall time (5 minutes by default) the turn is aborted. The time the card is open counts as work, unlike an `ask_user` or approval card, because the turn keeps working; while it is open the turn takes no steering. If the turn finishes first, the card closes saying so and the reply is posted as usual; a press after that gets the usual "no longer valid" reply. The card's buttons come from `ApprovalOptions.wording` on `Prompts.confirm`, with `supersede` closing it, and `ApprovalOptions.purpose` is `"turn-timeout"` on it (`"action"` or absent on a held call's approval), so a surface can tell the two apart and notify on the first; Discord ignores `purpose`. A surface that shows approvals shows it; one that ignores `wording` shows its own approve and decline labels.
|
|
4155
|
+
When a limit fires on a turn that can show a card (an owner's turn or a report turn on a surface with cards, the same turns that can ask with `ask_user` or an approval card), the turn is not aborted: it keeps working and a card asks the owner whether to go on. The card names the limit, how long the turn has worked, what it is doing now (the tool running and for how long, or waiting on the model, or compacting the conversation) and the tools it called so far, by name and count, never their inputs. **Continue** gives the turn another full budget from the answer; at the cap it moves the cap too, to a full budget from the answer, so the owner can keep going in steps. **Cancel** aborts the turn. With no answer within `turnLimits.cardSeconds` of wall time (5 minutes by default) the turn is aborted; in a yolo conversation the card waits `autonomy.yolo.timeoutCardSeconds` instead and the turn goes on (see [cards in a yolo conversation](#cards-in-a-yolo-conversation)). The time the card is open counts as work, unlike an `ask_user` or approval card, because the turn keeps working; while it is open the turn takes no steering. If the turn finishes first, the card closes saying so and the reply is posted as usual; a press after that gets the usual "no longer valid" reply. The card's buttons come from `ApprovalOptions.wording` on `Prompts.confirm`, with `supersede` closing it, and `ApprovalOptions.purpose` is `"turn-timeout"` on it (`"action"` or absent on a held call's approval), so a surface can tell the two apart and notify on the first; Discord ignores `purpose`. A surface that shows approvals shows it; one that ignores `wording` shows its own approve and decline labels.
|
|
4141
4156
|
A turn that cannot show a card (a schedule's turn, a member's turn, an agent's message, an agent's opening task, a turn without a surface) is aborted at once, as before, unless the conversation is [long-running](#long-running-conversations) or, for a schedule, the schedule has its own `time_limit_minutes`. The failure it posts in the conversation names the limit and, for such a turn, says that no one was in the conversation to ask for more time and how to allow it (`TurnTimeout.unasked`), so the owner sees where it stopped. Either way an aborted turn fails with a `TurnTimeoutError` (an `AgentRunError`) whose `timeout` (`TurnTimeout`) holds the limit, the work time, the running tools, whether it was compacting, the tools so far, and how it ended (`end`: `"timeout"`, `"cancelled"` by the owner, or `"unanswered"`); its message says the same in English, and the failure the conversation posts says it in the host's language instead of the generic failure notice.
|
|
4142
4157
|
The limits are the config's `turnLimits`, which the runtime plugin passes on to `turnTimeoutMs`, `turnMaxMs` and `turnTimeoutCardMs` of `PiAgentRuntimeOptions` (a host that builds the runtime itself sets those directly):
|
|
4143
4158
|
|
|
@@ -4153,7 +4168,7 @@ A turn whose model ignores its abort (a subprocess that does not exit) is given
|
|
|
4153
4168
|
|
|
4154
4169
|
### Long-running conversations
|
|
4155
4170
|
|
|
4156
|
-
An owner can switch a conversation to long-running mode, so a turn that reaches its limit goes on by itself instead of asking, or aborting when it cannot show a card. The server extends the limit exactly as **Continue** would, and logs `long-running mode continued the turn`. The cap is per turn: it counts the turn's work since it started, not the time spent on cards, and the extensions stop exactly at it (a `turnLimits.timeoutSeconds` of 3 hours with a 4-hour cap asks or aborts at 4 hours of work, not at 6). It is `autonomy.longRunning.maxSeconds` in the config (`600` to `604800`, default `14400`, four hours). At the cap the turn behaves as an ordinary one: a card whose `ApprovalOptions.purpose` is `"turn-timeout"`, so a surface can push it, or an abort. `ask_user` and approval cards are never answered automatically. `agent_create` takes `long_running: true` to switch the new agent's conversations to this mode before its opening task starts; only an owner's turn may (a lower tier, or the host's own turn, is refused before anything is made), and a host with no `modes` service refuses it.
|
|
4171
|
+
An owner can switch a conversation to long-running mode, so a turn that reaches its limit goes on by itself instead of asking, or aborting when it cannot show a card. The server extends the limit exactly as **Continue** would, and logs `long-running mode continued the turn`. The cap is per turn: it counts the turn's work since it started, not the time spent on cards, and the extensions stop exactly at it (a `turnLimits.timeoutSeconds` of 3 hours with a 4-hour cap asks or aborts at 4 hours of work, not at 6). It is `autonomy.longRunning.maxSeconds` in the config (`600` to `604800`, default `14400`, four hours). At the cap the turn behaves as an ordinary one: a card whose `ApprovalOptions.purpose` is `"turn-timeout"`, so a surface can push it, or an abort. `ask_user` and approval cards are never answered automatically (yolo mode lets an unanswered `ask_user` card go on without its answer; see [cards in a yolo conversation](#cards-in-a-yolo-conversation)). `agent_create` takes `long_running: true` to switch the new agent's conversations to this mode before its opening task starts; only an owner's turn may (a lower tier, or the host's own turn, is refused before anything is made), and a host with no `modes` service refuses it.
|
|
4157
4172
|
In a turn nobody is present in (a schedule's, an agent's message, an opening task) `ask_user` has no card to show: it refuses at once, telling the model to write the question as its final answer, which is posted to the agent's room, and the answer arrives as a new turn.
|
|
4158
4173
|
|
|
4159
4174
|
The mode is the conversation's, set with `MODES.set(channel, { longRunning: true }, speaker)` and read with `MODES.peek(channel)`; see [the `MODES` service](#conversation-modes-modes). A surface decides who may toggle it.
|
|
@@ -4461,6 +4476,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4461
4476
|
| `IdentityService` | `pi-roundtable` | type |
|
|
4462
4477
|
| `ImageDrawer` | `pi-roundtable` | type |
|
|
4463
4478
|
| `InboundMessage` | `pi-roundtable` | type |
|
|
4479
|
+
| `IntercomCard` | `pi-roundtable` | type |
|
|
4464
4480
|
| `InterimMessage` | `pi-roundtable` | type |
|
|
4465
4481
|
| `InterimPosts` | `pi-roundtable` | type |
|
|
4466
4482
|
| `InterimTextMode` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -30,6 +30,11 @@ export type CharacterLook =
|
|
|
30
30
|
export interface ComposeOptions {
|
|
31
31
|
/** Default `STATE_EXPRESSION[state ?? "idle"]`. */
|
|
32
32
|
expr?: Expression;
|
|
33
|
+
/**
|
|
34
|
+
* The mouth drawn open or closed in place of the expression's own, for the frames of a talking
|
|
35
|
+
* figure; the expression's own mouth when absent.
|
|
36
|
+
*/
|
|
37
|
+
mouth?: "open" | "closed";
|
|
33
38
|
/** With `still`: which overlay is baked in. Live output carries every overlay. */
|
|
34
39
|
state?: AvatarState;
|
|
35
40
|
/** Background and ring only. Default `"modern-light"`. */
|
|
@@ -55,6 +60,7 @@ export interface ComposeOptions {
|
|
|
55
60
|
|
|
56
61
|
const LOOKS = ["modern-light", "modern-dark", "camelot-light", "camelot-dark"];
|
|
57
62
|
const FRAMES = ["disc", "shield"] as const;
|
|
63
|
+
const MOUTHS = ["open", "closed"] as const;
|
|
58
64
|
const UID = /^[a-z0-9]{1,16}$/;
|
|
59
65
|
const MAX_LABEL = 200;
|
|
60
66
|
const MAX_SIZE = 4096;
|
|
@@ -174,6 +180,10 @@ export function composeSvg(
|
|
|
174
180
|
"look",
|
|
175
181
|
) as CharacterLook;
|
|
176
182
|
const frame = checkOneOf(FRAMES, options.frame ?? "disc", "frame");
|
|
183
|
+
const mouth =
|
|
184
|
+
options.mouth === undefined
|
|
185
|
+
? undefined
|
|
186
|
+
: checkOneOf(MOUTHS, options.mouth, "mouth");
|
|
177
187
|
const uid = options.uid ?? "a";
|
|
178
188
|
if (typeof uid !== "string" || !UID.test(uid))
|
|
179
189
|
throw new CharacterError("uid", "must match /^[a-z0-9]{1,16}$/");
|
|
@@ -203,6 +213,7 @@ export function composeSvg(
|
|
|
203
213
|
const face = faceParts(
|
|
204
214
|
{ ink: INK, brow: mix(s.hairColor, INK, 0.5), dot: mix(tones.p, INK, 0.1) },
|
|
205
215
|
expr,
|
|
216
|
+
mouth,
|
|
206
217
|
);
|
|
207
218
|
|
|
208
219
|
const hair = HAIR[s.hair](tones);
|
|
@@ -22,6 +22,22 @@ type MouthShape =
|
|
|
22
22
|
| "tongue"
|
|
23
23
|
| "yawn"
|
|
24
24
|
| "tilt";
|
|
25
|
+
/** The mouths that show an open mouth; the rest are a closed line. */
|
|
26
|
+
const OPEN_MOUTHS: ReadonlySet<MouthShape> = new Set([
|
|
27
|
+
"grin",
|
|
28
|
+
"o",
|
|
29
|
+
"tongue",
|
|
30
|
+
"yawn",
|
|
31
|
+
"tilt",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** A talking figure's mouth for one frame: the expression's own when it already is open or closed, else an open "o" or a closed smile. */
|
|
35
|
+
function talking(shape: MouthShape, mouth: "open" | "closed"): MouthShape {
|
|
36
|
+
const open = OPEN_MOUTHS.has(shape);
|
|
37
|
+
if (mouth === "open") return open ? shape : "o";
|
|
38
|
+
return open ? "smile" : shape;
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
type Extra = "blush" | "sweat" | "tear" | "sparkle" | "dots" | "burst";
|
|
26
42
|
|
|
27
43
|
interface ExpressionRow {
|
|
@@ -263,9 +279,14 @@ const star = (x: number, y: number, r: number, color: string): string =>
|
|
|
263
279
|
const drop = (x: number, y: number, s: number, color: string): string =>
|
|
264
280
|
`<path d="M${x} ${y - 3.6 * s}Q${x + 3 * s} ${y + 0.2 * s} ${x} ${y + 2.8 * s}Q${x - 3 * s} ${y + 0.2 * s} ${x} ${y - 3.6 * s}Z" fill="${color}"/>`;
|
|
265
281
|
|
|
266
|
-
export function faceParts(
|
|
282
|
+
export function faceParts(
|
|
283
|
+
colors: FaceColors,
|
|
284
|
+
expression: Expression,
|
|
285
|
+
talk?: "open" | "closed",
|
|
286
|
+
): Face {
|
|
267
287
|
const c = FACE;
|
|
268
288
|
const row = EXPRESSION_ROWS[expression];
|
|
289
|
+
const shape = talk === undefined ? row.mouth : talking(row.mouth, talk);
|
|
269
290
|
const [lx, ly] = row.look;
|
|
270
291
|
const eye = (cx: number, side: 0 | 1) => {
|
|
271
292
|
const big = row.big && !side;
|
|
@@ -279,7 +300,7 @@ export function faceParts(colors: FaceColors, expression: Expression): Face {
|
|
|
279
300
|
) =>
|
|
280
301
|
`<path d="M${-c.bl / 2} 0H${c.bl / 2}" transform="translate(${cx} ${f1(c.ey + c.bdy + dy)}) rotate(${side ? -tilt : tilt})" stroke="${colors.brow}" stroke-width="${c.bw}" stroke-linecap="round" fill="none"/>`;
|
|
281
302
|
const brows = `<g class="brows">${brow(c.ex[0], row.bl, 0)}${brow(c.ex[1], row.br, 1)}</g>`;
|
|
282
|
-
const mouth = `<g class="mouth" transform="translate(${c.mx} ${c.my}) scale(${c.ms})">${mouthShape(
|
|
303
|
+
const mouth = `<g class="mouth" transform="translate(${c.mx} ${c.my}) scale(${c.ms})">${mouthShape(shape, colors.ink)}</g>`;
|
|
283
304
|
const [fx, fy] = c.fx;
|
|
284
305
|
const blush = (cx: number) =>
|
|
285
306
|
`<ellipse cx="${cx}" cy="${c.ey + c.by}" rx="${c.br}" ry="${f1(c.br * 0.62)}" fill="#ff7a8e" opacity=".5"/>`;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ChannelKey } from "../domain/conversation.ts";
|
|
1
|
+
import type { ChannelKey, IntercomCard } from "../domain/conversation.ts";
|
|
2
2
|
import { AgentError, IdentityError } from "../domain/errors.ts";
|
|
3
3
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
4
4
|
import { HostStoppingError } from "../errors.ts";
|
|
5
|
-
import { messages } from "../i18n/index.ts";
|
|
6
5
|
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
6
|
+
import { intercomPlain } from "../presentation/intercom.ts";
|
|
7
7
|
import { isQuiet } from "../presentation/reply-body.ts";
|
|
8
8
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
9
9
|
import { addressee, type Speaker } from "../speakers.ts";
|
|
@@ -14,14 +14,12 @@ import {
|
|
|
14
14
|
agentReplyText,
|
|
15
15
|
chainLimitReason,
|
|
16
16
|
deliveredMessagePost,
|
|
17
|
-
returnedAnswerPost,
|
|
18
17
|
} from "./agent-prompt.ts";
|
|
19
18
|
import type { Agent } from "./agent-store.ts";
|
|
20
19
|
import { conversationLabel, messageTarget } from "./message-target.ts";
|
|
21
20
|
import { discordIdOf, discordKey, scopeOf } from "./team-keys.ts";
|
|
22
21
|
import {
|
|
23
22
|
type Chain,
|
|
24
|
-
type Exchange,
|
|
25
23
|
MAX_CHAIN_MESSAGES,
|
|
26
24
|
type TurnHost,
|
|
27
25
|
} from "./team-turn-types.ts";
|
|
@@ -90,25 +88,18 @@ export class AgentMessages {
|
|
|
90
88
|
chain.messages += 1;
|
|
91
89
|
// A temp knight is working from the moment a message is handed to it until it has answered.
|
|
92
90
|
this.#host.helpers.begin(target.name);
|
|
93
|
-
// The
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
// The request shows in the sender's turn channel as a small card under the sender's name.
|
|
92
|
+
this.#card(parent, sender, {
|
|
93
|
+
kind: "sent",
|
|
94
|
+
peer: this.#peer(target),
|
|
95
|
+
text,
|
|
96
|
+
});
|
|
96
97
|
void queue
|
|
97
98
|
.run(targetChannel, async () =>
|
|
98
|
-
this.#
|
|
99
|
-
sender,
|
|
100
|
-
target,
|
|
101
|
-
text,
|
|
102
|
-
chain,
|
|
103
|
-
await exchange,
|
|
104
|
-
targetChannel,
|
|
105
|
-
parent,
|
|
106
|
-
),
|
|
99
|
+
this.#exchange(sender, target, text, chain, targetChannel, parent),
|
|
107
100
|
)
|
|
108
101
|
.catch((error: unknown) => {
|
|
109
102
|
if (error instanceof HostStoppingError) {
|
|
110
|
-
// Refused before it started: the thread it opened has nothing more to hold.
|
|
111
|
-
void exchange.then((opened) => opened?.thread.close());
|
|
112
103
|
this.#keep({
|
|
113
104
|
kind: "message",
|
|
114
105
|
sender: sender.name,
|
|
@@ -139,69 +130,36 @@ export class AgentMessages {
|
|
|
139
130
|
return `Sent to "${target.displayName}" in its ${conversationLabel(targetChannel)}. It works there; its answer comes back to you as a new turn.`;
|
|
140
131
|
}
|
|
141
132
|
|
|
133
|
+
/** The other agent of a card, as it is named now. */
|
|
134
|
+
#peer(agent: Agent): IntercomCard["peer"] {
|
|
135
|
+
const current = this.#host.current(agent);
|
|
136
|
+
return { name: current.name, displayName: current.displayName };
|
|
137
|
+
}
|
|
138
|
+
|
|
142
139
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* rejects.
|
|
140
|
+
* Posts a card of the exchange in the channel under `as`'s name: the card where the channel's
|
|
141
|
+
* surface shows cards, and its heading and text where it shows none. Never rejects.
|
|
146
142
|
*/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
): Promise<Exchange | undefined> {
|
|
153
|
-
const thread = await this.#host.options.threads?.open(
|
|
154
|
-
parent,
|
|
155
|
-
`→ ${target.displayName}`,
|
|
156
|
-
);
|
|
157
|
-
if (!thread) return undefined;
|
|
158
|
-
await this.#host
|
|
159
|
-
.post(parent, this.#host.current(sender), {
|
|
160
|
-
chunks: splitReply(
|
|
161
|
-
deliveredMessagePost(this.#host.current(target), text),
|
|
162
|
-
),
|
|
163
|
-
threadId: thread.id,
|
|
143
|
+
#card(channel: ChannelKey, as: Agent, card: IntercomCard): Promise<void> {
|
|
144
|
+
return this.#host
|
|
145
|
+
.post(channel, this.#host.current(as), {
|
|
146
|
+
chunks: splitReply(intercomPlain(card)),
|
|
147
|
+
intercom: card,
|
|
164
148
|
})
|
|
165
149
|
.catch((error: unknown) =>
|
|
166
150
|
this.#host.options.logger.warn(
|
|
167
|
-
{ err: error },
|
|
168
|
-
"agent
|
|
151
|
+
{ channel, kind: card.kind, err: error },
|
|
152
|
+
"agent message card not posted",
|
|
169
153
|
),
|
|
170
154
|
);
|
|
171
|
-
return { parent, thread };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Runs the target's turn, then posts its answer and wakes the sender; closes the thread either way. */
|
|
175
|
-
async #deliver(
|
|
176
|
-
sender: Agent,
|
|
177
|
-
target: Agent,
|
|
178
|
-
text: string,
|
|
179
|
-
chain: Chain,
|
|
180
|
-
exchange: Exchange | undefined,
|
|
181
|
-
targetChannel: ChannelKey,
|
|
182
|
-
senderChannel: ChannelKey,
|
|
183
|
-
): Promise<void> {
|
|
184
|
-
try {
|
|
185
|
-
await this.#exchange(
|
|
186
|
-
sender,
|
|
187
|
-
target,
|
|
188
|
-
text,
|
|
189
|
-
chain,
|
|
190
|
-
exchange,
|
|
191
|
-
targetChannel,
|
|
192
|
-
senderChannel,
|
|
193
|
-
);
|
|
194
|
-
} finally {
|
|
195
|
-
await exchange?.thread.close();
|
|
196
|
-
}
|
|
197
155
|
}
|
|
198
156
|
|
|
157
|
+
/** Runs the target's turn, then shows its answer in the sender's channel and wakes the sender. */
|
|
199
158
|
async #exchange(
|
|
200
159
|
sender: Agent,
|
|
201
160
|
target: Agent,
|
|
202
161
|
text: string,
|
|
203
162
|
chain: Chain,
|
|
204
|
-
exchange: Exchange | undefined,
|
|
205
163
|
targetChannel: ChannelKey,
|
|
206
164
|
senderChannel: ChannelKey,
|
|
207
165
|
): Promise<void> {
|
|
@@ -223,9 +181,6 @@ export class AgentMessages {
|
|
|
223
181
|
chain,
|
|
224
182
|
{ unattended: true },
|
|
225
183
|
);
|
|
226
|
-
const thread = exchange?.thread;
|
|
227
|
-
if (thread && !result.ok)
|
|
228
|
-
await thread.close(messages().agentNoReply(result.error.message));
|
|
229
184
|
// A target with nothing to answer sends nothing back and wakes nobody.
|
|
230
185
|
if (isQuiet(result)) {
|
|
231
186
|
logger.info(
|
|
@@ -236,29 +191,22 @@ export class AgentMessages {
|
|
|
236
191
|
}
|
|
237
192
|
const current = this.#host.options.store.agent(sender.name);
|
|
238
193
|
if (current?.status !== "active") return;
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
})
|
|
246
|
-
.catch((error: unknown) =>
|
|
247
|
-
logger.error({ err: error }, "agent answer not posted back"),
|
|
248
|
-
);
|
|
249
|
-
await thread?.close();
|
|
194
|
+
// The answer, or why none came, shows in the sender's channel as a small card.
|
|
195
|
+
await this.#card(senderChannel, target, {
|
|
196
|
+
kind: result.ok ? "answered" : "failed",
|
|
197
|
+
peer: this.#peer(target),
|
|
198
|
+
text: result.ok ? result.text : result.error.message,
|
|
199
|
+
});
|
|
250
200
|
const followUp = result.ok
|
|
251
201
|
? agentReplyText(
|
|
252
202
|
addressee(chain.speaker, this.#host.options.owner),
|
|
253
203
|
this.#host.current(target),
|
|
254
204
|
result.text,
|
|
255
|
-
thread?.mention,
|
|
256
205
|
)
|
|
257
206
|
: agentFailureText(
|
|
258
207
|
addressee(chain.speaker, this.#host.options.owner),
|
|
259
208
|
this.#host.current(target),
|
|
260
209
|
result.error.message,
|
|
261
|
-
thread?.mention,
|
|
262
210
|
);
|
|
263
211
|
// The answer is a report back to the sender, so its turn may ask the owner on cards.
|
|
264
212
|
void queue
|
|
@@ -407,7 +355,7 @@ export class AgentMessages {
|
|
|
407
355
|
await this.#followUp(sender, entry.channel, entry.text, chain);
|
|
408
356
|
return;
|
|
409
357
|
}
|
|
410
|
-
// Its
|
|
358
|
+
// Its request card was posted when it was sent; the answer's card follows as it would have.
|
|
411
359
|
this.#host.helpers.begin(target.name);
|
|
412
360
|
try {
|
|
413
361
|
await this.#exchange(
|
|
@@ -415,7 +363,6 @@ export class AgentMessages {
|
|
|
415
363
|
target,
|
|
416
364
|
entry.text,
|
|
417
365
|
chain,
|
|
418
|
-
undefined,
|
|
419
366
|
entry.channel,
|
|
420
367
|
origin,
|
|
421
368
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentRuntime, ContextUse } from "../contract/runtime.ts";
|
|
2
|
+
import type { IntercomCard } from "../domain/conversation.ts";
|
|
2
3
|
import type { InterimPosts } from "../domain/interim.ts";
|
|
3
4
|
import type { ThinkingSetting } from "../models.ts";
|
|
4
5
|
|
|
@@ -24,6 +25,8 @@ export interface AgentPost {
|
|
|
24
25
|
files?: { name: string; data: Uint8Array }[];
|
|
25
26
|
/** A thread of the channel to post in instead, such as a dispatch's. */
|
|
26
27
|
threadId?: string;
|
|
28
|
+
/** A message between agents, shown as a small card in place of `chunks` where the channel can. */
|
|
29
|
+
intercom?: IntercomCard;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -134,16 +134,8 @@ export function agentReplyText(
|
|
|
134
134
|
who: OwnerIdentity,
|
|
135
135
|
from: Agent,
|
|
136
136
|
text: string,
|
|
137
|
-
thread?: string,
|
|
138
137
|
): string {
|
|
139
|
-
return `(The agent "${from.displayName}" (\`${from.name}\`) answered your message. Continue with it and tell ${who.name} what matters; your reply is posted in your channel
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Where the exchange was posted, when it had a thread. */
|
|
143
|
-
function threadNote(thread: string | undefined): string {
|
|
144
|
-
return thread
|
|
145
|
-
? ` The exchange is in the thread ${thread}, now archived; link it when useful.`
|
|
146
|
-
: "";
|
|
138
|
+
return `(The agent "${from.displayName}" (\`${from.name}\`) answered your message. Continue with it and tell ${who.name} what matters; your reply is posted in your channel.)\n\n${text}`;
|
|
147
139
|
}
|
|
148
140
|
|
|
149
141
|
/** What the sender receives when the target could not answer. */
|
|
@@ -151,9 +143,8 @@ export function agentFailureText(
|
|
|
151
143
|
who: OwnerIdentity,
|
|
152
144
|
from: Agent,
|
|
153
145
|
reason: string,
|
|
154
|
-
thread?: string,
|
|
155
146
|
): string {
|
|
156
|
-
return `(The agent "${from.displayName}" (\`${from.name}\`) could not answer your message: ${reason}. Tell ${who.name} briefly, or try again later
|
|
147
|
+
return `(The agent "${from.displayName}" (\`${from.name}\`) could not answer your message: ${reason}. Tell ${who.name} briefly, or try again later.)`;
|
|
157
148
|
}
|
|
158
149
|
|
|
159
150
|
/** A new agent's first turn. */
|
|
@@ -192,11 +183,6 @@ export function deliveredMessagePost(to: Agent, text: string): string {
|
|
|
192
183
|
return messages().messageDelivered(to.displayName, text);
|
|
193
184
|
}
|
|
194
185
|
|
|
195
|
-
/** How the target's answer appears in the sender's channel. */
|
|
196
|
-
export function returnedAnswerPost(text: string): string {
|
|
197
|
-
return messages().answerReturned(text);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
186
|
/** Why a chain of agent messages stops without the owner in between. */
|
|
201
187
|
export function chainLimitReason(who: OwnerIdentity): string {
|
|
202
188
|
const o = ownerWords(who);
|
|
@@ -425,7 +425,7 @@ function toolDefs(
|
|
|
425
425
|
name: MESSAGE_AGENT_TOOL,
|
|
426
426
|
label: "Message agent",
|
|
427
427
|
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 one it used most recently, so work it started in the web app is answered there. Give conversation to choose another: "discord", "web", or one of the conversation keys agent_get lists for it. The
|
|
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 one it used most recently, so work it started in the web app is answered there. 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
429
|
parameters: Type.Object({
|
|
430
430
|
to: NAME,
|
|
431
431
|
text: Type.String({ minLength: 1, maxLength: 4000 }),
|