pi-roundtable 0.25.0 → 0.26.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 +27 -0
- package/docs/plugins.md +129 -6
- package/package.json +1 -1
- package/src/cli/checks/agent-tools.ts +23 -0
- package/src/cli/doctor.ts +6 -0
- package/src/cli/principal.ts +9 -2
- package/src/core/agents/agent-prompt.ts +6 -1
- package/src/core/agents/agent-team.ts +2 -0
- package/src/core/agents/team-editing.ts +4 -0
- package/src/core/agents/team-options.ts +3 -1
- package/src/core/agents/team-selection.ts +6 -2
- package/src/core/builtin/agent-server.ts +1 -0
- package/src/core/builtin/channel-context.ts +21 -0
- package/src/core/builtin/discord.ts +4 -1
- package/src/core/config/config-schema.ts +22 -6
- package/src/core/config/config.ts +30 -2
- package/src/core/config/conversations.ts +21 -0
- package/src/core/config/profiles.ts +53 -0
- package/src/core/contract/channel-context.ts +106 -0
- package/src/core/contract/surface.ts +16 -0
- package/src/core/conversations/conversation-registry.ts +6 -0
- package/src/core/conversations/conversation-retention.ts +107 -0
- package/src/core/conversations/conversation-store.ts +6 -0
- package/src/core/conversations/conversations-plugin.ts +19 -3
- package/src/core/define-roundtable.ts +9 -2
- package/src/core/discord/channel-context.ts +47 -78
- package/src/core/discord/discord-surface.ts +16 -0
- package/src/core/events.ts +1 -0
- package/src/core/host-registration.ts +4 -0
- package/src/core/host.ts +3 -1
- package/src/core/identity/actor-facts.ts +7 -0
- package/src/core/identity/identity-plugin.ts +27 -5
- package/src/core/identity/identity-port.ts +62 -0
- package/src/core/identity/identity-schema.ts +2 -0
- package/src/core/identity/identity-service.ts +45 -52
- package/src/core/identity/identity-view.ts +1 -0
- package/src/core/identity/person-profiles.ts +275 -0
- package/src/core/identity/principal-tables.ts +10 -0
- package/src/core/identity/profile-choices.ts +45 -0
- package/src/core/identity/profile-memory.ts +65 -0
- package/src/core/identity/profile-picture.ts +117 -0
- package/src/core/identity/profiles-view.ts +20 -0
- package/src/core/identity/profiles.ts +82 -0
- package/src/core/modules/host-shell/shell-policy.ts +18 -3
- package/src/core/plugin.ts +40 -0
- package/src/core/registry/contributions.ts +8 -1
- package/src/core/runtime/chat-only.ts +32 -0
- package/src/core/runtime/conversation-sessions.ts +2 -1
- package/src/core/runtime/runtime-plugin.ts +1 -0
- package/src/core/runtime/runtime-types.ts +3 -1
- package/src/core/runtime/session-factory.ts +19 -30
- package/src/core/runtime/session-helpers.ts +27 -0
- package/src/core/services.ts +9 -0
- package/src/index.ts +18 -0
- package/src/kit/index.ts +1 -0
- package/src/kit/oidc.ts +16 -0
- package/src/testing.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.26.0] - 2026-10-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `IdentityService.peek(facts, { conversation }?)`: who these facts resolve to now, read only. It never links, claims, admits, records the person as seen or touches a tier, also when it refuses, and its tier is exactly what `resolve` gives (the lasting roles together with what the access rules give the facts), never the tier last recorded. It answers undefined for an unlinked identity, a disabled principal, or no tier. **Breaking** for code: a hand-built `IdentityService` stand-in adds `peek` (`testHost` and `servicePair` already cover tests).
|
|
13
|
+
- `ActorFacts.partial`: a surface that is not the authority on a person's roles marks its facts `partial: true`, and `resolve` records no lower tier and no refusal from them. Optional, not breaking.
|
|
14
|
+
- `PROFILES` (`PersonProfiles`), provided by the `identity` plugin: how the core names a person (D1, D3). A plugin registers a `ProfileSource` per provider (`provider`, `label`, `modelVisible`, `pictureHosts`, `fetch(subject, signal)`); a signed-in session reports the name its identity provider gave with `observed(principalId, provider, name)`; the person chooses which linked account represents them with `choose` (refused for an unlinked provider; `null` returns to the default), else the first provider in the new `profiles.prefer` they have linked; `of(principalId)` gives the name, the source and whether a picture exists, and `picture(principalId)` the picture through the source's allowed hosts only (https, no redirect, PNG/JPEG/GIF/WebP, at most 2 MiB, `nosniff`). Fetched and observed names and picture addresses live in memory for an hour at most and are written to no table; a fetch has two seconds and a miss, failure or timeout is remembered for ten minutes. Only the choice is stored, in the new `principal_profile_choices` table (migration `principal-profile-choices`, additive). Types: `ProfileSource`, `PersonProfiles`, `PersonDisplay`, `ProfileRules`, `ProfilesConfig`.
|
|
15
|
+
- **Breaking** for a host that replaces the `identity` plugin: it now provides `PROFILES` too, so a replacement lists `PROFILES` in `provides` and `replaces`, as it already does for `IDENTITY_LINKS`.
|
|
16
|
+
- `profiles.modelVisible` and `profiles.prefer` in the host's configuration. `Speaker.name` from `IDENTITY.resolve`, `peek` and `speakerFor` becomes the profile name for a provider the host lists in `profiles.modelVisible`; a source's own `modelVisible` flag never widens it. With no source, nothing observed and an empty list, every name is as in 0.25. **Breaking** only for a host that lists a provider: its agents then read that name.
|
|
17
|
+
- `pi-roundtable-line`: `lineProfileSource(api)`, a `ProfileSource` for provider `line` over LINE's `/v2/bot/profile/{userId}`, with the picture hosts `profile.line-scdn.net` and `obs.line-scdn.net` and `modelVisible: false`.
|
|
18
|
+
- `roundtable principal show` prints the profile source a person chose.
|
|
19
|
+
- `pi-roundtable/kit`: `oidcProvider` (`oidcProvider(issuer)`) and `oidcIdentity` (`oidcIdentity(issuer, subject)`), the one writer of `oidc:<base64url issuer>[:<subject>]`. `oidcSpeakerId` of `pi-roundtable-webchat` gives the same strings through it.
|
|
20
|
+
- `agentServer: { tools: "chat-only" }` (config), `HostEnv.agentTools` and `ResolvedConfig.agentTools` (`"full"` by default, `AgentTools`): the configuration form and the value plugins read as `context.env.agentTools`. `RoundtableOptions.environment.agentTools` carries it for hosts that build their options by hand. With it no agent or persona turn is offered `bash`, `read`, `edit` or `write`: the agent team's selection leaves them out, the agent's prompt says it has no shell, file tools or workspace, its sessions get no workspace, and `SessionFactory` refuses any selection naming one of them (`toolsFor`, `taskTools`) with a `ConfigError`, so the turn fails closed and is logged, whatever built the selection. `RoundtablePlugin.hostAccess?: true` marks a plugin whose tools reach the host (`pi-roundtable-coding` declares it): a chat-only host does not start with one, nor with a plugin whose `agentSelection` names a shell tool, and `pi-roundtable-webchat` refuses a persona `selection` that does. `roundtable doctor` gains an `agent tools` check that prints the mode. A host without the key behaves as before. See [Chat-only agents](docs/plugins.md#chat-only-agents-no-shell-file-or-workspace-tools).
|
|
21
|
+
- `conversations: { keepDays: { <kind>: days } }` (config; `ResolvedConfig.conversations`): a daily sweep deletes the recorded conversations of a kind that were last active longer ago, through `ConversationPort.deleteConversation`, skips a busy one until the next sweep and forgets the record with the new optional `ConversationRegistry.remove(key)`. A kind not listed, and a host without the key, keeps its conversations. Type: `ConversationsConfig`. See [Conversation retention](docs/plugins.md#conversation-retention-conversationskeepdays).
|
|
22
|
+
- Events: `EventHandlers.action(event)` and the optional `EventSink.action(event)`: a plugin reports what a person did through its surface (`ActionEvent`: `kind`, `actor`, `principalId`, `channel?`, `ok`, `at`, never content) and every plugin's handler hears it; a handler that throws is logged and never stops the others. The core reports none itself.
|
|
23
|
+
|
|
24
|
+
- Channel context for any surface. `ChatSurface.channelContext(message, options?)` is the new optional method a surface implements; the new built-in `channel-context` plugin provides `CHANNEL_CONTEXT` (`ChannelContexts`: `read(message, options?)`), which picks the surface by the message's channel key as `THREADS` does and returns what it reads, `undefined` for a surface without the method. `DISCORD.channelContext` is unchanged for callers and is the Discord surface's own answer. `ChannelContextOptions.authors(message)` (`ChannelContextSource` in, `ChannelContextVerdict` `"keep" | "skip" | "end"` or `undefined` out) generalises `foreignWebhook` to any message and surface and wins where it answers; `ChannelContextMessage.external` (and `ChannelContextSource.external`) marks an author outside the people the host serves, never an owner, and the block labels it `role="external"`. `ChannelContextSourceAttachment` is the attachment shape of a fetched message. `formatChannelContext` is now defined with the contract types and re-exported by the Discord entry unchanged.
|
|
25
|
+
|
|
26
|
+
### Breaking
|
|
27
|
+
|
|
28
|
+
- `HostEnv.agentTools` is required: code that builds a `HostEnv` by hand sets it (`testHost` and `testPlugin` fill `"full"`).
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- A held shell command's sentence on its approval card shows the command's first line, cut at 120 characters, instead of up to 600 characters of every line inside one code span: a multi-line script (a heredoc with `# comments`) broke the card's Markdown into headings. The card's code block still shows the whole command.
|
|
33
|
+
- `agent_create` from an agent's turn in a web room started the new agent's opening task in its Discord channel. It now starts on the surface the caller's turn runs on, as the web app's new-agent form already did; a turn on Discord, or on a surface that hosts no rooms, keeps the Discord channel.
|
|
34
|
+
|
|
8
35
|
## [0.25.0] - 2026-10-29
|
|
9
36
|
|
|
10
37
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -136,6 +136,7 @@ definePlugin({
|
|
|
136
136
|
provides: [], // optional: keys of the services setup provides to other plugins
|
|
137
137
|
replaces: [], // optional: keys of built-in services this plugin takes over
|
|
138
138
|
identities: [], // optional: identities the plugin's own credentials stand for
|
|
139
|
+
hostAccess: true, // optional: its tools reach the host's machine (shell, files)
|
|
139
140
|
preflight() {}, // optional: a check that runs before anything starts
|
|
140
141
|
setup(context) { // required: returns the parts the plugin adds
|
|
141
142
|
return { /* parts */ };
|
|
@@ -146,6 +147,9 @@ definePlugin({
|
|
|
146
147
|
`definePlugin` checks the name and the presence of `setup`, then returns the typed object you gave it.
|
|
147
148
|
Errors point to the plugin definition.
|
|
148
149
|
|
|
150
|
+
`hostAccess: true` marks a plugin whose tools reach the host's machine, such as one whose workers run shells or edit files (`pi-roundtable-coding` declares it).
|
|
151
|
+
A host that gives its agents [no shell](#chat-only-agents-no-shell-file-or-workspace-tools) refuses to start with such a plugin registered.
|
|
152
|
+
|
|
149
153
|
A plugin whose `setup` returns `{}` and has no migrations, providers, or hooks stops startup (see [Errors](#errors-and-their-fixes)).
|
|
150
154
|
|
|
151
155
|
`identities` is for a plugin that serves a credential of its own, such as the bearer token of an endpoint, and runs turns for whoever holds it.
|
|
@@ -165,7 +169,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
|
|
|
165
169
|
| Field | What it is |
|
|
166
170
|
|---|---|
|
|
167
171
|
| `logger` | The host's logger with `plugin: <your name>` on every line; each line is JSON on stdout. Its type is `Logger` (`debug`, `info`, `warn`, `error`, `fatal`, `child(fields)`), which a pino logger satisfies |
|
|
168
|
-
| `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone), `now()`, `scratchDir` when the host has one,
|
|
172
|
+
| `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone), `now()`, `scratchDir` when the host has one, `characterSkins`, the six skin tones the host draws its agents with ([host skins](#host-skins-the-charactersskins-option)), and `agentTools`, `"full"` or `"chat-only"` ([chat-only agents](#chat-only-agents-no-shell-file-or-workspace-tools)); read the zone from here rather than from the process, give `scratchDir` to a shell the plugin runs itself, and pass `characterSkins` to `composeSvg` when the plugin draws a character |
|
|
169
173
|
| `database()` | The host's one PostgreSQL connection (a Bun `SQL`), already migrated |
|
|
170
174
|
| `toolTiers` | What each tool needs; ask it when a tool is used, not during setup |
|
|
171
175
|
| `events` | Where the core reports turns and team changes to every plugin's handlers |
|
|
@@ -235,6 +239,7 @@ The built-in plugins provide these, from the main entry:
|
|
|
235
239
|
|---|---|---|---|
|
|
236
240
|
| `IDENTITY` | `IdentityService` | `identity` | Who the host serves, read-only: `resolve(facts, { conversation }?)` gives the `Speaker` with its `principalId` behind a surface's `ActorFacts`, or undefined; `principal(id)`, `list()`, `identities(principalId)`, `roles(principalId)`, `tierOf(principalId)` (the lasting roles' tier), `speakerFor(principalId, tier?)` for a turn on someone's behalf, `owners()`, and `principalOf(identity)`, the principal an identity written `<provider>:<subject>` is linked to, such as one a plugin declares in `identities`. Principals are written only by the configuration and `roundtable principal`. `SYSTEM_PRINCIPAL` is the host's own, which no plugin speaks as |
|
|
237
241
|
| `IDENTITY_LINKS` | `IdentityLinks` | `identity`, only with `access.selfLink` | Lets a plugin link a person's own account to their principal, which `IDENTITY` cannot: `link(principalId, identity)` and `unlink(identity)`, identities written `<provider>:<subject>`. See [Self-linking](#self-linking). Absent unless the configuration lists the providers; read it with `find` |
|
|
242
|
+
| `PROFILES` | `PersonProfiles` | `identity` | How the core names a person: `register(source)` a `ProfileSource` for a provider, `observed(principalId, provider, name)` a name a signed-in session reported, `sourceOf`, `choose`, `of(principalId)` (name, source, whether a picture exists) and `picture(principalId)` (the picture through the source's allowed hosts). Always on. See [Naming people: `PROFILES`](#naming-people-profiles) |
|
|
238
243
|
| `CONVERSATIONS` | `ConversationRegistry` | `conversations` | The conversations run through `context.turns`: `register`, `get(key)`, `list({ principal }?)`, `setTitle(key, title)`, and `adopt(key, principalId)` only for migrating a conversation recorded before 0.9; each a `ConversationRecord` of `key`, `surface`, `kind`, `visibility` (`"private"` or `"shared"`), `principalId?`, `title?`, `createdAt`, `lastActiveAt` |
|
|
239
244
|
| `MODES` | `ConversationModes` | `modes` | Each conversation's modes, stored per key and kept across restarts: `peek(channel)` gives its `ConversationMode` (`yolo` and `longRunning`, both off by default; `changedBy?`, `changedAt?`) from memory, `set(channel, { yolo?, longRunning? }, speaker)` changes them and throws `PluginError` unless the speaker is an owner other than `SYSTEM_PRINCIPAL`, `onChange(listener)` hears each change, and `autoApproved(entry)` with `onAutoApproved(listener)` report a held call that yolo mode approved. See [Conversation modes](#conversation-modes-modes) |
|
|
240
245
|
| `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
|
|
@@ -245,8 +250,14 @@ The built-in plugins provide these, from the main entry:
|
|
|
245
250
|
| `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
|
|
246
251
|
| `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(principalId)` gives that principal's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
|
|
247
252
|
| `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
|
|
253
|
+
| `CHANNEL_CONTEXT` | `pi-roundtable` | value |
|
|
254
|
+
| `ChannelContexts` | `pi-roundtable` | type |
|
|
255
|
+
| `ChannelContextSource` | `pi-roundtable` | type |
|
|
256
|
+
| `ChannelContextSourceAttachment` | `pi-roundtable` | type |
|
|
257
|
+
| `ChannelContextVerdict` | `pi-roundtable` | type |
|
|
248
258
|
| `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
|
|
249
259
|
| `THREADS` | `DispatchThreads` | `threads` | The threads of background dispatches (coding tasks, delegated tasks, messages between agents), 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) |
|
|
260
|
+
| `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) |
|
|
250
261
|
| `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) |
|
|
251
262
|
|
|
252
263
|
The data types the ports use (`Schedule`, `NewSchedule`, `Agent`, `AgentGroup`, `TeamStatus`, `Memory`, `SkillSet`, `DelegationJob`, and so on) are in the main entry too.
|
|
@@ -467,6 +478,21 @@ Owner is granted only through configuration or the principal CLI, never an IdP r
|
|
|
467
478
|
A map gives the mode per surface, such as `provisioning: { web: "admitted", discord: "linked" }`: with `members.everyone: ["web", "discord"]`, a Discord id someone linked is served at member, an unlinked one is refused and not recorded, and web admits as before. A surface the map leaves out is `linked`, so a misspelled name cannot admit everyone; the one-word form and an omitted field stay `admitted`. `roundtable doctor` warns about a map key that is not a known surface and prints the effective mode.
|
|
468
479
|
A disabled principal is refused, and CLI changes reach a running host within the identity cache's 30 seconds.
|
|
469
480
|
|
|
481
|
+
### Reading who someone is without recording it: `peek` and `partial`
|
|
482
|
+
|
|
483
|
+
`IDENTITY.resolve(facts)` is the call for a contact that happens: it links a first contact, may admit a new principal, and records the person as seen at their tier.
|
|
484
|
+
`IDENTITY.peek(facts, { conversation }?)` only reads.
|
|
485
|
+
It never links, claims, admits or records, also when it refuses, and it gives the tier `resolve` would give now: the principal's lasting roles together with what the access rules give these facts, never the tier last recorded.
|
|
486
|
+
It answers undefined for an identity nothing is linked to, for a disabled principal, and when no tier results.
|
|
487
|
+
Use it where a request must be checked again and again, such as a web session, so the check leaves no trace; a first contact still goes through `resolve`.
|
|
488
|
+
A plugin that replaces `IDENTITY` adds `peek` to its service.
|
|
489
|
+
|
|
490
|
+
A surface that cannot see everything a person holds reports `partial: true` in its `ActorFacts`.
|
|
491
|
+
The facts then show less than the person may hold, so `resolve` never records a lower tier from them and never records them as a refusal, as it does for Discord facts that lack the roles the rules decide by.
|
|
492
|
+
The owner app sets it on every app sign-in, because an identity provider's roles for another application say nothing about this one.
|
|
493
|
+
|
|
494
|
+
`oidcProvider(issuer)` and `oidcIdentity(issuer, subject)` from `pi-roundtable/kit` write the provider `oidc:<base64url issuer>` and the identity `oidc:<base64url issuer>:<subject>` that `access.owners[].identities`, `selfLink.providers` and `ActorFacts.provider` take.
|
|
495
|
+
|
|
470
496
|
### Self-linking
|
|
471
497
|
|
|
472
498
|
`access: { selfLink: { providers: ["discord"] } }` lets a plugin link the account a person controls to their principal.
|
|
@@ -613,6 +639,45 @@ test("an unavailable principal is refused even at owner tier", async () => {
|
|
|
613
639
|
|
|
614
640
|
For existing hosts and compatibility aliases, read [Migrating to 0.9](migration-0.9.md).
|
|
615
641
|
|
|
642
|
+
### Naming people: `PROFILES`
|
|
643
|
+
|
|
644
|
+
A person's linked accounts each carry a name and maybe a picture, and a host decides which one represents them.
|
|
645
|
+
`PROFILES` (provided by the `identity` plugin, always) holds the sources and the choices:
|
|
646
|
+
|
|
647
|
+
```ts
|
|
648
|
+
setup: ({ services }) => {
|
|
649
|
+
services.get(PROFILES).register({
|
|
650
|
+
provider: "github",
|
|
651
|
+
label: "GitHub",
|
|
652
|
+
modelVisible: false,
|
|
653
|
+
pictureHosts: ["avatars.githubusercontent.com"],
|
|
654
|
+
fetch: async (login, signal) => {
|
|
655
|
+
const user = await (await fetch(`https://api.github.com/users/${login}`, { signal })).json();
|
|
656
|
+
return { name: user.name ?? login, pictureUrl: user.avatar_url };
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
- **Who names a person.** Their stored choice (`choose(principalId, provider)`, which refuses a provider they have not linked; `null` returns to the default), else the first provider in `profiles.prefer` they have linked, else nobody, and `of` falls back to the principal's display name.
|
|
663
|
+
- **Observed names.** A surface that signs a person in reports the name its identity provider gave (`observed`), so an OIDC issuer names people with no fetch. It is held in memory for an hour and renewed by each sign-in.
|
|
664
|
+
- **A fetch** has two seconds. A miss, a failure or a timeout is remembered for ten minutes, so the name on the hot path costs the wait at most once per person per ten minutes. Fetched and observed names and picture addresses stay in memory for an hour at most and are written to no table; only the choice (`principal_profile_choices`) is stored.
|
|
665
|
+
- **What agents read.** `Speaker.name` from `IDENTITY.resolve`, `peek` and `speakerFor` is the profile name only for a provider the host lists in `profiles.modelVisible`; the source's own `modelVisible` flag never decides it. With no source, no observed name and an empty list nothing changes. Names are made safe first: control characters become spaces and at most 80 characters remain.
|
|
666
|
+
- **Pictures.** `picture(principalId)` fetches only an `https` address on the source's `pictureHosts`, follows no redirect, accepts PNG, JPEG, GIF and WebP only (an SVG is refused), at most 2 MiB, and answers `nosniff` and `private, max-age=3600`. The bytes stay in memory for an hour, and a failure is remembered for ten minutes.
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
export default defineRoundtable({
|
|
670
|
+
// ...
|
|
671
|
+
profiles: {
|
|
672
|
+
prefer: ["line"], // LINE names whoever linked it and has not chosen
|
|
673
|
+
modelVisible: ["line"], // and agents read that name
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
`pi-roundtable-line` ships `lineProfileSource(services.get(LINE).api)`, a source for provider `line` over LINE's `/v2/bot/profile/{userId}` (picture hosts `profile.line-scdn.net` and `obs.line-scdn.net`, `modelVisible: false`).
|
|
679
|
+
`roundtable principal show` prints the source a person chose.
|
|
680
|
+
|
|
616
681
|
## Tiers: who may use what
|
|
617
682
|
|
|
618
683
|
Every turn has a speaker with a tier: `owner`, `admin`, or `member`, from most to least trusted.
|
|
@@ -1225,6 +1290,7 @@ export const library = definePlugin({
|
|
|
1225
1290
|
| `turnEnded(turn)` | The turn ended; `turn.result` is `"ok"`, `"failed"`, or `"stopped"` |
|
|
1226
1291
|
| `turnProgress(event)` | A turn run through `context.turns` wrote text or ran a tool, between its start and its end: `event` is the turn's fields and `progress`, a `TurnProgress` (see the surface's `progress` below); a runtime without live progress reports none |
|
|
1227
1292
|
| `changed()` | The team changed: an agent or group was created, edited, arranged, archived, or started over |
|
|
1293
|
+
| `action(event)` | A plugin reported something a person did through its surface with `context.events.action?.(...)`: `event` is `{ kind, actor, principalId, channel?, ok, at }`, with `kind` in the reporting plugin's own words (a support inbox says `"assign"`, `"reply"` or `"close"`), `actor` the `Speaker.id`, and `channel` a `ChannelKey` or a `page:<name>`. It carries no content. The core reports none itself; a plugin hears the others' |
|
|
1228
1294
|
| `shutdown(left)` | The shutdown drain ended, before any service stops; `left` lists the work it gave up on |
|
|
1229
1295
|
|
|
1230
1296
|
The host logs errors thrown by a handler and continues running the others.
|
|
@@ -2117,6 +2183,48 @@ export function alwaysOn(tools: () => string[]) {
|
|
|
2117
2183
|
```
|
|
2118
2184
|
<!-- /example -->
|
|
2119
2185
|
|
|
2186
|
+
### Chat-only agents: no shell, file, or workspace tools
|
|
2187
|
+
|
|
2188
|
+
By default an agent's turn carries Pi's shell and file tools (`bash`, `read`, `edit`, `write`) and works in a shared workspace on the host.
|
|
2189
|
+
A host whose agents only talk, such as one that serves a team through a web app and has no use for a shell, switches them off:
|
|
2190
|
+
|
|
2191
|
+
```ts
|
|
2192
|
+
export default defineRoundtable({
|
|
2193
|
+
// ...
|
|
2194
|
+
agentServer: { tools: "chat-only" },
|
|
2195
|
+
});
|
|
2196
|
+
```
|
|
2197
|
+
|
|
2198
|
+
The agent server stays, with its team, rooms, memory, schedules and the tools plugins add; `agentServer: false` still leaves it out, and a key left out keeps the full tools.
|
|
2199
|
+
Plugins read the mode as `context.env.agentTools` (`"full"` or `"chat-only"`), and `ResolvedConfig.agentTools` and `RoundtableOptions.environment.agentTools` carry it for a host that builds its own options.
|
|
2200
|
+
With `"chat-only"`:
|
|
2201
|
+
|
|
2202
|
+
- No turn is offered `bash`, `read`, `edit` or `write`: the agent team's selection leaves them out, an agent's system prompt says it has no shell, file tools or workspace, its sessions get no workspace (no `attach_file`, no shell scratch dir) and its held-action gate judges none.
|
|
2203
|
+
- The host refuses to start with a plugin that declares `hostAccess: true` (`pi-roundtable-coding` does), with a `ConfigError` that names the plugin, and with a plugin whose `agentSelection` names one of the four tools. `roundtable doctor` prints `agent tools: chat-only (no shell, file or workspace tools)` and fails for a registered `hostAccess` plugin; with the full tools it prints `full`.
|
|
2204
|
+
- A selection that names one of the four tools anyway, whatever built it (a turn run through `context.turns` with its own `selection`, a plugin that grants tools at run time), is refused at the one place where `SessionFactory` turns a selection into active tools (`toolsFor` and `taskTools`): the turn fails closed with a `ConfigError`, which is logged as the turn's failure, and no session runs it.
|
|
2205
|
+
- `pi-roundtable-webchat` stops at start when a persona's own `selection` names one of the four tools.
|
|
2206
|
+
|
|
2207
|
+
The mode is the host's decision, not a permission: it does not change who may use which tool (`toolTiers`), and an MCP server or other plugin that reaches the machine in a way it does not declare is outside what it can see.
|
|
2208
|
+
A plugin whose tools reach the host declares `hostAccess`, so that the one flag decides.
|
|
2209
|
+
|
|
2210
|
+
### Conversation retention: `conversations.keepDays`
|
|
2211
|
+
|
|
2212
|
+
The conversations run through `context.turns` are recorded (`CONVERSATIONS`).
|
|
2213
|
+
`conversations.keepDays` deletes the idle ones of a kind after a number of days, so a host that must not keep a kind of conversation for ever, such as support threads, does not:
|
|
2214
|
+
|
|
2215
|
+
```ts
|
|
2216
|
+
export default defineRoundtable({
|
|
2217
|
+
// ...
|
|
2218
|
+
conversations: { keepDays: { support: 365, chat: 30 } },
|
|
2219
|
+
});
|
|
2220
|
+
```
|
|
2221
|
+
|
|
2222
|
+
The key is a conversation's kind (`ConversationRecord.kind`) and the value a whole number of days from 1 up, counted from the conversation's last activity.
|
|
2223
|
+
A sweep runs when the host starts and once a day after: for each recorded conversation of a listed kind past its days, it calls `ConversationPort.deleteConversation`, which its claim carries out, and then forgets the record (`ConversationRegistry.remove`, optional on a hand-built registry).
|
|
2224
|
+
A conversation with a turn running or waiting (`"busy"`) is skipped and tried again at the next sweep; one whose claim cannot delete it is logged and kept.
|
|
2225
|
+
A kind not listed, and a host without the key, keeps its conversations as before.
|
|
2226
|
+
The agents' own channels are not recorded conversations and are not touched.
|
|
2227
|
+
|
|
2120
2228
|
### `piPackages`: Pi extensions every session loads
|
|
2121
2229
|
|
|
2122
2230
|
List npm packages whose Pi extensions every conversation session should load, and install them in your project (`bun add pi-web-access`).
|
|
@@ -2520,6 +2628,8 @@ Channel context lets the turn for an addressed message also read what was posted
|
|
|
2520
2628
|
The agent server reads it for every turn in an agent's channel; a group's round does not, since it already carries what was said since the group's last turn.
|
|
2521
2629
|
A plugin's claim opts in by calling `DISCORD.channelContext(message, options?)` from `pi-roundtable/discord` inside its turn's `run`, and appending the result with `withChannelContext(text, context, imagesKept?)`, as `withReference` adds the replied-to message.
|
|
2522
2630
|
|
|
2631
|
+
The same call works on any surface. A chat surface a plugin contributes implements the optional `ChatSurface.channelContext(message, options?)`, and `CHANNEL_CONTEXT.read(message, options?)` (from the main entry) picks the surface by the prefix of the message's channel key, as `THREADS` does, and returns what that surface returns; a surface without the method, a channel no surface serves and a message with nothing new all give `undefined`. A support inbox whose rooms are email threads, say, answers with the customer's messages since the assistant last replied, each marked `external: true` (see below). `DISCORD.channelContext` is the Discord surface's own answer to the same call, so a claim that reads either sees the same context. `ChannelContext`, `ChannelContextOptions`, `ChannelContextMessage`, `withChannelContext`, `withChannelContextImages` and `formatChannelContext` stay in the `pi-roundtable/discord` entry, which a plugin for another surface imports from too; `CHANNEL_CONTEXT`, `ChannelContexts`, `ChannelContextSource`, `ChannelContextSourceAttachment` and `ChannelContextVerdict` are in the main entry.
|
|
2632
|
+
|
|
2523
2633
|
It reads Discord, not the host's history, so it survives restarts:
|
|
2524
2634
|
|
|
2525
2635
|
- It fetches the `fetch` messages before the addressed one (default 50, at most 100) and keeps those after the assistant's latest post there: its bot user's, or one of its application's webhooks, such as an agent's voice. Any post of the assistant ends the window, including the divider the core posts when a conversation starts over (see `discord.freshMarker`) and a notice that it did; a fresh-start reply must be a public post, not an ephemeral one, to cut the window.
|
|
@@ -2529,14 +2639,16 @@ It reads Discord, not the host's history, so it survives restarts:
|
|
|
2529
2639
|
- It keeps the newest `keep` (default 15), oldest first. Mentions read as names, and stickers and files as text with their names and links; by default nothing is downloaded or shown to the model as an image (see `attachments` below). A message is cut at `messageChars` (default 500), another bot's at `botMessageChars` (default 80).
|
|
2530
2640
|
- It never fails the turn over Discord: a Discord error, such as a channel the bot cannot read, is logged and the turn runs without context.
|
|
2531
2641
|
|
|
2532
|
-
A plugin may mirror an outside chat into a channel through a webhook of its own, created by the bot, so that the application owns it; by default such a post counts as the assistant's own, which ends the window and is left out, and the people mirrored never reach a turn. `foreignWebhook(webhookId)` changes that for the webhooks it returns true for: a post through one is another author's, kept like another bot's (`bot: true`, `authorName` the post's username, `authorId` the webhook's id) and no longer ends the window, which then ends only at the assistant's own posts: its bot user's and every webhook `foreignWebhook` returns false for, such as an agent's voice. It may also return `"skip"` for a webhook that is neither, such as one the plugin posts its own status notices through: a post through it is dropped from the context and does not end the window, as if it were not in the channel, so notices between two mirrored posts neither appear as someone's words nor cut the window, and they are not counted among the messages delivered. Any answer other than `true` and `"skip"` reads as false. A skipped post is still one of the `fetch` messages read, so it uses the budget the window is chosen from. The host sets it as `discord.channelContext.foreignWebhook` and a call overrides it for that call; being a function, it can be set only in code. Posts through a webhook that `foreignWebhook` returns true for are merged as near repeats only when the poster's name is the same too, since one webhook carries many people; every other message merges by author alone, as before. An edited message reads as Discord now holds it.
|
|
2642
|
+
A plugin may mirror an outside chat into a channel through a webhook of its own, created by the bot, so that the application owns it; by default such a post counts as the assistant's own, which ends the window and is left out, and the people mirrored never reach a turn. `foreignWebhook(webhookId)` changes that for the webhooks it returns true for: a post through one is another author's, kept like another bot's (`bot: true`, `authorName` the post's username, `authorId` the webhook's id) and no longer ends the window, which then ends only at the assistant's own posts: its bot user's and every webhook `foreignWebhook` returns false for, such as an agent's voice. It may also return `"skip"` for a webhook that is neither, such as one the plugin posts its own status notices through: a post through it is dropped from the context and does not end the window, as if it were not in the channel, so notices between two mirrored posts neither appear as someone's words nor cut the window, and they are not counted among the messages delivered. Any answer other than `true` and `"skip"` reads as false. A skipped post is still one of the `fetch` messages read, so it uses the budget the window is chosen from. The host sets it as `discord.channelContext.foreignWebhook` and a call overrides it for that call; being a function, it can be set only in code. `authors(message)` is the same decision for any surface and any message, not only a webhook's: it receives the fetched message (`ChannelContextSource`: `id`, `authorId`, `authorName`, `bot`, `own`, `webhookId?`, `external?`, `attachments?`, `at`, `text`) and answers `"keep"` (another author's, which does not end the window; `foreignWebhook` returning `true`), `"skip"` (dropped as if it were not in the channel, without ending the window; `"skip"` of `foreignWebhook`), `"end"` (the assistant's own, which ends the window and is left out; `foreignWebhook` returning `false` for an application webhook) or `undefined`, which leaves the default: the assistant's own posts end the window and everything else is kept. Where it answers, it wins over `foreignWebhook`; it is a function, so it can be set only in code, as a host default (`discord.channelContext.authors`) or per call. Posts through a webhook that `foreignWebhook` returns true for are merged as near repeats only when the poster's name is the same too, since one webhook carries many people; every other message merges by author alone, as before. An edited message reads as Discord now holds it.
|
|
2643
|
+
|
|
2644
|
+
A message a surface knows to come from outside the people the host serves, such as a customer whose email a support inbox mirrors, is marked `external: true` (`ChannelContextMessage.external`, `ChannelContextSource.external`): it is never an owner, and the block labels it `role="external"`, the model's cue that its text is untrusted. Discord marks none.
|
|
2533
2645
|
|
|
2534
2646
|
Every returned message has `attachments` (`ChannelContextAttachment`: `name`, `contentType`, `size`, `url`, `messageId`), whether or not anything was fetched. With `attachments: { images, maxBytes }` (`ChannelContextAttachmentOptions`; default none) a call also fetches up to `images` (at most 4, a turn's limit) PNG, JPEG, GIF or WebP images of the kept messages, the newest first, each at most `maxBytes` (default 10 MiB, at most 25 MiB), and returns them as `context.images`, prepared as a turn's own images are (large ones are scaled down) and kept in memory by this call and never logged; once a turn is given them they are in its session history like the turn's own images, and stay there. Pass them on with `withChannelContextImages(attachments, context)`, which returns `{ attachments, kept }`: the turn's attachments with the context's images after the turn's own, within the four a turn carries, and how many of them fitted (a turn that already carries four keeps none, and an image crowded out is not noted anywhere). Give `kept` to `withChannelContext(text, context, kept)`: above zero it adds a line that pictures are shown among the turn's images, and it adds none otherwise, so the block never claims images the turn does not have. The image is sent as the type its bytes show (PNG, JPEG, GIF or WebP), not as Discord's guess from the file name, and bytes of none of them are skipped as `unsupported type`. Up to two images are downloaded at once, within 20 seconds for the whole call; the images still to be fetched when it passes are skipped as `download failed`. An image that is not a type the model takes, is over `maxBytes`, cannot be downloaded or cannot be decoded is skipped, and its message's text says `[image not attached: <name>: <reason>]`, the reason being `unsupported type` (by Discord's type or by the bytes), `too large`, `download failed` or `could not be decoded`; skipped images do not count toward `images`, but at most twice that many are tried. An image of a message already handed over is not fetched again, since its message is not returned again.
|
|
2535
2647
|
|
|
2536
2648
|
`DISCORD.channelContext` resolves undefined in a direct message, on another surface, when nothing new was said, and when the host's `discord.channelContext` is `false`, which turns it off for the agent server and every claim.
|
|
2537
2649
|
The configuration's values are the host's defaults, and a call's `options` override them for that call.
|
|
2538
2650
|
Call it once per answered message: what it returns is remembered as handed over.
|
|
2539
|
-
A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function of a webhook id returning `true`, `false` or `"skip"`, `attachments.images` an integer from 0 to 4, `attachments.maxBytes` an integer from 1 to 25 MiB); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
|
|
2651
|
+
A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function of a webhook id returning `true`, `false` or `"skip"`, `authors` a function of a message returning `"keep"`, `"skip"`, `"end"` or `undefined`, `attachments.images` an integer from 0 to 4, `attachments.maxBytes` an integer from 1 to 25 MiB); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
|
|
2540
2652
|
It remembers what it handed over and what addressed the assistant, not every message that ran a turn: a post a claim answered some other way, such as a webhook report turn or a steered message, can also come in the context of a later turn if the assistant has not posted since.
|
|
2541
2653
|
|
|
2542
2654
|
What it returns is public channel text, visible to everyone in the channel; it is not private memory, carries no `privateTo`, and goes into the turn's text, so it lives in the conversation's history, not in the system prompt.
|
|
@@ -2554,10 +2666,10 @@ Discord does not render Markdown tables, so the Discord surface tells the model
|
|
|
2554
2666
|
The default says that Markdown renders except tables, that bullet lists or `key: value` lines replace them, that headings, bold, lists, quotes, inline code, fenced code blocks and `-#` small text work, and that a reply over 2,000 characters is split.
|
|
2555
2667
|
`discord.formatting` replaces the text; `false` tells the model nothing.
|
|
2556
2668
|
|
|
2557
|
-
The block gives each author as a name, their
|
|
2558
|
-
`ChannelContext` is `{ messages, text, images? }`: `messages` are `ChannelContextMessage`s (`id`, `authorId`, `authorName`, `bot`, `owner`, `text`, `at`, `attachments`), oldest first, `text` is the block, and `images` the fetched images (see above).
|
|
2669
|
+
The block gives each author as a name, their id and a role (`owner`, `bot`, `external` or `member`) in separate attributes, and says the messages were not addressed to the assistant. A name loses the characters `"`, `'`, `=`, `<`, `>`, `(`, `)` and `,`, so it cannot imitate another author, an attribute or a role, and every `<` (also the full-width `<`) in a message's text is replaced by `‹`, so a message can neither close the block nor open a message of its own.
|
|
2670
|
+
`ChannelContext` is `{ messages, text, images? }`: `messages` are `ChannelContextMessage`s (`id`, `authorId`, `authorName`, `bot`, `owner`, `external?`, `text`, `at`, `attachments`), oldest first, `text` is the block, and `images` the fetched images (see above).
|
|
2559
2671
|
`ChannelContextOptions` holds the options above, and `CHANNEL_CONTEXT_DEFAULTS` their defaults.
|
|
2560
|
-
`formatChannelContext(messages)` is the function that writes `ChannelContext.text`, exported so a host can test what the model sees without reaching into the core: it takes `ChannelContextMessage`s (only `authorId`, `authorName`, `bot`, `owner` and `text` are read) and returns the block, headed and delimited by `<channel-context>`. Each message is a `<message from="…" id="…" role="owner|member|bot">` line whose author is in the attributes, never in the text, and every `<` of a name or text (plain or full-width) is replaced by `‹`, so no message can pass as markup. It does not fetch, cut or select anything, and it is the same function the reader calls, so a block it returns is the block a turn carries.
|
|
2672
|
+
`formatChannelContext(messages)` is the function that writes `ChannelContext.text`, exported so a host can test what the model sees without reaching into the core: it takes `ChannelContextMessage`s (only `authorId`, `authorName`, `bot`, `owner`, `external` and `text` are read) and returns the block, headed and delimited by `<channel-context>`. Each message is a `<message from="…" id="…" role="owner|member|bot|external">` line whose author is in the attributes, never in the text, and every `<` of a name or text (plain or full-width) is replaced by `‹`, so no message can pass as markup. It does not fetch, cut or select anything, and it is the same function the reader calls, so a block it returns is the block a turn carries.
|
|
2561
2673
|
In a test, `fakeDiscord({ channelContext })` answers `DISCORD.channelContext` as the test says; by default with no context.
|
|
2562
2674
|
|
|
2563
2675
|
<!-- example: examples/channel-context.ts -->
|
|
@@ -4053,6 +4165,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4053
4165
|
| `AccessRules` | `pi-roundtable` | type |
|
|
4054
4166
|
| `AccessTier` | `pi-roundtable` | type |
|
|
4055
4167
|
| `AccessTierConfig` | `pi-roundtable` | type |
|
|
4168
|
+
| `ActionEvent` | `pi-roundtable` | type |
|
|
4056
4169
|
| `ActorFacts` | `pi-roundtable` | type |
|
|
4057
4170
|
| `AGENTS` | `pi-roundtable` | value |
|
|
4058
4171
|
| `AGENT_SERVER_PLUGIN` | `pi-roundtable` | value |
|
|
@@ -4071,6 +4184,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4071
4184
|
| `AgentSessions` | `pi-roundtable` | type |
|
|
4072
4185
|
| `AgentStatus` | `pi-roundtable` | type |
|
|
4073
4186
|
| `AgentTeam` | `pi-roundtable` | type |
|
|
4187
|
+
| `AgentTools` | `pi-roundtable` | type |
|
|
4074
4188
|
| `AgentTurnScope` | `pi-roundtable` | type |
|
|
4075
4189
|
| `Approval` | `pi-roundtable` | type |
|
|
4076
4190
|
| `ApprovalDetails` | `pi-roundtable` | type |
|
|
@@ -4123,6 +4237,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4123
4237
|
| `ConversationRegistration` | `pi-roundtable` | type |
|
|
4124
4238
|
| `ConversationRegistry` | `pi-roundtable` | type |
|
|
4125
4239
|
| `ConversationVisibility` | `pi-roundtable` | type |
|
|
4240
|
+
| `ConversationsConfig` | `pi-roundtable` | type |
|
|
4126
4241
|
| `ConversationTurnInput` | `pi-roundtable` | type |
|
|
4127
4242
|
| `ConversationTurns` | `pi-roundtable` | type |
|
|
4128
4243
|
| `DELEGATION` | `pi-roundtable` | value |
|
|
@@ -4208,11 +4323,16 @@ Import from the entries listed below; source area files are internal.
|
|
|
4208
4323
|
| `OwnerQuestion` | `pi-roundtable` | type |
|
|
4209
4324
|
| `PendingConfirmation` | `pi-roundtable` | type |
|
|
4210
4325
|
| `PerPrincipalLimits` | `pi-roundtable` | type |
|
|
4326
|
+
| `PersonDisplay` | `pi-roundtable` | type |
|
|
4327
|
+
| `PersonProfiles` | `pi-roundtable` | type |
|
|
4211
4328
|
| `Persona` | `pi-roundtable` | type |
|
|
4212
4329
|
| `PluginContext` | `pi-roundtable` | type |
|
|
4213
4330
|
| `PluginIdentity` | `pi-roundtable` | type |
|
|
4214
4331
|
| `PluginError` | `pi-roundtable` | value |
|
|
4215
4332
|
| `Principal` | `pi-roundtable` | type |
|
|
4333
|
+
| `ProfileRules` | `pi-roundtable` | type |
|
|
4334
|
+
| `ProfileSource` | `pi-roundtable` | type |
|
|
4335
|
+
| `ProfilesConfig` | `pi-roundtable` | type |
|
|
4216
4336
|
| `PromptDocumentSpec` | `pi-roundtable` | type |
|
|
4217
4337
|
| `PromptDocuments` | `pi-roundtable` | type |
|
|
4218
4338
|
| `PromptDocumentView` | `pi-roundtable` | type |
|
|
@@ -4252,6 +4372,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4252
4372
|
| `RuntimeFactory` | `pi-roundtable` | type |
|
|
4253
4373
|
| `SCHEDULES` | `pi-roundtable` | value |
|
|
4254
4374
|
| `PRECHECKS` | `pi-roundtable` | value |
|
|
4375
|
+
| `PROFILES` | `pi-roundtable` | value |
|
|
4255
4376
|
| `RUNTIME` | `pi-roundtable` | value |
|
|
4256
4377
|
| `PRECHECK_TIMEOUT_MS` | `pi-roundtable` | value |
|
|
4257
4378
|
| `Precheck` | `pi-roundtable` | type |
|
|
@@ -4541,6 +4662,8 @@ Import from the entries listed below; source area files are internal.
|
|
|
4541
4662
|
| `lastAssistant` | `pi-roundtable/kit` | value |
|
|
4542
4663
|
| `mcpAdapterExtension` | `pi-roundtable/kit` | value |
|
|
4543
4664
|
| `mcpExtension` | `pi-roundtable/kit` | value |
|
|
4665
|
+
| `oidcIdentity` | `pi-roundtable/kit` | value |
|
|
4666
|
+
| `oidcProvider` | `pi-roundtable/kit` | value |
|
|
4544
4667
|
| `outcome` | `pi-roundtable/kit` | value |
|
|
4545
4668
|
| `ownerAttachmentDir` | `pi-roundtable/kit` | value |
|
|
4546
4669
|
| `parseModelRef` | `pi-roundtable/kit` | value |
|
package/package.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Project } from "../project.ts";
|
|
2
|
+
import { fail, ok, type Result, skipped } from "../report.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the agents' turns may use of the machine: the shell and file tools ("full"), or none
|
|
6
|
+
* ("chat-only", `agentServer: { tools: "chat-only" }`). Fails for a chat-only host with a plugin
|
|
7
|
+
* whose tools reach the host, which the host would refuse to start with.
|
|
8
|
+
*/
|
|
9
|
+
export async function checkAgentTools(project: Project): Promise<Result> {
|
|
10
|
+
const assembled = await project.assembled();
|
|
11
|
+
if (!assembled.ok) return skipped("the configuration is not valid yet");
|
|
12
|
+
const { config, defined } = assembled.value;
|
|
13
|
+
if (!config.agentServer) return skipped("the agent server is off");
|
|
14
|
+
if (config.agentTools === "full")
|
|
15
|
+
return ok("full (the agents carry the shell and file tools)");
|
|
16
|
+
const reaching = defined.plugins.find((plugin) => plugin.hostAccess === true);
|
|
17
|
+
if (reaching)
|
|
18
|
+
return fail(
|
|
19
|
+
`plugin ${reaching.name} declares hostAccess: its tools reach the host's machine, and the configuration says agentServer: { tools: "chat-only" }.`,
|
|
20
|
+
`Remove the plugin ${reaching.name}, or remove tools: "chat-only" from agentServer.`,
|
|
21
|
+
);
|
|
22
|
+
return ok("chat-only (no shell, file or workspace tools)");
|
|
23
|
+
}
|
package/src/cli/doctor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkAgentTools } from "./checks/agent-tools.ts";
|
|
1
2
|
import { type BunFacts, checkBun } from "./checks/bun.ts";
|
|
2
3
|
import { checkCharacters } from "./checks/characters.ts";
|
|
3
4
|
import { checkConfiguration, checkPlugins } from "./checks/configuration.ts";
|
|
@@ -67,6 +68,11 @@ export function buildChecks(
|
|
|
67
68
|
},
|
|
68
69
|
{ name: "plugins", offline: true, run: () => checkPlugins(project) },
|
|
69
70
|
{ name: "access", offline: true, run: () => checkAccess(project) },
|
|
71
|
+
{
|
|
72
|
+
name: "agent tools",
|
|
73
|
+
offline: true,
|
|
74
|
+
run: () => checkAgentTools(project),
|
|
75
|
+
},
|
|
70
76
|
{
|
|
71
77
|
name: "image provider",
|
|
72
78
|
offline: true,
|
package/src/cli/principal.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type Pronouns,
|
|
12
12
|
type RoleGrant,
|
|
13
13
|
} from "../core/identity/principal-store.ts";
|
|
14
|
+
import { chosenProfileSource } from "../core/identity/profile-choices.ts";
|
|
14
15
|
import type { Tier } from "../core/speakers.ts";
|
|
15
16
|
import { CONFIG_FILE } from "./project.ts";
|
|
16
17
|
|
|
@@ -166,6 +167,7 @@ async function list(
|
|
|
166
167
|
|
|
167
168
|
async function show(
|
|
168
169
|
store: PgPrincipalStore,
|
|
170
|
+
sql: SQL,
|
|
169
171
|
principal: PrincipalRecord,
|
|
170
172
|
io: PrincipalIo,
|
|
171
173
|
configured: Configured,
|
|
@@ -178,6 +180,10 @@ async function show(
|
|
|
178
180
|
` last seen: ${principal.lastSeenAt ? `${stamp(principal.lastSeenAt)} as ${principal.lastTier ?? "unknown"}` : "never"}`,
|
|
179
181
|
);
|
|
180
182
|
io.out(` roles: ${rolesText(await store.rolesOf(principal.id))}`);
|
|
183
|
+
const chosen = await chosenProfileSource(sql, principal.id);
|
|
184
|
+
io.out(
|
|
185
|
+
` profile source: ${chosen ?? "none chosen (the profiles.prefer order, else the display name)"}`,
|
|
186
|
+
);
|
|
181
187
|
const links = await store.identitiesOf(principal.id);
|
|
182
188
|
io.out(` identities: ${links.length === 0 ? "none" : ""}`.trimEnd());
|
|
183
189
|
for (const link of links)
|
|
@@ -236,6 +242,7 @@ function listedUnder(
|
|
|
236
242
|
|
|
237
243
|
async function run(
|
|
238
244
|
store: PgPrincipalStore,
|
|
245
|
+
sql: SQL,
|
|
239
246
|
args: readonly string[],
|
|
240
247
|
io: PrincipalIo,
|
|
241
248
|
configured: Configured,
|
|
@@ -317,7 +324,7 @@ async function run(
|
|
|
317
324
|
return;
|
|
318
325
|
}
|
|
319
326
|
const principal = await find(store, rest[0]);
|
|
320
|
-
if (command === "show") return show(store, principal, io, configured);
|
|
327
|
+
if (command === "show") return show(store, sql, principal, io, configured);
|
|
321
328
|
if (command === "link") {
|
|
322
329
|
const identity = identityArgument(rest[1]);
|
|
323
330
|
const spends = claimable(principal, await store.rolesOf(principal.id));
|
|
@@ -398,7 +405,7 @@ export async function principalCommand(
|
|
|
398
405
|
);
|
|
399
406
|
return 1;
|
|
400
407
|
}
|
|
401
|
-
await run(await PgPrincipalStore.attach(sql), args, io, configured);
|
|
408
|
+
await run(await PgPrincipalStore.attach(sql), sql, args, io, configured);
|
|
402
409
|
return 0;
|
|
403
410
|
} catch (error) {
|
|
404
411
|
if (error instanceof Refusal || error instanceof IdentityError) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { messages } from "../i18n/index.ts";
|
|
2
2
|
import { type OwnerIdentity, ownerWords } from "../identity.ts";
|
|
3
|
+
import type { AgentTools } from "../plugin.ts";
|
|
3
4
|
import { addressee, type Speaker } from "../speakers.ts";
|
|
4
5
|
import type { Agent, AgentGroup, Backlog } from "./agent-store.ts";
|
|
5
6
|
|
|
@@ -25,6 +26,8 @@ export function agentSystemPrompt(input: {
|
|
|
25
26
|
speaker?: Speaker;
|
|
26
27
|
/** Whether the session offers generate_image; true only with an image provider. Default false. */
|
|
27
28
|
images?: boolean;
|
|
29
|
+
/** "chat-only": the agent has no shell, file tools or workspace, and the prompt says so. Default "full". */
|
|
30
|
+
agentTools?: AgentTools;
|
|
28
31
|
}): string {
|
|
29
32
|
const { agent, coordinator, shared, workDir, owner, shellUser, group } =
|
|
30
33
|
input;
|
|
@@ -37,7 +40,9 @@ export function agentSystemPrompt(input: {
|
|
|
37
40
|
`You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} Discord agent server. Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
|
|
38
41
|
roleText(agent, coordinator, who),
|
|
39
42
|
`The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; agent_avatar changes an agent's look (class, colors, parts); schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
|
|
40
|
-
|
|
43
|
+
input.agentTools === "chat-only"
|
|
44
|
+
? `You have no shell or file tools and no workspace on ${o.his} machine: you work through the conversation and the tools listed to you.${input.images ? " To make a new picture, describe it to generate_image, which saves it and attaches it to your reply." : ""}`
|
|
45
|
+
: `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}.${input.scratchDir ? ` Write temporary files under the scratch dir ${input.scratchDir} ($TMPDIR), not elsewhere in /tmp; make them with \`mktemp -p "$TMPDIR"\`, since a bare \`mktemp\` ignores $TMPDIR on some systems.` : ""} Commands that are destructive or change the system, and writes outside the workspace${input.scratchDir ? " and the scratch dir" : ""}, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm. To show a file you have, attach it to your reply with attach_file or give its path to discord_send_message; never read an image or base64 a file to send it.${input.images ? " To make a new picture, describe it to generate_image, which saves it and attaches it to your reply." : ""}`,
|
|
41
46
|
];
|
|
42
47
|
if (group) {
|
|
43
48
|
const others = group.members
|
|
@@ -149,6 +149,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
|
|
|
149
149
|
owner,
|
|
150
150
|
shellUser,
|
|
151
151
|
scratchDir,
|
|
152
|
+
agentTools,
|
|
152
153
|
} = this.#options;
|
|
153
154
|
const agent = store.agent(scope.name);
|
|
154
155
|
if (!agent) throw new AgentError(`no agent ${scope.name}`);
|
|
@@ -170,6 +171,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
|
|
|
170
171
|
shellUser,
|
|
171
172
|
...(scratchDir ? { scratchDir } : {}),
|
|
172
173
|
images: this.#images(),
|
|
174
|
+
...(agentTools ? { agentTools } : {}),
|
|
173
175
|
...(group
|
|
174
176
|
? {
|
|
175
177
|
group: {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
resolveCharacter,
|
|
6
6
|
suggestCharacter,
|
|
7
7
|
} from "../../characters/index.ts";
|
|
8
|
+
import { parseChannelKey } from "../contract/surface.ts";
|
|
8
9
|
import { AgentError } from "../domain/errors.ts";
|
|
9
10
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
10
11
|
import type { AgentChange } from "../services.ts";
|
|
@@ -102,11 +103,14 @@ export class TeamEditing {
|
|
|
102
103
|
throw new AgentError(
|
|
103
104
|
"agent_create can only be used during an agent turn.",
|
|
104
105
|
);
|
|
106
|
+
// The opening task starts on the surface the caller's turn runs on, as the web form's does.
|
|
107
|
+
const working = this.#ctx.turns.workingIn(caller.name);
|
|
105
108
|
const made = await this.#create(
|
|
106
109
|
{
|
|
107
110
|
speaker,
|
|
108
111
|
by: caller.name,
|
|
109
112
|
creator: this.#ctx.options.store.agent(caller.name),
|
|
113
|
+
openIn: working ? parseChannelKey(working).surface : undefined,
|
|
110
114
|
},
|
|
111
115
|
input,
|
|
112
116
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
2
2
|
import type { ThinkingSetting } from "../models.ts";
|
|
3
|
-
import type { PromptSection } from "../plugin.ts";
|
|
3
|
+
import type { AgentTools, PromptSection } from "../plugin.ts";
|
|
4
4
|
import type { AvatarStudio, SkillRegistry } from "../services.ts";
|
|
5
5
|
import type { ToolSelection } from "../sessions.ts";
|
|
6
6
|
import type { AgentChannels, AgentModels } from "./agent-ports.ts";
|
|
@@ -18,6 +18,8 @@ export interface AgentTeamOptions
|
|
|
18
18
|
seeds: () => readonly AgentSeed[];
|
|
19
19
|
/** Sections the plugins add to every agent turn's system prompt, read before each run. */
|
|
20
20
|
promptSections?: () => readonly PromptSection[];
|
|
21
|
+
/** "chat-only": agent turns carry no shell or file tools, and the prompt says so; default "full". */
|
|
22
|
+
agentTools?: AgentTools;
|
|
21
23
|
/** The tools plugins defined for agents, read before each agent turn. */
|
|
22
24
|
pluginTools?: () => readonly string[];
|
|
23
25
|
/**
|
|
@@ -6,7 +6,10 @@ import type { AgentTeamOptions } from "./team-options.ts";
|
|
|
6
6
|
|
|
7
7
|
/** The tools every agent turn runs with: the plugins', plus the shell and agent tools. */
|
|
8
8
|
export function teamSelection(
|
|
9
|
-
options: Pick<
|
|
9
|
+
options: Pick<
|
|
10
|
+
AgentTeamOptions,
|
|
11
|
+
"pluginSelection" | "pluginTools" | "agentTools"
|
|
12
|
+
>,
|
|
10
13
|
scope?: AgentTurnScope,
|
|
11
14
|
): TurnSelection {
|
|
12
15
|
const plugged = options.pluginSelection();
|
|
@@ -15,7 +18,8 @@ export function teamSelection(
|
|
|
15
18
|
tools: [
|
|
16
19
|
...new Set([
|
|
17
20
|
...plugged.tools,
|
|
18
|
-
|
|
21
|
+
// A chat-only host's agents carry no shell and no file tools.
|
|
22
|
+
...(options.agentTools === "chat-only" ? [] : SHELL_TOOLS),
|
|
19
23
|
// Group members hand off by mentioning each other, so a group seat has no message_agent.
|
|
20
24
|
...AGENT_TOOLS.filter(
|
|
21
25
|
(tool) => !(scope?.group && tool === MESSAGE_AGENT_TOOL),
|
|
@@ -273,6 +273,7 @@ export function agentServerPlugin(
|
|
|
273
273
|
: {}),
|
|
274
274
|
seeds: () => context.sessions().seeds,
|
|
275
275
|
promptSections: () => context.sessions().prompt,
|
|
276
|
+
agentTools: context.env.agentTools,
|
|
276
277
|
pluginTools: () => context.sessions().agentTools,
|
|
277
278
|
pluginSelection: () => context.sessions().agentSelection(),
|
|
278
279
|
events: context.events,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RoundtablePlugin } from "../plugin.ts";
|
|
2
|
+
import { CHANNEL_CONTEXT } from "../services.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The channel context of every surface that reads it. The plugin provides one `CHANNEL_CONTEXT`
|
|
6
|
+
* service that picks the surface by the message's channel key, as the `threads` plugin does for
|
|
7
|
+
* dispatch threads, and asks its `channelContext`; a surface without one answers undefined.
|
|
8
|
+
*/
|
|
9
|
+
export function channelContextPlugin(): RoundtablePlugin {
|
|
10
|
+
return {
|
|
11
|
+
name: "channel-context",
|
|
12
|
+
provides: [CHANNEL_CONTEXT],
|
|
13
|
+
setup: ({ surfaces, services }) => {
|
|
14
|
+
services.provide(CHANNEL_CONTEXT, {
|
|
15
|
+
read: async (message, options) =>
|
|
16
|
+
surfaces.of(message.channel)?.channelContext?.(message, options),
|
|
17
|
+
});
|
|
18
|
+
return {};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -142,6 +142,8 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
142
142
|
ownerId: options.ownerId,
|
|
143
143
|
ownerName: options.ownerName,
|
|
144
144
|
prompts: (channel, scope) => cards.prompts(channel, scope),
|
|
145
|
+
// Built below, once the surface can read messages; called only after setup.
|
|
146
|
+
channelContext: (message, given) => context.of(message, given),
|
|
145
147
|
logger,
|
|
146
148
|
...(options.freshMarker === undefined
|
|
147
149
|
? {}
|
|
@@ -189,7 +191,8 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
189
191
|
threads,
|
|
190
192
|
// Built over the client's REST queue and sends nothing until a plugin calls it.
|
|
191
193
|
poster: restPoster(restOf(connected.rest())),
|
|
192
|
-
channelContext: (message, given) =>
|
|
194
|
+
channelContext: (message, given) =>
|
|
195
|
+
connected.channelContext(message, given),
|
|
193
196
|
});
|
|
194
197
|
return {
|
|
195
198
|
// The host starts the surface first; it registers the composed commands as it connects.
|