@evandu/super-pi 0.1.103 → 0.1.104
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/dist/client/.super-pi-client-sources.json +1 -1
- package/dist/server/application/conversation-service.d.ts +9 -0
- package/dist/server/application/conversation-service.js +13 -0
- package/dist/server/command-handler.js +13 -2
- package/dist/server/pi-session-events.d.ts +34 -1
- package/dist/server/pi-session-events.js +78 -20
- package/dist/server/pi-session-pool.js +12 -2
- package/dist/server/pi-session.d.ts +29 -1
- package/dist/server/pi-session.js +26 -2
- package/dist/server/runtime.d.ts +20 -7
- package/dist/server/runtime.js +38 -12
- package/package.json +3 -1
|
@@ -24,5 +24,5 @@
|
|
|
24
24
|
"src/client/types.ts": "c2d8b16e056173a3951d87f3e7b63dcc53dc9178a5e73fa3e48b2d1ffd4e93c7",
|
|
25
25
|
"src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
|
|
26
26
|
},
|
|
27
|
-
"recordedAt": "2026-10-
|
|
27
|
+
"recordedAt": "2026-10-21T15:04:31.114Z"
|
|
28
28
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Store } from "../store.js";
|
|
2
2
|
import type { AgentRuntime } from "../runtime.js";
|
|
3
3
|
import type { SubagentRunRecorder } from "../subagent-runs.js";
|
|
4
|
+
import type { ModelSelection } from "../types.js";
|
|
4
5
|
import type { EventBroadcaster } from "../server-context.js";
|
|
5
6
|
import type { CatalogService } from "./catalog-service.js";
|
|
6
7
|
import type { RunService } from "./run-service.js";
|
|
@@ -45,6 +46,14 @@ export declare class ConversationService {
|
|
|
45
46
|
* a log and in a retry.
|
|
46
47
|
*/
|
|
47
48
|
autoRenameConversation(conversationId: string): Promise<string>;
|
|
49
|
+
/**
|
|
50
|
+
* The model a conversation has selected, or undefined when it follows Pi's default.
|
|
51
|
+
*
|
|
52
|
+
* THIS IS WHAT THE CATALOG'S THINKING LEVELS DEPEND ON. Pi computes the levels from the model
|
|
53
|
+
* itself, so the levels of the model in force are the levels that conversation gets. A catalog read
|
|
54
|
+
* without this is a catalog of whatever Pi would default to.
|
|
55
|
+
*/
|
|
56
|
+
modelSelectionFor(conversationId: string | undefined): ModelSelection | undefined;
|
|
48
57
|
/**
|
|
49
58
|
* Selects a conversation and broadcasts the catalog for that conversation's model.
|
|
50
59
|
*/
|
|
@@ -107,6 +107,19 @@ export class ConversationService {
|
|
|
107
107
|
throw new Error("当前没有可用的 Pi 进程,无法生成标题");
|
|
108
108
|
return this.runtime.summarizeConversation(conversation, workspace.path);
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The model a conversation has selected, or undefined when it follows Pi's default.
|
|
112
|
+
*
|
|
113
|
+
* THIS IS WHAT THE CATALOG'S THINKING LEVELS DEPEND ON. Pi computes the levels from the model
|
|
114
|
+
* itself, so the levels of the model in force are the levels that conversation gets. A catalog read
|
|
115
|
+
* without this is a catalog of whatever Pi would default to.
|
|
116
|
+
*/
|
|
117
|
+
modelSelectionFor(conversationId) {
|
|
118
|
+
if (!conversationId)
|
|
119
|
+
return undefined;
|
|
120
|
+
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
121
|
+
return conversation?.model;
|
|
122
|
+
}
|
|
110
123
|
/**
|
|
111
124
|
* Selects a conversation and broadcasts the catalog for that conversation's model.
|
|
112
125
|
*/
|
|
@@ -237,10 +237,21 @@ export class CommandHandler {
|
|
|
237
237
|
return;
|
|
238
238
|
}
|
|
239
239
|
if (command.type === "catalog.fetch") {
|
|
240
|
+
// THE CATALOG OF THE CONVERSATION BEING SHOWN, not of whatever Pi would default to.
|
|
241
|
+
//
|
|
242
|
+
// The THINKING LEVELS belong to the model in force: Pi computes them from the model itself, and a
|
|
243
|
+
// model that cannot reason offers only "off". This command used to ask for the catalog with NO
|
|
244
|
+
// selection, so the levels were always the DEFAULT model's - which means the picker offered the
|
|
245
|
+
// levels of a model the person was not using, and hid the ones they were. The model list is the
|
|
246
|
+
// same either way, so only the levels were wrong; the levels are the part a person reads.
|
|
247
|
+
//
|
|
248
|
+
// `conversation.select` already asked for it this way; this is the same answer, for the other
|
|
249
|
+
// way a client asks for it.
|
|
250
|
+
const selection = this.conversationService.modelSelectionFor(command.conversationId);
|
|
240
251
|
if (sendToSocket)
|
|
241
|
-
sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(
|
|
252
|
+
sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(selection, command.conversationId) });
|
|
242
253
|
else
|
|
243
|
-
await this.catalogService.broadcastCatalog(
|
|
254
|
+
await this.catalogService.broadcastCatalog(selection, command.conversationId);
|
|
244
255
|
return;
|
|
245
256
|
}
|
|
246
257
|
// The unfiltered catalog, for the settings window's model panel. Sent to the ASKING socket only:
|
|
@@ -73,6 +73,37 @@ export interface TurnEventHandlers {
|
|
|
73
73
|
followUp: string[];
|
|
74
74
|
}): void;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The failure Pi reported in the message that ended the turn, if it reported one.
|
|
78
|
+
*
|
|
79
|
+
* WHY IT IS HELD AND NOT THROWN ON THE SPOT. Pi retries on its own: an assistant message that ends
|
|
80
|
+
* with `stopReason: "error"` may be followed by another assistant message, and only the LAST one
|
|
81
|
+
* decides whether the turn failed. So the failure is remembered when it arrives and raised when Pi
|
|
82
|
+
* SETTLES - which is the moment Pi has stopped working on its own and the answer is final. A failure
|
|
83
|
+
* raised at the moment it arrives would abort a turn Pi was about to retry, and the retry would then
|
|
84
|
+
* look like the user's problem.
|
|
85
|
+
*
|
|
86
|
+
* THE SAME RULES AS THE ONE-SHOT PATH, which already got this right (`runtime.ts`: the RPC drain loop
|
|
87
|
+
* reads the terminal outcome off `agent_end` and throws at `agent_settled`). A provider failure is a
|
|
88
|
+
* failure; a response that hit the length limit is an INCOMPLETE answer and is one too. An `"aborted"`
|
|
89
|
+
* is NOT a failure here: the person stopped the turn, and a stopped turn is a turn that ended - the
|
|
90
|
+
* one-shot path can tell the difference because it knows whether the signal was aborted, and the
|
|
91
|
+
* session path does not need to: an abort is the user's own action and the run service already
|
|
92
|
+
* records it as stopped.
|
|
93
|
+
*/
|
|
94
|
+
export interface TurnFailure {
|
|
95
|
+
/** The message Pi reported, in its own words. */
|
|
96
|
+
message: string;
|
|
97
|
+
/** The raw reason, kept so a caller can tell a provider failure from anything else. */
|
|
98
|
+
stopReason: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Reads the failure out of a `message_end` record, or undefined when it is not a failed one.
|
|
102
|
+
*
|
|
103
|
+
* ONLY THE ASSISTANT'S MESSAGE COUNTS. `message_end` also fires for user messages, tool results and
|
|
104
|
+
* custom messages, and none of those is a turn failing.
|
|
105
|
+
*/
|
|
106
|
+
export declare function describeMessageFailure(record: unknown): TurnFailure | undefined;
|
|
76
107
|
/**
|
|
77
108
|
* A queue of turn items, with the waiters that are blocked on it.
|
|
78
109
|
*
|
|
@@ -108,4 +139,6 @@ export declare class TurnQueue {
|
|
|
108
139
|
* then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
|
|
109
140
|
* understands is worth saying out loud exactly once.
|
|
110
141
|
*/
|
|
111
|
-
export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void
|
|
142
|
+
export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void, failure?: {
|
|
143
|
+
current: TurnFailure | undefined;
|
|
144
|
+
}): boolean;
|
|
@@ -62,6 +62,31 @@ export function describeDeliveredUserMessage(record) {
|
|
|
62
62
|
.filter((part) => part !== undefined);
|
|
63
63
|
return parts.length ? parts.join("\n") : undefined;
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Reads the failure out of a `message_end` record, or undefined when it is not a failed one.
|
|
67
|
+
*
|
|
68
|
+
* ONLY THE ASSISTANT'S MESSAGE COUNTS. `message_end` also fires for user messages, tool results and
|
|
69
|
+
* custom messages, and none of those is a turn failing.
|
|
70
|
+
*/
|
|
71
|
+
export function describeMessageFailure(record) {
|
|
72
|
+
const event = record;
|
|
73
|
+
if (event?.type !== "message_end")
|
|
74
|
+
return undefined;
|
|
75
|
+
const message = event.message;
|
|
76
|
+
if (!message || typeof message !== "object" || message.role !== "assistant")
|
|
77
|
+
return undefined;
|
|
78
|
+
const text = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
|
|
79
|
+
if (message.stopReason === "error") {
|
|
80
|
+
return { message: text || "Pi 报告模型或服务提供方错误。", stopReason: "error" };
|
|
81
|
+
}
|
|
82
|
+
if (message.stopReason === "length") {
|
|
83
|
+
// A response that hit the length limit is an INCOMPLETE answer. Pi compacts and continues after a
|
|
84
|
+
// recoverable one, so a turn that SETTLES with this reason is one whose answer is missing its
|
|
85
|
+
// end - and saying so is better than a turn that looks finished.
|
|
86
|
+
return { message: text || "模型输出达到本轮长度上限,回答未完成。", stopReason: "length" };
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
65
90
|
/**
|
|
66
91
|
* A queue of turn items, with the waiters that are blocked on it.
|
|
67
92
|
*
|
|
@@ -149,11 +174,53 @@ export class TurnQueue {
|
|
|
149
174
|
* then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
|
|
150
175
|
* understands is worth saying out loud exactly once.
|
|
151
176
|
*/
|
|
152
|
-
export function handleSessionEvent(event, queue, handlers, report) {
|
|
177
|
+
export function handleSessionEvent(event, queue, handlers, report, failure) {
|
|
153
178
|
const record = event;
|
|
154
179
|
if (!record || typeof record.type !== "string")
|
|
155
180
|
return false;
|
|
156
181
|
switch (record.type) {
|
|
182
|
+
case "message_end": {
|
|
183
|
+
// THE AUTHORITATIVE END OF AN ASSISTANT MESSAGE. `message_end` is the completed message, and its
|
|
184
|
+
// `stopReason` is the only place Pi says whether the model actually answered. The bridge used to
|
|
185
|
+
// ignore this event, which meant a provider that failed was indistinguishable from one that
|
|
186
|
+
// finished: the turn settled, the status became "completed", and the reader saw a turn that
|
|
187
|
+
// looked fine and had no answer in it.
|
|
188
|
+
//
|
|
189
|
+
// A failure is HELD, not thrown, because Pi retries on its own - the message that failed may be
|
|
190
|
+
// followed by another one. The retry starts a new assistant message, and THAT clears the held
|
|
191
|
+
// failure, so only the last message of the turn decides.
|
|
192
|
+
const reported = describeMessageFailure(record);
|
|
193
|
+
if (reported && failure)
|
|
194
|
+
failure.current = reported;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
case "message_start": {
|
|
198
|
+
// A NEW ASSISTANT MESSAGE MEANS A NEW ATTEMPT. Pi's retry starts one, and the failure of the
|
|
199
|
+
// attempt before it is no longer the failure of this turn.
|
|
200
|
+
if (record.message?.role === "assistant" && failure)
|
|
201
|
+
failure.current = undefined;
|
|
202
|
+
// A STEER THIS TURN DELIVERED. Pi announces the moment a queued message stops waiting and starts
|
|
203
|
+
// being answered by emitting `message_start` with the user message it picked up, immediately after
|
|
204
|
+
// the `queue_update` above that shows the entry gone. The host uses it to split the turn so the
|
|
205
|
+
// answer lands under the message it answers.
|
|
206
|
+
//
|
|
207
|
+
// A NON-user `message_start` is not a delivery: it gets the same once-per-type report every other
|
|
208
|
+
// unrouted event gets, which is the same policy the RPC drain loop applies.
|
|
209
|
+
const delivered = describeDeliveredUserMessage(record);
|
|
210
|
+
if (delivered === undefined) {
|
|
211
|
+
if (record.message?.role === "assistant")
|
|
212
|
+
return true;
|
|
213
|
+
report(`type ${JSON.stringify(record.type)}`);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
handlers.onSteerDelivered?.(delivered);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
157
224
|
case "message_update": {
|
|
158
225
|
const assistant = record.assistantMessageEvent;
|
|
159
226
|
if (!assistant)
|
|
@@ -240,28 +307,19 @@ export function handleSessionEvent(event, queue, handlers, report) {
|
|
|
240
307
|
}
|
|
241
308
|
return true;
|
|
242
309
|
}
|
|
243
|
-
case "
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// the
|
|
247
|
-
// answer lands under the message it answers.
|
|
310
|
+
case "agent_settled": {
|
|
311
|
+
// THE TURN IS OVER, AND IT MAY HAVE FAILED. Pi settling is the end of the turn, and a turn that
|
|
312
|
+
// ended because the provider failed has to say so: a failure that is not raised is a failed turn
|
|
313
|
+
// that the reader sees as a completed one, with no answer in it and no banner above it.
|
|
248
314
|
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
253
|
-
|
|
315
|
+
// The failure is raised HERE and not when it arrived, because Pi retries on its own - a message
|
|
316
|
+
// that failed may be followed by another one, and only the last one decides.
|
|
317
|
+
const held = failure?.current;
|
|
318
|
+
if (held) {
|
|
319
|
+
failure.current = undefined;
|
|
320
|
+
queue.push({ kind: "error", error: new Error(held.message) });
|
|
254
321
|
return true;
|
|
255
322
|
}
|
|
256
|
-
try {
|
|
257
|
-
handlers.onSteerDelivered?.(delivered);
|
|
258
|
-
}
|
|
259
|
-
catch (error) {
|
|
260
|
-
console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
261
|
-
}
|
|
262
|
-
return true;
|
|
263
|
-
}
|
|
264
|
-
case "agent_settled": {
|
|
265
323
|
queue.push({ kind: "end" });
|
|
266
324
|
return true;
|
|
267
325
|
}
|
|
@@ -188,9 +188,19 @@ export class PiSessionPool {
|
|
|
188
188
|
const same = running?.provider === wanted.provider && (running?.id ?? running?.id) === wanted.modelId;
|
|
189
189
|
if (!same) {
|
|
190
190
|
try {
|
|
191
|
-
|
|
191
|
+
// THE WHOLE MODEL, NOT THE PAIR. Pi's `setModel` puts what it is given straight into the
|
|
192
|
+
// agent's state, so a `{provider, id}` pair would not be a shorthand for a model - it would BE
|
|
193
|
+
// the model the agent runs on, with no `api` and no `contextWindow`. Measured: with the whole
|
|
194
|
+
// model the session reports `api=bedrock-converse-stream`, `contextWindow=1000000` and the
|
|
195
|
+
// thinking levels `[off, minimal, low, medium, high]`; with the pair the api and the context
|
|
196
|
+
// window are null and the levels collapse to `[off]`, which is what the picker shows.
|
|
197
|
+
//
|
|
198
|
+
// The pair is still the fallback, because a session that cannot resolve a model should still
|
|
199
|
+
// be able to run the one it has.
|
|
200
|
+
const whole = session.resolveModel?.(wanted.provider, wanted.modelId);
|
|
201
|
+
await session.setModel(whole ?? { provider: wanted.provider, id: wanted.modelId });
|
|
192
202
|
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
193
|
-
console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.model-applied model=${wanted.provider}/${wanted.modelId}`);
|
|
203
|
+
console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.model-applied model=${wanted.provider}/${wanted.modelId} whole=${whole ? "yes" : "no"}`);
|
|
194
204
|
}
|
|
195
205
|
catch (error) {
|
|
196
206
|
console.warn(`PiSessionPool: conversation ${request.conversationId} asked for ${wanted.provider}/${wanted.modelId} and Pi declined: ${error instanceof Error ? error.message : String(error)}. The turn will run on the model already loaded.`);
|
|
@@ -68,11 +68,15 @@ export interface SdkCatalog {
|
|
|
68
68
|
*
|
|
69
69
|
* Separate from the models because it is the expensive half: it needs a session, so a caller that
|
|
70
70
|
* only wants the list does not pay for it.
|
|
71
|
+
*
|
|
72
|
+
* UNDEFINED MEANS "THIS RUNTIME DOES NOT KNOW THAT MODEL", and it is not the same answer as "that
|
|
73
|
+
* model has no levels but off". A caller that gets undefined asks whoever does know the model; a
|
|
74
|
+
* caller that got a list would be showing another model's levels.
|
|
71
75
|
*/
|
|
72
76
|
thinkingLevelsFor(model: {
|
|
73
77
|
provider: string;
|
|
74
78
|
modelId: string;
|
|
75
|
-
}): Promise<string[]>;
|
|
79
|
+
}): Promise<string[] | undefined>;
|
|
76
80
|
}
|
|
77
81
|
/**
|
|
78
82
|
* Builds a zero-process catalog reader.
|
|
@@ -235,11 +239,35 @@ export interface PiSessionLike {
|
|
|
235
239
|
* running session, so a model change does not need a new session - and making one would cost a
|
|
236
240
|
* transcript replay, which is the most expensive thing this runtime does. A user who picks a different
|
|
237
241
|
* model in the picker would otherwise pay for the whole conversation's history to be re-read.
|
|
242
|
+
*
|
|
243
|
+
* THE ARGUMENT MUST BE A WHOLE MODEL. Pi's `setModel` puts it straight into the agent's state
|
|
244
|
+
* (`agent-session.ts:2514`), so a `{provider, id}` pair is not a shorthand for a model - it IS the
|
|
245
|
+
* model the agent will use, and it has no `api`, no `contextWindow` and no `maxTokens`. Measured:
|
|
246
|
+
* with a whole model the session reports `api=bedrock-converse-stream`,
|
|
247
|
+
* `contextWindow=1000000` and the thinking levels `[off, minimal, low, medium, high]`; with a pair
|
|
248
|
+
* the api and the context window are null and the levels collapse to `[off]`. `resolveModel` is how
|
|
249
|
+
* the whole model is obtained.
|
|
238
250
|
*/
|
|
239
251
|
setModel?(model: {
|
|
240
252
|
provider: string;
|
|
241
253
|
id: string;
|
|
254
|
+
[key: string]: unknown;
|
|
242
255
|
}): Promise<void>;
|
|
256
|
+
/**
|
|
257
|
+
* The WHOLE model for a provider/id pair, the way Pi's own `set_model` gets it.
|
|
258
|
+
*
|
|
259
|
+
* Pi's RPC mode looks the pair up in the model runtime's snapshot and hands the FOUND MODEL to
|
|
260
|
+
* `session.setModel` (`rpc-mode.ts:470-477`); the pair alone is never what Pi takes. This is the same
|
|
261
|
+
* lookup, exposed so the caller does not have to own a second model runtime.
|
|
262
|
+
*
|
|
263
|
+
* Optional because a test double has no models; its absence means "this session cannot resolve a
|
|
264
|
+
* model", and the caller then falls back to the pair it has.
|
|
265
|
+
*/
|
|
266
|
+
resolveModel?(provider: string, modelId: string): {
|
|
267
|
+
provider: string;
|
|
268
|
+
id: string;
|
|
269
|
+
[key: string]: unknown;
|
|
270
|
+
} | undefined;
|
|
243
271
|
/** The thinking levels the session's current model supports. Pi's own answer, not a derivation. */
|
|
244
272
|
getAvailableThinkingLevels?(): string[];
|
|
245
273
|
setThinkingLevel?(level: string): void;
|
|
@@ -103,6 +103,14 @@ export async function createSdkCatalog(cwd) {
|
|
|
103
103
|
const cached = levelsByModel.get(key);
|
|
104
104
|
if (cached)
|
|
105
105
|
return cached;
|
|
106
|
+
// THE MODEL HAS TO BE FOUND FIRST, and the reason it has to be found is that a session created
|
|
107
|
+
// WITHOUT one runs on Pi's DEFAULT model - and then it answers with the DEFAULT MODEL'S LEVELS.
|
|
108
|
+
// That is a silent wrong answer: the picker would offer the levels of a model the person is not
|
|
109
|
+
// using, and hide the ones they are. A model this runtime does not know is not a model with no
|
|
110
|
+
// levels; it is a model whose levels have to be asked of whoever does know it.
|
|
111
|
+
const whole = modelRuntime.getModel?.(model.provider, model.modelId);
|
|
112
|
+
if (!whole)
|
|
113
|
+
return undefined;
|
|
106
114
|
// A model that is not running has no levels of its own, so a short-lived in-memory session is
|
|
107
115
|
// built to ask Pi. It costs a session - measured at ~30ms warm, and no process at all - and it is
|
|
108
116
|
// paid once per model rather than once per picker open.
|
|
@@ -112,7 +120,7 @@ export async function createSdkCatalog(cwd) {
|
|
|
112
120
|
modelRuntime,
|
|
113
121
|
settingsManager,
|
|
114
122
|
sessionManager: sdk.SessionManager.inMemory(cwd, { id: "catalog-levels" }),
|
|
115
|
-
model:
|
|
123
|
+
model: whole,
|
|
116
124
|
});
|
|
117
125
|
try {
|
|
118
126
|
const levels = sessionLevels(session);
|
|
@@ -269,7 +277,23 @@ export async function createPiSession(creation) {
|
|
|
269
277
|
};
|
|
270
278
|
const { session } = await sdk.createAgentSession(options);
|
|
271
279
|
traceSession(creation.sessionId, "sdk.session.created", startedAt);
|
|
272
|
-
|
|
280
|
+
const live = session;
|
|
281
|
+
// THE WHOLE MODEL, LOOKED UP THE WAY PI LOOKS IT UP. Pi's `setModel` puts the model it is given
|
|
282
|
+
// straight into the agent's state, so what it needs is the WHOLE model - the one with an `api`, a
|
|
283
|
+
// `contextWindow` and a `maxTokens` - and not the `{provider, id}` pair the picker holds. The only
|
|
284
|
+
// thing that has a whole model is the model runtime this session was built with, and Pi's own RPC
|
|
285
|
+
// `set_model` does exactly this lookup before it calls `setModel` (`rpc-mode.ts:470-477`).
|
|
286
|
+
//
|
|
287
|
+
// It is attached to the session rather than kept beside it because the session is what outlives a
|
|
288
|
+
// turn: a model change applies to a LIVE session, and the live session is the thing that has to be
|
|
289
|
+
// able to answer "give me this model whole".
|
|
290
|
+
live.resolveModel = (provider, modelId) => {
|
|
291
|
+
const found = modelRuntime.getModel?.(provider, modelId);
|
|
292
|
+
if (!found || typeof found !== "object")
|
|
293
|
+
return undefined;
|
|
294
|
+
return found;
|
|
295
|
+
};
|
|
296
|
+
return live;
|
|
273
297
|
});
|
|
274
298
|
}
|
|
275
299
|
function transcriptBytes(sessionManager) {
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -431,14 +431,27 @@ export declare function describeSteerDisposition(data: unknown): SteerDispositio
|
|
|
431
431
|
/**
|
|
432
432
|
* Keys Pi's model answers by. Exported so the store and the tests agree with the runtime.
|
|
433
433
|
*
|
|
434
|
-
* THE MODEL IS
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
* selection is the THINKING LEVELS, which belong to the model in use.
|
|
434
|
+
* THE MODEL LIST IS SHARED. The list of models Pi offers is the same answer whichever model a
|
|
435
|
+
* conversation has selected - it is not the list of models one conversation may use. So the list has
|
|
436
|
+
* ONE key, and every conversation shares it.
|
|
438
437
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
438
|
+
* THE THINKING LEVELS BELONG TO THE MODEL IN USE, so they get a key PER MODEL. This used to be one
|
|
439
|
+
* "levels" key for every model, on the assumption that the level list is the same whichever model is
|
|
440
|
+
* selected - and that assumption is wrong: Pi computes the levels from the model itself
|
|
441
|
+
* (`getSupportedThinkingLevels`: a model that cannot reason offers only "off", and a model whose
|
|
442
|
+
* thinking map says so offers fewer). Measured with one key: after reading the levels for a reasoning
|
|
443
|
+
* model, a NON-reasoning model was offered the reasoning model's levels, and the other way round - so
|
|
444
|
+
* the picker offered levels the model in force does not have, and hid the ones it does. Super Pi
|
|
445
|
+
* cannot compute the levels itself instead: Pi's rule is not exported, and a second implementation of
|
|
446
|
+
* it would drift from Pi's.
|
|
447
|
+
*
|
|
448
|
+
* THE COST OF A KEY PER MODEL, and why it is not what it used to be. An earlier version of this
|
|
449
|
+
* comment said that keying on the model "meant a cache miss for every model a person ever picked, and
|
|
450
|
+
* every miss was a process start - so the picker went grey again after every model change". That was
|
|
451
|
+
* true when the catalog ALWAYS answered by spawning a Pi, which is no longer how the ordinary path
|
|
452
|
+
* works: the SDK path reads the levels out of a session (measured ~30ms, no process at all) and caches
|
|
453
|
+
* them per model. The one-shot path - a Pi the person named explicitly - does pay a spawn for a model
|
|
454
|
+
* it has not read yet, and that is the price of asking Pi rather than guessing at its rule.
|
|
442
455
|
*/
|
|
443
456
|
export declare const catalogKey: (selection?: ModelSelection) => string;
|
|
444
457
|
/**
|
package/dist/server/runtime.js
CHANGED
|
@@ -196,16 +196,29 @@ const CATALOG_COMMAND_TIMEOUT_MS = 15_000;
|
|
|
196
196
|
/**
|
|
197
197
|
* Keys Pi's model answers by. Exported so the store and the tests agree with the runtime.
|
|
198
198
|
*
|
|
199
|
-
* THE MODEL IS
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* selection is the THINKING LEVELS, which belong to the model in use.
|
|
199
|
+
* THE MODEL LIST IS SHARED. The list of models Pi offers is the same answer whichever model a
|
|
200
|
+
* conversation has selected - it is not the list of models one conversation may use. So the list has
|
|
201
|
+
* ONE key, and every conversation shares it.
|
|
203
202
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
203
|
+
* THE THINKING LEVELS BELONG TO THE MODEL IN USE, so they get a key PER MODEL. This used to be one
|
|
204
|
+
* "levels" key for every model, on the assumption that the level list is the same whichever model is
|
|
205
|
+
* selected - and that assumption is wrong: Pi computes the levels from the model itself
|
|
206
|
+
* (`getSupportedThinkingLevels`: a model that cannot reason offers only "off", and a model whose
|
|
207
|
+
* thinking map says so offers fewer). Measured with one key: after reading the levels for a reasoning
|
|
208
|
+
* model, a NON-reasoning model was offered the reasoning model's levels, and the other way round - so
|
|
209
|
+
* the picker offered levels the model in force does not have, and hid the ones it does. Super Pi
|
|
210
|
+
* cannot compute the levels itself instead: Pi's rule is not exported, and a second implementation of
|
|
211
|
+
* it would drift from Pi's.
|
|
212
|
+
*
|
|
213
|
+
* THE COST OF A KEY PER MODEL, and why it is not what it used to be. An earlier version of this
|
|
214
|
+
* comment said that keying on the model "meant a cache miss for every model a person ever picked, and
|
|
215
|
+
* every miss was a process start - so the picker went grey again after every model change". That was
|
|
216
|
+
* true when the catalog ALWAYS answered by spawning a Pi, which is no longer how the ordinary path
|
|
217
|
+
* works: the SDK path reads the levels out of a session (measured ~30ms, no process at all) and caches
|
|
218
|
+
* them per model. The one-shot path - a Pi the person named explicitly - does pay a spawn for a model
|
|
219
|
+
* it has not read yet, and that is the price of asking Pi rather than guessing at its rule.
|
|
207
220
|
*/
|
|
208
|
-
export const catalogKey = (selection) => (selection?.provider && selection.modelId ?
|
|
221
|
+
export const catalogKey = (selection) => (selection?.provider && selection.modelId ? `levels:${selection.provider}/${selection.modelId}` : "list");
|
|
209
222
|
/**
|
|
210
223
|
* Where the `task` extension lives, as an ABSOLUTE path.
|
|
211
224
|
*
|
|
@@ -721,9 +734,14 @@ export class PiRpcRuntime {
|
|
|
721
734
|
announced.add(key);
|
|
722
735
|
console.warn(`PiRpcRuntime: unhandled Pi session event ${key} - consumed without action.`);
|
|
723
736
|
};
|
|
737
|
+
// THE FAILURE THE TURN ENDED ON, if it ended on one. Held here rather than thrown on the spot,
|
|
738
|
+
// because Pi retries on its own and only the LAST assistant message of a turn decides whether the
|
|
739
|
+
// turn failed. The bridge clears it when a new assistant message starts, and raises it when Pi
|
|
740
|
+
// settles.
|
|
741
|
+
const failure = { current: undefined };
|
|
724
742
|
const unsubscribe = session.subscribe((event) => {
|
|
725
743
|
try {
|
|
726
|
-
handleSessionEvent(event, queue, handlers, report);
|
|
744
|
+
handleSessionEvent(event, queue, handlers, report, failure);
|
|
727
745
|
}
|
|
728
746
|
catch (error) {
|
|
729
747
|
console.warn(`PiRpcRuntime: the session event handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -2346,14 +2364,22 @@ export class PiRpcRuntime {
|
|
|
2346
2364
|
const pick = (model) => (model ? { provider: model.provider, modelId: model.modelId, label: model.label } : undefined);
|
|
2347
2365
|
// The levels belong to the SELECTED model, so they are asked of the model in question rather
|
|
2348
2366
|
// than of whatever Pi would have defaulted to.
|
|
2367
|
+
//
|
|
2368
|
+
// AND WHEN THE SDK DOES NOT KNOW THAT MODEL, THE WHOLE READ IS HANDED OVER rather than answered
|
|
2369
|
+
// with another model's levels. The model in force may belong to a Pi the SDK is not (a Pi the
|
|
2370
|
+
// person named explicitly), and a list of levels for a DIFFERENT model is worse than no answer:
|
|
2371
|
+
// the picker would offer levels the model in force does not have.
|
|
2349
2372
|
const forModel = selection ?? reader.defaultModel;
|
|
2350
|
-
const levels = forModel ? await reader.thinkingLevelsFor(forModel) :
|
|
2373
|
+
const levels = forModel ? await reader.thinkingLevelsFor(forModel) : undefined;
|
|
2374
|
+
if (forModel && !levels)
|
|
2375
|
+
return undefined;
|
|
2376
|
+
const levelNames = levels ?? [];
|
|
2351
2377
|
const value = {
|
|
2352
2378
|
source: "pi",
|
|
2353
2379
|
models,
|
|
2354
|
-
levels,
|
|
2380
|
+
levels: levelNames,
|
|
2355
2381
|
defaultModel: pick(reader.defaultModel ? describeModel({ provider: reader.defaultModel.provider, id: reader.defaultModel.modelId, name: reader.defaultModel.modelId }) : undefined),
|
|
2356
|
-
defaultThinkingLevel: reader.defaultThinkingLevel ??
|
|
2382
|
+
defaultThinkingLevel: reader.defaultThinkingLevel ?? levelNames[0],
|
|
2357
2383
|
fetchedAt: new Date().toISOString(),
|
|
2358
2384
|
};
|
|
2359
2385
|
return value;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.104",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"scripts/stream-checkpoint.test.mjs",
|
|
44
44
|
"scripts/stream-transport.test.mjs",
|
|
45
45
|
"scripts/pi-session.test.mjs",
|
|
46
|
+
"scripts/pi-model-switch.test.mjs",
|
|
47
|
+
"scripts/pi-thinking-levels.test.mjs",
|
|
46
48
|
"scripts/diagnose-cli.test.mjs",
|
|
47
49
|
"scripts/lan-access.test.mjs",
|
|
48
50
|
"scripts/terminal-pty.test.mjs",
|