pi-roundtable 0.9.9 → 0.9.11

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 CHANGED
@@ -5,6 +5,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.11] - 2026-10-11
9
+
10
+ ### Added
11
+
12
+ - `ToolTurn.client`, `TurnRequest.client`, `ConversationTurnInput.client` and `SessionContext.client()`: what the client that started a turn can do for its tools, as a `ClientPort` (`names`, and `call(name, args, options?)` with `ClientCallOptions`: `{ timeoutMs?, maxValueBytes?, signal? }`). A transport that carries a client able to run functions for a tool, such as the browser extension of a web chat, provides one with `context.turns.run({ ..., client })`; the runtime keeps it with the turn and clears it when the turn ends. `call` never rejects for a transport problem: it resolves `ClientCallResult`, `{ ok: true, value, parts }` with `ClientPart`s (`{ mimeType, data }`, such as a screenshot) or `{ ok: false, error, message }` where `error` is a `ClientCallError`: `no_client`, `refused`, `timeout`, `cancelled`, `disconnected`, `too_large`, `busy` or `failed`. One call is open on a client at a time. The core adds the turn's signal to every call, so stopping the turn cancels a call in flight. A turn whose transport gives none (Discord, a schedule, an agent's turn, the CLI) leaves it undefined, and `SessionContext.client` is optional on the type so a context built before this still type-checks.
13
+ - `ToolOutput` (`string | { text, images? }`): a tool's `run` may return text with images, which reach the model as image parts of the tool result. Each image goes through the same checks as a user's attachment (png, jpeg, gif or webp, size and pixel limits, at most 4, a large one downscaled); one that fails is the tool's error result, not a provider rejection.
14
+ - `ToolTurn.attachments?()` (optional, so a `ToolTurn` built by hand still type-checks; the turns the core builds always have it; `ToolAttachmentInfo`: `{ file, name, contentType, size, addedAt }`): the files someone attached to the conversation, the newest first, only the names `ToolTurn.attachment()` opens; a session without an attachment store lists none.
15
+ - `testPlugin().runTool` takes `client` in its options (a `ClientPort` the test fakes), and `testPlugin().images` records the images a successful `runTool` returned.
16
+
17
+ ## [0.9.10] - 2026-10-10
18
+
19
+ ### Added
20
+
21
+ - `ToolTurn.origin`, `TurnRequest.origin`, `ConversationTurnInput.origin` and `SessionContext.origin()`: where a turn's request came from, as the transport that carried it reported it, such as the browser origin of a web chat connection (`https://chat.example.com`). A tool's `run` and its `hold` read it from the `ToolTurn` they receive, so a tool can label what it creates (an issue's source line) and fail closed when it is undefined. A claim passes it to `context.turns.run({ ..., origin })`; the runtime keeps it with the turn's speaker and clears it when the turn ends. Only the transport sets it, never the message text or the model. A turn whose channel gives none (Discord, a schedule, an agent's turn, the CLI) leaves it undefined, and `testPlugin().runTool` takes `origin` in its options. Additive: nothing changes for a claim or a tool that does not use it, and `SessionContext.origin` is optional on the type so a context built before this still type-checks.
22
+
8
23
  ## [0.9.9] - 2026-10-10
9
24
 
10
25
  ### Added
package/docs/plugins.md CHANGED
@@ -586,13 +586,29 @@ The host logs counts only (`files`, `bytes`, `unattributedBytes`), never a file
586
586
  ### `tools`: what agents can call
587
587
 
588
588
  `defineTool` takes a name (lowercase words joined by underscores), a description the model reads to decide when to call it, a Typebox parameter schema, the lowest tier that may call it, and the function.
589
- `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `workspace`, `attachFile`, and `attachment`.
589
+ `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `workspace`, `attachFile`, `attachment`, `attachments`, `origin`, and `client`.
590
590
  `turn.workspace` is `{ workspace, scratchDir? }` in a session with a shared workspace (an agent's), the same roots as `SessionContext.workspace`, and undefined elsewhere: a tool that makes a file can save it there, under the scratch dir when there is one, and name its path, so the model sends it on with `attach_file` or a `discord_send_message` file `path` instead of carrying its bytes.
591
- It returns the text the model reads.
591
+ It returns the text the model reads, or `{ text, images? }` (`ToolOutput`) to show the model images beside it: each image is `{ mimeType, data: Uint8Array }` and reaches the model as an image part of the tool result.
592
+ `turn.origin` is where the turn's request came from, as the transport that carried it reported it, and undefined when the channel gives none: the web chat sets it to the browser origin its connection opened with (`https://chat.example.com`, `chrome-extension://<id>`), a Discord message, a schedule or an agent's turn leave it undefined.
593
+ Only a channel's transport sets it, never the message text or the model, so a tool may write it on a record it creates, such as an issue's source line; a tool that needs it fails closed when it is undefined.
594
+ A claim passes it to `context.turns.run` as `origin`, and the hold of the same call sees it too, since `hold` receives the same `ToolTurn`.
595
+
592
596
  `turn.attachment(file)` opens a file someone attached to the conversation, by the name the turn's `## Attachments` block lists, and returns `{ file, name, contentType, size, bytes() }`: a tool passes a user's file on without the model carrying its bytes.
593
597
  Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads, and so does a file the host's [retention period](#retention-removing-used-files-after-a-period) removed, which says so.
598
+ `turn.attachments?.()` lists those files, the newest first, as `{ file, name, contentType, size, addedAt }` (`ToolAttachmentInfo`): the names `attachment` opens and nothing else; a session that keeps no attachments lists none. The turns the core builds always have it; it is optional on the type for code that builds a turn by hand.
594
599
  Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
595
600
 
601
+ ##### `turn.client`: asking the party that started the turn
602
+
603
+ Some transports carry a client that can do something for a tool: the web chat's browser extension can read the page the person has open.
604
+ `turn.client` is that client's `ClientPort` when the turn's transport provides one and the client declared it, and undefined otherwise (a Discord message, a schedule, an agent's turn, and a web chat connection that declared nothing have none), so a tool that needs it fails closed with a `ToolRefusal` when it is undefined.
605
+ `client.names` lists the functions the client declared, and `client.call(name, args, { timeoutMs?, maxValueBytes? })` asks it to run one.
606
+ The call never rejects for a transport problem: it resolves `{ ok: true, value, parts }`, with the value the client answered with and its binary `parts` (`{ mimeType, data }`, such as a screenshot), or `{ ok: false, error, message }` where `error` is `no_client`, `refused`, `timeout`, `cancelled`, `disconnected`, `too_large`, `busy`, or `failed`.
607
+ One call is open on a client at a time: a second one made meanwhile resolves `busy`, so make calls one after another.
608
+ The core gives every call the turn's `signal`, so stopping the turn cancels the call in flight (`cancelled`) and the tool need not handle it.
609
+ The value and the parts come from the client, outside the host, so a tool treats them as untrusted data: it checks their shape, and passes images on as `ToolOutput` images rather than trusting their names or types.
610
+ The transport sets `client` and bounds each call (its default timeout, its ceiling and its size limits); a claim passes it to `context.turns.run` as `client`.
611
+
596
612
  <!-- example: examples/tools.ts -->
597
613
  ```ts
598
614
  import { definePlugin, defineTool, ToolRefusal } from "pi-roundtable";
@@ -746,7 +762,7 @@ export const cleanup = definePlugin({
746
762
  #### A hold that looks things up
747
763
 
748
764
  A hold may need something it must fetch as the speaker before it can describe the call, such as the display name of the entity an id stands for, read with the speaker's own credentials.
749
- A tool's `hold` may then return a promise and takes the turn as a second argument: the `ToolTurn` its `run` receives, with the `speaker`, the `channel` and the `agent` of the turn that makes the call.
765
+ A tool's `hold` may then return a promise and takes the turn as a second argument: the `ToolTurn` its `run` receives, with the `speaker`, the `channel`, the `agent` and the `origin` of the turn that makes the call.
750
766
 
751
767
  ```ts
752
768
  defineTool({
@@ -1861,6 +1877,8 @@ The factory gets the session's `SessionContext`, fixed when the session is made:
1861
1877
  - `addressee` is whom tool descriptions should name: the primary owner exactly as configured in their own private conversation, another person by their name and pronouns (their name alone when they gave none), and `THE_SPEAKER` in a shared conversation, or a private one of someone the host knows by no name yet.
1862
1878
  - `memory` is `"none"` for a persona that declares it, and `"speaker"` otherwise; a memory tool of your own loads nothing for `"none"`. A task the session runs with `runTask` keeps its conversation's `memory`.
1863
1879
  - `speaker()` is the running turn's speaker, undefined between turns; a tool reads it when it runs, never when the session is made, since a session outlives the turn it is built in.
1880
+ - `client()` is what the client that started the running turn can do for its tools, as `TurnRequest.client` says, and undefined between turns and for a turn whose transport gives none; it is optional on the type so a context built before 0.9.11 still type-checks.
1881
+ - `origin()` is where the running turn's request came from, as `TurnRequest.origin` says, and undefined between turns and for a turn whose transport gives none; read it when the tool runs, like `speaker()`. It is optional on the type so a context built before 0.9.10 still type-checks.
1864
1882
 
1865
1883
  The core's Jev compactor is one such extension:
1866
1884
 
@@ -2012,7 +2030,7 @@ Call `context.turns.run(input)` inside your claim's queue task to run a turn in
2012
2030
  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`.
2013
2031
  It catches runtime errors as failed results and posts the answer, failure notice, or stopped notice through the surface.
2014
2032
  Supply `reply(result)` to handle the reply yourself.
2015
- `input` has `channel`, `kind`, `text`, `speaker`, and optionally `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed`, `reply`, and `conversation`.
2033
+ `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`, `reply`, and `conversation`.
2016
2034
  It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose runtime plugin has not provided a runtime.
2017
2035
 
2018
2036
  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.
@@ -2955,7 +2973,8 @@ It returns:
2955
2973
  | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
2956
2974
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
2957
2975
  | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined`; `holds.inTurn(tool, input, context, turn)` asks them as a turn does, and returns a promise when a hold looks things up |
2958
- | `runTool(name, args, { speaker, channel, workspace }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads; `workspace` (`{ workspace, scratchDir? }`) is the session's `turn.workspace` |
2976
+ | `runTool(name, args, { speaker, origin, client, channel, workspace }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads; `origin` is the turn's `turn.origin`, `client` its `turn.client` (a `ClientPort` the test fakes), and `workspace` (`{ workspace, scratchDir? }`) is the session's `turn.workspace` |
2977
+ | `images` | The images successful `runTool` calls returned as `ToolOutput` images, recorded as `{ channel, image: { mimeType, data } }` |
2959
2978
  | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
2960
2979
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
2961
2980
  | `conversations`, `turns`, `surfaces` | What the plugin sees as `context.conversations`, `context.turns`, and `context.surfaces`, for a test to drive its claims |
@@ -3360,6 +3379,11 @@ Import from the entries listed below; source area files are internal.
3360
3379
  | `ChannelClaim` | `pi-roundtable` | type |
3361
3380
  | `ChannelKey` | `pi-roundtable` | type |
3362
3381
  | `ChatSurface` | `pi-roundtable` | type |
3382
+ | `ClientCallError` | `pi-roundtable` | type |
3383
+ | `ClientCallOptions` | `pi-roundtable` | type |
3384
+ | `ClientCallResult` | `pi-roundtable` | type |
3385
+ | `ClientPart` | `pi-roundtable` | type |
3386
+ | `ClientPort` | `pi-roundtable` | type |
3363
3387
  | `ConfigError` | `pi-roundtable` | value |
3364
3388
  | `ContextUse` | `pi-roundtable` | type |
3365
3389
  | `Contribution` | `pi-roundtable` | type |
@@ -3522,7 +3546,9 @@ Import from the entries listed below; source area files are internal.
3522
3546
  | `Tier` | `pi-roundtable` | type |
3523
3547
  | `TierConfig` | `pi-roundtable` | type |
3524
3548
  | `ToolAttachment` | `pi-roundtable` | type |
3549
+ | `ToolAttachmentInfo` | `pi-roundtable` | type |
3525
3550
  | `ToolContribution` | `pi-roundtable` | type |
3551
+ | `ToolOutput` | `pi-roundtable` | type |
3526
3552
  | `ToolRefusal` | `pi-roundtable` | value |
3527
3553
  | `ToolSelection` | `pi-roundtable` | type |
3528
3554
  | `ToolSpec` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,5 +1,6 @@
1
+ import { stat } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
- import { RECORDS } from "./attachment-dir.ts";
3
+ import { listDir, RECORDS } from "./attachment-dir.ts";
3
4
  import { expiredMessage, wasExpired } from "./attachment-expiry.ts";
4
5
  import { isAttachmentName, plainText } from "./attachment-name.ts";
5
6
 
@@ -15,6 +16,18 @@ export interface ToolAttachment {
15
16
  bytes(): Promise<Uint8Array>;
16
17
  }
17
18
 
19
+ /** A file someone attached to the conversation, as a tool lists it. */
20
+ export interface ToolAttachmentInfo {
21
+ /** The file name inside the conversation's attachment store; what `## Attachments` lists. */
22
+ file: string;
23
+ /** The name the person gave it, when the core recorded one; else the file name. */
24
+ name: string;
25
+ contentType: string;
26
+ size: number;
27
+ /** When the file was saved, as an ISO time. */
28
+ addedAt: string;
29
+ }
30
+
18
31
  /** A name that is no attachment of the conversation, or a file that is not there. */
19
32
  export class AttachmentLookupError extends Error {
20
33
  override name = "AttachmentLookupError";
@@ -72,3 +85,32 @@ export async function openAttachment(
72
85
  },
73
86
  };
74
87
  }
88
+
89
+ /**
90
+ * The files of a conversation's attachment store that a tool may open, the newest first. Only
91
+ * names an attachment can have are listed: no hidden entry, no directory. A directory that is not
92
+ * there lists nothing.
93
+ */
94
+ export async function listAttachments(
95
+ dir: string,
96
+ ): Promise<ToolAttachmentInfo[]> {
97
+ const listed: ToolAttachmentInfo[] = [];
98
+ for (const file of await listDir(dir)) {
99
+ if (!isAttachmentName(file)) continue;
100
+ const info = await stat(join(dir, file)).catch(() => undefined);
101
+ if (!info?.isFile()) continue;
102
+ const opened = await openAttachment(dir, file).catch(() => undefined);
103
+ if (!opened) continue;
104
+ listed.push({
105
+ file,
106
+ name: opened.name,
107
+ contentType: opened.contentType,
108
+ size: opened.size,
109
+ addedAt: info.mtime.toISOString(),
110
+ });
111
+ }
112
+ return listed.sort(
113
+ (a, b) =>
114
+ b.addedAt.localeCompare(a.addedAt) || a.file.localeCompare(b.file),
115
+ );
116
+ }
@@ -1,12 +1,20 @@
1
1
  import type { Static, TObject } from "typebox";
2
- import type { ToolAttachment } from "./attachments/tool-attachment.ts";
2
+ import type {
3
+ ToolAttachment,
4
+ ToolAttachmentInfo,
5
+ } from "./attachments/tool-attachment.ts";
6
+ import type { ClientPort } from "./domain/client.ts";
3
7
  import type { ReplyFile } from "./domain/conversation.ts";
4
8
  import { PluginError } from "./errors.ts";
5
9
  import { type HoldRule, isPromise } from "./holds.ts";
6
10
  import { messages } from "./i18n/index.ts";
7
11
  import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
8
12
  import type { AgentTurnScope, ChannelKey, SessionTool } from "./sessions.ts";
9
- import { toolError, toolText } from "./shared/tool-result.ts";
13
+ import {
14
+ type ToolOutput,
15
+ toolError,
16
+ toolOutput,
17
+ } from "./shared/tool-result.ts";
10
18
  import { type Speaker, TIERS, type Tier } from "./speakers.ts";
11
19
  import { toolTurn } from "./tool-turn.ts";
12
20
 
@@ -26,6 +34,22 @@ export interface ToolTurn {
26
34
  /** The agent whose turn it is. */
27
35
  agent: AgentTurnScope | undefined;
28
36
  signal: AbortSignal | undefined;
37
+ /**
38
+ * Where the turn's request came from, as its transport reported it, such as the browser origin
39
+ * of a web chat connection (`https://chat.example.com`); undefined for a turn whose channel gives
40
+ * none, such as a Discord message or a schedule. Only the transport sets it, never the message
41
+ * text or the model, so a tool may label a record with it; a tool that needs one fails closed
42
+ * when it is undefined.
43
+ */
44
+ origin?: string;
45
+ /**
46
+ * What the client that started the turn can do for this tool, such as run a function in the
47
+ * browser the person chats from; undefined for a turn whose transport gives none, such as a
48
+ * Discord message or a schedule, or whose client declared nothing. Every call carries this turn's
49
+ * `signal`, so stopping the turn cancels a call in flight. A tool that needs one fails closed
50
+ * when it is undefined.
51
+ */
52
+ client?: ClientPort;
29
53
  /**
30
54
  * The session's shared workspace and scratch dir, where a tool may save a file for the model to
31
55
  * pass on by path; undefined for a session without a workspace.
@@ -40,6 +64,12 @@ export interface ToolTurn {
40
64
  * refuses every call.
41
65
  */
42
66
  attachment(file: string): Promise<ToolAttachment>;
67
+ /**
68
+ * The files someone attached to this conversation, the newest first; only names that
69
+ * `attachment` opens. A session without an attachment store lists none. The turns the core builds
70
+ * always have it; code that builds a turn by hand may leave it out.
71
+ */
72
+ attachments?(): Promise<readonly ToolAttachmentInfo[]>;
43
73
  }
44
74
 
45
75
  /**
@@ -77,9 +107,11 @@ export interface ToolSpec<Schema extends TObject> {
77
107
  args: Static<Schema>,
78
108
  turn: ToolTurn,
79
109
  ) => string | undefined | Promise<string | undefined>;
80
- run(args: Static<Schema>, turn: ToolTurn): Promise<string> | string;
110
+ run(args: Static<Schema>, turn: ToolTurn): Promise<ToolOutput> | ToolOutput;
81
111
  }
82
112
 
113
+ export type { ToolAttachmentInfo, ToolOutput };
114
+
83
115
  /** A tool a plugin adds, as `defineTool` builds it. */
84
116
  export interface ToolContribution {
85
117
  readonly name: string;
@@ -134,7 +166,7 @@ export function defineTool<Schema extends TObject>(
134
166
  parameters: spec.parameters,
135
167
  execute: async (_toolCallId, params, signal) => {
136
168
  try {
137
- return toolText(
169
+ return toolOutput(
138
170
  await spec.run(
139
171
  params as Static<Schema>,
140
172
  toolTurn(context, signal),
@@ -0,0 +1,61 @@
1
+ /** A binary piece of a client call's result, such as a screenshot. */
2
+ export interface ClientPart {
3
+ mimeType: string;
4
+ data: Uint8Array;
5
+ }
6
+
7
+ /** Why a client call did not return a value. */
8
+ export type ClientCallError =
9
+ /** The turn has no client, or its client is gone. */
10
+ | "no_client"
11
+ /** The client declined the call. */
12
+ | "refused"
13
+ /** The client did not answer in time. */
14
+ | "timeout"
15
+ /** The turn was stopped, or the caller's signal aborted, before the client answered. */
16
+ | "cancelled"
17
+ /** The client's connection closed before it answered. */
18
+ | "disconnected"
19
+ /** The client's answer passed the size the transport accepts. */
20
+ | "too_large"
21
+ /** A call is already open on this client; make calls one at a time. */
22
+ | "busy"
23
+ /** The client tried and failed, or answered with something that is not a result. */
24
+ | "failed";
25
+
26
+ /** What a client call comes to: the value the client answered with and its binary parts, or why not. */
27
+ export type ClientCallResult =
28
+ | { ok: true; value: unknown; parts: readonly ClientPart[] }
29
+ | { ok: false; error: ClientCallError; message: string };
30
+
31
+ /** How one client call is bounded. */
32
+ export interface ClientCallOptions {
33
+ /** How long to wait for the client's answer; the transport has a default and its own ceiling. */
34
+ timeoutMs?: number;
35
+ /** The most bytes of JSON the answer's value may take; the transport has a default and its own ceiling. */
36
+ maxValueBytes?: number;
37
+ /**
38
+ * Cancels the call when it aborts: the client is told, and the call resolves `cancelled`. A tool
39
+ * need not pass the turn's signal; the core adds it to every call.
40
+ */
41
+ signal?: AbortSignal;
42
+ }
43
+
44
+ /**
45
+ * What the client that started a turn can do for its tools, such as a browser extension running a
46
+ * function on the page the person has open. Only the transport that carries the turn provides one.
47
+ */
48
+ export interface ClientPort {
49
+ /** The names of the functions the client declared it can run. */
50
+ readonly names: readonly string[];
51
+ /**
52
+ * Asks the client to run `name` with `args`. It never rejects for a transport problem: a failure
53
+ * resolves `{ ok: false }`. One call is open at a time; another call meanwhile resolves `busy`.
54
+ * The value and the parts come from the client, so a tool treats them as untrusted data.
55
+ */
56
+ call(
57
+ name: string,
58
+ args: unknown,
59
+ options?: ClientCallOptions,
60
+ ): Promise<ClientCallResult>;
61
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AgentTurnScope, TurnSelection } from "../sessions.ts";
2
2
  import type { Speaker } from "../speakers.ts";
3
3
  import type { TurnAttachments } from "./attachment.ts";
4
+ import type { ClientPort } from "./client.ts";
4
5
  import type { ChannelKey } from "./conversation.ts";
5
6
  import type { InterimPosts } from "./interim.ts";
6
7
  import type { TurnProgress } from "./progress.ts";
@@ -30,6 +31,19 @@ export interface TurnRequest {
30
31
  * starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`.
31
32
  */
32
33
  speaker: Speaker;
34
+ /**
35
+ * Where the request came from, as the transport that carried it knows: the browser origin of a
36
+ * web chat connection, such as `https://chat.example.com`. A channel with no such thing leaves it
37
+ * out. Only a channel's transport sets it, never the message text or the model; a tool reads it
38
+ * as `ToolTurn.origin`.
39
+ */
40
+ origin?: string;
41
+ /**
42
+ * What the client that started the turn can do for its tools, such as run a function in a
43
+ * browser; a tool reads it as `ToolTurn.client`. A channel whose transport has no such client
44
+ * leaves it out. Only a channel's transport provides one, never the message text or the model.
45
+ */
46
+ client?: ClientPort;
33
47
  /**
34
48
  * Who the conversation belongs to, as the host records it: `private` to one principal, or
35
49
  * `shared` by whoever its claim admits, which it is taken to be when absent. A private
@@ -5,6 +5,7 @@ import type {
5
5
  ConversationVisibility,
6
6
  } from "../conversations/conversation-registry.ts";
7
7
  import type { TurnAttachments } from "../domain/attachment.ts";
8
+ import type { ClientPort } from "../domain/client.ts";
8
9
  import type { TurnResult } from "../domain/conversation.ts";
9
10
  import type { TurnConversation } from "../domain/ports.ts";
10
11
  import type { TurnProgress } from "../domain/progress.ts";
@@ -29,6 +30,17 @@ export interface ConversationTurnInput {
29
30
  text: string;
30
31
  /** Who the turn is for; its tier limits the tools. */
31
32
  speaker: Speaker;
33
+ /**
34
+ * Where the request came from, as the claim's transport knows it, such as a web chat
35
+ * connection's browser origin; tools read it as `ToolTurn.origin`. Leave it out for a turn the
36
+ * transport gives none, and never fill it from message text.
37
+ */
38
+ origin?: string;
39
+ /**
40
+ * What the client that started the turn can do for its tools, as the claim's transport knows it;
41
+ * tools read it as `ToolTurn.client`. Leave it out for a turn the transport gives none.
42
+ */
43
+ client?: ClientPort;
32
44
  /** Files and images the message carries. */
33
45
  attachments?: TurnAttachments;
34
46
  /** The turn's tools; by default the plugins' `agentSelection`, read at the turn. */
@@ -184,6 +196,8 @@ export function conversationTurns(
184
196
  },
185
197
  text: input.text,
186
198
  speaker,
199
+ ...(input.origin === undefined ? {} : { origin: input.origin }),
200
+ ...(input.client === undefined ? {} : { client: input.client }),
187
201
  ...(conversation ? { conversation } : {}),
188
202
  ...(input.attachments
189
203
  ? { attachments: input.attachments }
@@ -24,7 +24,6 @@ import { promptScopeOf } from "../interactions/prompts.ts";
24
24
  import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
25
25
  import { withoutReplyFiles } from "../reply-files.ts";
26
26
  import type { TransientTask } from "../sessions.ts";
27
- import type { Speaker, Tier } from "../speakers.ts";
28
27
  import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
29
28
  import { AbortGuard } from "./abort-guard.ts";
30
29
  import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
@@ -36,6 +35,7 @@ import { interimPoster } from "./interim-text.ts";
36
35
  import { preflightTools } from "./preflight-tools.ts";
37
36
  import { PromptSlot, workTimeout } from "./prompt-slot.ts";
38
37
  import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
38
+ import type { RunningTurn } from "./running-turn.ts";
39
39
  import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
40
40
  import { archiveSessions } from "./session-archive.ts";
41
41
  import {
@@ -71,11 +71,8 @@ export class PiAgentRuntime implements AgentRuntime {
71
71
  readonly #tiers: ToolTiers;
72
72
  readonly #factory: SessionFactory;
73
73
  readonly #sessions: ConversationSessions;
74
- /** The tier and speaker of each conversation's running turn. */
75
- readonly #turns = new Map<
76
- ChannelKey,
77
- { tier: Tier; speaker: Speaker; withheld: boolean }
78
- >();
74
+ /** The tier, speaker, origin and client of each conversation's running turn. */
75
+ readonly #turns = new Map<ChannelKey, RunningTurn>();
79
76
  /** Each conversation's turn in progress, which the owner may steer or stop. */
80
77
  readonly #running = new Map<ChannelKey, SteerableRun>();
81
78
  readonly #memoryTurns = new Map<ChannelKey, () => void>();
@@ -87,6 +84,8 @@ export class PiAgentRuntime implements AgentRuntime {
87
84
  this.#tiers = options.toolTiers ?? toolTiers();
88
85
  this.#factory = new SessionFactory(options, {
89
86
  speaker: (channel) => this.#turns.get(channel)?.speaker,
87
+ origin: (channel) => this.#turns.get(channel)?.origin,
88
+ client: (channel) => this.#turns.get(channel)?.client,
90
89
  withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
91
90
  runTask: (scope, task) => this.#runTask(scope, task),
92
91
  });
@@ -192,7 +191,13 @@ export class PiAgentRuntime implements AgentRuntime {
192
191
  loadsMemory(registered),
193
192
  );
194
193
  this.#memoryTurns.set(key, markPrivateMemory);
195
- this.#turns.set(key, { tier, speaker: request.speaker, withheld });
194
+ this.#turns.set(key, {
195
+ tier,
196
+ speaker: request.speaker,
197
+ ...(request.origin === undefined ? {} : { origin: request.origin }),
198
+ ...(request.client === undefined ? {} : { client: request.client }),
199
+ withheld,
200
+ });
196
201
  const missing = wanted.filter((name) => !registered.has(name));
197
202
  if (missing.length > 0) {
198
203
  logger.warn(
@@ -0,0 +1,14 @@
1
+ import type { ClientPort } from "../domain/client.ts";
2
+ import type { Speaker, Tier } from "../speakers.ts";
3
+
4
+ /** What the runtime keeps about a conversation's running turn, for its tools to read. */
5
+ export interface RunningTurn {
6
+ tier: Tier;
7
+ speaker: Speaker;
8
+ /** Where the turn's request came from, as `TurnRequest.origin` says. */
9
+ origin?: string;
10
+ /** What the client that started the turn can do, as `TurnRequest.client` says. */
11
+ client?: ClientPort;
12
+ /** Whether the turn loads no memory. */
13
+ withheld: boolean;
14
+ }
@@ -10,6 +10,7 @@ import {
10
10
  type ToolDefinition,
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import { channelSegment } from "../attachments/attachment-dir.ts";
13
+ import type { ClientPort } from "../domain/client.ts";
13
14
  import type { ChannelKey } from "../domain/conversation.ts";
14
15
  import { ConfigError } from "../domain/errors.ts";
15
16
  import type { AgentTurnScope } from "../domain/ports.ts";
@@ -73,6 +74,10 @@ import {
73
74
  export interface SessionFactoryDeps {
74
75
  /** The person the conversation's running turn is for. */
75
76
  speaker(channel: ChannelKey): Speaker | undefined;
77
+ /** Where the conversation's running turn came from, as its transport reported it. */
78
+ origin(channel: ChannelKey): string | undefined;
79
+ /** What the client that started the conversation's running turn can do for its tools. */
80
+ client(channel: ChannelKey): ClientPort | undefined;
76
81
  /** Whether the conversation's running turn loads no memory, as the agent server's option keeps a speaker out of it. */
77
82
  withholdsMemory(channel: ChannelKey): boolean;
78
83
  /**
@@ -355,6 +360,8 @@ export class SessionFactory {
355
360
  return state.memory;
356
361
  },
357
362
  speaker: () => this.#deps.speaker(turnKey),
363
+ origin: () => this.#deps.origin(turnKey),
364
+ client: () => this.#deps.client(turnKey),
358
365
  runTask: (task) =>
359
366
  this.#deps.runTask({ turn: turnKey, home: channel }, task),
360
367
  };
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { ClientPort } from "./domain/client.ts";
2
3
  import { PluginError } from "./errors.ts";
3
4
  import type { Principal } from "./identity/principal-store.ts";
4
5
  import type { OwnerIdentity } from "./identity.ts";
@@ -92,6 +93,18 @@ export interface SessionContext {
92
93
  attachmentDir?: string;
93
94
  /** The person the session's running turn is for; undefined between turns. */
94
95
  speaker(): Speaker | undefined;
96
+ /**
97
+ * Where the running turn's request came from, as `TurnRequest.origin` says; undefined between
98
+ * turns and for a turn whose transport gives none. A context built before 0.9.10 has no such
99
+ * method, which reads as undefined.
100
+ */
101
+ origin?(): string | undefined;
102
+ /**
103
+ * What the client that started the running turn can do for its tools, as `TurnRequest.client`
104
+ * says; undefined between turns and for a turn whose transport gives none. A context built
105
+ * before 0.9.11 has no such method, which reads as undefined.
106
+ */
107
+ client?(): ClientPort | undefined;
95
108
  /** Runs a task beside this session, under its confirmation gate. */
96
109
  runTask(task: TransientTask): Promise<string>;
97
110
  }
@@ -1,8 +1,56 @@
1
+ import {
2
+ isModelImage,
3
+ MAX_IMAGES_PER_TURN,
4
+ prepareImageBytes,
5
+ } from "../attachments/image-prep.ts";
6
+ import { ImagePreparationError } from "../attachments/image-preparation-error.ts";
7
+
1
8
  /** The text a tool returns, in the shape Pi expects. */
2
9
  export function toolText(text: string) {
3
10
  return { content: [{ type: "text" as const, text }], details: {} };
4
11
  }
5
12
 
13
+ /** A tool's output: text, or text with images the model reads beside it. */
14
+ export type ToolOutput =
15
+ | string
16
+ | {
17
+ text: string;
18
+ images?: readonly { mimeType: string; data: Uint8Array }[];
19
+ };
20
+
21
+ /** `output` in the shape Pi expects: a text part, then one image part for each image. */
22
+ export async function toolOutput(output: ToolOutput) {
23
+ if (typeof output === "string") return toolText(output);
24
+ const images = output.images ?? [];
25
+ if (images.length > MAX_IMAGES_PER_TURN)
26
+ return toolError(
27
+ `the tool returned ${images.length} images; at most ${MAX_IMAGES_PER_TURN} fit in one result.`,
28
+ );
29
+ const parts: { type: "image"; data: string; mimeType: string }[] = [];
30
+ for (const [index, image] of images.entries()) {
31
+ const label = `image ${index + 1} could not be prepared`;
32
+ const mimeType = image.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
33
+ if (!isModelImage({ contentType: mimeType }))
34
+ return toolError(
35
+ `${label}: ${image.mimeType} is not an image type the model reads (png, jpeg, gif or webp).`,
36
+ );
37
+ try {
38
+ const ready = await prepareImageBytes(image.data, mimeType);
39
+ parts.push({ type: "image", ...ready });
40
+ } catch (error) {
41
+ if (error instanceof ImagePreparationError)
42
+ return toolError(`${label}: ${error.message}`);
43
+ return toolError(
44
+ `${label}: the bytes are not a readable ${mimeType} image.`,
45
+ );
46
+ }
47
+ }
48
+ return {
49
+ content: [{ type: "text" as const, text: output.text }, ...parts],
50
+ details: {},
51
+ };
52
+ }
53
+
6
54
  /** A refused call's reason, which the model reads as the tool's error result. */
7
55
  export function toolError(text: string) {
8
56
  return { ...toolText(text), isError: true };
@@ -1,12 +1,34 @@
1
1
  import {
2
2
  AttachmentLookupError,
3
+ listAttachments,
3
4
  openAttachment,
4
5
  } from "./attachments/tool-attachment.ts";
5
6
  import type { ToolTurn } from "./define.ts";
6
7
  import { ToolRefusal } from "./define.ts";
8
+ import type { ClientPort } from "./domain/client.ts";
7
9
  import { attachReplyFile } from "./reply-files.ts";
8
10
  import type { SessionContext } from "./sessions.ts";
9
11
 
12
+ /**
13
+ * `port` as a tool sees it in a turn: every call carries the turn's `signal` beside the caller's,
14
+ * so stopping the turn cancels the call in flight.
15
+ */
16
+ function clientIn(port: ClientPort, turn: AbortSignal | undefined): ClientPort {
17
+ return {
18
+ names: port.names,
19
+ call: (name, args, options) => {
20
+ const signals = [options?.signal, turn].filter(
21
+ (signal): signal is AbortSignal => signal !== undefined,
22
+ );
23
+ const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
24
+ return port.call(name, args, {
25
+ ...options,
26
+ ...(signal ? { signal } : {}),
27
+ });
28
+ },
29
+ };
30
+ }
31
+
10
32
  /**
11
33
  * The turn a tool's `run` and `hold` receive, built from the session that makes the call and the
12
34
  * signal that stops it.
@@ -15,13 +37,21 @@ export function toolTurn(
15
37
  context: SessionContext,
16
38
  signal: AbortSignal | undefined,
17
39
  ): ToolTurn {
40
+ const origin = context.origin?.();
41
+ const client = context.client?.();
18
42
  return {
19
43
  speaker: context.speaker(),
20
44
  channel: context.turnChannel,
21
45
  agent: context.agent,
22
46
  signal,
47
+ ...(origin === undefined ? {} : { origin }),
48
+ ...(client === undefined ? {} : { client: clientIn(client, signal) }),
23
49
  ...(context.workspace ? { workspace: context.workspace } : {}),
24
50
  attachFile: attachReplyFile,
51
+ attachments: async () =>
52
+ context.attachmentDir === undefined
53
+ ? []
54
+ : listAttachments(context.attachmentDir),
25
55
  attachment: async (file) => {
26
56
  if (context.attachmentDir === undefined)
27
57
  throw new ToolRefusal("this session keeps no attachments");
package/src/index.ts CHANGED
@@ -17,7 +17,10 @@ export type {
17
17
  TeamStatus,
18
18
  } from "./core/agents/team-status.ts";
19
19
  export type { AttachmentRetention } from "./core/attachments/attachment-retention.ts";
20
- export type { ToolAttachment } from "./core/attachments/tool-attachment.ts";
20
+ export type {
21
+ ToolAttachment,
22
+ ToolAttachmentInfo,
23
+ } from "./core/attachments/tool-attachment.ts";
21
24
  export {
22
25
  AGENT_SERVER_PLUGIN,
23
26
  AGENT_TEAM_SERVICE,
@@ -86,7 +89,12 @@ export type {
86
89
  } from "./core/conversations/conversation-registry.ts";
87
90
  export type { Migration, MigrationReport } from "./core/db/migrations.ts";
88
91
  export { migrateDatabase } from "./core/db/migrations.ts";
89
- export type { ToolContribution, ToolSpec, ToolTurn } from "./core/define.ts";
92
+ export type {
93
+ ToolContribution,
94
+ ToolOutput,
95
+ ToolSpec,
96
+ ToolTurn,
97
+ } from "./core/define.ts";
90
98
  export { definePlugin, defineTool, ToolRefusal } from "./core/define.ts";
91
99
  export type {
92
100
  DefinedRoundtable,
@@ -100,6 +108,13 @@ export type {
100
108
  TurnAttachments,
101
109
  } from "./core/domain/attachment.ts";
102
110
  export { NO_ATTACHMENTS } from "./core/domain/attachment.ts";
111
+ export type {
112
+ ClientCallError,
113
+ ClientCallOptions,
114
+ ClientCallResult,
115
+ ClientPart,
116
+ ClientPort,
117
+ } from "./core/domain/client.ts";
103
118
  export type {
104
119
  HeldCall,
105
120
  OutboundReply,
package/src/testing.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  commandGuard,
31
31
  ownerRootCommand,
32
32
  } from "./core/discord/owner-command.ts";
33
+ import type { ClientPart, ClientPort } from "./core/domain/client.ts";
33
34
  import type {
34
35
  PendingConfirmation,
35
36
  ReplyFile,
@@ -264,12 +265,18 @@ export interface TestPluginResult {
264
265
  events: RecordedEvent[];
265
266
  /** Files accepted by successful runTool calls, copied and recorded with their channel. */
266
267
  files: { channel: ChannelKey; file: ReplyFile }[];
268
+ /** Images returned by successful runTool calls, with their channel. */
269
+ images: { channel: ChannelKey; image: ClientPart }[];
267
270
  runTool(
268
271
  name: string,
269
272
  args: Record<string, unknown>,
270
273
  /** `workspace` gives the session a workspace and scratch dir, as an agent's session has. */
271
274
  options?: {
272
275
  speaker?: Speaker;
276
+ /** Where the turn's request came from, as `ToolTurn.origin` shows it to the tool. */
277
+ origin?: string;
278
+ /** What the party that started the turn can do for the tool, as `ToolTurn.client` shows it. */
279
+ client?: ClientPort;
273
280
  channel?: ChannelKey;
274
281
  workspace?: SessionContext["workspace"];
275
282
  },
@@ -604,6 +611,7 @@ export async function testPlugin(
604
611
  await runtime?.preflight?.();
605
612
  let stopped = false;
606
613
  const files: { channel: ChannelKey; file: ReplyFile }[] = [];
614
+ const images: { channel: ChannelKey; image: ClientPart }[] = [];
607
615
  return {
608
616
  contribution,
609
617
  holds: linked.holds,
@@ -615,6 +623,7 @@ export async function testPlugin(
615
623
  tiers,
616
624
  events,
617
625
  files,
626
+ images,
618
627
  async runTool(name, args, runOptions) {
619
628
  const tool = registry.tools.find((item) => item.name === name);
620
629
  if (!tool) throw new PluginError(`tool ${name} is not registered`);
@@ -624,7 +633,12 @@ export async function testPlugin(
624
633
  params: Record<string, unknown>,
625
634
  signal?: AbortSignal,
626
635
  ): Promise<{
627
- content: readonly { type: string; text?: string }[];
636
+ content: readonly {
637
+ type: string;
638
+ text?: string;
639
+ data?: string;
640
+ mimeType?: string;
641
+ }[];
628
642
  isError?: boolean;
629
643
  }>;
630
644
  }[] = [];
@@ -640,6 +654,8 @@ export async function testPlugin(
640
654
  ...(runOptions?.workspace ? { workspace: runOptions.workspace } : {}),
641
655
  attachmentDir: ownerAttachmentDir(attachmentDir, channel),
642
656
  speaker: () => runOptions?.speaker,
657
+ origin: () => runOptions?.origin,
658
+ client: () => runOptions?.client,
643
659
  runTask: async () => {
644
660
  throw new Error("test session cannot run tasks");
645
661
  },
@@ -658,9 +674,21 @@ export async function testPlugin(
658
674
  surfaces.of(channel)?.supportsFiles === true,
659
675
  async () => {
660
676
  const output = await execute("test-call", args);
677
+ for (const item of output.content)
678
+ if (item.type === "image" && item.data && item.mimeType)
679
+ images.push({
680
+ channel,
681
+ image: {
682
+ mimeType: item.mimeType,
683
+ data: new Uint8Array(Buffer.from(item.data, "base64")),
684
+ },
685
+ });
661
686
  return {
662
687
  ok: true,
663
- text: output.content.map((item) => item.text ?? "").join("\n"),
688
+ text: output.content
689
+ .filter((item) => item.type === "text")
690
+ .map((item) => item.text ?? "")
691
+ .join("\n"),
664
692
  };
665
693
  },
666
694
  );