pi-ultracode 0.1.0 → 0.1.2
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 +401 -148
- package/extensions/ultracode.ts +134 -16
- package/package.json +22 -1
- package/src/commands.ts +32 -84
- package/src/index.ts +20 -0
- package/src/mode.ts +445 -34
- package/src/prompts.ts +2 -2
- package/src/thinking.ts +27 -0
- package/src/workflow/agent-runner.ts +903 -53
- package/src/workflow/agent-types.ts +3 -6
- package/src/workflow/display-text.ts +461 -0
- package/src/workflow/display.ts +188 -55
- package/src/workflow/journal.ts +0 -0
- package/src/workflow/registry.ts +106 -10
- package/src/workflow/run-details.ts +1276 -0
- package/src/workflow/runtime.ts +189 -38
- package/src/workflow/tool.ts +285 -47
- package/src/workflow/workflow-overlay.ts +719 -0
- package/types/workflow.d.ts +1 -1
|
@@ -9,61 +9,211 @@
|
|
|
9
9
|
* allowlist), and an alternate cwd for git-worktree isolation.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import {
|
|
13
15
|
createAgentSession,
|
|
14
16
|
createCodingTools,
|
|
15
17
|
getAgentDir,
|
|
16
18
|
SessionManager,
|
|
17
19
|
SettingsManager,
|
|
20
|
+
VERSION as PI_VERSION,
|
|
21
|
+
type CreateAgentSessionOptions,
|
|
18
22
|
type ToolDefinition,
|
|
19
23
|
} from "@earendil-works/pi-coding-agent";
|
|
20
24
|
import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
|
|
21
25
|
import type { Static, TSchema } from "typebox";
|
|
22
26
|
import { createStructuredOutputTool, type StructuredOutputCapture } from "./structured-output.ts";
|
|
27
|
+
import {
|
|
28
|
+
DISPLAY_INPUT_LIMIT,
|
|
29
|
+
displayOneLine,
|
|
30
|
+
redactCommand,
|
|
31
|
+
safeCommandPreview,
|
|
32
|
+
safeDisplayText,
|
|
33
|
+
safeTranscriptText,
|
|
34
|
+
truncateDisplay,
|
|
35
|
+
} from "./display-text.ts";
|
|
23
36
|
import { jsonSchemaToTypeBox } from "./json-schema.ts";
|
|
37
|
+
import {
|
|
38
|
+
LEGACY_ULTRACODE_THINKING_LEVEL,
|
|
39
|
+
ULTRACODE_THINKING_LEVEL,
|
|
40
|
+
isThinkingLevel,
|
|
41
|
+
piVersionSupportsMaxThinking,
|
|
42
|
+
type ThinkingLevel,
|
|
43
|
+
} from "../thinking.ts";
|
|
24
44
|
import type { AgentTypeDef } from "./agent-types.ts";
|
|
25
45
|
|
|
26
|
-
export type ThinkingLevel
|
|
46
|
+
export type { ThinkingLevel } from "../thinking.ts";
|
|
27
47
|
|
|
28
48
|
/** A minimal structural view of a Pi model (avoids importing the heavy generic type). */
|
|
29
49
|
export interface ModelLike {
|
|
30
50
|
provider: string;
|
|
31
51
|
id: string;
|
|
32
52
|
name?: string;
|
|
53
|
+
/** Extended levels are supported only when Pi exposes a non-null mapping. */
|
|
54
|
+
thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
|
|
33
55
|
}
|
|
34
56
|
|
|
35
57
|
export interface ModelRegistryLike {
|
|
36
58
|
getAvailable(): ModelLike[];
|
|
59
|
+
/** Legacy public projection retained for consumers of this structural type. */
|
|
37
60
|
getAll?(): ModelLike[];
|
|
61
|
+
getRegisteredProviderIds?(): readonly string[];
|
|
62
|
+
getRegisteredProviderConfig?(provider: string): unknown;
|
|
63
|
+
getProviderAuthStatus?(provider: string): {
|
|
64
|
+
configured: boolean;
|
|
65
|
+
source?: string;
|
|
66
|
+
};
|
|
67
|
+
getApiKeyForProvider?(provider: string): Promise<string | undefined>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Structural ModelRuntime seam that remains loadable on pre-0.80.8 Pi. */
|
|
71
|
+
export interface ModelRuntimeLike {
|
|
72
|
+
getModel?(provider: string, modelId: string): ModelLike | undefined;
|
|
73
|
+
registerProvider?(provider: string, config: any): void;
|
|
74
|
+
refresh?(options?: { allowNetwork?: boolean }): Promise<unknown>;
|
|
75
|
+
setRuntimeApiKey?(provider: string, apiKey: string): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ModelRuntimeCreateOptions {
|
|
79
|
+
authPath: string;
|
|
80
|
+
modelsPath: string;
|
|
81
|
+
/** Child sessions reuse the parent's catalog snapshot and must not refresh remotely. */
|
|
82
|
+
allowModelNetwork: false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type ModelRuntimeFactory = (
|
|
86
|
+
options: ModelRuntimeCreateOptions,
|
|
87
|
+
) => Promise<ModelRuntimeLike | undefined>;
|
|
88
|
+
|
|
89
|
+
export interface AgentTurnUsage {
|
|
90
|
+
inputTokens: number;
|
|
91
|
+
outputTokens: number;
|
|
92
|
+
cacheReadTokens: number;
|
|
93
|
+
cacheWriteTokens: number;
|
|
94
|
+
totalTokens: number;
|
|
95
|
+
cost: number;
|
|
38
96
|
}
|
|
39
97
|
|
|
40
98
|
export interface AgentUsage {
|
|
99
|
+
/** Optional on injected legacy runners; production runners always provide it. */
|
|
100
|
+
inputTokens?: number;
|
|
41
101
|
outputTokens: number;
|
|
102
|
+
/** Compact token use: input + output, excluding cache traffic. */
|
|
42
103
|
totalTokens: number;
|
|
104
|
+
cacheReadTokens?: number;
|
|
105
|
+
cacheWriteTokens?: number;
|
|
43
106
|
cost: number;
|
|
107
|
+
/** Completed assistant messages. */
|
|
108
|
+
turns?: number;
|
|
109
|
+
/** Tool executions that reached tool_execution_start. */
|
|
110
|
+
toolUses?: number;
|
|
111
|
+
retries?: number;
|
|
112
|
+
compactions?: number;
|
|
44
113
|
}
|
|
45
114
|
|
|
46
115
|
export interface AgentRunResult {
|
|
47
116
|
value: unknown;
|
|
48
117
|
usage: AgentUsage;
|
|
118
|
+
/** Model id and effort actually applied by the child session. */
|
|
119
|
+
modelId?: string;
|
|
120
|
+
effort?: ThinkingLevel;
|
|
49
121
|
/** cwd the agent actually ran in (differs from the shared cwd under worktree isolation). */
|
|
50
122
|
cwd: string;
|
|
51
123
|
}
|
|
52
124
|
|
|
125
|
+
/** Detailed child-session events consumed by the private transcript store. */
|
|
126
|
+
export type AgentTelemetryEvent =
|
|
127
|
+
| { kind: "model_requested"; modelId?: string; effort?: ThinkingLevel }
|
|
128
|
+
| { kind: "model_resolved"; modelId?: string; effort: ThinkingLevel }
|
|
129
|
+
| { kind: "turn_start"; turnIndex?: number }
|
|
130
|
+
| { kind: "text_delta"; delta: string }
|
|
131
|
+
| { kind: "message_end"; text: string; usage?: AgentTurnUsage; error?: string }
|
|
132
|
+
| { kind: "thinking_start" | "thinking_end" }
|
|
133
|
+
| { kind: "tool_start"; toolCallId: string; toolName: string; toolArgs?: string }
|
|
134
|
+
| {
|
|
135
|
+
kind: "tool_end";
|
|
136
|
+
toolCallId: string;
|
|
137
|
+
toolName: string;
|
|
138
|
+
isError: boolean;
|
|
139
|
+
resultPreview?: string;
|
|
140
|
+
}
|
|
141
|
+
| { kind: "retry"; state: "start" | "end"; detail: string }
|
|
142
|
+
| { kind: "compaction"; state: "start" | "end"; detail: string }
|
|
143
|
+
| {
|
|
144
|
+
kind: "run_error";
|
|
145
|
+
error: string;
|
|
146
|
+
usage: AgentUsage;
|
|
147
|
+
modelId?: string;
|
|
148
|
+
effort?: ThinkingLevel;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export interface AgentSessionLike {
|
|
152
|
+
thinkingLevel: ThinkingLevel;
|
|
153
|
+
model?: ModelLike;
|
|
154
|
+
supportsThinking(): boolean;
|
|
155
|
+
prompt(
|
|
156
|
+
prompt: string,
|
|
157
|
+
options?: { preflightResult?: (success: boolean) => void },
|
|
158
|
+
): Promise<void>;
|
|
159
|
+
abort(): Promise<void>;
|
|
160
|
+
subscribe(listener: (event: unknown) => void): () => void;
|
|
161
|
+
dispose(): void;
|
|
162
|
+
messages: unknown[];
|
|
163
|
+
getSessionStats?(): unknown;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type AgentSessionCreateOptions = Omit<
|
|
167
|
+
CreateAgentSessionOptions,
|
|
168
|
+
"model" | "modelRegistry" | "modelRuntime"
|
|
169
|
+
> & {
|
|
170
|
+
model?: ModelLike;
|
|
171
|
+
/** Legacy Pi option, selected only when ModelRuntime is unavailable. */
|
|
172
|
+
modelRegistry?: ModelRegistryLike;
|
|
173
|
+
/** Pi 0.80.8+ canonical model/auth runtime. */
|
|
174
|
+
modelRuntime?: ModelRuntimeLike;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export type AgentSessionFactory = (
|
|
178
|
+
options: AgentSessionCreateOptions,
|
|
179
|
+
) => Promise<{ session: AgentSessionLike }>;
|
|
180
|
+
|
|
53
181
|
export interface WorkflowAgentRunnerOptions {
|
|
54
182
|
cwd: string;
|
|
183
|
+
/** Synchronous extension facade used only for model selection and state replay. */
|
|
55
184
|
modelRegistry?: ModelRegistryLike;
|
|
185
|
+
/** Canonical runtime to share across child sessions when supplied by an SDK host. */
|
|
186
|
+
modelRuntime?: ModelRuntimeLike;
|
|
56
187
|
/** Default model used when an agent() call does not override it. */
|
|
57
188
|
model?: ModelLike;
|
|
58
189
|
/** Default thinking level for subagents. */
|
|
59
190
|
thinkingLevel?: ThinkingLevel;
|
|
191
|
+
/** Test seam for session construction and initialization races. */
|
|
192
|
+
createSession?: AgentSessionFactory;
|
|
193
|
+
/** Test/compatibility seam for async ModelRuntime initialization. */
|
|
194
|
+
createModelRuntime?: ModelRuntimeFactory;
|
|
195
|
+
/** Override runtime feature detection for pre-max Pi compatibility tests. */
|
|
196
|
+
supportsMaxThinking?: boolean;
|
|
60
197
|
}
|
|
61
198
|
|
|
62
|
-
/** Normalized activity
|
|
63
|
-
export
|
|
64
|
-
kind: "
|
|
65
|
-
detail
|
|
66
|
-
|
|
199
|
+
/** Normalized, valid-by-construction activity signals from a running subagent. */
|
|
200
|
+
export type AgentActivityInput =
|
|
201
|
+
| { kind: "waiting" | "retry" | "compaction"; detail: string }
|
|
202
|
+
| { kind: "thinking"; detail: string }
|
|
203
|
+
| {
|
|
204
|
+
kind: "text";
|
|
205
|
+
detail: "responding";
|
|
206
|
+
/** @deprecated Standard runners never emit raw assistant text. */
|
|
207
|
+
streamDelta?: string;
|
|
208
|
+
}
|
|
209
|
+
| {
|
|
210
|
+
kind: "tool";
|
|
211
|
+
detail: string;
|
|
212
|
+
toolCallId: string;
|
|
213
|
+
toolName: string;
|
|
214
|
+
toolArgs?: string;
|
|
215
|
+
toolState: "start" | "update" | "end";
|
|
216
|
+
};
|
|
67
217
|
|
|
68
218
|
export interface AgentRunCall {
|
|
69
219
|
prompt: string;
|
|
@@ -76,21 +226,34 @@ export interface AgentRunCall {
|
|
|
76
226
|
agentTypeDef?: AgentTypeDef;
|
|
77
227
|
/** Override cwd (worktree). */
|
|
78
228
|
cwd?: string;
|
|
79
|
-
/**
|
|
229
|
+
/** Safe compact activity stream used by the inline workflow status. */
|
|
80
230
|
onActivity?: (event: AgentActivityInput) => void;
|
|
231
|
+
/** Private detailed stream used by the task transcript store. */
|
|
232
|
+
onTelemetry?: (event: AgentTelemetryEvent) => void;
|
|
81
233
|
}
|
|
82
234
|
|
|
83
235
|
export class WorkflowAgentRunner {
|
|
84
236
|
private readonly baseCwd: string;
|
|
85
237
|
private readonly modelRegistry?: ModelRegistryLike;
|
|
238
|
+
private readonly providedModelRuntime?: ModelRuntimeLike;
|
|
86
239
|
private readonly defaultModel?: ModelLike;
|
|
87
240
|
private readonly defaultThinking?: ThinkingLevel;
|
|
241
|
+
private readonly createSession: AgentSessionFactory;
|
|
242
|
+
private readonly createModelRuntime?: ModelRuntimeFactory;
|
|
243
|
+
private modelRuntimePromise?: Promise<ModelRuntimeLike | undefined>;
|
|
244
|
+
private readonly runtimeSupportsMaxThinking: boolean;
|
|
88
245
|
|
|
89
246
|
constructor(options: WorkflowAgentRunnerOptions) {
|
|
90
247
|
this.baseCwd = options.cwd;
|
|
91
248
|
this.modelRegistry = options.modelRegistry;
|
|
249
|
+
this.providedModelRuntime = options.modelRuntime;
|
|
92
250
|
this.defaultModel = options.model;
|
|
93
251
|
this.defaultThinking = options.thinkingLevel;
|
|
252
|
+
const usesPiSessionFactory = options.createSession === undefined;
|
|
253
|
+
this.createSession = options.createSession ?? (createAgentSession as unknown as AgentSessionFactory);
|
|
254
|
+
this.createModelRuntime = options.createModelRuntime
|
|
255
|
+
?? (options.modelRuntime !== undefined || !usesPiSessionFactory ? undefined : createPiModelRuntime);
|
|
256
|
+
this.runtimeSupportsMaxThinking = options.supportsMaxThinking ?? piVersionSupportsMaxThinking(PI_VERSION);
|
|
94
257
|
}
|
|
95
258
|
|
|
96
259
|
async run(call: AgentRunCall): Promise<AgentRunResult> {
|
|
@@ -113,39 +276,147 @@ export class WorkflowAgentRunner {
|
|
|
113
276
|
if (toolAllowlist) toolAllowlist.push("structured_output");
|
|
114
277
|
}
|
|
115
278
|
|
|
116
|
-
const
|
|
117
|
-
|
|
279
|
+
const selection = this.resolveModel(call.modelPattern, call.agentTypeDef);
|
|
280
|
+
const thinkingLevel = selection.thinkingLevel;
|
|
118
281
|
const agentDir = getAgentDir();
|
|
119
|
-
|
|
282
|
+
|
|
283
|
+
let modelRuntime: ModelRuntimeLike | undefined;
|
|
284
|
+
let model: ModelLike | undefined;
|
|
285
|
+
try {
|
|
286
|
+
modelRuntime = await waitForSharedInitialization(
|
|
287
|
+
this.getModelRuntime(agentDir),
|
|
288
|
+
call.signal,
|
|
289
|
+
);
|
|
290
|
+
if (call.signal?.aborted) throw abortedError();
|
|
291
|
+
model = selection.model && modelRuntime?.getModel
|
|
292
|
+
? modelRuntime.getModel(selection.model.provider, selection.model.id) ?? selection.model
|
|
293
|
+
: selection.model;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
safeEmitTelemetry(call.onTelemetry, {
|
|
296
|
+
kind: "run_error",
|
|
297
|
+
error: safeDisplayText(errorText(error), 512),
|
|
298
|
+
usage: emptyAgentUsage(),
|
|
299
|
+
});
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const createSession = (level: ThinkingLevel | undefined) => this.createSession({
|
|
120
304
|
cwd,
|
|
121
305
|
agentDir,
|
|
122
306
|
sessionManager: SessionManager.inMemory(cwd),
|
|
123
307
|
settingsManager: SettingsManager.create(cwd, agentDir),
|
|
124
308
|
customTools,
|
|
125
309
|
...(model ? { model: model as any } : {}),
|
|
126
|
-
...(
|
|
310
|
+
...(level ? { thinkingLevel: level as any } : {}),
|
|
127
311
|
...(toolAllowlist ? { tools: toolAllowlist } : {}),
|
|
128
|
-
...(
|
|
312
|
+
...(modelRuntime
|
|
313
|
+
? { modelRuntime }
|
|
314
|
+
: this.modelRegistry
|
|
315
|
+
? { modelRegistry: this.modelRegistry as any }
|
|
316
|
+
: {}),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const sessionThinking = resolveSessionThinkingLevel(thinkingLevel, model);
|
|
320
|
+
safeEmitTelemetry(call.onTelemetry, {
|
|
321
|
+
kind: "model_requested",
|
|
322
|
+
modelId: model?.id,
|
|
323
|
+
effort: thinkingLevel,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
let created: { session: AgentSessionLike };
|
|
327
|
+
try {
|
|
328
|
+
created = await createSession(sessionThinking);
|
|
329
|
+
if (call.signal?.aborted) {
|
|
330
|
+
disposeQuietly(created.session);
|
|
331
|
+
throw abortedError();
|
|
332
|
+
}
|
|
333
|
+
const selectedModel = created.session.model;
|
|
334
|
+
const selectedModelAdvertisesMax = (model ?? selectedModel)?.thinkingLevelMap?.max != null;
|
|
335
|
+
if (
|
|
336
|
+
thinkingLevel === ULTRACODE_THINKING_LEVEL &&
|
|
337
|
+
sessionThinking === ULTRACODE_THINKING_LEVEL &&
|
|
338
|
+
created.session.thinkingLevel !== ULTRACODE_THINKING_LEVEL &&
|
|
339
|
+
created.session.supportsThinking() &&
|
|
340
|
+
(!this.runtimeSupportsMaxThinking || selectedModelAdvertisesMax)
|
|
341
|
+
) {
|
|
342
|
+
// A legacy runtime may clamp an unknown `max` to medium/high instead of
|
|
343
|
+
// off. Recreate with xhigh; never mutate the user's global default effort.
|
|
344
|
+
disposeQuietly(created.session);
|
|
345
|
+
created = await createSession(LEGACY_ULTRACODE_THINKING_LEVEL);
|
|
346
|
+
if (call.signal?.aborted) {
|
|
347
|
+
disposeQuietly(created.session);
|
|
348
|
+
throw abortedError();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} catch (error) {
|
|
352
|
+
safeEmitTelemetry(call.onTelemetry, {
|
|
353
|
+
kind: "run_error",
|
|
354
|
+
error: safeDisplayText(errorText(error), 512),
|
|
355
|
+
usage: emptyAgentUsage(),
|
|
356
|
+
});
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
const { session } = created;
|
|
360
|
+
const actualModelId = session.model?.id ?? model?.id;
|
|
361
|
+
const actualEffort = session.thinkingLevel;
|
|
362
|
+
const telemetryCounters = { retries: 0, compactions: 0, turns: 0, toolUses: 0, observing: false };
|
|
363
|
+
safeEmitTelemetry(call.onTelemetry, {
|
|
364
|
+
kind: "model_resolved",
|
|
365
|
+
modelId: actualModelId,
|
|
366
|
+
effort: actualEffort,
|
|
129
367
|
});
|
|
130
368
|
|
|
131
369
|
let removeAbort: (() => void) | undefined;
|
|
132
370
|
let unsubscribe: (() => void) | undefined;
|
|
371
|
+
let aborting: Promise<void> | undefined;
|
|
372
|
+
let hasPrimaryError = false;
|
|
133
373
|
try {
|
|
134
374
|
if (call.signal) {
|
|
135
|
-
const onAbort = () =>
|
|
375
|
+
const onAbort = () => {
|
|
376
|
+
// Attach the rejection handler synchronously so a failing abort can
|
|
377
|
+
// never become an unhandled rejection while prompt() is unwinding.
|
|
378
|
+
aborting = session.abort().catch(() => {});
|
|
379
|
+
};
|
|
136
380
|
call.signal.addEventListener("abort", onAbort, { once: true });
|
|
137
381
|
removeAbort = () => call.signal?.removeEventListener("abort", onAbort);
|
|
382
|
+
// AbortSignal does not replay an abort that happened before listener
|
|
383
|
+
// registration, so close the initialization race explicitly.
|
|
384
|
+
if (call.signal.aborted) {
|
|
385
|
+
onAbort();
|
|
386
|
+
await aborting;
|
|
387
|
+
throw abortedError();
|
|
388
|
+
}
|
|
138
389
|
}
|
|
139
390
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
391
|
+
// One subscription feeds both compact status and the private transcript
|
|
392
|
+
// stream. Telemetry callbacks are isolated so observability can never
|
|
393
|
+
// change the child run's outcome.
|
|
394
|
+
if (call.onActivity || call.onTelemetry) {
|
|
395
|
+
telemetryCounters.observing = true;
|
|
396
|
+
unsubscribe = session.subscribe((event: unknown) => {
|
|
397
|
+
const sessionEvent = event as { type?: unknown; message?: { role?: unknown } };
|
|
398
|
+
const eventType = sessionEvent?.type;
|
|
399
|
+
if (eventType === "message_end" && sessionEvent.message?.role === "assistant") telemetryCounters.turns++;
|
|
400
|
+
if (eventType === "tool_execution_start") telemetryCounters.toolUses++;
|
|
401
|
+
if (eventType === "auto_retry_start") telemetryCounters.retries++;
|
|
402
|
+
if (eventType === "compaction_start") telemetryCounters.compactions++;
|
|
403
|
+
if (call.onActivity) forwardActivity(event, call.onActivity);
|
|
404
|
+
if (call.onTelemetry) forwardTelemetry(event, call.onTelemetry);
|
|
405
|
+
});
|
|
145
406
|
}
|
|
146
407
|
|
|
147
|
-
await session.prompt(this.buildPrompt(call, Boolean(call.schema))
|
|
148
|
-
|
|
408
|
+
await session.prompt(this.buildPrompt(call, Boolean(call.schema)), {
|
|
409
|
+
// Pi invokes this after async input/before_agent_start preflight and
|
|
410
|
+
// immediately before _runAgentPrompt(). Throwing here closes the window
|
|
411
|
+
// where abort() sees an idle session and therefore cannot stop streaming.
|
|
412
|
+
preflightResult: () => {
|
|
413
|
+
if (call.signal?.aborted) throw abortedError();
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
if (call.signal?.aborted) {
|
|
417
|
+
await aborting;
|
|
418
|
+
throw abortedError();
|
|
419
|
+
}
|
|
149
420
|
|
|
150
421
|
let value: unknown;
|
|
151
422
|
if (call.schema) {
|
|
@@ -155,11 +426,46 @@ export class WorkflowAgentRunner {
|
|
|
155
426
|
value = lastAssistantText(session.messages as unknown[]);
|
|
156
427
|
}
|
|
157
428
|
|
|
158
|
-
return {
|
|
429
|
+
return {
|
|
430
|
+
value,
|
|
431
|
+
usage: readUsage(session, telemetryCounters),
|
|
432
|
+
modelId: actualModelId,
|
|
433
|
+
effort: actualEffort,
|
|
434
|
+
cwd,
|
|
435
|
+
};
|
|
436
|
+
} catch (error) {
|
|
437
|
+
hasPrimaryError = true;
|
|
438
|
+
safeEmitTelemetry(call.onTelemetry, {
|
|
439
|
+
kind: "run_error",
|
|
440
|
+
error: safeDisplayText(errorText(error), 512),
|
|
441
|
+
usage: readUsage(session, telemetryCounters),
|
|
442
|
+
modelId: actualModelId,
|
|
443
|
+
effort: actualEffort,
|
|
444
|
+
});
|
|
445
|
+
throw error;
|
|
159
446
|
} finally {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
447
|
+
let hasCleanupError = false;
|
|
448
|
+
let cleanupError: unknown;
|
|
449
|
+
const cleanup = (fn: (() => void) | undefined) => {
|
|
450
|
+
if (!fn) return;
|
|
451
|
+
try {
|
|
452
|
+
fn();
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (!hasCleanupError) {
|
|
455
|
+
hasCleanupError = true;
|
|
456
|
+
cleanupError = error;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
cleanup(removeAbort);
|
|
461
|
+
// If cancellation started while prompt() was failing, let AgentSession
|
|
462
|
+
// finish abort teardown before disposing resources underneath it.
|
|
463
|
+
await aborting;
|
|
464
|
+
cleanup(unsubscribe);
|
|
465
|
+
cleanup(() => session.dispose());
|
|
466
|
+
// Cleanup must always run to completion. Its first failure is surfaced only
|
|
467
|
+
// when there is no prompt/abort failure (including reject(undefined)).
|
|
468
|
+
if (!hasPrimaryError && hasCleanupError) throw cleanupError;
|
|
163
469
|
}
|
|
164
470
|
}
|
|
165
471
|
|
|
@@ -186,6 +492,61 @@ export class WorkflowAgentRunner {
|
|
|
186
492
|
return parts.filter(Boolean).join("\n\n");
|
|
187
493
|
}
|
|
188
494
|
|
|
495
|
+
private getModelRuntime(agentDir: string): Promise<ModelRuntimeLike | undefined> {
|
|
496
|
+
if (this.providedModelRuntime) return Promise.resolve(this.providedModelRuntime);
|
|
497
|
+
if (!this.createModelRuntime) return Promise.resolve(undefined);
|
|
498
|
+
if (!this.modelRuntimePromise) {
|
|
499
|
+
// Cache the in-flight promise so parallel agent() calls never initialize
|
|
500
|
+
// separate runtimes or race provider/auth replay. Clear only this failed
|
|
501
|
+
// attempt so a later serial agent can retry transient initialization errors.
|
|
502
|
+
const pending = this.initializeModelRuntime(agentDir);
|
|
503
|
+
this.modelRuntimePromise = pending;
|
|
504
|
+
void pending.catch(() => {
|
|
505
|
+
if (this.modelRuntimePromise === pending) this.modelRuntimePromise = undefined;
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return this.modelRuntimePromise;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private async initializeModelRuntime(agentDir: string): Promise<ModelRuntimeLike | undefined> {
|
|
512
|
+
const runtime = await this.createModelRuntime?.({
|
|
513
|
+
authPath: path.join(agentDir, "auth.json"),
|
|
514
|
+
modelsPath: path.join(agentDir, "models.json"),
|
|
515
|
+
allowModelNetwork: false,
|
|
516
|
+
});
|
|
517
|
+
if (!runtime) return undefined;
|
|
518
|
+
|
|
519
|
+
const registeredProviderIds = this.modelRegistry?.getRegisteredProviderIds?.() ?? [];
|
|
520
|
+
let providersChanged = false;
|
|
521
|
+
for (const provider of registeredProviderIds) {
|
|
522
|
+
const config = this.modelRegistry?.getRegisteredProviderConfig?.(provider);
|
|
523
|
+
if (config === undefined || !runtime.registerProvider) continue;
|
|
524
|
+
runtime.registerProvider(provider, config);
|
|
525
|
+
providersChanged = true;
|
|
526
|
+
}
|
|
527
|
+
if (providersChanged && runtime.refresh) {
|
|
528
|
+
await runtime.refresh({ allowNetwork: false });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Only replay CLI/SDK runtime overrides. Stored credentials, OAuth, env,
|
|
532
|
+
// and models.json auth must stay owned by the new runtime so they can refresh.
|
|
533
|
+
if (
|
|
534
|
+
runtime.setRuntimeApiKey
|
|
535
|
+
&& this.modelRegistry?.getProviderAuthStatus
|
|
536
|
+
&& this.modelRegistry.getApiKeyForProvider
|
|
537
|
+
) {
|
|
538
|
+
const providers = new Set(this.modelRegistry.getAvailable().map((model) => model.provider));
|
|
539
|
+
for (const provider of registeredProviderIds) providers.add(provider);
|
|
540
|
+
for (const provider of providers) {
|
|
541
|
+
if (this.modelRegistry.getProviderAuthStatus(provider).source !== "runtime") continue;
|
|
542
|
+
const apiKey = await this.modelRegistry.getApiKeyForProvider(provider);
|
|
543
|
+
if (apiKey) await runtime.setRuntimeApiKey(provider, apiKey);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return runtime;
|
|
548
|
+
}
|
|
549
|
+
|
|
189
550
|
private resolveModel(
|
|
190
551
|
pattern: string | undefined,
|
|
191
552
|
role: AgentTypeDef | undefined,
|
|
@@ -201,20 +562,56 @@ export class WorkflowAgentRunner {
|
|
|
201
562
|
}
|
|
202
563
|
}
|
|
203
564
|
|
|
204
|
-
|
|
565
|
+
interface ModelRuntimeConstructorLike {
|
|
566
|
+
create(options: ModelRuntimeCreateOptions): Promise<ModelRuntimeLike>;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Capability detection avoids importing a named export that Pi 0.80.7 lacks. */
|
|
570
|
+
async function createPiModelRuntime(
|
|
571
|
+
options: ModelRuntimeCreateOptions,
|
|
572
|
+
): Promise<ModelRuntimeLike | undefined> {
|
|
573
|
+
const runtimeClass = (PiCodingAgent as unknown as {
|
|
574
|
+
ModelRuntime?: ModelRuntimeConstructorLike;
|
|
575
|
+
}).ModelRuntime;
|
|
576
|
+
return runtimeClass?.create ? runtimeClass.create(options) : undefined;
|
|
577
|
+
}
|
|
205
578
|
|
|
206
579
|
export function splitThinkingSuffix(pattern: string): { base: string; thinking?: ThinkingLevel } {
|
|
207
580
|
const idx = pattern.lastIndexOf(":");
|
|
208
581
|
if (idx === -1) return { base: pattern };
|
|
209
582
|
const raw = pattern.slice(idx + 1).trim();
|
|
210
|
-
|
|
211
|
-
if (THINKING_LEVELS.has(suffix)) return { base: pattern.slice(0, idx).trim(), thinking: suffix };
|
|
583
|
+
if (isThinkingLevel(raw)) return { base: pattern.slice(0, idx).trim(), thinking: raw };
|
|
212
584
|
// Trailing colon with no/invalid suffix (e.g. "sonnet:"): strip it so the base
|
|
213
585
|
// is still matchable instead of silently falling back to the default model.
|
|
214
586
|
if (raw === "") return { base: pattern.slice(0, idx).trim() };
|
|
215
587
|
return { base: pattern };
|
|
216
588
|
}
|
|
217
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Avoid sending an unknown `max` value to runtimes/models that do not advertise
|
|
592
|
+
* it. Requesting xhigh is equivalent to max for those models because Pi clamps
|
|
593
|
+
* xhigh to their strongest supported level.
|
|
594
|
+
*/
|
|
595
|
+
export function resolveSessionThinkingLevel(
|
|
596
|
+
requested: ThinkingLevel | undefined,
|
|
597
|
+
model: ModelLike | undefined,
|
|
598
|
+
): ThinkingLevel | undefined {
|
|
599
|
+
if (requested !== ULTRACODE_THINKING_LEVEL || !model) return requested;
|
|
600
|
+
// Known models without max can skip a wasted session. For an unknown/default
|
|
601
|
+
// model, pass max first; the post-create compatibility check retries xhigh on
|
|
602
|
+
// pre-max Pi regardless of whether it clamped to off, medium, or high.
|
|
603
|
+
return model.thinkingLevelMap?.max == null
|
|
604
|
+
? LEGACY_ULTRACODE_THINKING_LEVEL
|
|
605
|
+
: ULTRACODE_THINKING_LEVEL;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function matchExactModelIn(models: ModelLike[] | undefined, pattern: string): ModelLike | undefined {
|
|
609
|
+
if (!models || !pattern.trim()) return undefined;
|
|
610
|
+
const lower = pattern.trim().toLowerCase();
|
|
611
|
+
return models.find((m) => `${m.provider}/${m.id}`.toLowerCase() === lower)
|
|
612
|
+
?? models.find((m) => m.id.toLowerCase() === lower);
|
|
613
|
+
}
|
|
614
|
+
|
|
218
615
|
/** Match a model pattern against a registry list: exact provider/id, then exact id, then substring. */
|
|
219
616
|
export function matchModelIn(models: ModelLike[] | undefined, pattern: string): ModelLike | undefined {
|
|
220
617
|
if (!models) return undefined;
|
|
@@ -223,10 +620,8 @@ export function matchModelIn(models: ModelLike[] | undefined, pattern: string):
|
|
|
223
620
|
if (!pattern.trim()) return undefined;
|
|
224
621
|
const lower = pattern.trim().toLowerCase();
|
|
225
622
|
const slash = lower.includes("/");
|
|
226
|
-
// 1. exact provider/id, 2. exact id, 3. substring on id / provider/id / name.
|
|
227
623
|
return (
|
|
228
|
-
models
|
|
229
|
-
models.find((m) => m.id.toLowerCase() === lower) ??
|
|
624
|
+
matchExactModelIn(models, pattern) ??
|
|
230
625
|
models.find((m) =>
|
|
231
626
|
slash
|
|
232
627
|
? `${m.provider}/${m.id}`.toLowerCase().includes(lower)
|
|
@@ -256,36 +651,285 @@ export function resolveModelSelection(args: {
|
|
|
256
651
|
if (!effectivePattern) {
|
|
257
652
|
return { model: defaultModel, thinkingLevel: roleThinking ?? defaultThinking };
|
|
258
653
|
}
|
|
654
|
+
// A literal model id wins before suffix parsing, so ids/tags such as
|
|
655
|
+
// `ollama/coder:max` remain addressable. Parse :level only without an exact hit.
|
|
656
|
+
const exactModel = matchExactModelIn(models, effectivePattern)
|
|
657
|
+
?? matchExactModelIn(defaultModel ? [defaultModel] : undefined, effectivePattern);
|
|
658
|
+
if (exactModel) {
|
|
659
|
+
return { model: exactModel, thinkingLevel: roleThinking ?? defaultThinking };
|
|
660
|
+
}
|
|
259
661
|
const { base, thinking } = splitThinkingSuffix(effectivePattern);
|
|
260
662
|
const model = base ? matchModelIn(models, base) ?? defaultModel : defaultModel;
|
|
261
663
|
return { model, thinkingLevel: thinking ?? roleThinking ?? defaultThinking };
|
|
262
664
|
}
|
|
263
665
|
|
|
264
|
-
function
|
|
666
|
+
function waitForSharedInitialization<T>(
|
|
667
|
+
pending: Promise<T>,
|
|
668
|
+
signal: AbortSignal | undefined,
|
|
669
|
+
): Promise<T> {
|
|
670
|
+
if (!signal) return pending;
|
|
671
|
+
if (signal.aborted) return Promise.reject(abortedError());
|
|
672
|
+
|
|
673
|
+
return new Promise<T>((resolve, reject) => {
|
|
674
|
+
let settled = false;
|
|
675
|
+
const finish = (callback: () => void) => {
|
|
676
|
+
if (settled) return;
|
|
677
|
+
settled = true;
|
|
678
|
+
signal.removeEventListener("abort", onAbort);
|
|
679
|
+
callback();
|
|
680
|
+
};
|
|
681
|
+
const onAbort = () => finish(() => reject(abortedError()));
|
|
682
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
683
|
+
pending.then(
|
|
684
|
+
(value) => finish(() => resolve(value)),
|
|
685
|
+
(error) => finish(() => reject(error)),
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function abortedError(): Error {
|
|
691
|
+
return new Error("Subagent was aborted");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function disposeQuietly(session: { dispose(): void }): void {
|
|
695
|
+
try {
|
|
696
|
+
session.dispose();
|
|
697
|
+
} catch {
|
|
698
|
+
// A failed provisional-session cleanup must not block compatibility fallback.
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function emptyAgentUsage(): AgentUsage {
|
|
703
|
+
return {
|
|
704
|
+
inputTokens: 0,
|
|
705
|
+
outputTokens: 0,
|
|
706
|
+
cacheReadTokens: 0,
|
|
707
|
+
cacheWriteTokens: 0,
|
|
708
|
+
totalTokens: 0,
|
|
709
|
+
cost: 0,
|
|
710
|
+
turns: 0,
|
|
711
|
+
toolUses: 0,
|
|
712
|
+
retries: 0,
|
|
713
|
+
compactions: 0,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function errorText(error: unknown): string {
|
|
718
|
+
return error instanceof Error ? error.message : String(error);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function safeEmitTelemetry(
|
|
722
|
+
listener: ((event: AgentTelemetryEvent) => void) | undefined,
|
|
723
|
+
event: AgentTelemetryEvent,
|
|
724
|
+
): void {
|
|
725
|
+
if (!listener) return;
|
|
726
|
+
try {
|
|
727
|
+
listener(event);
|
|
728
|
+
} catch {
|
|
729
|
+
// Detailed observability is best-effort and must never affect execution.
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function normalizeTurnUsage(value: unknown): AgentTurnUsage | undefined {
|
|
734
|
+
if (!value || typeof value !== "object") return undefined;
|
|
735
|
+
const usage = value as any;
|
|
736
|
+
const inputTokens = finiteNumber(usage.input) ?? finiteNumber(usage.inputTokens) ?? 0;
|
|
737
|
+
const outputTokens = finiteNumber(usage.output) ?? finiteNumber(usage.outputTokens) ?? 0;
|
|
738
|
+
return {
|
|
739
|
+
inputTokens,
|
|
740
|
+
outputTokens,
|
|
741
|
+
cacheReadTokens: finiteNumber(usage.cacheRead) ?? 0,
|
|
742
|
+
cacheWriteTokens: finiteNumber(usage.cacheWrite) ?? 0,
|
|
743
|
+
// Compact task stats intentionally exclude cache traffic from token use.
|
|
744
|
+
totalTokens: inputTokens + outputTokens,
|
|
745
|
+
cost: finiteNumber(usage.cost?.total) ?? finiteNumber(usage.cost) ?? 0,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function assistantText(message: any): string {
|
|
750
|
+
if (!Array.isArray(message?.content)) return "";
|
|
751
|
+
return message.content
|
|
752
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
753
|
+
.map((part: any) => part.text)
|
|
754
|
+
.join("");
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function resultText(result: any): string {
|
|
758
|
+
if (!Array.isArray(result?.content)) return "";
|
|
759
|
+
return result.content
|
|
760
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
761
|
+
.map((part: any) => part.text)
|
|
762
|
+
.join("\n");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function tailByUtf8Bytes(value: string, maxBytes: number): string {
|
|
766
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
767
|
+
let low = 0;
|
|
768
|
+
let high = value.length;
|
|
769
|
+
while (low < high) {
|
|
770
|
+
const mid = Math.floor((low + high) / 2);
|
|
771
|
+
if (Buffer.byteLength(value.slice(mid), "utf8") <= maxBytes) high = mid;
|
|
772
|
+
else low = mid + 1;
|
|
773
|
+
}
|
|
774
|
+
return value.slice(low);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function toolResultPreview(result: unknown): string | undefined {
|
|
778
|
+
const raw = resultText(result);
|
|
779
|
+
if (!raw.trim()) return undefined;
|
|
780
|
+
const safe = safeTranscriptText(raw, 64 * 1024);
|
|
781
|
+
const lines = safe.split(/\r?\n/).filter((line) => line.trim()).slice(-20).join("\n");
|
|
782
|
+
const bounded = tailByUtf8Bytes(lines, 8 * 1024);
|
|
783
|
+
return bounded.trim() || undefined;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Convert child session events into the private task-detail stream. */
|
|
787
|
+
export function forwardTelemetry(
|
|
788
|
+
event: unknown,
|
|
789
|
+
onTelemetry: (event: AgentTelemetryEvent) => void,
|
|
790
|
+
): void {
|
|
791
|
+
try {
|
|
792
|
+
const e = event as any;
|
|
793
|
+
switch (e?.type) {
|
|
794
|
+
case "turn_start":
|
|
795
|
+
safeEmitTelemetry(onTelemetry, {
|
|
796
|
+
kind: "turn_start",
|
|
797
|
+
turnIndex: finiteNumber(e.turnIndex),
|
|
798
|
+
});
|
|
799
|
+
return;
|
|
800
|
+
case "message_update": {
|
|
801
|
+
const update = e.assistantMessageEvent;
|
|
802
|
+
if (update?.type === "text_delta" && typeof update.delta === "string") {
|
|
803
|
+
safeEmitTelemetry(onTelemetry, { kind: "text_delta", delta: update.delta });
|
|
804
|
+
} else if (update?.type === "thinking_start") {
|
|
805
|
+
safeEmitTelemetry(onTelemetry, { kind: "thinking_start" });
|
|
806
|
+
} else if (update?.type === "thinking_end") {
|
|
807
|
+
safeEmitTelemetry(onTelemetry, { kind: "thinking_end" });
|
|
808
|
+
}
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
case "message_end":
|
|
812
|
+
if (e.message?.role === "assistant") {
|
|
813
|
+
safeEmitTelemetry(onTelemetry, {
|
|
814
|
+
kind: "message_end",
|
|
815
|
+
text: assistantText(e.message),
|
|
816
|
+
usage: normalizeTurnUsage(e.message.usage),
|
|
817
|
+
error: typeof e.message.errorMessage === "string" ? e.message.errorMessage : undefined,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
case "tool_execution_start":
|
|
822
|
+
if (typeof e.toolName === "string") {
|
|
823
|
+
const args = toolArgsPreview(e.toolName, e.args);
|
|
824
|
+
safeEmitTelemetry(onTelemetry, {
|
|
825
|
+
kind: "tool_start",
|
|
826
|
+
toolCallId: typeof e.toolCallId === "string" ? e.toolCallId : `${e.toolName}:unknown`,
|
|
827
|
+
toolName: e.toolName,
|
|
828
|
+
...(args ? { toolArgs: args } : {}),
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
return;
|
|
832
|
+
case "tool_execution_end":
|
|
833
|
+
safeEmitTelemetry(onTelemetry, {
|
|
834
|
+
kind: "tool_end",
|
|
835
|
+
toolCallId: typeof e.toolCallId === "string" ? e.toolCallId : `${String(e.toolName ?? "tool")}:unknown`,
|
|
836
|
+
toolName: typeof e.toolName === "string" ? e.toolName : "tool",
|
|
837
|
+
isError: Boolean(e.isError),
|
|
838
|
+
resultPreview: toolResultPreview(e.result),
|
|
839
|
+
});
|
|
840
|
+
return;
|
|
841
|
+
case "auto_retry_start": {
|
|
842
|
+
const attempt = finiteNumber(e.attempt) ?? 0;
|
|
843
|
+
const maxAttempts = finiteNumber(e.maxAttempts) ?? 0;
|
|
844
|
+
const reason = typeof e.errorMessage === "string" ? safeDisplayText(e.errorMessage, 160) : "";
|
|
845
|
+
safeEmitTelemetry(onTelemetry, {
|
|
846
|
+
kind: "retry",
|
|
847
|
+
state: "start",
|
|
848
|
+
detail: `retry ${attempt}/${maxAttempts} in ${formatDelay(finiteNumber(e.delayMs) ?? 0)}${reason ? `: ${reason}` : ""}`,
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
case "auto_retry_end": {
|
|
853
|
+
const failure = typeof e.finalError === "string" ? safeDisplayText(e.finalError, 180) : "";
|
|
854
|
+
safeEmitTelemetry(onTelemetry, {
|
|
855
|
+
kind: "retry",
|
|
856
|
+
state: "end",
|
|
857
|
+
detail: e.success ? "retry succeeded" : failure ? `retry failed: ${failure}` : "retry failed",
|
|
858
|
+
});
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
case "compaction_start":
|
|
862
|
+
safeEmitTelemetry(onTelemetry, {
|
|
863
|
+
kind: "compaction",
|
|
864
|
+
state: "start",
|
|
865
|
+
detail: compactionStartDetail(e.reason),
|
|
866
|
+
});
|
|
867
|
+
return;
|
|
868
|
+
case "compaction_end":
|
|
869
|
+
safeEmitTelemetry(onTelemetry, {
|
|
870
|
+
kind: "compaction",
|
|
871
|
+
state: "end",
|
|
872
|
+
detail: e.aborted ? "compaction aborted" : e.errorMessage ? "compaction failed" : "compaction complete",
|
|
873
|
+
});
|
|
874
|
+
return;
|
|
875
|
+
default:
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
} catch {
|
|
879
|
+
// best-effort: malformed provider events must not affect the run
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function readUsage(
|
|
884
|
+
session: any,
|
|
885
|
+
counters: {
|
|
886
|
+
retries?: number;
|
|
887
|
+
compactions?: number;
|
|
888
|
+
turns?: number;
|
|
889
|
+
toolUses?: number;
|
|
890
|
+
observing?: boolean;
|
|
891
|
+
} = {},
|
|
892
|
+
): AgentUsage {
|
|
265
893
|
try {
|
|
266
894
|
const stats = session.getSessionStats?.();
|
|
267
895
|
if (stats?.tokens) {
|
|
268
896
|
return {
|
|
897
|
+
inputTokens: stats.tokens.input ?? 0,
|
|
269
898
|
outputTokens: stats.tokens.output ?? 0,
|
|
270
|
-
|
|
899
|
+
cacheReadTokens: stats.tokens.cacheRead ?? 0,
|
|
900
|
+
cacheWriteTokens: stats.tokens.cacheWrite ?? 0,
|
|
901
|
+
totalTokens: (stats.tokens.input ?? 0) + (stats.tokens.output ?? 0),
|
|
271
902
|
cost: stats.cost ?? 0,
|
|
903
|
+
turns: counters.observing ? counters.turns ?? 0 : stats.assistantMessages ?? 0,
|
|
904
|
+
toolUses: counters.observing ? counters.toolUses ?? 0 : stats.toolCalls ?? 0,
|
|
905
|
+
retries: counters.retries ?? 0,
|
|
906
|
+
compactions: counters.compactions ?? 0,
|
|
272
907
|
};
|
|
273
908
|
}
|
|
274
909
|
} catch {
|
|
275
910
|
// fall through to message-based estimate
|
|
276
911
|
}
|
|
277
912
|
// Fallback: sum assistant usage from messages.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
let cost = 0;
|
|
281
|
-
for (const message of (session.messages ?? []) as Array<Partial<AssistantMessage>>) {
|
|
913
|
+
const usage = emptyAgentUsage();
|
|
914
|
+
for (const message of (session.messages ?? []) as Array<any>) {
|
|
282
915
|
if (message?.role === "assistant" && message.usage) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
916
|
+
usage.turns = (usage.turns ?? 0) + 1;
|
|
917
|
+
usage.inputTokens = (usage.inputTokens ?? 0) + (message.usage.input ?? 0);
|
|
918
|
+
usage.outputTokens += message.usage.output ?? 0;
|
|
919
|
+
usage.cacheReadTokens = (usage.cacheReadTokens ?? 0) + (message.usage.cacheRead ?? 0);
|
|
920
|
+
usage.cacheWriteTokens = (usage.cacheWriteTokens ?? 0) + (message.usage.cacheWrite ?? 0);
|
|
921
|
+
usage.cost += message.usage.cost?.total ?? 0;
|
|
286
922
|
}
|
|
923
|
+
if (message?.role === "toolResult") usage.toolUses = (usage.toolUses ?? 0) + 1;
|
|
924
|
+
}
|
|
925
|
+
if (counters.observing) {
|
|
926
|
+
usage.turns = counters.turns ?? 0;
|
|
927
|
+
usage.toolUses = counters.toolUses ?? 0;
|
|
287
928
|
}
|
|
288
|
-
|
|
929
|
+
usage.totalTokens = (usage.inputTokens ?? 0) + usage.outputTokens;
|
|
930
|
+
usage.retries = counters.retries ?? 0;
|
|
931
|
+
usage.compactions = counters.compactions ?? 0;
|
|
932
|
+
return usage;
|
|
289
933
|
}
|
|
290
934
|
|
|
291
935
|
function lastAssistantText(messages: unknown[]): string {
|
|
@@ -308,22 +952,228 @@ function lastAssistantText(messages: unknown[]): string {
|
|
|
308
952
|
export function forwardActivity(event: unknown, onActivity: (e: AgentActivityInput) => void): void {
|
|
309
953
|
try {
|
|
310
954
|
const e = event as any;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
955
|
+
switch (e?.type) {
|
|
956
|
+
case "agent_start":
|
|
957
|
+
onActivity({ kind: "waiting", detail: "starting agent" });
|
|
958
|
+
return;
|
|
959
|
+
case "turn_start":
|
|
960
|
+
// Pi emits this immediately before authentication/request setup and then
|
|
961
|
+
// waits for the provider's first stream event.
|
|
962
|
+
onActivity({ kind: "waiting", detail: "waiting for model" });
|
|
963
|
+
return;
|
|
964
|
+
case "message_start":
|
|
965
|
+
if (e.message?.role === "assistant") {
|
|
966
|
+
onActivity({ kind: "waiting", detail: "model stream opened" });
|
|
967
|
+
}
|
|
968
|
+
return;
|
|
969
|
+
case "message_update":
|
|
970
|
+
forwardMessageActivity(e.assistantMessageEvent, onActivity);
|
|
971
|
+
return;
|
|
972
|
+
case "message_end":
|
|
973
|
+
if (e.message?.role === "assistant") {
|
|
974
|
+
const error = compactText(e.message.errorMessage, 100);
|
|
975
|
+
onActivity({
|
|
976
|
+
kind: "waiting",
|
|
977
|
+
detail: error ? `model response ended: ${error}` : "model response complete",
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
return;
|
|
981
|
+
case "tool_execution_start":
|
|
982
|
+
forwardToolActivity(e, "start", onActivity);
|
|
983
|
+
return;
|
|
984
|
+
case "tool_execution_update":
|
|
985
|
+
forwardToolActivity(e, "update", onActivity);
|
|
986
|
+
return;
|
|
987
|
+
case "tool_execution_end":
|
|
988
|
+
forwardToolActivity(e, "end", onActivity);
|
|
989
|
+
return;
|
|
990
|
+
case "turn_end":
|
|
991
|
+
onActivity({ kind: "waiting", detail: "turn complete" });
|
|
992
|
+
return;
|
|
993
|
+
case "agent_end":
|
|
994
|
+
onActivity({
|
|
995
|
+
kind: "waiting",
|
|
996
|
+
detail: e.willRetry ? "agent ended; retry pending" : "finishing agent",
|
|
997
|
+
});
|
|
998
|
+
return;
|
|
999
|
+
case "agent_settled":
|
|
1000
|
+
onActivity({ kind: "waiting", detail: "agent settled" });
|
|
1001
|
+
return;
|
|
1002
|
+
case "auto_retry_start": {
|
|
1003
|
+
const attempt = finiteNumber(e.attempt) ?? 0;
|
|
1004
|
+
const maxAttempts = finiteNumber(e.maxAttempts) ?? 0;
|
|
1005
|
+
const delay = formatDelay(finiteNumber(e.delayMs) ?? 0);
|
|
1006
|
+
const error = compactText(e.errorMessage, 100);
|
|
1007
|
+
onActivity({
|
|
1008
|
+
kind: "retry",
|
|
1009
|
+
detail: `retry ${attempt}/${maxAttempts} in ${delay}${error ? `: ${error}` : ""}`,
|
|
1010
|
+
});
|
|
1011
|
+
return;
|
|
320
1012
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
1013
|
+
case "auto_retry_end": {
|
|
1014
|
+
const attempt = finiteNumber(e.attempt) ?? 0;
|
|
1015
|
+
const error = compactText(e.finalError, 100);
|
|
1016
|
+
onActivity({
|
|
1017
|
+
kind: "retry",
|
|
1018
|
+
detail: e.success
|
|
1019
|
+
? `retry ${attempt} succeeded`
|
|
1020
|
+
: `retry ${attempt} failed${error ? `: ${error}` : ""}`,
|
|
1021
|
+
});
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
case "compaction_start":
|
|
1025
|
+
onActivity({ kind: "compaction", detail: compactionStartDetail(e.reason) });
|
|
1026
|
+
return;
|
|
1027
|
+
case "compaction_end": {
|
|
1028
|
+
const error = compactText(e.errorMessage, 100);
|
|
1029
|
+
onActivity({
|
|
1030
|
+
kind: "compaction",
|
|
1031
|
+
detail: error
|
|
1032
|
+
? `compaction failed: ${error}`
|
|
1033
|
+
: e.aborted
|
|
1034
|
+
? "compaction aborted"
|
|
1035
|
+
: e.willRetry
|
|
1036
|
+
? "compaction complete; retrying model"
|
|
1037
|
+
: "compaction complete",
|
|
1038
|
+
});
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
case "queue_update": {
|
|
1042
|
+
const queued = (Array.isArray(e.steering) ? e.steering.length : 0)
|
|
1043
|
+
+ (Array.isArray(e.followUp) ? e.followUp.length : 0);
|
|
1044
|
+
if (queued > 0) onActivity({ kind: "waiting", detail: `${queued} queued message${queued === 1 ? "" : "s"}` });
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
case "thinking_level_changed":
|
|
1048
|
+
if (typeof e.level === "string") {
|
|
1049
|
+
onActivity({ kind: "waiting", detail: `thinking level: ${e.level}` });
|
|
1050
|
+
}
|
|
1051
|
+
return;
|
|
1052
|
+
default:
|
|
1053
|
+
return;
|
|
325
1054
|
}
|
|
326
1055
|
} catch {
|
|
327
1056
|
// best-effort: never let observability break the run
|
|
328
1057
|
}
|
|
329
1058
|
}
|
|
1059
|
+
|
|
1060
|
+
function forwardMessageActivity(
|
|
1061
|
+
event: any,
|
|
1062
|
+
onActivity: (e: AgentActivityInput) => void,
|
|
1063
|
+
): void {
|
|
1064
|
+
switch (event?.type) {
|
|
1065
|
+
case "text_start":
|
|
1066
|
+
case "text_end":
|
|
1067
|
+
onActivity({ kind: "text", detail: "responding" });
|
|
1068
|
+
return;
|
|
1069
|
+
case "text_delta":
|
|
1070
|
+
if (typeof event.delta === "string") {
|
|
1071
|
+
onActivity({ kind: "text", detail: "responding" });
|
|
1072
|
+
}
|
|
1073
|
+
return;
|
|
1074
|
+
case "thinking_start":
|
|
1075
|
+
case "thinking_delta":
|
|
1076
|
+
case "thinking_end":
|
|
1077
|
+
onActivity({ kind: "thinking", detail: "thinking" });
|
|
1078
|
+
return;
|
|
1079
|
+
case "toolcall_start":
|
|
1080
|
+
case "toolcall_delta":
|
|
1081
|
+
// Current Pi events do not expose toolName until toolcall_end.
|
|
1082
|
+
onActivity({ kind: "waiting", detail: "preparing tool call" });
|
|
1083
|
+
return;
|
|
1084
|
+
case "toolcall_end": {
|
|
1085
|
+
const name = typeof event.toolCall?.name === "string" ? event.toolCall.name : "tool";
|
|
1086
|
+
const args = toolArgsPreview(name, event.toolCall?.arguments);
|
|
1087
|
+
onActivity({
|
|
1088
|
+
kind: "waiting",
|
|
1089
|
+
detail: `preparing ${toolLabel(name, args)}`,
|
|
1090
|
+
});
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function forwardToolActivity(
|
|
1097
|
+
event: any,
|
|
1098
|
+
state: "start" | "update" | "end",
|
|
1099
|
+
onActivity: (e: AgentActivityInput) => void,
|
|
1100
|
+
): void {
|
|
1101
|
+
if (typeof event.toolName !== "string") return;
|
|
1102
|
+
const name = event.toolName;
|
|
1103
|
+
const args = state === "end" ? undefined : toolArgsPreview(name, event.args);
|
|
1104
|
+
onActivity({
|
|
1105
|
+
kind: "tool",
|
|
1106
|
+
detail: state === "end"
|
|
1107
|
+
? `${name} ${event.isError ? "failed" : "finished"}`
|
|
1108
|
+
: toolLabel(name, args),
|
|
1109
|
+
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : `${name}:unknown`,
|
|
1110
|
+
toolName: name,
|
|
1111
|
+
...(args ? { toolArgs: args } : {}),
|
|
1112
|
+
toolState: state,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function toolLabel(name: string, args: string | undefined): string {
|
|
1117
|
+
const safeName = safeDisplayText(name, 40) || "tool";
|
|
1118
|
+
return args ? `${safeName}: ${args}` : safeName;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const PATH_PREVIEW_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
|
|
1122
|
+
|
|
1123
|
+
/** Keep tool status useful without displaying free-form payload bodies. */
|
|
1124
|
+
export function toolArgsPreview(toolName: string, value: unknown, max = 80): string | undefined {
|
|
1125
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1126
|
+
const args = value as Record<string, unknown>;
|
|
1127
|
+
const normalizedName = safeDisplayText(toolName, 40).toLowerCase();
|
|
1128
|
+
if (normalizedName === "bash") {
|
|
1129
|
+
const command = scalarPreview(args.command);
|
|
1130
|
+
if (command) return safeDisplayText(safeCommandPreview(command), max);
|
|
1131
|
+
}
|
|
1132
|
+
if (PATH_PREVIEW_TOOLS.has(normalizedName)) {
|
|
1133
|
+
for (const key of ["path", "file_path"]) {
|
|
1134
|
+
const filePath = scalarPreview(args[key]);
|
|
1135
|
+
if (filePath) return safePathPreview(filePath, max);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function safePathPreview(value: string, max: number): string {
|
|
1142
|
+
const clean = displayOneLine(value);
|
|
1143
|
+
if (!clean || /:\/\/|[?&#@]/.test(clean)) return "path";
|
|
1144
|
+
const segments = clean.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
1145
|
+
const basename = segments.at(-1) ?? clean;
|
|
1146
|
+
return safeDisplayText(basename, max) || "path";
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function scalarPreview(value: unknown): string | undefined {
|
|
1150
|
+
if (typeof value === "string") {
|
|
1151
|
+
const sample = value.length > DISPLAY_INPUT_LIMIT
|
|
1152
|
+
? value.slice(0, DISPLAY_INPUT_LIMIT)
|
|
1153
|
+
: value;
|
|
1154
|
+
if (sample.trim()) return value;
|
|
1155
|
+
}
|
|
1156
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function compactionStartDetail(reason: unknown): string {
|
|
1161
|
+
if (reason === "overflow") return "context overflow; compacting";
|
|
1162
|
+
if (reason === "threshold") return "context threshold reached; compacting";
|
|
1163
|
+
return "compacting context";
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
1167
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function formatDelay(ms: number): string {
|
|
1171
|
+
if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
|
|
1172
|
+
const seconds = ms / 1000;
|
|
1173
|
+
return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)}s`;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function compactText(value: unknown, max: number): string {
|
|
1177
|
+
if (typeof value !== "string") return "";
|
|
1178
|
+
return truncateDisplay(redactCommand(value), max);
|
|
1179
|
+
}
|