@evandu/super-pi 0.1.77 → 0.1.78
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/agents/super-pi-task/core.ts +23 -0
- package/agents/super-pi-task/index.ts +6 -4
- package/dist/client/.super-pi-client-sources.json +25 -0
- package/dist/client/assets/index-C7wCG7Jn.css +1 -0
- package/dist/client/assets/index-DCtDhAuw.js +58 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +45 -0
- package/dist/server/application/run-service.js +139 -5
- package/dist/server/http-server.js +50 -12
- package/dist/server/runtime.d.ts +5 -2
- package/dist/server/runtime.js +7 -1
- package/dist/server/store.d.ts +3 -1
- package/dist/server/store.js +9 -2
- package/dist/server/subagent-runs.d.ts +8 -0
- package/dist/server/subagent-runs.js +1 -1
- package/dist/server/tool-runner.js +3 -3
- package/dist/server/types.d.ts +42 -0
- package/dist/server/ws-server.js +11 -4
- package/package.json +3 -2
- package/dist/client/assets/index-CKdlMXJt.css +0 -1
- package/dist/client/assets/index-DiG980gH.js +0 -59
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-DCtDhAuw.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C7wCG7Jn.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -17,6 +17,18 @@ export interface SubagentDispatchRequestBody {
|
|
|
17
17
|
role?: string;
|
|
18
18
|
/** The per-turn dispatch capability, presented by the primary agent's extension. See `capability.ts`. */
|
|
19
19
|
capability?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Pi's id for the tool call that asked for this dispatch, which the extension already receives as
|
|
22
|
+
* `execute`'s first argument and previously discarded. The server resolves it to a position in the
|
|
23
|
+
* dispatching message so the card renders AT THE CALL rather than at a point in time.
|
|
24
|
+
*
|
|
25
|
+
* UNVERIFIED AND THEREFORE INERT: it is a claim about which row a card belongs in, not a credential,
|
|
26
|
+
* so it is checked against nothing. An id that resolves to no call is simply no anchor. That is
|
|
27
|
+
* deliberate - this field crosses a boundary a model can reach, and a sub-agent inventing an id to park
|
|
28
|
+
* its card inside somebody else's tool call would be a way to make a run look like it belongs to work it
|
|
29
|
+
* never did. A position is not a permission, and it grants none.
|
|
30
|
+
*/
|
|
31
|
+
toolCallId?: string;
|
|
20
32
|
}
|
|
21
33
|
/**
|
|
22
34
|
* A whole orchestrated plan, as it arrives over the wire.
|
|
@@ -32,6 +44,9 @@ export interface WorkflowRunRequestBody {
|
|
|
32
44
|
steps?: unknown;
|
|
33
45
|
/** The per-turn dispatch capability, presented by the primary agent's extension. See `capability.ts`. */
|
|
34
46
|
capability?: string;
|
|
47
|
+
/** Pi's id for the tool call that asked for this, and resolved the same inert way as a sub-agent
|
|
48
|
+
* dispatch's - a position in the transcript, never a permission. */
|
|
49
|
+
toolCallId?: string;
|
|
35
50
|
}
|
|
36
51
|
/**
|
|
37
52
|
* Service managing execution of conversation turns, message queueing,
|
|
@@ -49,6 +64,36 @@ export declare class RunService {
|
|
|
49
64
|
setMemory(memory: import("../active-memory.js").ActiveMemory): void;
|
|
50
65
|
reviewMemoryBoundary(conversationId: string): void;
|
|
51
66
|
private readonly activeRuns;
|
|
67
|
+
/**
|
|
68
|
+
* WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
|
|
69
|
+
*
|
|
70
|
+
* Keyed by Pi's own `toolCallId` - the id the model sees, which Pi also hands to the tool as `execute`'s
|
|
71
|
+
* first argument. That is the only handle shared by the two halves of a dispatch: the server learns the
|
|
72
|
+
* call exists while streaming the turn, and learns it was a sub-agent only when the child, running in a
|
|
73
|
+
* SEPARATE process, posts back over HTTP. `rowByToolCallId` already maps the same key to a row, but it is
|
|
74
|
+
* a local of `streamTurn` and the dispatch arrives in `dispatchTask` - a different closure, reached by an
|
|
75
|
+
* HTTP request. Without a bridge between them the server could only stamp a card with a start time, and
|
|
76
|
+
* a card sorted by start time lands BELOW every message still streaming after the call that asked for it.
|
|
77
|
+
*
|
|
78
|
+
* The outer map is per conversation rather than global so two conversations cannot read each other's
|
|
79
|
+
* positions, and it is dropped when the turn ends because that is when the fact stops being true: a
|
|
80
|
+
* dispatch that arrives after its turn has finished gets no anchor, which renders as it always did.
|
|
81
|
+
*/
|
|
82
|
+
private readonly toolCallLanes;
|
|
83
|
+
private readonly toolCallWaiters;
|
|
84
|
+
private readonly toolCallSites;
|
|
85
|
+
/** Records that Pi's `toolCallId` became row `row` of `messageId`. */
|
|
86
|
+
private noteToolCallSite;
|
|
87
|
+
/**
|
|
88
|
+
* The call a dispatch belongs to, as the two fields a card carries. Absent - rather than row zero - when
|
|
89
|
+
* there is no call to point at: a person pressing 派子智能体 has no tool call, a dispatch from an older
|
|
90
|
+
* extension has no id, and a dispatch that landed after its turn ended has outlived the map.
|
|
91
|
+
*/
|
|
92
|
+
originForToolCall(conversationId: string, toolCallId: string | undefined): {
|
|
93
|
+
originMessageId: string;
|
|
94
|
+
originCallRow: number;
|
|
95
|
+
} | undefined;
|
|
96
|
+
waitForToolCall(conversationId: string, toolCallId: string | undefined): Promise<ReturnType<RunService["originForToolCall"]>>;
|
|
52
97
|
private execService;
|
|
53
98
|
setExecService(execService: import("../exec-service.js").ExecService): void;
|
|
54
99
|
/**
|
|
@@ -58,6 +58,70 @@ export class RunService {
|
|
|
58
58
|
this.memory?.review(conversation);
|
|
59
59
|
}
|
|
60
60
|
activeRuns = new Map();
|
|
61
|
+
/**
|
|
62
|
+
* WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
|
|
63
|
+
*
|
|
64
|
+
* Keyed by Pi's own `toolCallId` - the id the model sees, which Pi also hands to the tool as `execute`'s
|
|
65
|
+
* first argument. That is the only handle shared by the two halves of a dispatch: the server learns the
|
|
66
|
+
* call exists while streaming the turn, and learns it was a sub-agent only when the child, running in a
|
|
67
|
+
* SEPARATE process, posts back over HTTP. `rowByToolCallId` already maps the same key to a row, but it is
|
|
68
|
+
* a local of `streamTurn` and the dispatch arrives in `dispatchTask` - a different closure, reached by an
|
|
69
|
+
* HTTP request. Without a bridge between them the server could only stamp a card with a start time, and
|
|
70
|
+
* a card sorted by start time lands BELOW every message still streaming after the call that asked for it.
|
|
71
|
+
*
|
|
72
|
+
* The outer map is per conversation rather than global so two conversations cannot read each other's
|
|
73
|
+
* positions, and it is dropped when the turn ends because that is when the fact stops being true: a
|
|
74
|
+
* dispatch that arrives after its turn has finished gets no anchor, which renders as it always did.
|
|
75
|
+
*/
|
|
76
|
+
toolCallLanes = new Map();
|
|
77
|
+
toolCallWaiters = new Map();
|
|
78
|
+
toolCallSites = new Map();
|
|
79
|
+
/** Records that Pi's `toolCallId` became row `row` of `messageId`. */
|
|
80
|
+
noteToolCallSite(conversationId, toolCallId, messageId, row) {
|
|
81
|
+
if (!toolCallId)
|
|
82
|
+
return;
|
|
83
|
+
let sites = this.toolCallSites.get(conversationId);
|
|
84
|
+
if (!sites) {
|
|
85
|
+
sites = new Map();
|
|
86
|
+
this.toolCallSites.set(conversationId, sites);
|
|
87
|
+
}
|
|
88
|
+
sites.set(toolCallId, { messageId, row });
|
|
89
|
+
for (const wake of this.toolCallWaiters.get(`${conversationId}:${toolCallId}`) ?? [])
|
|
90
|
+
wake();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The call a dispatch belongs to, as the two fields a card carries. Absent - rather than row zero - when
|
|
94
|
+
* there is no call to point at: a person pressing 派子智能体 has no tool call, a dispatch from an older
|
|
95
|
+
* extension has no id, and a dispatch that landed after its turn ended has outlived the map.
|
|
96
|
+
*/
|
|
97
|
+
originForToolCall(conversationId, toolCallId) {
|
|
98
|
+
if (!toolCallId)
|
|
99
|
+
return undefined;
|
|
100
|
+
const site = this.toolCallSites.get(conversationId)?.get(toolCallId);
|
|
101
|
+
return site ? { originMessageId: site.messageId, originCallRow: site.row } : undefined;
|
|
102
|
+
}
|
|
103
|
+
async waitForToolCall(conversationId, toolCallId) {
|
|
104
|
+
const origin = this.originForToolCall(conversationId, toolCallId);
|
|
105
|
+
if (origin) {
|
|
106
|
+
await this.toolCallLanes.get(conversationId);
|
|
107
|
+
return origin;
|
|
108
|
+
}
|
|
109
|
+
if (!toolCallId || !this.activeRuns.has(conversationId))
|
|
110
|
+
return undefined;
|
|
111
|
+
// The tool POST and Pi's stdout use independent transports. HTTP may win even
|
|
112
|
+
// though Pi emitted tool_execution_start first; wait for that event, not a clock position.
|
|
113
|
+
const key = `${conversationId}:${toolCallId}`;
|
|
114
|
+
await new Promise(resolve => {
|
|
115
|
+
const waiters = this.toolCallWaiters.get(key) ?? new Set();
|
|
116
|
+
const wake = () => { clearTimeout(timer); waiters.delete(wake); if (!waiters.size)
|
|
117
|
+
this.toolCallWaiters.delete(key); resolve(); };
|
|
118
|
+
const timer = setTimeout(wake, 2000);
|
|
119
|
+
waiters.add(wake);
|
|
120
|
+
this.toolCallWaiters.set(key, waiters);
|
|
121
|
+
});
|
|
122
|
+
await this.toolCallLanes.get(conversationId);
|
|
123
|
+
return this.originForToolCall(conversationId, toolCallId);
|
|
124
|
+
}
|
|
61
125
|
execService;
|
|
62
126
|
setExecService(execService) { this.execService = execService; }
|
|
63
127
|
/**
|
|
@@ -78,6 +142,15 @@ export class RunService {
|
|
|
78
142
|
const message = `<task id="${sessionId}" state="${exitCode === 0 ? "completed" : "error"}" kind="exec">\n<summary>${task.title}</summary>\n<${tag}>\n${body}</${tag}>\n</task>`;
|
|
79
143
|
void this.trackDetached((async () => {
|
|
80
144
|
try {
|
|
145
|
+
// Keep the dispatch card at its call after the running-task list is cleaned up.
|
|
146
|
+
// The result message below keeps its separate arrival position in the transcript.
|
|
147
|
+
if (task.originMessageId !== undefined && task.originCallRow !== undefined
|
|
148
|
+
&& this.store.snapshot().conversations.find(conversation => conversation.id === task.conversationId)
|
|
149
|
+
?.messages.find(message => message.id === task.originMessageId)?.toolCalls?.[task.originCallRow]) {
|
|
150
|
+
const toolCalls = await this.store.updateToolCall(task.conversationId, task.originMessageId, task.originCallRow, { backgroundTask: { id: sessionId, status: exitCode === 0 ? "completed" : "failed", output: (stdout + stderr).slice(-512_000), exitCode } });
|
|
151
|
+
this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
|
|
152
|
+
}
|
|
153
|
+
this.broadcaster.broadcast({ type: "exec.exit", sessionId, exitCode, stdout, stderr });
|
|
81
154
|
await this.store.addToolMessage(task.conversationId, message);
|
|
82
155
|
this.broadcaster.broadcastState();
|
|
83
156
|
}
|
|
@@ -367,6 +440,7 @@ export class RunService {
|
|
|
367
440
|
}).catch((error) => {
|
|
368
441
|
console.warn(`Super Pi: recording ${what} failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
369
442
|
});
|
|
443
|
+
this.toolCallLanes.set(conversation.id, pendingTools);
|
|
370
444
|
};
|
|
371
445
|
// HOW MUCH OF THE ANSWER HAD BEEN SPOKEN WHEN EACH CALL WAS ANNOUNCED, counted HERE rather than read
|
|
372
446
|
// from the store when the write runs.
|
|
@@ -383,6 +457,18 @@ export class RunService {
|
|
|
383
457
|
// The running total is what lets a call made during reasoning anchor inside the thinking block rather than
|
|
384
458
|
// after it, which is the order the events had.
|
|
385
459
|
let thought = 0;
|
|
460
|
+
let thinkingActive = false;
|
|
461
|
+
let thinkingBlock = -1;
|
|
462
|
+
let thinkingAnswerAt = -1;
|
|
463
|
+
let thinkingInterrupted = false;
|
|
464
|
+
const currentThinkingBlock = () => {
|
|
465
|
+
if (thinkingActive)
|
|
466
|
+
return thinkingBlock;
|
|
467
|
+
// An outside call separates two thoughts even if no answer text was emitted.
|
|
468
|
+
if (thinkingBlock >= 0)
|
|
469
|
+
thinkingInterrupted = true;
|
|
470
|
+
return null;
|
|
471
|
+
};
|
|
386
472
|
/**
|
|
387
473
|
* ONE COUNTER FOR EVERY ROW, AND THE ONLY WAY A ROW NUMBER IS EVER MADE.
|
|
388
474
|
*
|
|
@@ -432,6 +518,7 @@ export class RunService {
|
|
|
432
518
|
// this one was not, and the JSON persistence backend's slower writes widen the window enough for
|
|
433
519
|
// the browser test to catch the difference between capturing and reading.
|
|
434
520
|
const thinkingAt = thought;
|
|
521
|
+
const callThinkingBlock = currentThinkingBlock();
|
|
435
522
|
// The block says WHICH TOOL and WHAT IT IS AIMED AT as soon as either is known. 「工具」 as the name
|
|
436
523
|
// and 「正在生成参数…」 as the only line below it told a reader nothing for the seconds a large `edit`
|
|
437
524
|
// spends writing its replacement text - and by the time the file path was on screen, the block looked
|
|
@@ -443,6 +530,7 @@ export class RunService {
|
|
|
443
530
|
preparing: true,
|
|
444
531
|
at,
|
|
445
532
|
thinkingAt,
|
|
533
|
+
thinkingBlock: callThinkingBlock,
|
|
446
534
|
}));
|
|
447
535
|
preparingRows.set(call.contentIndex, { row, name: call.name });
|
|
448
536
|
};
|
|
@@ -466,18 +554,23 @@ export class RunService {
|
|
|
466
554
|
// The id is bound to the row it was FILLED IN, not to a row of the caller's own counting. Every
|
|
467
555
|
// later update and the outcome resolve through this binding, so a wrong number here is a block
|
|
468
556
|
// that never settles and one that belongs to a different call.
|
|
469
|
-
if (call.id)
|
|
557
|
+
if (call.id) {
|
|
470
558
|
rowByToolCallId.set(call.id, row);
|
|
559
|
+
this.noteToolCallSite(conversation.id, call.id, assistant.id, row);
|
|
560
|
+
}
|
|
471
561
|
enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, row, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false, ...(call.question ? { question: call.question } : {}) }));
|
|
472
562
|
return;
|
|
473
563
|
}
|
|
474
564
|
const row = rows++;
|
|
475
|
-
if (call.id)
|
|
565
|
+
if (call.id) {
|
|
476
566
|
rowByToolCallId.set(call.id, row);
|
|
567
|
+
this.noteToolCallSite(conversation.id, call.id, assistant.id, row);
|
|
568
|
+
}
|
|
477
569
|
// Captured for the same reason as the preparing path above: the queued append must anchor the call
|
|
478
570
|
// where it was MADE, not where the queue happened to be when it drained.
|
|
479
571
|
const thinkingAt = thought;
|
|
480
|
-
|
|
572
|
+
const callThinkingBlock = currentThinkingBlock();
|
|
573
|
+
enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt, thinkingBlock: callThinkingBlock, ...(call.question ? { question: call.question } : {}) }));
|
|
481
574
|
};
|
|
482
575
|
const onToolUpdate = (call) => {
|
|
483
576
|
const index = call.id ? rowByToolCallId.get(call.id) : undefined;
|
|
@@ -508,12 +601,36 @@ export class RunService {
|
|
|
508
601
|
// BROADCAST AND KEEP. It used to only broadcast, into an event no one handled - so the reasoning
|
|
509
602
|
// reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
|
|
510
603
|
// arrives, and the message keeps it so the finished turn has something to go back to.
|
|
604
|
+
//
|
|
605
|
+
// `active: true` on every delta is not a field Pi sent - it is the conclusion Super Pi draws from
|
|
606
|
+
// having received one. Pi announces the END of reasoning with `thinking_end` and puts nothing on
|
|
607
|
+
// the delta, so these two halves together are the whole lifecycle.
|
|
511
608
|
onThinking: (delta) => {
|
|
609
|
+
if (!delta)
|
|
610
|
+
return;
|
|
611
|
+
const newBlock = thinkingAnswerAt !== spoken || thinkingInterrupted;
|
|
612
|
+
if (newBlock) {
|
|
613
|
+
thinkingBlock++;
|
|
614
|
+
thinkingAnswerAt = spoken;
|
|
615
|
+
}
|
|
616
|
+
thinkingInterrupted = false;
|
|
617
|
+
thinkingActive = true;
|
|
618
|
+
this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
|
|
512
619
|
thought += delta.length;
|
|
513
|
-
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken);
|
|
514
|
-
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken });
|
|
620
|
+
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
|
|
621
|
+
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
|
|
515
622
|
scheduleCheckpoint();
|
|
516
623
|
},
|
|
624
|
+
// AN EMPTY DELTA THAT ONLY CARRIES THE FLAG. `thinking_end` is a fact the browser needs and not
|
|
625
|
+
// text, so it rides as a delta of length zero and the client is required to leave `thinking` and
|
|
626
|
+
// `thinkingBlocks` alone when it sees one. Without that rule this event appends an EMPTY fold
|
|
627
|
+
// block to every turn whose second reasoning run starts after the answer has already begun,
|
|
628
|
+
// because that block's `at` then differs from the previous one's.
|
|
629
|
+
onThinkingEnd: () => {
|
|
630
|
+
thinkingActive = false;
|
|
631
|
+
this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
|
|
632
|
+
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta: "", active: false });
|
|
633
|
+
},
|
|
517
634
|
images,
|
|
518
635
|
})) {
|
|
519
636
|
this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
|
|
@@ -529,8 +646,20 @@ export class RunService {
|
|
|
529
646
|
finally {
|
|
530
647
|
// A failed or cancelled producer still owns live text and a checkpoint timer.
|
|
531
648
|
// Drain the lane before the caller changes status or permits another turn.
|
|
649
|
+
this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
|
|
650
|
+
checkpointPending = true;
|
|
532
651
|
checkpointNow();
|
|
533
652
|
await pendingTools;
|
|
653
|
+
// The positions are facts ABOUT THIS TURN and stop being true when it ends, so they go with it.
|
|
654
|
+
// A dispatch whose POST arrives after this line gets no anchor and renders where it always did -
|
|
655
|
+
// which is the honest outcome, because by then the call that asked for it may no longer be on screen.
|
|
656
|
+
this.toolCallSites.delete(conversation.id);
|
|
657
|
+
this.toolCallLanes.delete(conversation.id);
|
|
658
|
+
for (const [key, waiters] of this.toolCallWaiters) {
|
|
659
|
+
if (key.startsWith(`${conversation.id}:`))
|
|
660
|
+
for (const wake of waiters)
|
|
661
|
+
wake();
|
|
662
|
+
}
|
|
534
663
|
}
|
|
535
664
|
if (!controller.signal.aborted) {
|
|
536
665
|
const completed = this.store.snapshot().conversations.find(c => c.id === conversation.id);
|
|
@@ -741,6 +870,9 @@ export class RunService {
|
|
|
741
870
|
if (parent)
|
|
742
871
|
checkSubagentDepth(1);
|
|
743
872
|
const backgroundId = body.background === true ? newSubagentRunId() : undefined;
|
|
873
|
+
// WHERE THE CARD GOES. Resolved once, here, and written onto the record at creation - the run is born
|
|
874
|
+
// knowing where it was asked for, so no later update has to supply it and a card cannot drift.
|
|
875
|
+
const origin = await this.waitForToolCall(conversationId, body.toolCallId);
|
|
744
876
|
const dispatch = this.subagents.dispatch({
|
|
745
877
|
...(backgroundId ? { id: backgroundId } : {}),
|
|
746
878
|
conversationId: conversation.id,
|
|
@@ -749,6 +881,7 @@ export class RunService {
|
|
|
749
881
|
cwd,
|
|
750
882
|
model: conversation.model,
|
|
751
883
|
dispatchedBy: "model",
|
|
884
|
+
...(origin ? origin : {}),
|
|
752
885
|
...(parent ? { parentRunId: parent.id } : {}),
|
|
753
886
|
depth: authoritativeDepth,
|
|
754
887
|
...(body.loopId ? { loopId: body.loopId } : {}),
|
|
@@ -764,6 +897,7 @@ export class RunService {
|
|
|
764
897
|
status: "running",
|
|
765
898
|
startedAt: new Date().toISOString(),
|
|
766
899
|
resultBlockId: backgroundId,
|
|
900
|
+
...(origin ? origin : {}),
|
|
767
901
|
});
|
|
768
902
|
this.broadcaster.broadcastState();
|
|
769
903
|
void this.trackDetached(dispatch.then(async (run) => {
|
|
@@ -173,9 +173,12 @@ export function createHttpServer(options) {
|
|
|
173
173
|
return createServer(async (request, response) => {
|
|
174
174
|
// Internal tools use a narrow per-run capability, never the global management token.
|
|
175
175
|
const requestPath = request.url?.split("?")[0] ?? "";
|
|
176
|
-
// Tool
|
|
177
|
-
// there, while an interactive browser uses the ordinary server access token.
|
|
178
|
-
|
|
176
|
+
// Tool REGISTRATION is checked after its JSON body is parsed: an agent proves its turn capability
|
|
177
|
+
// there, while an interactive browser uses the ordinary server access token. Reading and DELETING
|
|
178
|
+
// the registry have no such second channel, so they are NOT exempted here and fall through to the
|
|
179
|
+
// ordinary token check below - exempting the whole path is what let any local process list and
|
|
180
|
+
// delete a person's mounted tools on a server that had `SUPER_PI_ACCESS_TOKEN` set.
|
|
181
|
+
if (requestPath !== "/api/memory/tool" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
|
|
179
182
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
|
|
180
183
|
return;
|
|
181
184
|
}
|
|
@@ -194,7 +197,10 @@ export function createHttpServer(options) {
|
|
|
194
197
|
json(400, { error: "id required" });
|
|
195
198
|
return;
|
|
196
199
|
}
|
|
197
|
-
|
|
200
|
+
const removed = await toolRegistry.remove(id);
|
|
201
|
+
if (removed)
|
|
202
|
+
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
203
|
+
json(removed ? 200 : 404, { ok: removed });
|
|
198
204
|
return;
|
|
199
205
|
}
|
|
200
206
|
if (request.method !== "POST") {
|
|
@@ -236,9 +242,17 @@ export function createHttpServer(options) {
|
|
|
236
242
|
}
|
|
237
243
|
}
|
|
238
244
|
if (url.pathname.startsWith("/tools/")) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
245
|
+
let id;
|
|
246
|
+
let resource;
|
|
247
|
+
try {
|
|
248
|
+
const parts = url.pathname.slice("/tools/".length).split("/");
|
|
249
|
+
id = decodeURIComponent(parts.shift() ?? "");
|
|
250
|
+
resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
response.writeHead(400).end("Malformed tool resource");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
242
256
|
const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
|
|
243
257
|
if (!listed) {
|
|
244
258
|
response.writeHead(404).end("Tool not found");
|
|
@@ -259,7 +273,7 @@ export function createHttpServer(options) {
|
|
|
259
273
|
return;
|
|
260
274
|
}
|
|
261
275
|
if (request.method === "OPTIONS") {
|
|
262
|
-
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "vary": "Origin" }).end();
|
|
276
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
263
277
|
return;
|
|
264
278
|
}
|
|
265
279
|
if (request.method !== "POST") {
|
|
@@ -296,10 +310,17 @@ export function createHttpServer(options) {
|
|
|
296
310
|
return;
|
|
297
311
|
}
|
|
298
312
|
if (request.method === "OPTIONS") {
|
|
299
|
-
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "vary": "Origin" }).end();
|
|
313
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
let runId;
|
|
317
|
+
try {
|
|
318
|
+
runId = decodeURIComponent(runMatch[1]);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
toolApi(400, { error: "Malformed tool resource" });
|
|
300
322
|
return;
|
|
301
323
|
}
|
|
302
|
-
const runId = decodeURIComponent(runMatch[1]);
|
|
303
324
|
if (request.method === "GET") {
|
|
304
325
|
const run = toolRunner.get(runId, id);
|
|
305
326
|
if (!run) {
|
|
@@ -331,7 +352,11 @@ export function createHttpServer(options) {
|
|
|
331
352
|
response.writeHead(404).end("Tool not found");
|
|
332
353
|
return;
|
|
333
354
|
}
|
|
334
|
-
|
|
355
|
+
// Mounted tools run in an opaque sandbox origin (the iframe deliberately omits
|
|
356
|
+
// `allow-same-origin`). In that context CSP's `'self'` is the opaque origin and
|
|
357
|
+
// does not authorize a request back to this server, even for a relative URL.
|
|
358
|
+
const policy = "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'";
|
|
359
|
+
response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": policy });
|
|
335
360
|
createReadStream(resolved.file).pipe(response);
|
|
336
361
|
return;
|
|
337
362
|
}
|
|
@@ -568,7 +593,7 @@ export function createHttpServer(options) {
|
|
|
568
593
|
json(400, { error: "invalid JSON" });
|
|
569
594
|
return;
|
|
570
595
|
}
|
|
571
|
-
const { cmd, cwd, conversationId, capability } = body;
|
|
596
|
+
const { cmd, cwd, conversationId, capability, toolCallId } = body;
|
|
572
597
|
if (!cmd || !cwd || !conversationId) {
|
|
573
598
|
json(400, { error: "cmd, cwd and conversationId are required" });
|
|
574
599
|
return;
|
|
@@ -591,6 +616,10 @@ export function createHttpServer(options) {
|
|
|
591
616
|
// Set up the background task record BEFORE spawning, so the UI shows it immediately
|
|
592
617
|
const { randomUUID } = await import("node:crypto");
|
|
593
618
|
const sessionId = `exec_${randomUUID()}`;
|
|
619
|
+
// The same anchor a sub-agent card carries, for the same reason: a background command asked for
|
|
620
|
+
// inside a turn belongs at that call. Resolved AFTER the capability check above, because a position
|
|
621
|
+
// is worth reading only from a caller already inside the turn.
|
|
622
|
+
const origin = await runService.waitForToolCall(conversationId, toolCallId);
|
|
594
623
|
await store.setBackgroundTask({
|
|
595
624
|
id: sessionId,
|
|
596
625
|
conversationId,
|
|
@@ -599,13 +628,22 @@ export function createHttpServer(options) {
|
|
|
599
628
|
status: "running",
|
|
600
629
|
startedAt: new Date().toISOString(),
|
|
601
630
|
resultBlockId: sessionId,
|
|
631
|
+
...(origin ? origin : {}),
|
|
602
632
|
});
|
|
633
|
+
if (origin) {
|
|
634
|
+
const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "running" } });
|
|
635
|
+
broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
|
|
636
|
+
}
|
|
603
637
|
broadcaster.broadcastState();
|
|
604
638
|
try {
|
|
605
639
|
execService.spawn(sessionId, cmd, cwd);
|
|
606
640
|
json(202, { sessionId, status: "running" });
|
|
607
641
|
}
|
|
608
642
|
catch (error) {
|
|
643
|
+
if (origin) {
|
|
644
|
+
const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "failed", output: error instanceof Error ? error.message : "spawn failed" } });
|
|
645
|
+
broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
|
|
646
|
+
}
|
|
609
647
|
await store.removeBackgroundTask(sessionId);
|
|
610
648
|
broadcaster.broadcastState();
|
|
611
649
|
json(500, { error: error instanceof Error ? error.message : "spawn failed" });
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -100,8 +100,11 @@ export interface AnswerInput {
|
|
|
100
100
|
* replacement text, and one that shows the call arriving.
|
|
101
101
|
*/
|
|
102
102
|
onToolPreparing?(call: ToolCallPreparing): void;
|
|
103
|
-
/** Called while the primary model is emitting reasoning, so the UI can show live progress.
|
|
103
|
+
/** Called while the primary model is emitting reasoning, so the UI can show live progress. A call means
|
|
104
|
+
* the model is STILL THINKING - there is no separate flag, because Pi does not send one. */
|
|
104
105
|
onThinking?(delta: string): void;
|
|
106
|
+
/** Called when the reasoning phase ends - the only signal that it has. */
|
|
107
|
+
onThinkingEnd?(): void;
|
|
105
108
|
}
|
|
106
109
|
/**
|
|
107
110
|
* What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
|
|
@@ -492,7 +495,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
492
495
|
* own address, so it is the same string on every turn and is read once from the constructor's options.
|
|
493
496
|
*/
|
|
494
497
|
private taskExtensionEnv;
|
|
495
|
-
answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
|
|
498
|
+
answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
|
|
496
499
|
/**
|
|
497
500
|
* Reads back the id of the user message this turn just created, and hands it to the caller.
|
|
498
501
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -398,7 +398,7 @@ export class PiRpcRuntime {
|
|
|
398
398
|
[TASK_CAPABILITY_ENV]: capability,
|
|
399
399
|
};
|
|
400
400
|
}
|
|
401
|
-
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
|
|
401
|
+
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId }) {
|
|
402
402
|
const latencyStartedAt = performance.now();
|
|
403
403
|
traceLatency(conversation.id, "answer.begin", latencyStartedAt);
|
|
404
404
|
await mkdir(this.sessionDir, { recursive: true });
|
|
@@ -723,8 +723,14 @@ export class PiRpcRuntime {
|
|
|
723
723
|
const detail = describeToolCallInProgress(name, text);
|
|
724
724
|
onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(text ? { live: text } : {}), ...(detail ? { detail } : {}) });
|
|
725
725
|
}
|
|
726
|
+
// Pi's reasoning lifecycle is three events, not one flag: `thinking_delta` means the model is
|
|
727
|
+
// STILL THINKING RIGHT NOW, and `thinking_end` is the only thing that says it stopped. There is
|
|
728
|
+
// deliberately no `active` field read here - every `thinking_delta` in Pi's wire shape carries
|
|
729
|
+
// `{ type, contentIndex, delta, partial }` and nothing else, so a delta is itself the evidence.
|
|
726
730
|
else if (event?.type === "thinking_delta")
|
|
727
731
|
onThinking?.(typeof event.delta === "string" ? event.delta : "");
|
|
732
|
+
else if (event?.type === "thinking_end")
|
|
733
|
+
onThinkingEnd?.();
|
|
728
734
|
else {
|
|
729
735
|
noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
|
|
730
736
|
}
|
package/dist/server/store.d.ts
CHANGED
|
@@ -241,7 +241,8 @@ export declare class Store {
|
|
|
241
241
|
* to go back to. Persisting here rather than only in the browser is the point of its living on the
|
|
242
242
|
* message: a reasoning stream that vanishes on reload was never a record of anything.
|
|
243
243
|
*/
|
|
244
|
-
appendThinkingDeltaLive(conversationId: string, messageId: string, delta: string, at?: number): void;
|
|
244
|
+
appendThinkingDeltaLive(conversationId: string, messageId: string, delta: string, at?: number, newBlock?: boolean): void;
|
|
245
|
+
setThinkingActiveLive(conversationId: string, messageId: string, active: boolean): void;
|
|
245
246
|
appendThinkingDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
|
|
246
247
|
/** Persists the message exactly as it stands now, including any live text, thinking and tool updates. */
|
|
247
248
|
checkpointMessage(conversationId: string, messageId: string): Promise<void>;
|
|
@@ -304,6 +305,7 @@ export declare class Store {
|
|
|
304
305
|
live?: string;
|
|
305
306
|
preparing?: boolean;
|
|
306
307
|
question?: ToolCallQuestion;
|
|
308
|
+
backgroundTask?: ToolCallBlock["backgroundTask"];
|
|
307
309
|
}): Promise<ToolCallBlock[]>;
|
|
308
310
|
setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
|
|
309
311
|
/** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
|
package/dist/server/store.js
CHANGED
|
@@ -907,7 +907,7 @@ export class Store {
|
|
|
907
907
|
* to go back to. Persisting here rather than only in the browser is the point of its living on the
|
|
908
908
|
* message: a reasoning stream that vanishes on reload was never a record of anything.
|
|
909
909
|
*/
|
|
910
|
-
appendThinkingDeltaLive(conversationId, messageId, delta, at) {
|
|
910
|
+
appendThinkingDeltaLive(conversationId, messageId, delta, at, newBlock = false) {
|
|
911
911
|
const conversation = this.requireConversation(conversationId);
|
|
912
912
|
const message = conversation.messages.find((item) => item.id === messageId);
|
|
913
913
|
if (!message)
|
|
@@ -915,12 +915,17 @@ export class Store {
|
|
|
915
915
|
message.thinking = `${message.thinking ?? ""}${delta}`;
|
|
916
916
|
const blocks = (message.thinkingBlocks ??= []);
|
|
917
917
|
const last = blocks.at(-1);
|
|
918
|
-
if (last && last.at === (at ?? last.at))
|
|
918
|
+
if (!newBlock && last && last.at === (at ?? last.at))
|
|
919
919
|
last.text += delta;
|
|
920
920
|
else
|
|
921
921
|
blocks.push({ at: at ?? message.content.length, text: delta });
|
|
922
922
|
conversation.updatedAt = now();
|
|
923
923
|
}
|
|
924
|
+
setThinkingActiveLive(conversationId, messageId, active) {
|
|
925
|
+
const message = this.requireConversation(conversationId).messages.find((item) => item.id === messageId);
|
|
926
|
+
if (message)
|
|
927
|
+
message.thinkingActive = active;
|
|
928
|
+
}
|
|
924
929
|
async appendThinkingDelta(conversationId, messageId, delta) {
|
|
925
930
|
this.appendThinkingDeltaLive(conversationId, messageId, delta);
|
|
926
931
|
await this.checkpointMessage(conversationId, messageId);
|
|
@@ -1050,6 +1055,8 @@ export class Store {
|
|
|
1050
1055
|
// whatever the latest update happened to carry loses every key the update left out, which on the very
|
|
1051
1056
|
// first update is all of them. Merging can only ever add, so a sparse update can no longer erase what
|
|
1052
1057
|
// an earlier one already reported - and the cap is re-applied here rather than trusted from outside.
|
|
1058
|
+
if (patch.backgroundTask !== undefined)
|
|
1059
|
+
call.backgroundTask = structuredClone(patch.backgroundTask);
|
|
1053
1060
|
if (patch.label !== undefined)
|
|
1054
1061
|
call.label = patch.label;
|
|
1055
1062
|
if (patch.detail !== undefined)
|
|
@@ -41,6 +41,14 @@ export type SubagentDispatch = {
|
|
|
41
41
|
* passes "model" explicitly.
|
|
42
42
|
*/
|
|
43
43
|
dispatchedBy?: "human" | "model";
|
|
44
|
+
/**
|
|
45
|
+
* Where in the dispatching message this run was asked for, resolved by the caller from the tool call's
|
|
46
|
+
* id. Copied straight onto the record, because a card's position is a fact about its BIRTH - it is
|
|
47
|
+
* settled here and no update has to supply it, so the card cannot drift while the child streams.
|
|
48
|
+
* Omitted means "no call asked for it" (a person pressing the button), which is not row zero and never is.
|
|
49
|
+
*/
|
|
50
|
+
originMessageId?: string;
|
|
51
|
+
originCallRow?: number;
|
|
44
52
|
parentRunId?: string;
|
|
45
53
|
depth?: number;
|
|
46
54
|
loopId?: string;
|
|
@@ -124,7 +124,7 @@ export class SubagentRunRecorder {
|
|
|
124
124
|
const { conversationId, agentId, task, cwd, model, denyTools, dispatchedBy = "human" } = input;
|
|
125
125
|
// Generated HERE, not by `run()`, because the record has to exist first - see this file's header.
|
|
126
126
|
const id = input.id ?? newSubagentRunId();
|
|
127
|
-
const started = { id, conversationId, agentId, task, dispatchedBy, parentRunId: input.parentRunId, depth: input.depth ?? 0, loopId: input.loopId, role: input.role, status: "running", startedAt: new Date().toISOString(), sessionId: id };
|
|
127
|
+
const started = { id, conversationId, agentId, task, dispatchedBy, parentRunId: input.parentRunId, depth: input.depth ?? 0, loopId: input.loopId, role: input.role, originMessageId: input.originMessageId, originCallRow: input.originCallRow, status: "running", startedAt: new Date().toISOString(), sessionId: id };
|
|
128
128
|
await this.hooks.store.beginSubagentRun(started);
|
|
129
129
|
// The recorder's own copy, held separately from the store's. Mutating the object `beginSubagentRun`
|
|
130
130
|
// was handed would reach into the store's state behind its back, and every snapshot it hands out
|
|
@@ -19,7 +19,7 @@ export class ToolRunner {
|
|
|
19
19
|
child.stdout.on("data", (data) => { active.stdout = clipped(`${active.stdout}${data.toString("utf8")}`); });
|
|
20
20
|
child.stderr.on("data", log);
|
|
21
21
|
child.once("error", (error) => this.finish(id, "failed", error.message));
|
|
22
|
-
child.once("
|
|
22
|
+
child.once("close", (code) => {
|
|
23
23
|
if (active.run.status !== "running")
|
|
24
24
|
return;
|
|
25
25
|
if (code !== 0) {
|
|
@@ -34,7 +34,6 @@ export class ToolRunner {
|
|
|
34
34
|
this.finish(id, "succeeded");
|
|
35
35
|
}
|
|
36
36
|
catch (error) {
|
|
37
|
-
active.run.logs = clipped(`${active.run.logs}${active.stdout}`);
|
|
38
37
|
this.finish(id, "failed", `脚本没有输出一个 JSON 对象:${error instanceof Error ? error.message : "invalid output"}`);
|
|
39
38
|
}
|
|
40
39
|
});
|
|
@@ -48,7 +47,8 @@ export class ToolRunner {
|
|
|
48
47
|
this.finish(id, "cancelled");
|
|
49
48
|
} return copy(active.run); }
|
|
50
49
|
finish(id, status, error) { const active = this.runs.get(id); if (!active || active.run.status !== "running")
|
|
51
|
-
return; clearTimeout(active.timer);
|
|
50
|
+
return; clearTimeout(active.timer); if (status !== "succeeded" && active.stdout)
|
|
51
|
+
active.run.logs = clipped(`${active.run.logs}${active.stdout}`); active.run.status = status; active.run.finishedAt = new Date().toISOString(); if (error)
|
|
52
52
|
active.run.error = error; if (!active.child.killed) {
|
|
53
53
|
try {
|
|
54
54
|
active.child.kill();
|