pi-roundtable 0.26.1 → 0.26.2
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 +33 -0
- package/docs/plugins.md +12 -7
- package/package.json +1 -1
- package/src/core/agents/agent-claim.ts +1 -0
- package/src/core/agents/agent-conversations.ts +94 -0
- package/src/core/agents/agent-messages.ts +247 -56
- package/src/core/agents/agent-outbox.ts +91 -0
- package/src/core/agents/agent-prompt.ts +40 -1
- package/src/core/agents/agent-schema.ts +6 -0
- package/src/core/agents/agent-store.ts +22 -3
- package/src/core/agents/agent-team-fixture.ts +81 -88
- package/src/core/agents/agent-team-seeds.ts +29 -0
- package/src/core/agents/agent-team.ts +17 -14
- package/src/core/agents/agent-tools.ts +34 -5
- package/src/core/agents/group-turns.ts +1 -1
- package/src/core/agents/message-target.ts +79 -0
- package/src/core/agents/team-editing.ts +30 -2
- package/src/core/agents/team-keys.ts +7 -0
- package/src/core/agents/team-options.ts +3 -0
- package/src/core/agents/team-text.ts +4 -0
- package/src/core/agents/team-turn-types.ts +28 -0
- package/src/core/agents/team-turns.ts +36 -25
- package/src/core/assets/prompts/shared-guest.md +1 -3
- package/src/core/assets/prompts/shared.md +1 -3
- package/src/core/builtin/agent-server.ts +5 -0
- package/src/core/builtin/modules.ts +6 -0
- package/src/core/contract/channels.ts +5 -0
- package/src/core/contract/surface.ts +5 -0
- package/src/core/discord/channel-context.ts +3 -1
- package/src/core/discord/embed-text.ts +45 -0
- package/src/core/discord/inbound-message.ts +7 -2
- package/src/core/domain/ports.ts +17 -0
- package/src/core/domain/turn-timeout.ts +2 -0
- package/src/core/i18n/turn-timeout.ts +8 -2
- package/src/core/modules/background/background-turns.ts +3 -0
- package/src/core/modules/schedules/precheck-catalog.ts +49 -0
- package/src/core/modules/schedules/schedule-store.ts +27 -3
- package/src/core/modules/schedules/schedule-tools.ts +43 -86
- package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
- package/src/core/modules/schedules/schedules.ts +26 -2
- package/src/core/modules/schedules/time-limit.ts +27 -0
- package/src/core/presentation/reply-body.ts +8 -0
- package/src/core/routing/conversation-turns.ts +21 -2
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +31 -27
- package/src/core/runtime/extensions/hold-lookups.ts +21 -0
- package/src/core/runtime/extensions/late-releases.ts +76 -0
- package/src/core/runtime/pi-agent-runtime.ts +21 -19
- package/src/core/runtime/turn-answer.ts +5 -1
- package/src/core/runtime/turn-budget.ts +1 -0
- package/src/core/runtime/turn-limits.ts +12 -2
- package/src/core/runtime/turn-tools.ts +26 -0
- package/src/core/shared/schedule-tools.ts +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.26.2] - 2026-10-30
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- An agent answering in a room of another surface (the web app's `app:agent.<name>`, group and host rooms) no longer believes it is on Discord. The agent addendum and the group seat text now say where this conversation is, from the new optional `ChatSurface.label` (Discord, the web app, the web chat and LINE supply theirs; the key prefix is the fallback), and which other surfaces the agent has places on, and the shared prompts no longer carry Discord's Markdown guidance: how a reply renders comes from each surface's `formatting`, as before. The tool description of an agent's display name no longer says "Discord messages".
|
|
13
|
+
- A card answered after its turn stopped waiting no longer leaves a release that any later identical call can spend. The release now expires an hour after the answer, is spent only 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), never in a turn without a speaker such as an automatic, scheduled or agent-message turn, which shows the card again, and is dropped when the identical tool call runs without a hold, as that is the run the person approved. Before, it was kept for the life of the gate and spent by whichever turn made the identical call next, so an automatic turn could run a call on a stale approval with no card. A late approval still releases exactly one call, in the turn it starts. See [the plugin guide](docs/plugins.md).
|
|
14
|
+
- Agent messages (`message_agent`) and their answers no longer get lost when the host stops: a message sent while the host shuts down, and the follow-up turn that carries a target's answer back to the sender, used to be refused by the stopping queue and only logged, so after the restart the sender waited for an answer that never came. They are now written to the new table `agent_outbox` (additive migration `agent-outbox`, in the agent server's migrations) when the queue refuses them, and the next start delivers them oldest first through the conversation they addressed, once: an entry leaves the table when its turn starts, so a second start or another host reading the same table does not run it again. The normal path writes nothing and is as fast as before; the shutdown waits for the write. An entry whose agent was archived or moved to another channel meanwhile is dropped with a warning. A request's thread is closed when it is refused, so the replayed answer returns to the sender's room.
|
|
15
|
+
- A turn nobody is present in (a schedule, an agent's message, an agent's opening task) that ends with an empty final answer no longer fails with "the final assistant message has no text": it ends quietly, posts nothing, wakes nobody (an agent that answers a message with nothing sends no answer back and no follow-up turn), and logs `unattended turn had nothing to say; nothing posted` once. A schedule that says "send nothing if there is no progress" therefore works. A turn someone waits on (an owner's message, a report turn, a remote caller's turn) keeps failing on an empty answer. The new `TurnRequest.unattended` and `ConversationTurnInput.unattended` mark such a turn; the agent server sets them for schedules, agent messages and opening tasks, and `pi-roundtable-webchat` for its background turns that are not reports. A turn that leaves them out is attended, as before. `turnAnswer` takes the new `attended` argument.
|
|
16
|
+
- A turn nobody is present in that reaches its time limit still stops at it (it can show no card), but the notice it posts now says that no one was in the conversation to ask for more time and how to allow more (`TurnTimeout.unasked`, en and zh-TW), so the owner can see where an agent's first task or a schedule stopped.
|
|
17
|
+
- `ask_user` in a turn nobody is present in refuses at once with a message that tells the model to write the question as its final answer, which is posted to the agent's room, instead of the earlier one-line "ask in your reply".
|
|
18
|
+
- `pi-roundtable-web`: `auth.reauthHours` now holds on an open socket, not only on HTTP. The socket's 30-second recheck used to look only at the session's own idle limit (`sessionDays`), so a person removed at the provider who kept a tab open kept receiving room frames, and kept firing `auth.onSessionVerified`, until that limit. Past `reauthHours` the recheck now closes the socket with the code the page already handles for an ended session, so it shows sign-in again, and `onSessionVerified` is reported only for a session inside `reauthHours` on both the socket and HTTP paths. With `reauthHours` unset nothing changes. The check is one extra column comparison in the recheck's existing session query.
|
|
19
|
+
- `pi-roundtable-web`: the knight sheet's profile line names an automatic thinking level in words ("Automatic", zh-TW has its own wording) instead of the raw `auto`.
|
|
20
|
+
- `message_agent` delivers into the conversation the target agent used most recently, not always its Discord channel. An agent has a conversation on each surface (`discord:<id>`, `app:agent.<name>`), so a reminder to an agent that was working in the web app now lands there, and the agent answers in the web app. The answer and the follow-up turn it wakes return to the conversation the sender wrote from. A message from a room never follows that latest conversation out to Discord, so what was said in an owners-only web room is not posted into the target's Discord channel; it goes on following it from Discord into a room, and `conversation: "discord"` still chooses Discord. The last conversation of each agent is kept in the table `agent_latest_conversation` (additive migration `agent-latest-conversation`), so it survives a restart. An agent that has run no turn yet is reached on the sender's surface, as before.
|
|
21
|
+
- `schedule_list`, `schedule_update` and `schedule_cancel` of an agent now cover all its own schedules across its conversations (Discord channel and rooms), not only the current channel's. A schedule made in the web conversation can be listed and edited from Discord, and the list says where each one fires. Another agent's schedules are still read-only by name (`agent`, taken by `schedule_list` alone) and never changed: `schedule_update` and `schedule_cancel` ignore `agent`, which they used to honor undocumented.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `pi-roundtable-web`: opening a room from a seat of the round table is a dive: the view zooms toward the seat that was tapped, creeping at first and then rushing in (`cubic-bezier(0.7, 0, 0.84, 0)`, about 380 ms), and the room settles in already at full size, with no blank frame. A mouse click, a tap and a key press on a seat all do it, and the camera of the 3D table dives into a seat from the whole ring the same way. It uses transform and opacity only, a second tap during the zoom is dropped, Back or leaving the table mid-zoom gives the view back and opens nothing, and `prefers-reduced-motion: reduce` switches at once. The code is part of the round table's lazy chunk, not the first load.
|
|
26
|
+
- Discord embeds are read as text. A message's embeds (author, title, description, each field as `name: value`, footer, link) are added to the inbound text after its own content, so a post that is only an embed, such as a CI bot's or an alerting tool's webhook post (Discord renders a Slack-compatible webhook's attachments as embeds), now starts the same labelled report turn in an agent's channel as a text post: before, its text was empty and no turn started. The text is plain, markup is left as the sender wrote it, and the embeds add at most 4,000 characters, cut with `…`. A replied-to message's embeds are read the same way, and `discord.channelContext` lists them after a message's text, so an alert in the channel's history is no longer blank. Always on; a message without embeds reads as before. See [the plugin guide](docs/plugins.md).
|
|
27
|
+
- `agent_create` takes `long_running`: `true` turns on long-running mode for the new agent's conversations (its Discord channel and its rooms) before its opening task starts, so the first task is not stopped at 10 minutes. Only an owner's turn may; any other tier or the host's own turn is refused before anything is made, and so is a host without the `modes` service. The agent team takes the new `modes` option, which the agent server fills from `MODES`.
|
|
28
|
+
- A schedule may carry its own time limit: `time_limit_minutes` on `schedule_create` and `schedule_update` (1 to 240, `MAX_SCHEDULE_TIME_LIMIT_MINUTES`; `null` on update removes it). It replaces the 10-minute default for that schedule's turns and never passes the long-running cap (`autonomy.longRunning.maxSeconds`). Additive column `schedules.time_limit_minutes` (migration `schedules-time-limit`); `Schedule`, `NewSchedule` and `ScheduleChange` gain `timeLimitMinutes`, `BackgroundTurn` gains `timeLimitMs`, and `ConversationTurnInput` and `TurnRequest` gain `timeLimitMs`, which a claim's `background` passes on (the agent server and `pi-roundtable-webchat` do).
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `message_agent` takes an optional `conversation` to choose the target's conversation: `"discord"`, `"web"` (or the name of a surface that hosts rooms), or one of the keys `agent_get` lists, which now has a `Conversations:` line marking the most recent. A conversation the target does not have is refused with the ones it has. `AgentOps.message` and `AgentTeam.message` take the same optional fourth argument; `schedulesExtension` takes an optional fifth, the agent's conversations.
|
|
33
|
+
- `pi-roundtable-web`: the owners' round table also seats the host rooms the owner may list, so a host whose assistants live in host rooms (for example a support desk's ticket rooms) and has no core agent team no longer shows an empty table. The agents sit first as before, then the host rooms of every kind the owner may list, newest first, by the same lists and `access` as the member and admin table; a room the kind does not list for the owner is no seat. A seat opens the room's own page. The server decides what is seated: the page seats host rooms only where the config names kinds of room for the visitor, so a host with no host room kinds sees exactly what it saw before, and there is no new option.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- `pi-roundtable-web`: the Files page no longer flashes when you switch folders. Every move re-mounted the whole page: the tree collapsed to a "...", the list turned into grey skeletons and the whole screen cross-faded. The page now keeps one name across places and a move inside it skips the view transition. A folder that was listed before shows at once and is fetched again behind it, a folder not seen yet leaves the old rows in place, dimmed after a beat, until its own rows arrive, a branch of the tree opened a second time keeps its rows, and a new folder opens at its top while a refetch of the same one does not move it. The files' stylesheet now loads with the Files page instead of with every visit, which makes the first load about 1 KB gzipped smaller and pays for the drop-down style.
|
|
38
|
+
- `pi-roundtable-web`: the drop-downs (the Files page's workspace picker, the model, archive and notes pickers, and the host forms) have one look: the inputs' height, radius, border and focus ring and one chevron of the icon set, in place of the system's inset control. The workspace picker is a pill of the breadcrumb chips' height and tint, and under a coarse pointer a drop-down's text is 16px so iOS Safari does not zoom in when it takes focus.
|
|
39
|
+
- `pi-roundtable-web`: the round table's message box had a vertical three-dot button at its end that hid the box and gave no hint of it. It is now a chevron that points where the box goes (down to tuck it away, up to bring it back), with a tooltip, so it no longer reads as a second "more" beside the three horizontal dots.
|
|
40
|
+
|
|
8
41
|
## [0.26.1] - 2026-10-30
|
|
9
42
|
|
|
10
43
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -197,6 +197,8 @@ Use `QueuePort` from the main entry for `context.queue`; the kit's `ChannelQueue
|
|
|
197
197
|
|
|
198
198
|
With `defineRoundtable`, the host's logger sends every `error` and `fatal` line to the ops agent named by `config.ops.agent`, which reports it in its channel, or, with `config.ops.conversation`, to that conversation as a visible message and a report turn its claim answers.
|
|
199
199
|
The report turn is the host's own background turn of the `owner` target: it runs as the system principal (`SYSTEM_PRINCIPAL`) at the owner tier, as in 0.8, and so does the report turn of an outside Discord webhook's post in an agent's channel, whose text is untrusted input; neither can set up a schedule or delegate a task. The host does not start, with a `ConfigError` naming `config ops.conversation` and the reason, when no chat surface serves the key, no plugin's claim owns it, no plugin contributes the `owner` background target, the claim that owns it takes no background turns (has no `background`), or that claim says `takesSystemReports: false`, since its conversations are each private to their person and refuse the system principal's turns; a report is then never only logged. A claim like that is answered at startup, not by a warning at each report.
|
|
200
|
+
A webhook's post in an agent's channel starts that report turn with the post's text, and an embed counts as text: the post's embeds (author, title, description, each field as `name: value`, footer, link) follow its own content, plain and at most 4,000 characters, so a CI bot that posts only an embed, such as "Build 42 failed" with the branch and the run's link as fields, reaches the agent as `Webhook "CI" posted in your channel:` followed by those lines. A post with neither text nor embed text starts no turn. A replied-to message's embeds and the embeds in `discord.channelContext` are read the same way.
|
|
201
|
+
|
|
200
202
|
The core contributes `PERSONAL_TARGET` (`"owner"`) with or without Discord; `ops.conversation` still needs a surface and a claim that accepts system report turns.
|
|
201
203
|
Webchat accepts personal background turns but declares `takesSystemReports: false`, so `web:<id>` fails at startup; name a shared conversation of another surface, or an agent with Discord.
|
|
202
204
|
It then calls `DefineOverrides.errorSink(entry)` if you supply one; this function must not throw.
|
|
@@ -1413,6 +1415,8 @@ services: [
|
|
|
1413
1415
|
Use a service for anything with a lifetime: a timer, a queue, a connection.
|
|
1414
1416
|
Agents create schedules with the built-in `schedule_create` tool, and the built-in scheduler fires them.
|
|
1415
1417
|
A scheduled turn is an ordinary agent turn that can call your tools, so schedules need no separate plugin part.
|
|
1418
|
+
Nobody waits on a scheduled turn, so it ends quietly when it has nothing to say: an empty final answer posts nothing and fails nothing (the log says `unattended turn had nothing to say; nothing posted`), which lets a schedule say "send nothing if there is no progress". A turn someone waits on (an owner's message, a report turn, a remote caller's turn) still fails on an empty answer. A turn is unattended only when its caller says so: `TurnRequest.unattended`, or `unattended` on the `context.turns.run` input, which the agent server sets for schedules, agent messages and opening tasks; a custom claim's `background` hook sets it for its schedule turns, and a turn that leaves it out is attended. When such a turn ends empty, `context.turns.run` resolves `{ ok: true, text: "" }`, posts nothing and does not call `reply`.
|
|
1419
|
+
A schedule may carry its own time limit, `time_limit_minutes` on `schedule_create` and `schedule_update` (1 to 240, the four hours of `MAX_SCHEDULE_TIME_LIMIT_MINUTES`; `null` on update returns to the default; stored in the additive column `schedules.time_limit_minutes`, migration `schedules-time-limit`). It replaces the 10-minute default for that schedule's turns, and the runtime never lets it pass `autonomy.longRunning.maxSeconds`. A custom claim's `background` hook reads it as `BackgroundTurn.timeLimitMs` and passes it on as `ConversationTurnInput.timeLimitMs` (`TurnRequest.timeLimitMs`).
|
|
1416
1420
|
For a timer of your own, write a service like this one.
|
|
1417
1421
|
|
|
1418
1422
|
<!-- example: examples/services.ts -->
|
|
@@ -1723,7 +1727,7 @@ An `AgentRuntime` has these methods:
|
|
|
1723
1727
|
| `preflight?()` | Runs after every plugin's preflight, before anything starts; a throw stops the boot |
|
|
1724
1728
|
| `dispose?()` | Runs when the host stops the runtime plugin's `runtime` service |
|
|
1725
1729
|
|
|
1726
|
-
A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker` (required: the person the turn is for, at whose tier it runs; the built-in runtime refuses a request without one with an `AgentRunError` before asking the model, and a plugin that starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`), `conversation` (`TurnConversation`: `private` to a `principalId`, or `shared`, as `context.turns` records it; shared when absent), flags (`steerable`, `interactive`, `confirmed`: the speaker's message approved the conversation's held calls, which the runtime releases for the speaker, except those that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of, which stay held), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
|
|
1730
|
+
A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker` (required: the person the turn is for, at whose tier it runs; the built-in runtime refuses a request without one with an `AgentRunError` before asking the model, and a plugin that starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`), `conversation` (`TurnConversation`: `private` to a `principalId`, or `shared`, as `context.turns` records it; shared when absent), flags (`steerable`, `interactive`, `byMessage`: a person's message started the turn, so only such a turn spends a late approval's release, which a message path sets and a schedule, report or agent-message turn leaves out; `confirmed`: the speaker's message approved the conversation's held calls, which the runtime releases for the speaker, except those that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of, which stay held), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
|
|
1727
1731
|
An agent's turn also has `agent`, the agent's scope, whose `session` is the conversation's key.
|
|
1728
1732
|
Other turns use `kind` to name the conversation's persona, defaulting to `"owner"` when absent.
|
|
1729
1733
|
|
|
@@ -2472,7 +2476,7 @@ Call `context.turns.run(input)` inside your claim's queue task to run a turn in
|
|
|
2472
2476
|
It shows typing and the stop control on the channel's surface, runs the turn, and emits `turnStarted` and `turnEnded` with the turn's `kind`.
|
|
2473
2477
|
It catches runtime errors as failed results and posts the answer, failure notice, or stopped notice through the surface.
|
|
2474
2478
|
Supply `reply(result)` to handle the reply yourself.
|
|
2475
|
-
`input` has `channel`, `kind`, `text`, `speaker`, and optionally `origin` (where the request came from, as your claim's transport knows it, such as a connection's browser origin; tools read it as `turn.origin`, so never fill it from message text), `client` (a `ClientPort` for what the client that started the turn can do, which tools read as `turn.client`; only your transport provides one), `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed` (the speaker's message approved the held calls; calls that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of stay held), `reply`, and `conversation`.
|
|
2479
|
+
`input` has `channel`, `kind`, `text`, `speaker`, and optionally `origin` (where the request came from, as your claim's transport knows it, such as a connection's browser origin; tools read it as `turn.origin`, so never fill it from message text), `client` (a `ClientPort` for what the client that started the turn can do, which tools read as `turn.client`; only your transport provides one), `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `byMessage` (the speaker's message started the turn; set it only for a turn a person's message started, since it lets the turn spend the speaker's late approval), `confirmed` (the speaker's message approved the held calls; calls that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of stay held), `reply`, and `conversation`.
|
|
2476
2480
|
It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose runtime plugin has not provided a runtime.
|
|
2477
2481
|
|
|
2478
2482
|
Before each turn runs, `context.turns.run` records its conversation in the host's registry, `CONVERSATIONS`: at the first turn its key, surface, kind, visibility, owner, and title, and at every later turn only that it was active.
|
|
@@ -2945,8 +2949,8 @@ A surface that declares `agentRooms: true` hosts a room for each agent and group
|
|
|
2945
2949
|
A room is a conversation of its own, apart from the agent's or group's Discord channel, and the same team answers in it: the same agents with the same prompt, model, notes and schedules.
|
|
2946
2950
|
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.
|
|
2947
2951
|
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.
|
|
2948
|
-
A schedule an agent sets from a room belongs to the room.
|
|
2949
|
-
`message_agent`
|
|
2952
|
+
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.
|
|
2953
|
+
`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. When the surface of the sender hosts [dispatch threads](#dispatch-threads-on-any-surface) the exchange opens its thread there, as it does in a Discord channel, and the request and the answer go there under each agent's name.
|
|
2950
2954
|
Starting a room over resets that room's session (or a group room's seats) alone.
|
|
2951
2955
|
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.
|
|
2952
2956
|
|
|
@@ -3049,7 +3053,7 @@ A stop cancels a prompt only while its turn still waits on it; after that the pr
|
|
|
3049
3053
|
|
|
3050
3054
|
The runtime gives both of its prompts a `wait`.
|
|
3051
3055
|
`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.
|
|
3052
|
-
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
|
|
3056
|
+
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.
|
|
3053
3057
|
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.
|
|
3054
3058
|
|
|
3055
3059
|
On Discord the grace period is `discord.cardGraceSeconds` (default 120), and the cards carry no countdown.
|
|
@@ -3989,7 +3993,7 @@ Every call shares the same shutdown.
|
|
|
3989
3993
|
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.
|
|
3990
3994
|
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.
|
|
3991
3995
|
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.
|
|
3992
|
-
A turn that cannot show a card (a schedule's turn, a member's turn, a turn without a surface) is aborted at once, as before. 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.
|
|
3996
|
+
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.
|
|
3993
3997
|
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):
|
|
3994
3998
|
|
|
3995
3999
|
| Key | Default | What it is |
|
|
@@ -4004,7 +4008,8 @@ A turn whose model ignores its abort (a subprocess that does not exit) is given
|
|
|
4004
4008
|
|
|
4005
4009
|
### Long-running conversations
|
|
4006
4010
|
|
|
4007
|
-
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.
|
|
4011
|
+
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.
|
|
4012
|
+
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.
|
|
4008
4013
|
|
|
4009
4014
|
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.
|
|
4010
4015
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
|
+
|
|
4
|
+
interface Row {
|
|
5
|
+
agent_name: string;
|
|
6
|
+
channel_key: ChannelKey;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The conversation each agent last ran a turn in, kept in the database so a message to an agent
|
|
11
|
+
* still lands where it was working after the host restarts. The map answers the reads; a write
|
|
12
|
+
* goes to it at once and to the table behind it. Without a database it is the map alone.
|
|
13
|
+
*/
|
|
14
|
+
export class AgentConversations {
|
|
15
|
+
readonly #sql: SQL | undefined;
|
|
16
|
+
readonly #guild: string;
|
|
17
|
+
readonly #latest = new Map<string, ChannelKey>();
|
|
18
|
+
/** The tail of the writes, so the table ends with the last conversation noted. */
|
|
19
|
+
#tail: Promise<unknown> = Promise.resolve();
|
|
20
|
+
|
|
21
|
+
private constructor(sql: SQL | undefined, guildId: string) {
|
|
22
|
+
this.#sql = sql;
|
|
23
|
+
this.#guild = guildId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The table, created by the agents' migrations. */
|
|
27
|
+
static readonly migration = {
|
|
28
|
+
name: "agent-latest-conversation",
|
|
29
|
+
up: async (sql: SQL) => {
|
|
30
|
+
await sql`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS agent_latest_conversation (
|
|
32
|
+
guild_id text NOT NULL,
|
|
33
|
+
agent_name text NOT NULL,
|
|
34
|
+
channel_key text NOT NULL,
|
|
35
|
+
active_at timestamptz NOT NULL DEFAULT now(),
|
|
36
|
+
PRIMARY KEY (guild_id, agent_name)
|
|
37
|
+
)`;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The guild's rows, read from the table. */
|
|
42
|
+
static async load(sql: SQL, guildId: string): Promise<AgentConversations> {
|
|
43
|
+
const log = new AgentConversations(sql, guildId);
|
|
44
|
+
const rows: Row[] = await sql`
|
|
45
|
+
SELECT agent_name, channel_key FROM agent_latest_conversation
|
|
46
|
+
WHERE guild_id = ${guildId}`;
|
|
47
|
+
for (const row of rows) log.#latest.set(row.agent_name, row.channel_key);
|
|
48
|
+
return log;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An empty log that writes nowhere, for a host with no database to read. */
|
|
52
|
+
static memory(): AgentConversations {
|
|
53
|
+
return new AgentConversations(undefined, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The conversation the agent last ran a turn in. */
|
|
57
|
+
latest(agent: string): ChannelKey | undefined {
|
|
58
|
+
return this.#latest.get(agent);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Records the conversation the agent's turn runs in without waiting; a failed write is logged. */
|
|
62
|
+
keep(
|
|
63
|
+
agent: string,
|
|
64
|
+
channel: ChannelKey,
|
|
65
|
+
logger: { warn(fields: Record<string, unknown>, message: string): void },
|
|
66
|
+
): void {
|
|
67
|
+
this.note(agent, channel).catch((error: unknown) =>
|
|
68
|
+
logger.warn({ agent, err: error }, "latest conversation not kept"),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The conversation keys with the agent's most recent one marked. */
|
|
73
|
+
mark(agent: string, keys: readonly ChannelKey[]): string[] {
|
|
74
|
+
const latest = this.#latest.get(agent);
|
|
75
|
+
return keys.map((key) => (key === latest ? `${key} (most recent)` : key));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Records the conversation the agent's turn runs in; resolves once it is written, and rejects if the write fails. */
|
|
79
|
+
note(agent: string, channel: ChannelKey): Promise<void> {
|
|
80
|
+
if (this.#latest.get(agent) === channel) return Promise.resolve();
|
|
81
|
+
this.#latest.set(agent, channel);
|
|
82
|
+
const sql = this.#sql;
|
|
83
|
+
if (!sql) return Promise.resolve();
|
|
84
|
+
const write = this.#tail.then(
|
|
85
|
+
() => sql`
|
|
86
|
+
INSERT INTO agent_latest_conversation (guild_id, agent_name, channel_key)
|
|
87
|
+
VALUES (${this.#guild}, ${agent}, ${channel})
|
|
88
|
+
ON CONFLICT (guild_id, agent_name)
|
|
89
|
+
DO UPDATE SET channel_key = ${channel}, active_at = now()`,
|
|
90
|
+
);
|
|
91
|
+
this.#tail = write.catch(() => undefined);
|
|
92
|
+
return write.then(() => undefined);
|
|
93
|
+
}
|
|
94
|
+
}
|