@bermudi/pi-delegate 0.1.0
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 +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
package/timer.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Node clamps timer delays larger than the signed 32-bit limit (~24.8 days)
|
|
2
|
+
// to ~1ms. Long waits are therefore scheduled against an absolute deadline in
|
|
3
|
+
// safe-sized chunks, so an intentionally patient timeout or inactivity window
|
|
4
|
+
// cannot be collapsed into an immediate fire.
|
|
5
|
+
|
|
6
|
+
/** Maximum delay a single `setTimeout` can represent before Node clamps it to
|
|
7
|
+
* ~1ms (the signed 32-bit millisecond limit). */
|
|
8
|
+
export const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Invoke `onDeadline` at the absolute `deadline` timestamp, re-arming the timer
|
|
12
|
+
* in sub-`MAX_TIMER_DELAY_MS` chunks so Node's clamp cannot turn a long wait
|
|
13
|
+
* into an immediate fire.
|
|
14
|
+
*
|
|
15
|
+
* Returns a `clear()` that cancels the pending timer. `clear()` is idempotent
|
|
16
|
+
* and also suppresses a callback already dequeued but not yet run, so a deadline
|
|
17
|
+
* firing after cancellation is a no-op rather than a spurious action — callers
|
|
18
|
+
* therefore do not need their own "already settled / already aborted" re-check
|
|
19
|
+
* inside the terminal callback.
|
|
20
|
+
*/
|
|
21
|
+
export function scheduleDeadline(
|
|
22
|
+
deadline: number,
|
|
23
|
+
onDeadline: () => void,
|
|
24
|
+
): () => void {
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
let cleared = false;
|
|
27
|
+
const arm = (): void => {
|
|
28
|
+
if (cleared) return;
|
|
29
|
+
const remaining = deadline - Date.now();
|
|
30
|
+
const delay = Math.min(Math.max(remaining, 0), MAX_TIMER_DELAY_MS);
|
|
31
|
+
timer = setTimeout(() => {
|
|
32
|
+
if (cleared) return;
|
|
33
|
+
if (Date.now() < deadline) {
|
|
34
|
+
arm();
|
|
35
|
+
} else {
|
|
36
|
+
onDeadline();
|
|
37
|
+
}
|
|
38
|
+
}, delay);
|
|
39
|
+
};
|
|
40
|
+
arm();
|
|
41
|
+
return () => {
|
|
42
|
+
cleared = true;
|
|
43
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
44
|
+
timer = undefined;
|
|
45
|
+
};
|
|
46
|
+
}
|
package/tools.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import {
|
|
3
|
+
createBashTool,
|
|
4
|
+
createEditTool,
|
|
5
|
+
createFindTool,
|
|
6
|
+
createGrepTool,
|
|
7
|
+
createLsTool,
|
|
8
|
+
createReadTool,
|
|
9
|
+
createWriteTool,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { DEFAULT_TOOLS, READONLY_TOOLS } from "./constants.ts";
|
|
12
|
+
|
|
13
|
+
/** Shorthand → concrete tool list. `*` = full agent (bash subsumes search);
|
|
14
|
+
* `ro` = read-only scout (search without shell). */
|
|
15
|
+
const TOOL_GROUPS: Record<string, string[]> = {
|
|
16
|
+
"*": DEFAULT_TOOLS,
|
|
17
|
+
ro: READONLY_TOOLS,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> = {
|
|
21
|
+
read: createReadTool,
|
|
22
|
+
write: createWriteTool,
|
|
23
|
+
edit: createEditTool,
|
|
24
|
+
bash: createBashTool,
|
|
25
|
+
grep: createGrepTool,
|
|
26
|
+
find: createFindTool,
|
|
27
|
+
ls: createLsTool,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
|
|
31
|
+
* Unknown names pass through unchanged for the caller to validate.
|
|
32
|
+
* Returns a deduped list. */
|
|
33
|
+
export function resolveToolGroups(tools: string[]): string[] {
|
|
34
|
+
const resolved: string[] = [];
|
|
35
|
+
for (const t of tools) {
|
|
36
|
+
const group = TOOL_GROUPS[t];
|
|
37
|
+
if (group) resolved.push(...group);
|
|
38
|
+
else resolved.push(t);
|
|
39
|
+
}
|
|
40
|
+
return [...new Set(resolved)];
|
|
41
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ThinkingLevel,
|
|
3
|
+
AgentToolResult,
|
|
4
|
+
AgentToolUpdateCallback,
|
|
5
|
+
} from "@earendil-works/pi-agent-core";
|
|
6
|
+
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
7
|
+
import type {
|
|
8
|
+
AgentSession,
|
|
9
|
+
ModelRegistry,
|
|
10
|
+
SessionManager,
|
|
11
|
+
SessionEntry,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { Static } from "@sinclair/typebox";
|
|
14
|
+
import type { delegateArgumentsSchema } from "./schema.ts";
|
|
15
|
+
|
|
16
|
+
export interface AgentConfig {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
thinking: ThinkingLevel;
|
|
21
|
+
tools: string[];
|
|
22
|
+
systemPrompt: string;
|
|
23
|
+
/** Origin of the profile. `claude` denotes imported .claude/agents files. */
|
|
24
|
+
scope?: "project" | "global" | "claude";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Tool parameter types — derived from the TypeBox schema ────────────────
|
|
28
|
+
// `delegateArgumentsSchema` in schema.ts is the single source of truth; these are
|
|
29
|
+
// projections of it, so schema and types cannot drift. Field semantics live
|
|
30
|
+
// in the schema's `description`s (which the calling model also sees).
|
|
31
|
+
// The import is type-only, so the schema.ts ↔ types.ts cycle is erased at
|
|
32
|
+
// compile time.
|
|
33
|
+
|
|
34
|
+
export type DelegateArguments = Static<typeof delegateArgumentsSchema>;
|
|
35
|
+
export type TaskDef = NonNullable<DelegateArguments["tasks"]>[number];
|
|
36
|
+
/** Top-level async ticket action: "poll" | "cancel" | "wait". */
|
|
37
|
+
export type DelegateAction = NonNullable<DelegateArguments["action"]>;
|
|
38
|
+
/** Per-task session action: "prompt" | "close" | "list". */
|
|
39
|
+
export type SessionAction = NonNullable<TaskDef["action"]>;
|
|
40
|
+
|
|
41
|
+
// ── Async Ticket Types ─────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface TicketWaiter {
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
onUpdate?: AgentToolUpdateCallback<DelegateDetails>;
|
|
46
|
+
resolve: (result: AgentToolResult<DelegateDetails>) => void;
|
|
47
|
+
reject: (reason: unknown) => void;
|
|
48
|
+
clearDeadline?: () => void;
|
|
49
|
+
settled: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AsyncTicket {
|
|
53
|
+
id: string;
|
|
54
|
+
created: number;
|
|
55
|
+
completedAt?: number;
|
|
56
|
+
tasks: TaskDef[];
|
|
57
|
+
resolved: ResolvedTask[];
|
|
58
|
+
status: "running" | "cancelling" | "done" | "failed" | "cancelled";
|
|
59
|
+
results: (TaskResult | undefined)[];
|
|
60
|
+
progress: TaskProgress[];
|
|
61
|
+
controller: AbortController;
|
|
62
|
+
error?: string;
|
|
63
|
+
parentModelId?: string;
|
|
64
|
+
/** Active blocking waiters. Resolved by terminal delivery or timeout/abort. */
|
|
65
|
+
waiters?: TicketWaiter[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ReuseIntent {
|
|
69
|
+
/** Explicit model requested by this call/profile; omitted means use frozen. */
|
|
70
|
+
model?: Model<Api>;
|
|
71
|
+
/** Explicit base prompt requested by this call/profile; omitted means frozen. */
|
|
72
|
+
systemPrompt?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ResolvedTask {
|
|
76
|
+
prompt: string;
|
|
77
|
+
agent?: string;
|
|
78
|
+
model: Model<Api>;
|
|
79
|
+
tools: string[];
|
|
80
|
+
thinking: ThinkingLevel;
|
|
81
|
+
systemPrompt: string;
|
|
82
|
+
cwd: string;
|
|
83
|
+
context?: "fresh" | "with-parent-transcript";
|
|
84
|
+
sessionId?: string;
|
|
85
|
+
action?: SessionAction;
|
|
86
|
+
resumeFrom?: string;
|
|
87
|
+
agentName: string;
|
|
88
|
+
warnings: string[];
|
|
89
|
+
/** Explicit settings that must match a live pooled session on reuse. */
|
|
90
|
+
reuseIntent?: ReuseIntent;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ToolActivity {
|
|
94
|
+
id: string;
|
|
95
|
+
name: string;
|
|
96
|
+
args: Record<string, unknown>;
|
|
97
|
+
result?: {
|
|
98
|
+
content: Array<{ type: string; text?: string }>;
|
|
99
|
+
isError: boolean;
|
|
100
|
+
};
|
|
101
|
+
startTime: number;
|
|
102
|
+
endTime?: number;
|
|
103
|
+
/** Live stdout/stderr preview from tool_execution_update events. */
|
|
104
|
+
liveOutput?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Stable machine-readable reason for a task failure.
|
|
108
|
+
* - `stalled`: inactivity watchdog fired; the prompt was cooperatively aborted.
|
|
109
|
+
* - `model_error`: the failure is attributable to the resolved model/provider
|
|
110
|
+
* (account usage limit, quota exhausted, auth lost) — not transient for that
|
|
111
|
+
* model, so same-model retry is pointless. The parent should resume with a
|
|
112
|
+
* different `model` (see `resumeFrom` + `model`). */
|
|
113
|
+
export type TaskFailureKind = "stalled" | "model_error";
|
|
114
|
+
|
|
115
|
+
export interface TaskProgress {
|
|
116
|
+
index: number;
|
|
117
|
+
agent: string;
|
|
118
|
+
task: string;
|
|
119
|
+
status: "pending" | "running" | "done" | "failed";
|
|
120
|
+
durationMs: number;
|
|
121
|
+
tokens: number;
|
|
122
|
+
toolUses: number;
|
|
123
|
+
error?: string;
|
|
124
|
+
failureKind?: TaskFailureKind;
|
|
125
|
+
model?: string;
|
|
126
|
+
lastActivityAt?: number;
|
|
127
|
+
activities: ToolActivity[];
|
|
128
|
+
/** Human-facing notices (e.g. unknown tools ignored). Surfaced in the TUI
|
|
129
|
+
* under the task; the LLM gets the same text in `content` already. */
|
|
130
|
+
warnings?: string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface DelegateDetails {
|
|
134
|
+
tasks: TaskDef[];
|
|
135
|
+
results: (TaskResult | { error: string })[];
|
|
136
|
+
progress: TaskProgress[];
|
|
137
|
+
parentModel?: string;
|
|
138
|
+
ticketId?: string;
|
|
139
|
+
/** Terminal/live ticket status when this result comes from an async ticket. */
|
|
140
|
+
status?: AsyncTicket["status"];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface TaskResult {
|
|
144
|
+
agent: string;
|
|
145
|
+
output: string;
|
|
146
|
+
error?: string;
|
|
147
|
+
/** Stable machine-readable failure reason; error remains human-facing. */
|
|
148
|
+
failureKind?: TaskFailureKind;
|
|
149
|
+
durationMs: number;
|
|
150
|
+
/** Display token count for the task, derived from the compaction-inclusive
|
|
151
|
+
* session-stat delta. This matches `usage.totalTokens`; the usage object
|
|
152
|
+
* additionally preserves the provider breakdown and cost. */
|
|
153
|
+
tokens: number;
|
|
154
|
+
/** Full provider Usage consumed by this task, including compacted-away
|
|
155
|
+
* history. Always present (`emptyUsage()` on no-op/early-failure paths) so a
|
|
156
|
+
* sync delegate call can fold subagent spend into the parent's session
|
|
157
|
+
* total. Aggregate `cost.total` is accurate; the per-component cost fields
|
|
158
|
+
* stay 0 because `getSessionStats()` exposes only the aggregate cost — and
|
|
159
|
+
* Pi sums `cost.total` for nested usage anyway. */
|
|
160
|
+
usage: Usage;
|
|
161
|
+
sessionFile?: string;
|
|
162
|
+
touchedFiles: string[];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Single source of truth for a subagent's runtime configuration.
|
|
166
|
+
* Passed to `createAgentSession` as `model` / `thinkingLevel` / `tools` / `cwd`. */
|
|
167
|
+
export interface AgentRunConfig {
|
|
168
|
+
systemPrompt: string;
|
|
169
|
+
model: Model<Api>;
|
|
170
|
+
thinking: ThinkingLevel;
|
|
171
|
+
tools: string[];
|
|
172
|
+
cwd: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface AgentProgressUpdate {
|
|
176
|
+
tokens: number;
|
|
177
|
+
toolUses: number;
|
|
178
|
+
durationMs: number;
|
|
179
|
+
lastActivityAt?: number;
|
|
180
|
+
activities: ToolActivity[];
|
|
181
|
+
/** Set as soon as a terminal condition is detected, before cleanup settles. */
|
|
182
|
+
failureKind?: TaskFailureKind;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface TaskRunEnv {
|
|
186
|
+
/** Abort signal — parent's for sync, ticket's for async. May be undefined when no parent signal is available. */
|
|
187
|
+
signal: AbortSignal | undefined;
|
|
188
|
+
modelRegistry: ModelRegistry;
|
|
189
|
+
/** Parent session manager — used to link subagent sessions for /resume. */
|
|
190
|
+
parentSessionManager: { getSessionFile?(): string | undefined } | undefined;
|
|
191
|
+
/** Ticket id for busy-guard self-checks. undefined for sync. */
|
|
192
|
+
ticketId?: string;
|
|
193
|
+
/** When the delegate started. Used for close/list progress (elapsed time). */
|
|
194
|
+
delegateStartedAt: number;
|
|
195
|
+
/** Called for every progress update from runAgentSession. */
|
|
196
|
+
onProgress: (p: TaskProgress, u: AgentProgressUpdate) => void;
|
|
197
|
+
/** Called after every TaskProgress mutation (early-returns, completion). Sync uses this to fire onUpdate. */
|
|
198
|
+
onStatusChange?: () => void;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Structural subset of Pi's `ExtensionContext` used by delegate's
|
|
202
|
+
* task-resolution and dispatch modules. Kept loose so the orchestrator can
|
|
203
|
+
* pass the real ctx through without re-typing every Pi field. The
|
|
204
|
+
* `sessionManager` mirrors Pi's `ReadonlySessionManager` (not re-exported
|
|
205
|
+
* from the package index) — only the members delegate actually touches are
|
|
206
|
+
* listed. */
|
|
207
|
+
export interface DelegateToolCtx {
|
|
208
|
+
cwd: string;
|
|
209
|
+
model: Model<Api> | undefined;
|
|
210
|
+
modelRegistry: ModelRegistry;
|
|
211
|
+
sessionManager:
|
|
212
|
+
| {
|
|
213
|
+
getEntries(): SessionEntry[];
|
|
214
|
+
getLeafId(): string | null;
|
|
215
|
+
getSessionFile?(): string | undefined;
|
|
216
|
+
}
|
|
217
|
+
| undefined;
|
|
218
|
+
/** Optional hook Pi exposes for extensions to read the live system prompt. */
|
|
219
|
+
getSystemPrompt?: () => string | undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Shape returned by the delegate tool's `execute`. Mirrors Pi's
|
|
223
|
+
* `AgentToolResult<DelegateDetails>` without depending on the generic.
|
|
224
|
+
* `usage` (sync dispatch only) is the aggregate subagent spend; Pi 0.81+
|
|
225
|
+
* persists it on the tool result and folds it into the parent's
|
|
226
|
+
* session/footer totals. Older hosts ignore it. Async tickets can't attach
|
|
227
|
+
* usage — their results arrive via a follow-up message with no usage slot. */
|
|
228
|
+
export interface DelegateToolResult {
|
|
229
|
+
content: Array<{ type: "text"; text: string }>;
|
|
230
|
+
details: DelegateDetails;
|
|
231
|
+
/** Aggregate subagent usage for sync dispatch. Pi reads this for session
|
|
232
|
+
* totals on 0.81+; harmless on older versions. */
|
|
233
|
+
usage?: Usage;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface AcquiredSession {
|
|
237
|
+
/** The live AgentSession — constructed once and reused across prompts (pool hits). */
|
|
238
|
+
session: AgentSession;
|
|
239
|
+
sessionManager: SessionManager | undefined;
|
|
240
|
+
sessionFile: string | undefined;
|
|
241
|
+
/** Fresh/resumed sessions are lifecycle-owned until a successful pool commit. */
|
|
242
|
+
lifecycleOwnsSession: boolean;
|
|
243
|
+
}
|
package/usage.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
/** Snapshot of the cumulative session usage fields we read for delta accounting. */
|
|
5
|
+
export interface SessionUsageSnapshot {
|
|
6
|
+
input: number;
|
|
7
|
+
output: number;
|
|
8
|
+
cacheRead: number;
|
|
9
|
+
cacheWrite: number;
|
|
10
|
+
cost: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Return a zero-valued Usage suitable for an action that made no model call. */
|
|
14
|
+
export function emptyUsage(): Usage {
|
|
15
|
+
return {
|
|
16
|
+
input: 0,
|
|
17
|
+
output: 0,
|
|
18
|
+
cacheRead: 0,
|
|
19
|
+
cacheWrite: 0,
|
|
20
|
+
totalTokens: 0,
|
|
21
|
+
cost: {
|
|
22
|
+
input: 0,
|
|
23
|
+
output: 0,
|
|
24
|
+
cacheRead: 0,
|
|
25
|
+
cacheWrite: 0,
|
|
26
|
+
total: 0,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read cumulative provider usage from a live AgentSession.
|
|
33
|
+
*
|
|
34
|
+
* `getSessionStats()` covers history that has been compacted away (and, on
|
|
35
|
+
* newer Pi hosts, compaction/branch-summary calls), so this is the right
|
|
36
|
+
* snapshot for pooled or resumed sessions — reading `session.messages` alone
|
|
37
|
+
* would under-count once a compaction boundary is crossed.
|
|
38
|
+
*/
|
|
39
|
+
export function snapshotSessionUsage(
|
|
40
|
+
session: Pick<AgentSession, "getSessionStats">,
|
|
41
|
+
): SessionUsageSnapshot {
|
|
42
|
+
const stats = session.getSessionStats();
|
|
43
|
+
return {
|
|
44
|
+
input: stats.tokens.input,
|
|
45
|
+
output: stats.tokens.output,
|
|
46
|
+
cacheRead: stats.tokens.cacheRead,
|
|
47
|
+
cacheWrite: stats.tokens.cacheWrite,
|
|
48
|
+
cost: stats.cost,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function delta(after: number, before: number): number {
|
|
53
|
+
// A session can be replaced/branched by host code. Never report a negative
|
|
54
|
+
// billable amount if cumulative stats move backwards between snapshots.
|
|
55
|
+
return Math.max(0, after - before);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Convert a cumulative-snapshot delta into Pi's nested-tool Usage shape. */
|
|
59
|
+
export function usageDelta(
|
|
60
|
+
before: SessionUsageSnapshot,
|
|
61
|
+
after: SessionUsageSnapshot,
|
|
62
|
+
): Usage {
|
|
63
|
+
const input = delta(after.input, before.input);
|
|
64
|
+
const output = delta(after.output, before.output);
|
|
65
|
+
const cacheRead = delta(after.cacheRead, before.cacheRead);
|
|
66
|
+
const cacheWrite = delta(after.cacheWrite, before.cacheWrite);
|
|
67
|
+
return {
|
|
68
|
+
input,
|
|
69
|
+
output,
|
|
70
|
+
cacheRead,
|
|
71
|
+
cacheWrite,
|
|
72
|
+
totalTokens: input + output + cacheRead + cacheWrite,
|
|
73
|
+
// SessionStats exposes the aggregate cost only; Pi's session/footer
|
|
74
|
+
// accounting reads cost.total for nested tool usage. The component fields
|
|
75
|
+
// stay zero rather than inventing a provider-specific split.
|
|
76
|
+
cost: {
|
|
77
|
+
input: 0,
|
|
78
|
+
output: 0,
|
|
79
|
+
cacheRead: 0,
|
|
80
|
+
cacheWrite: 0,
|
|
81
|
+
total: delta(after.cost, before.cost),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Add two Usage values, preserving optional provider breakdowns when present. */
|
|
87
|
+
export function addUsage(left: Usage, right: Usage): Usage {
|
|
88
|
+
const cacheWrite1h =
|
|
89
|
+
left.cacheWrite1h !== undefined || right.cacheWrite1h !== undefined
|
|
90
|
+
? (left.cacheWrite1h ?? 0) + (right.cacheWrite1h ?? 0)
|
|
91
|
+
: undefined;
|
|
92
|
+
const reasoning =
|
|
93
|
+
left.reasoning !== undefined || right.reasoning !== undefined
|
|
94
|
+
? (left.reasoning ?? 0) + (right.reasoning ?? 0)
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
input: left.input + right.input,
|
|
99
|
+
output: left.output + right.output,
|
|
100
|
+
cacheRead: left.cacheRead + right.cacheRead,
|
|
101
|
+
cacheWrite: left.cacheWrite + right.cacheWrite,
|
|
102
|
+
...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
|
|
103
|
+
...(reasoning === undefined ? {} : { reasoning }),
|
|
104
|
+
totalTokens: left.totalTokens + right.totalTokens,
|
|
105
|
+
cost: {
|
|
106
|
+
input: left.cost.input + right.cost.input,
|
|
107
|
+
output: left.cost.output + right.cost.output,
|
|
108
|
+
cacheRead: left.cost.cacheRead + right.cost.cacheRead,
|
|
109
|
+
cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
|
|
110
|
+
total: left.cost.total + right.cost.total,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Sum optional per-task usage without making callers manufacture zero values. */
|
|
116
|
+
export function sumUsage(usages: readonly (Usage | undefined)[]): Usage {
|
|
117
|
+
return usages.reduce<Usage>(
|
|
118
|
+
(total, usage) => (usage ? addUsage(total, usage) : total),
|
|
119
|
+
emptyUsage(),
|
|
120
|
+
);
|
|
121
|
+
}
|
package/utils.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
4
|
+
|
|
5
|
+
/** Resolve a path relative to the caller's working directory. Tilde paths and
|
|
6
|
+
* absolute paths intentionally ignore `baseCwd`. */
|
|
7
|
+
export function resolveCwd(cwd: string, baseCwd = process.cwd()): string {
|
|
8
|
+
const expanded = cwd.startsWith("~")
|
|
9
|
+
? path.join(os.homedir(), cwd.slice(1))
|
|
10
|
+
: cwd;
|
|
11
|
+
return path.isAbsolute(expanded)
|
|
12
|
+
? path.resolve(expanded)
|
|
13
|
+
: path.resolve(baseCwd, expanded);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Validate the absolute `.jsonl` path accepted by `resumeFrom`. */
|
|
17
|
+
export function validateResumeFromPath(resumeFrom: string): string | undefined {
|
|
18
|
+
if (!resumeFrom.trim()) {
|
|
19
|
+
return "expected an absolute .jsonl session file path copied from delegate retry output";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!resumeFrom.endsWith(".jsonl")) {
|
|
23
|
+
return "expected an absolute .jsonl session file path copied from delegate retry output";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const expanded = resumeFrom.startsWith("~")
|
|
27
|
+
? path.join(os.homedir(), resumeFrom.slice(1))
|
|
28
|
+
: resumeFrom;
|
|
29
|
+
if (!path.isAbsolute(expanded)) {
|
|
30
|
+
return "expected an absolute .jsonl session file path copied from delegate retry output";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Extract text content from a partial tool result (tool_execution_update). */
|
|
37
|
+
export function extractTextFromPartialResult(
|
|
38
|
+
partialResult: unknown,
|
|
39
|
+
): string | undefined {
|
|
40
|
+
if (
|
|
41
|
+
!partialResult ||
|
|
42
|
+
typeof partialResult !== "object" ||
|
|
43
|
+
!("content" in partialResult)
|
|
44
|
+
)
|
|
45
|
+
return undefined;
|
|
46
|
+
const content = (partialResult as { content?: unknown }).content;
|
|
47
|
+
if (!Array.isArray(content)) return undefined;
|
|
48
|
+
const text = content
|
|
49
|
+
.filter(
|
|
50
|
+
(c): c is { type: string; text?: string } =>
|
|
51
|
+
c && typeof c === "object" && "type" in c && c.type === "text",
|
|
52
|
+
)
|
|
53
|
+
.map((c) => c.text)
|
|
54
|
+
.filter((t): t is string => typeof t === "string")
|
|
55
|
+
.join("\n");
|
|
56
|
+
return text || undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Strip ANSI escape sequences from text. */
|
|
60
|
+
export function stripAnsi(text: string): string {
|
|
61
|
+
// eslint-disable-next-line no-control-regex
|
|
62
|
+
return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve carriage-return progress bars to their final line state. */
|
|
66
|
+
export function resolveCarriageReturn(text: string): string {
|
|
67
|
+
return text
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((line) => {
|
|
70
|
+
const parts = line.split("\r");
|
|
71
|
+
return parts[parts.length - 1] ?? "";
|
|
72
|
+
})
|
|
73
|
+
.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Concatenate text blocks from assistant messages in a session slice. */
|
|
77
|
+
export function extractOutput(messages: AgentMessage[]): string {
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
for (const msg of messages) {
|
|
80
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
81
|
+
for (const block of msg.content) {
|
|
82
|
+
if (block.type === "text" && block.text) parts.push(block.text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parts.join("\n\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface UsageLike {
|
|
89
|
+
input?: number;
|
|
90
|
+
output?: number;
|
|
91
|
+
cacheRead?: number;
|
|
92
|
+
total?: number;
|
|
93
|
+
totalTokens?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isUsageLike(u: unknown): u is UsageLike {
|
|
97
|
+
if (u === null || typeof u !== "object") return false;
|
|
98
|
+
const r = u as Record<string, unknown>;
|
|
99
|
+
const numericKeys: (keyof UsageLike)[] = [
|
|
100
|
+
"input",
|
|
101
|
+
"output",
|
|
102
|
+
"cacheRead",
|
|
103
|
+
"total",
|
|
104
|
+
"totalTokens",
|
|
105
|
+
];
|
|
106
|
+
let hasNumeric = false;
|
|
107
|
+
for (const k of numericKeys) {
|
|
108
|
+
const v = r[k as string];
|
|
109
|
+
if (v !== undefined) {
|
|
110
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
111
|
+
hasNumeric = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return hasNumeric;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Sum finite usage fields from assistant messages. */
|
|
118
|
+
export function extractUsage(messages: AgentMessage[]) {
|
|
119
|
+
const usage = { input: 0, output: 0, cacheRead: 0, total: 0 };
|
|
120
|
+
for (const msg of messages) {
|
|
121
|
+
if (msg.role !== "assistant") continue;
|
|
122
|
+
const rawUsage: unknown = msg.usage;
|
|
123
|
+
if (!isUsageLike(rawUsage)) continue;
|
|
124
|
+
const u = rawUsage;
|
|
125
|
+
usage.input += u.input ?? 0;
|
|
126
|
+
usage.output += u.output ?? 0;
|
|
127
|
+
usage.cacheRead += u.cacheRead ?? 0;
|
|
128
|
+
usage.total += u.total ?? u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0);
|
|
129
|
+
}
|
|
130
|
+
return usage;
|
|
131
|
+
}
|