@chrok/pi-braid 0.3.3 → 0.3.5
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/README.md +79 -25
- package/dist/assistant.js +345 -0
- package/dist/command.js +259 -34
- package/dist/display.js +19 -4
- package/dist/human-input.js +60 -0
- package/dist/index.js +41 -82
- package/dist/jobs.js +47 -15
- package/dist/runner.js +33 -4
- package/dist/session-view.js +27 -1
- package/dist/shell-tools.js +12 -5
- package/dist/status-text.js +179 -0
- package/dist/transcript.js +14 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -26,6 +26,8 @@ It registers:
|
|
|
26
26
|
- `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
|
|
27
27
|
- `/braid [jobId]` — open a live flow panel in interactive Pi.
|
|
28
28
|
- `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
|
|
29
|
+
- `/braid:clear` — clear the progress widget above the editor; run it again while
|
|
30
|
+
the widget is empty to restore what was cleared.
|
|
29
31
|
|
|
30
32
|
Submission and completion reminders use short session handles such as `job-1`.
|
|
31
33
|
Status, cancellation and the panel accept either that exact handle or the original
|
|
@@ -42,8 +44,10 @@ On completion, failure, or cancellation, the extension sends a custom
|
|
|
42
44
|
Pi queues it as steering during streaming: it enters context after the current
|
|
43
45
|
assistant response and its entire tool batch, before the next model step. It
|
|
44
46
|
does not wait for the whole foreground task to finish or skip remaining tools.
|
|
45
|
-
When idle, it starts a new agent turn automatically.
|
|
46
|
-
|
|
47
|
+
When idle, it starts a new agent turn automatically. Tool descriptions, the
|
|
48
|
+
system prompt, and the submission receipt tell the agent to continue independent
|
|
49
|
+
work, or end its turn if it needs the result, and not to sleep, use a timer, or
|
|
50
|
+
poll in a loop to wait for the job.
|
|
47
51
|
Stopping the foreground response does not stop background jobs.
|
|
48
52
|
|
|
49
53
|
Jobs live in memory for the current Pi session. Quitting, reloading extensions,
|
|
@@ -105,9 +109,10 @@ All four types accept `model`, `notifyOnCompletion`, `pauseAfter`, and
|
|
|
105
109
|
default to writable worktrees in Git; outside Git, all workers are read-only.
|
|
106
110
|
The model-facing schema exposes these fields in one object with a `type` enum;
|
|
107
111
|
core validation enforces the type-specific requirements before a submission or
|
|
108
|
-
update takes effect.
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
update takes effect. The submission reply gives the job ID and how to wait for
|
|
113
|
+
it; it does not repeat the graph the agent just sent. Update replies list the
|
|
114
|
+
resulting graph without prompts. If a definition problem repeats,
|
|
115
|
+
inspect/report the mismatch instead of launching more probe jobs.
|
|
111
116
|
|
|
112
117
|
Edges use exact node IDs in `from` and `to`. `choice` is an exact label declared
|
|
113
118
|
by the source decision; omitting it makes the edge unconditional. Pass `edges: []`
|
|
@@ -150,7 +155,7 @@ drops the queued message. Session shutdown suppresses delivery.
|
|
|
150
155
|
|
|
151
156
|
Set `pauseAfter: true` to hold an execution's outgoing scheduling and receive a
|
|
152
157
|
pause reminder. Independent branches continue. Fetch `braid_status` to obtain
|
|
153
|
-
|
|
158
|
+
the current graph revision and paused execution IDs, then:
|
|
154
159
|
|
|
155
160
|
```json
|
|
156
161
|
{
|
|
@@ -176,9 +181,9 @@ Pause reminders describe the event when it occurred: check current status and
|
|
|
176
181
|
paused IDs before attempting an update/resume, since the job can time out or be
|
|
177
182
|
cancelled before the parent handles the reminder.
|
|
178
183
|
|
|
179
|
-
Focused `braid_status` reads also
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
Focused `braid_status` reads also start with the current control fields: graph
|
|
185
|
+
status, revision, and paused execution IDs. Use that current revision for
|
|
186
|
+
updates even when inspecting an older invocation.
|
|
182
187
|
After cancellation or finalization there are no resumable paused executions;
|
|
183
188
|
the event log still records where pauses occurred.
|
|
184
189
|
|
|
@@ -212,7 +217,10 @@ Braid job-1 · running · 2/5 done · review
|
|
|
212
217
|
The widget shows node progress, active node IDs, paused gates, and failures.
|
|
213
218
|
It shows up to three active jobs, with an overflow count for the rest. When no
|
|
214
219
|
jobs are active, the newest job's final completed/failed/cancelled status stays
|
|
215
|
-
visible until another job is submitted or the session ends.
|
|
220
|
+
visible until another job is submitted or the session ends. Run `/braid:clear`
|
|
221
|
+
to clear the widget: it clears every job submitted so far, including running
|
|
222
|
+
ones, and jobs submitted afterwards still appear. Running it again while the widget
|
|
223
|
+
is empty restores the most recently cleared jobs. Counts use the
|
|
216
224
|
latest state of each current node definition; failed and skipped nodes count as
|
|
217
225
|
done, and reruns or live edits can change progress and totals. RPC, JSON, and
|
|
218
226
|
print modes do not create a terminal widget.
|
|
@@ -241,9 +249,51 @@ activity (see below)—stays pinned at the top while the session scrolls. The
|
|
|
241
249
|
session follows the newest lines; scroll up to pause, and press End to follow again. Left/Right switch between
|
|
242
250
|
invocations of the same node (for example loop iterations), and ctrl+o expands
|
|
243
251
|
tool output. Escape steps back one level—session, then node selection, then the
|
|
244
|
-
graph—and closes the panel from the graph; `q` closes it from anywhere.
|
|
245
|
-
|
|
246
|
-
|
|
252
|
+
graph—and closes the panel from the graph; `q` closes it from anywhere. Running
|
|
253
|
+
invocations and the 20 most recently finished ones per job keep their session;
|
|
254
|
+
older ones show the summary and activity history.
|
|
255
|
+
|
|
256
|
+
### Assistant
|
|
257
|
+
|
|
258
|
+
Press Tab to open an assistant and Tab again to hide it. While it is open,
|
|
259
|
+
every key goes to it, including `q` and `c`; type a question and press Enter.
|
|
260
|
+
Escape stops an answer in progress, or hides the assistant. PgUp/PgDn or the
|
|
261
|
+
mouse wheel scroll the conversation. A panel at least 100 columns wide shows
|
|
262
|
+
the assistant beside the graph or session; a narrower one shows it in their
|
|
263
|
+
place.
|
|
264
|
+
|
|
265
|
+
Conversations are scoped. On the graph, the assistant talks about the whole
|
|
266
|
+
job. With a node selected (`v`) or its session open, it uses that node's own
|
|
267
|
+
conversation, shared by all of the node's executions; each question says which
|
|
268
|
+
execution is shown. Each scope keeps its history for the Pi session, including
|
|
269
|
+
while the assistant or the panel is hidden.
|
|
270
|
+
|
|
271
|
+
In fullscreen mode the mouse wheel also scrolls the left side: the graph, a
|
|
272
|
+
node session, or a node's details and history. Scrolling a session up stops it
|
|
273
|
+
following new output; scrolling back to the bottom resumes.
|
|
274
|
+
|
|
275
|
+
The assistant uses the main session's current model. It does not see the main
|
|
276
|
+
conversation; each question tells it what the panel shows (the graph, the
|
|
277
|
+
selected node, or the open session), and it reads the job through its own
|
|
278
|
+
tools: job status and graph, an execution's details and activity, and the
|
|
279
|
+
latest messages of a worker's conversation. It cannot edit the graph, cancel,
|
|
280
|
+
or resume; ask the main agent for that.
|
|
281
|
+
|
|
282
|
+
To steer a running execution, tell the assistant what it should do. The
|
|
283
|
+
assistant messages that execution for you, and only when you ask. The message
|
|
284
|
+
waits, shown as pending in the execution's session, and is delivered before the
|
|
285
|
+
worker's next model request. A tool batch that is running finishes first and is
|
|
286
|
+
never interrupted. If it arrives while the worker is writing its final answer,
|
|
287
|
+
the worker gets one more round to take it into account. The worker receives it
|
|
288
|
+
labelled as a message from the supervising human, separate from the node prompt
|
|
289
|
+
and predecessor outputs; it cannot lift tool, budget, workspace, or decision
|
|
290
|
+
constraints (a decision node that has already called `decide` keeps its
|
|
291
|
+
choice). Delivered messages appear in the session, in the activity history, in
|
|
292
|
+
the event log, and in the execution's `humanInputs` in `braid_status` results,
|
|
293
|
+
so it stays visible that the result did not come from the graph alone. Messages
|
|
294
|
+
still pending when the execution ends are marked as not delivered and are not
|
|
295
|
+
recorded as input. Finished executions, including those held by `pauseAfter`,
|
|
296
|
+
cannot be messaged.
|
|
247
297
|
|
|
248
298
|
Run `/braid <jobId> <nodeId>` to open that node's latest invocation directly in
|
|
249
299
|
the session view, or `/braid <jobId> <executionId>` for an exact invocation such
|
|
@@ -270,9 +320,15 @@ counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
|
|
|
270
320
|
tool also renders a flowchart; expand its result to see more log events.
|
|
271
321
|
When selecting `nodeId` or `executionId`, it instead shows that invocation's
|
|
272
322
|
output/error and activity, with the full text and history available on expansion.
|
|
273
|
-
|
|
323
|
+
|
|
324
|
+
The Braid tools return plain-text documents to the model, not JSON. A
|
|
325
|
+
whole-job read lists the control state, every execution, their outputs,
|
|
326
|
+
workspaces, and the current graph without prompts. A focused read shows one
|
|
327
|
+
execution's output and its activity with the latest 40 history entries; pass
|
|
274
328
|
`activityBefore` with an entry sequence to page earlier ones. While the invocation
|
|
275
|
-
runs,
|
|
329
|
+
runs, the activity comes before the output so it survives preview truncation.
|
|
330
|
+
Worker `git` results read like Pi's bash output, and `decide` returns
|
|
331
|
+
`Recorded choice: <choice>`.
|
|
276
332
|
|
|
277
333
|
`/braid` now opens this panel; it no longer arms the next prompt. To request
|
|
278
334
|
Braid explicitly, ask the agent to analyze the task using Braid.
|
|
@@ -283,11 +339,9 @@ model lookup, credentials/OAuth, provider transport, filesystem tool execution,
|
|
|
283
339
|
and token/cost accounting. The first
|
|
284
340
|
whole-job `braid_status` retrieval of a finished job reports its accumulated Pi usage;
|
|
285
341
|
subsequent retrievals do not count the same usage again.
|
|
286
|
-
|
|
287
|
-
rounds from nodes that later fail or time out
|
|
288
|
-
|
|
289
|
-
returned by runners. Large running status reads save a complete snapshot too,
|
|
290
|
-
so truncation never requires waiting for job completion to inspect the graph.
|
|
342
|
+
The whole-job status text reports Pi's totals as `Usage`, including completed
|
|
343
|
+
provider rounds from nodes that later fail or time out; core `metadata.usage`
|
|
344
|
+
only includes usage returned by runners.
|
|
291
345
|
|
|
292
346
|
## When Pi will use Braid
|
|
293
347
|
|
|
@@ -331,7 +385,7 @@ only the current source IDs and diagnoses missing, duplicate or unexpected IDs.
|
|
|
331
385
|
|
|
332
386
|
## Install from npm
|
|
333
387
|
|
|
334
|
-
Requires Node.js 22.19+ and Pi 1.0
|
|
388
|
+
Requires Node.js 22.19+ and Pi 1.2.0 (the tested version):
|
|
335
389
|
|
|
336
390
|
```sh
|
|
337
391
|
pi install npm:@chrok/pi-braid
|
|
@@ -342,7 +396,7 @@ The package depends on the exact matching `@chrok/braid` release; npm installs
|
|
|
342
396
|
core automatically. It does not bundle core or depend on a source checkout.
|
|
343
397
|
Pi supplies its core peer packages at runtime.
|
|
344
398
|
Their wildcard ranges follow Pi's packaging convention, not universal version
|
|
345
|
-
compatibility. Development and CI pin Pi 1.0.
|
|
399
|
+
compatibility. Development and CI pin Pi 1.2.0.
|
|
346
400
|
|
|
347
401
|
## Install this local checkout in Pi
|
|
348
402
|
|
|
@@ -571,9 +625,9 @@ writes already made. Core waits for tracked writes, preserves checkpoints, and
|
|
|
571
625
|
removes worker worktrees before completing cancellation.
|
|
572
626
|
|
|
573
627
|
The tool output is capped at 50KB/2000 lines to protect Pi context. When exceeded,
|
|
574
|
-
the adapter writes
|
|
575
|
-
inherited ACLs on Windows) and
|
|
576
|
-
|
|
628
|
+
the adapter writes the complete text to a temporary file (mode 600 on POSIX;
|
|
629
|
+
inherited ACLs on Windows) and ends the preview with its path, for running jobs
|
|
630
|
+
as well as finished ones.
|
|
577
631
|
|
|
578
632
|
For a test of proactive selection, start a fresh Pi turn with a task such as:
|
|
579
633
|
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { Type, validateToolCall, } from "@earendil-works/pi-ai";
|
|
2
|
+
import { truncateHead } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
/** Bounds a single answer, independently of how the model behaves. */
|
|
5
|
+
const MAX_ROUNDS = 24;
|
|
6
|
+
const ACTIVITY_ENTRIES = 40;
|
|
7
|
+
const TRANSCRIPT_MESSAGES = 20;
|
|
8
|
+
const MESSAGE_PREVIEW = 1500;
|
|
9
|
+
const id = (description) => Type.String({ minLength: 1, description });
|
|
10
|
+
const TOOLS = [
|
|
11
|
+
{
|
|
12
|
+
name: "job_status",
|
|
13
|
+
description: "Read this job's goal, status, revision, graph definition (node prompts and edges), the latest result per node, and every execution with its status.",
|
|
14
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "execution_details",
|
|
18
|
+
description: "Read one execution: status, output or error, decision, workspace, usage, human inputs, and live activity (phase, model/tool waits, recent history). Pass executionId, or nodeId for the node's latest execution.",
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
executionId: Type.Optional(id("Exact execution ID.")),
|
|
21
|
+
nodeId: Type.Optional(id("Node ID; reads its latest execution.")),
|
|
22
|
+
activityBefore: Type.Optional(Type.Integer({ minimum: 1, description: "Page activity entries before this sequence number." })),
|
|
23
|
+
}, { additionalProperties: false }),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "execution_transcript",
|
|
27
|
+
description: "Read the latest messages of one execution's worker conversation: assistant text, tool calls and results, and human messages. Available while it runs and for recently finished executions.",
|
|
28
|
+
parameters: Type.Object({
|
|
29
|
+
executionId: id("Exact execution ID."),
|
|
30
|
+
last: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: `Number of latest messages; default ${TRANSCRIPT_MESSAGES}.` })),
|
|
31
|
+
}, { additionalProperties: false }),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "message_execution",
|
|
35
|
+
description: "Send a message to a running execution's worker on the user's behalf. It is delivered before the worker's next model request (a running tool batch finishes first) and is recorded as human input on the execution. Use only when the user asks to guide, correct, or inform a running execution. Write a self-contained instruction the worker can act on; it cannot see this conversation.",
|
|
36
|
+
parameters: Type.Object({
|
|
37
|
+
executionId: id("Exact ID of a running execution."),
|
|
38
|
+
message: id("The instruction for the worker."),
|
|
39
|
+
}, { additionalProperties: false }),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
const short = (value) => typeof value === "string" ? (value.length > 13 ? `${value.slice(0, 8)}…` : value) : "?";
|
|
43
|
+
const target = (args) => args.executionId !== undefined ? `execution ${short(args.executionId)}` : args.nodeId !== undefined ? `node ${JSON.stringify(args.nodeId)}` : "";
|
|
44
|
+
/** Compact tool cards: what was read or sent, with full results on ctrl+o. */
|
|
45
|
+
function renderer(call) {
|
|
46
|
+
return {
|
|
47
|
+
renderCall: (args, theme) => new Text(call(args ?? {}, theme), 0, 0),
|
|
48
|
+
renderResult: (result, options, theme) => {
|
|
49
|
+
const text = textOf(result.content);
|
|
50
|
+
const first = text.split("\n").find(line => line.trim()) ?? "";
|
|
51
|
+
return new Text(options.expanded ? text : theme.fg("dim", first.length > 160 ? `${first.slice(0, 159)}…` : first), 0, 0);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const RENDERERS = new Map([
|
|
56
|
+
["job_status", renderer((_args, theme) => theme.fg("toolTitle", theme.bold("Read job status")))],
|
|
57
|
+
["execution_details", renderer((args, theme) => theme.fg("toolTitle", theme.bold("Read details")) + " " + theme.fg("muted", target(args)))],
|
|
58
|
+
["execution_transcript", renderer((args, theme) => theme.fg("toolTitle", theme.bold("Read conversation")) + " " + theme.fg("muted", `of ${target(args)}`))],
|
|
59
|
+
["message_execution", renderer((args, theme) => theme.fg("warning", theme.bold("✎ Message")) + " " + theme.fg("muted", `to ${target(args)}`) +
|
|
60
|
+
(typeof args.message === "string" ? `\n${args.message}` : ""))],
|
|
61
|
+
]);
|
|
62
|
+
const clip = (text, max = MESSAGE_PREVIEW) => text.length <= max ? text : `${text.slice(0, max)}… [${text.length - max} more characters]`;
|
|
63
|
+
const textOf = (content) => content.flatMap(block => block.type === "text" && block.text ? [block.text] : []).join("\n");
|
|
64
|
+
function describeTranscript(transcript, last) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
const messages = transcript.messages.slice(-last);
|
|
67
|
+
if (messages.length < transcript.messages.length)
|
|
68
|
+
lines.push(`[${transcript.messages.length - messages.length} earlier messages omitted]`);
|
|
69
|
+
else
|
|
70
|
+
lines.push(`[node prompt] ${clip(transcript.intro.prompt)}`);
|
|
71
|
+
for (const message of messages) {
|
|
72
|
+
if (message.role === "human")
|
|
73
|
+
lines.push(`[human input · ${message.delivered ? "delivered" : "not delivered"}] ${clip(message.text)}`);
|
|
74
|
+
else if (message.role === "toolResult")
|
|
75
|
+
lines.push(`[${message.toolName} ${message.isError ? "error" : "result"}] ${clip(textOf(message.content), 800)}`);
|
|
76
|
+
else if (message.role === "assistant") {
|
|
77
|
+
for (const block of message.content) {
|
|
78
|
+
if (block.type === "text" && block.text.trim())
|
|
79
|
+
lines.push(`[assistant] ${clip(block.text)}`);
|
|
80
|
+
else if (block.type === "toolCall")
|
|
81
|
+
lines.push(`[tool call ${block.name}] ${clip(JSON.stringify(block.arguments), 400)}`);
|
|
82
|
+
}
|
|
83
|
+
if (message.stopReason === "error" || message.stopReason === "aborted")
|
|
84
|
+
lines.push(`[${message.stopReason}] ${message.errorMessage ?? ""}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (transcript.streaming)
|
|
88
|
+
lines.push(`[assistant, streaming] ${clip(textOf(transcript.streaming.content))}`);
|
|
89
|
+
for (const callId of transcript.running.keys())
|
|
90
|
+
lines.push(`[tool running] ${callId}`);
|
|
91
|
+
lines.push(transcript.finished ? "[execution finished]" : "[execution running]");
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A side conversation about one Braid job, or about one node of it. Each
|
|
96
|
+
* scope keeps its own history. It inspects the job through
|
|
97
|
+
* read-only tools and can message running executions; graph changes,
|
|
98
|
+
* cancellation, and resumption stay with the main agent.
|
|
99
|
+
*/
|
|
100
|
+
export class BraidAssistant {
|
|
101
|
+
jobs;
|
|
102
|
+
jobId;
|
|
103
|
+
onChange;
|
|
104
|
+
nodeId;
|
|
105
|
+
conversation;
|
|
106
|
+
history = [];
|
|
107
|
+
queued = [];
|
|
108
|
+
controller;
|
|
109
|
+
pending;
|
|
110
|
+
disposed = false;
|
|
111
|
+
constructor(jobs, jobId, cwd, onChange,
|
|
112
|
+
/** The node this conversation is about; absent for the whole job. */
|
|
113
|
+
nodeId) {
|
|
114
|
+
this.jobs = jobs;
|
|
115
|
+
this.jobId = jobId;
|
|
116
|
+
this.onChange = onChange;
|
|
117
|
+
this.nodeId = nodeId;
|
|
118
|
+
this.conversation = { cwd, messages: [], running: new Map(), renderers: RENDERERS, version: 0 };
|
|
119
|
+
}
|
|
120
|
+
get busy() {
|
|
121
|
+
return this.controller !== undefined;
|
|
122
|
+
}
|
|
123
|
+
/** Ask a question or request an intervention. `view` describes what the panel shows. */
|
|
124
|
+
send(text, view, model) {
|
|
125
|
+
if (this.disposed || !text.trim())
|
|
126
|
+
return;
|
|
127
|
+
this.conversation.messages.push({ role: "user", content: text, timestamp: Date.now() });
|
|
128
|
+
this.queued.push({ text, view });
|
|
129
|
+
this.changed();
|
|
130
|
+
if (!this.controller)
|
|
131
|
+
void this.run(model);
|
|
132
|
+
}
|
|
133
|
+
/** Stop the current answer; queued questions are dropped. */
|
|
134
|
+
abort() {
|
|
135
|
+
this.queued = [];
|
|
136
|
+
this.controller?.abort();
|
|
137
|
+
}
|
|
138
|
+
dispose() {
|
|
139
|
+
this.disposed = true;
|
|
140
|
+
this.abort();
|
|
141
|
+
clearTimeout(this.pending);
|
|
142
|
+
}
|
|
143
|
+
systemPrompt() {
|
|
144
|
+
const job = this.jobs.get(this.jobId);
|
|
145
|
+
return "You are the Braid assistant inside the /braid panel of Pi. You help the user understand and steer one background Braid job " +
|
|
146
|
+
`(${job?.handle ?? this.jobId}: ${JSON.stringify(job?.goal ?? "")}). ` +
|
|
147
|
+
"A Braid job is a graph of nodes; each run of a node is an execution with its own isolated worker conversation. " +
|
|
148
|
+
"Use your tools to inspect the job instead of guessing, and cite node and execution IDs. " +
|
|
149
|
+
"When the user asks to guide, correct, or inform a running execution, use message_execution with a clear, self-contained instruction. " +
|
|
150
|
+
"If it is unclear which execution they mean, or none is running, say so instead of guessing. Never message an execution on your own initiative. " +
|
|
151
|
+
"Messages reach the worker before its next model request; they cannot change finished executions, the graph, or a decision already made. " +
|
|
152
|
+
"You cannot edit the graph, cancel, or resume; tell the user to ask the main Pi agent for that. " +
|
|
153
|
+
"You do not see the main conversation. Answer concisely, in the user's language." +
|
|
154
|
+
(this.nodeId === undefined
|
|
155
|
+
? " This conversation is about the whole job; separate conversations exist for individual nodes."
|
|
156
|
+
: ` This conversation is about node ${JSON.stringify(this.nodeId)} and all of its executions; when the user says "it" or "this node", they mean this node. ` +
|
|
157
|
+
"Each question says which execution the panel shows. You may still read other nodes for context, but message only this node's executions unless the user names another.");
|
|
158
|
+
}
|
|
159
|
+
async run(model) {
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
this.controller = controller;
|
|
162
|
+
try {
|
|
163
|
+
while (this.queued.length && !controller.signal.aborted) {
|
|
164
|
+
for (const { text, view } of this.queued.splice(0)) {
|
|
165
|
+
this.history.push({ role: "user", content: `[Panel view: ${view}]\n\n${text}`, timestamp: Date.now() });
|
|
166
|
+
}
|
|
167
|
+
await this.answer(model, controller.signal);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
this.closeToolCalls();
|
|
173
|
+
this.fail(controller.signal.aborted ? "aborted" : "error", controller.signal.aborted ? "Stopped" : message);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
this.controller = undefined;
|
|
177
|
+
this.conversation.running.clear();
|
|
178
|
+
this.changed();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async answer(source, signal) {
|
|
182
|
+
const model = source.model;
|
|
183
|
+
if (!model)
|
|
184
|
+
throw new Error("Choose a Pi model with /model to use the Braid assistant");
|
|
185
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
186
|
+
signal.throwIfAborted();
|
|
187
|
+
const context = { systemPrompt: this.systemPrompt(), messages: this.history, tools: TOOLS };
|
|
188
|
+
const options = { signal, maxRetries: 0 };
|
|
189
|
+
let response;
|
|
190
|
+
if (source.registry.stream) {
|
|
191
|
+
const stream = source.registry.stream(model, context, options);
|
|
192
|
+
for await (const event of stream) {
|
|
193
|
+
if (event.type === "done" || event.type === "error")
|
|
194
|
+
continue;
|
|
195
|
+
this.conversation.streaming = event.partial;
|
|
196
|
+
this.changed(true);
|
|
197
|
+
}
|
|
198
|
+
response = await stream.result();
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
response = await source.registry.complete(model, context, options);
|
|
202
|
+
}
|
|
203
|
+
delete this.conversation.streaming;
|
|
204
|
+
this.conversation.messages.push(response);
|
|
205
|
+
this.changed();
|
|
206
|
+
if (response.stopReason === "aborted")
|
|
207
|
+
signal.throwIfAborted();
|
|
208
|
+
// Failed responses are shown, but never sent back to the provider.
|
|
209
|
+
if (response.stopReason === "error" || response.stopReason === "aborted")
|
|
210
|
+
return;
|
|
211
|
+
this.history.push(response);
|
|
212
|
+
const calls = response.content.filter((block) => block.type === "toolCall");
|
|
213
|
+
if (!calls.length)
|
|
214
|
+
return;
|
|
215
|
+
for (const call of calls) {
|
|
216
|
+
signal.throwIfAborted();
|
|
217
|
+
this.conversation.running.set(call.id, undefined);
|
|
218
|
+
this.changed();
|
|
219
|
+
const result = this.execute(call);
|
|
220
|
+
this.conversation.running.delete(call.id);
|
|
221
|
+
this.history.push(result);
|
|
222
|
+
this.conversation.messages.push(result);
|
|
223
|
+
this.changed();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
this.fail("error", `Stopped after ${MAX_ROUNDS} tool rounds`);
|
|
227
|
+
}
|
|
228
|
+
execute(call) {
|
|
229
|
+
let text;
|
|
230
|
+
let isError = false;
|
|
231
|
+
try {
|
|
232
|
+
const args = validateToolCall(TOOLS, call);
|
|
233
|
+
text = this.tool(call.name, args);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
text = error instanceof Error ? error.message : String(error);
|
|
237
|
+
isError = true;
|
|
238
|
+
}
|
|
239
|
+
return { role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text }], isError, timestamp: Date.now() };
|
|
240
|
+
}
|
|
241
|
+
tool(name, args) {
|
|
242
|
+
const job = this.jobs.get(this.jobId);
|
|
243
|
+
if (!job)
|
|
244
|
+
throw this.jobs.unknownJob(this.jobId);
|
|
245
|
+
const preview = (value) => {
|
|
246
|
+
const full = JSON.stringify(value, null, 2);
|
|
247
|
+
const result = truncateHead(full);
|
|
248
|
+
return result.truncated ? `${result.content}\n[Truncated; ask about a specific node or execution.]` : full;
|
|
249
|
+
};
|
|
250
|
+
switch (name) {
|
|
251
|
+
case "job_status": {
|
|
252
|
+
const state = job.result ?? job.execution;
|
|
253
|
+
return preview({
|
|
254
|
+
handle: job.handle, goal: job.goal, status: job.status, error: job.error ?? state?.error,
|
|
255
|
+
revision: job.execution?.revision, pausedExecutionIds: job.execution?.pausedExecutionIds,
|
|
256
|
+
graph: job.execution?.graph,
|
|
257
|
+
nodes: state?.nodes,
|
|
258
|
+
executions: Object.values(state?.executions ?? {}).map(execution => ({
|
|
259
|
+
executionId: execution.executionId, nodeId: execution.id, status: execution.status,
|
|
260
|
+
...(execution.iteration ? { iteration: execution.iteration } : {}),
|
|
261
|
+
...(execution.humanInputs?.length ? { humanInputs: execution.humanInputs.length } : {}),
|
|
262
|
+
})),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
case "execution_details": {
|
|
266
|
+
if (args.executionId === undefined && args.nodeId === undefined)
|
|
267
|
+
throw new Error("Pass executionId or nodeId");
|
|
268
|
+
const node = this.jobs.getNode(this.jobId, args.nodeId, args.executionId);
|
|
269
|
+
const activity = node.executionId ? this.jobs.getActivity(this.jobId, node.executionId, {
|
|
270
|
+
limit: ACTIVITY_ENTRIES, ...(args.activityBefore === undefined ? {} : { before: args.activityBefore }),
|
|
271
|
+
}) : undefined;
|
|
272
|
+
return preview({ node, ...(activity ? { activity } : {}) });
|
|
273
|
+
}
|
|
274
|
+
case "execution_transcript": {
|
|
275
|
+
const transcript = this.jobs.getTranscript(this.jobId, args.executionId);
|
|
276
|
+
if (!transcript)
|
|
277
|
+
throw new Error("No live conversation is retained for this execution; use execution_details for its output and activity history.");
|
|
278
|
+
return describeTranscript(transcript, args.last ?? TRANSCRIPT_MESSAGES);
|
|
279
|
+
}
|
|
280
|
+
case "message_execution": {
|
|
281
|
+
this.jobs.sendInput(this.jobId, args.executionId, args.message);
|
|
282
|
+
return "Queued. The worker receives it before its next model request, after any running tool batch.";
|
|
283
|
+
}
|
|
284
|
+
default:
|
|
285
|
+
throw new Error(`Unknown tool ${name}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Answer tool calls interrupted by an abort, so the next request is well formed. */
|
|
289
|
+
closeToolCalls() {
|
|
290
|
+
let index = this.history.length - 1;
|
|
291
|
+
while (index >= 0 && this.history[index].role !== "assistant")
|
|
292
|
+
index--;
|
|
293
|
+
const last = this.history[index];
|
|
294
|
+
if (last?.role !== "assistant")
|
|
295
|
+
return;
|
|
296
|
+
const answered = new Set(this.history.slice(index + 1).flatMap(message => message.role === "toolResult" ? [message.toolCallId] : []));
|
|
297
|
+
for (const block of last.content) {
|
|
298
|
+
if (block.type !== "toolCall" || answered.has(block.id))
|
|
299
|
+
continue;
|
|
300
|
+
const result = {
|
|
301
|
+
role: "toolResult", toolCallId: block.id, toolName: block.name,
|
|
302
|
+
content: [{ type: "text", text: "Stopped by the user before this tool ran" }], isError: true, timestamp: Date.now(),
|
|
303
|
+
};
|
|
304
|
+
this.history.push(result);
|
|
305
|
+
this.conversation.messages.push(result);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
fail(stopReason, errorMessage) {
|
|
309
|
+
const partial = this.conversation.streaming;
|
|
310
|
+
delete this.conversation.streaming;
|
|
311
|
+
const message = {
|
|
312
|
+
role: "assistant", api: partial?.api ?? "braid", provider: partial?.provider ?? "braid", model: partial?.model ?? "assistant",
|
|
313
|
+
content: partial?.content ?? [], stopReason, errorMessage, timestamp: Date.now(),
|
|
314
|
+
usage: partial?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
315
|
+
};
|
|
316
|
+
// The model sees what was said, without a dangling unanswered tool call.
|
|
317
|
+
if (message.content.some(block => block.type === "toolCall"))
|
|
318
|
+
message.content = message.content.filter(block => block.type !== "toolCall");
|
|
319
|
+
this.conversation.messages.push(message);
|
|
320
|
+
if (message.content.length)
|
|
321
|
+
this.history.push({ ...message, stopReason: "stop" });
|
|
322
|
+
this.changed();
|
|
323
|
+
}
|
|
324
|
+
/** Token bursts coalesce into at most one notification per 100 ms. */
|
|
325
|
+
changed(burst = false) {
|
|
326
|
+
this.conversation.version++;
|
|
327
|
+
if (!burst) {
|
|
328
|
+
clearTimeout(this.pending);
|
|
329
|
+
this.pending = undefined;
|
|
330
|
+
this.emit();
|
|
331
|
+
}
|
|
332
|
+
else if (!this.pending) {
|
|
333
|
+
this.pending = setTimeout(() => { this.pending = undefined; this.emit(); }, 100);
|
|
334
|
+
this.pending.unref?.();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
emit() {
|
|
338
|
+
if (this.disposed)
|
|
339
|
+
return;
|
|
340
|
+
try {
|
|
341
|
+
this.onChange();
|
|
342
|
+
}
|
|
343
|
+
catch { /* Observers cannot fail the assistant. */ }
|
|
344
|
+
}
|
|
345
|
+
}
|