opencode-ultracode 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/LICENSE +21 -0
- package/README.md +243 -0
- package/package.json +60 -0
- package/skills/workflow-authoring/SKILL.md +89 -0
- package/src/runtime/engine.ts +1020 -0
- package/src/runtime/schema.ts +92 -0
- package/src/runtime/script.ts +220 -0
- package/src/runtime/selftest.ts +321 -0
- package/src/server/index.ts +394 -0
- package/src/shared/format.ts +123 -0
- package/src/shared/state.ts +189 -0
- package/src/tui/index.tsx +1517 -0
- package/src/tui/requests.ts +265 -0
- package/src/tui/store.ts +266 -0
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
// Workflow engine: runs a parsed workflow script against the opencode SDK.
|
|
2
|
+
// Deterministic-ish sandbox (see script.ts), concurrency pool, live state
|
|
3
|
+
// writing to /tmp/opencode-workflows/<project>/<runId>/state.json, pause/stop control.
|
|
4
|
+
|
|
5
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync, existsSync, rmSync } from "node:fs"
|
|
6
|
+
import { createHash } from "node:crypto"
|
|
7
|
+
import { cpus } from "node:os"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import {
|
|
10
|
+
type AgentState,
|
|
11
|
+
type AgentStatus,
|
|
12
|
+
type RunState,
|
|
13
|
+
type RunStatus,
|
|
14
|
+
controlPath,
|
|
15
|
+
journalPath,
|
|
16
|
+
runDir,
|
|
17
|
+
statePath,
|
|
18
|
+
} from "../shared/state.ts"
|
|
19
|
+
import { extractJson, validateSchema } from "./schema.ts"
|
|
20
|
+
import { buildScriptFunction, parseScript, type AgentOpts, type Primitives } from "./script.ts"
|
|
21
|
+
|
|
22
|
+
export interface EngineClient {
|
|
23
|
+
session: {
|
|
24
|
+
create(args: { body: { parentID?: string; title?: string } }): Promise<any>
|
|
25
|
+
prompt(args: { path: { id: string }; body: any }): Promise<any>
|
|
26
|
+
abort(args: { path: { id: string } }): Promise<any>
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EngineDeps {
|
|
31
|
+
client: EngineClient
|
|
32
|
+
/** absolute path to the project's .opencode dir (saved workflows live in <dir>/workflows) */
|
|
33
|
+
opencodeDir: string
|
|
34
|
+
/** absolute directory that holds one folder per run (see runsRoot() in shared/state.ts) */
|
|
35
|
+
runsRoot: string
|
|
36
|
+
mainSessionID: string
|
|
37
|
+
defaultModel?: string
|
|
38
|
+
availableModels: Set<string>
|
|
39
|
+
runArgs?: any
|
|
40
|
+
/** called when a child session is created for an agent (agentId, sessionId) */
|
|
41
|
+
onChildSession?: (agentId: string, sessionId: string) => void
|
|
42
|
+
log?: (level: "debug" | "info" | "warn" | "error", message: string, meta?: Record<string, unknown>) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RunOptions {
|
|
46
|
+
script?: string
|
|
47
|
+
scriptPath?: string
|
|
48
|
+
name?: string
|
|
49
|
+
args?: any
|
|
50
|
+
budgetTotal?: number | null
|
|
51
|
+
/**
|
|
52
|
+
* Resume a run whose engine died (opencode exited/crashed, or it was stopped).
|
|
53
|
+
* The engine must be constructed with the SAME runId. The prior script is
|
|
54
|
+
* re-executed; every agent() whose (phase, label, prompt) matches a completed
|
|
55
|
+
* agent of the prior run returns that agent's journaled result instantly and
|
|
56
|
+
* is marked `replayed`. Everything else runs for real.
|
|
57
|
+
*/
|
|
58
|
+
resume?: PriorRun
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** what loadPriorRun() recovers from a run folder for a resume */
|
|
62
|
+
export interface PriorRun {
|
|
63
|
+
state: RunState
|
|
64
|
+
script: string
|
|
65
|
+
/** replay key (see replayKey) -> journaled results + agent snapshots, in spawn order */
|
|
66
|
+
replay: Map<string, Array<{ result: any; agent: AgentState }>>
|
|
67
|
+
/** completed agents that can be replayed */
|
|
68
|
+
replayable: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** agents are matched across runs by where they ran and what they were asked */
|
|
72
|
+
function replayKey(phase: string, label: string, prompt: string): string {
|
|
73
|
+
return `${phase}\u0000${label}\u0000${createHash("sha1").update(prompt).digest("hex")}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read a finished/stopped run's folder so it can be resumed in place. Returns
|
|
78
|
+
* undefined when the folder has no usable state or script. Results come from
|
|
79
|
+
* journal.jsonl (`agent-done` entries carry the full value); the state file
|
|
80
|
+
* supplies prompts and token/cost snapshots. A result that was truncated in
|
|
81
|
+
* the journal is not replayable, so that agent simply runs again.
|
|
82
|
+
*/
|
|
83
|
+
export function loadPriorRun(runsRootDir: string, runId: string): PriorRun | undefined {
|
|
84
|
+
let state: RunState
|
|
85
|
+
let script: string
|
|
86
|
+
try {
|
|
87
|
+
state = JSON.parse(readFileSync(statePath(runsRootDir, runId), "utf8"))
|
|
88
|
+
script = readFileSync(join(runDir(runsRootDir, runId), "script.js"), "utf8")
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
if (!state || typeof state !== "object" || !state.agents || !script) return undefined
|
|
93
|
+
const results = new Map<string, any>()
|
|
94
|
+
try {
|
|
95
|
+
for (const line of readFileSync(journalPath(runsRootDir, runId), "utf8").split("\n")) {
|
|
96
|
+
if (!line) continue
|
|
97
|
+
let e: any
|
|
98
|
+
try {
|
|
99
|
+
e = JSON.parse(line)
|
|
100
|
+
} catch {
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (e?.type !== "agent-done" || e.status !== "completed" || typeof e.result !== "string") continue
|
|
104
|
+
try {
|
|
105
|
+
results.set(e.id, JSON.parse(e.result))
|
|
106
|
+
} catch {
|
|
107
|
+
// truncated or otherwise unparsable → not replayable
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {}
|
|
111
|
+
const replay = new Map<string, Array<{ result: any; agent: AgentState }>>()
|
|
112
|
+
let replayable = 0
|
|
113
|
+
for (const id of state.agentOrder ?? Object.keys(state.agents)) {
|
|
114
|
+
const a = state.agents[id]
|
|
115
|
+
if (!a || a.status !== "completed") continue
|
|
116
|
+
let result: any
|
|
117
|
+
if (results.has(id)) result = results.get(id)
|
|
118
|
+
else if (a.outcome !== undefined) result = a.outcome
|
|
119
|
+
else continue
|
|
120
|
+
const key = replayKey(a.phase, a.label, a.prompt ?? "")
|
|
121
|
+
const list = replay.get(key) ?? []
|
|
122
|
+
list.push({ result, agent: a })
|
|
123
|
+
replay.set(key, list)
|
|
124
|
+
replayable++
|
|
125
|
+
}
|
|
126
|
+
return { state, script, replay, replayable }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class RunAbortedError extends Error {
|
|
130
|
+
constructor() {
|
|
131
|
+
super("workflow run was stopped")
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface ModelRef {
|
|
136
|
+
providerID: string
|
|
137
|
+
modelID: string
|
|
138
|
+
label: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function generateRunId(): string {
|
|
142
|
+
const ts = Date.now().toString(36)
|
|
143
|
+
const rand = Math.random().toString(36).slice(2, 8)
|
|
144
|
+
return `run_${ts}${rand}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class RunEngine {
|
|
148
|
+
state!: RunState
|
|
149
|
+
private stopRequested = false
|
|
150
|
+
private paused = false
|
|
151
|
+
private flushTimer: any = null
|
|
152
|
+
private flushScheduled = false
|
|
153
|
+
private heartbeat: any = null
|
|
154
|
+
private controlTimer: any = null
|
|
155
|
+
private agentSeq = 0
|
|
156
|
+
private sessionToAgent = new Map<string, string>()
|
|
157
|
+
private sessionMsgTotals = new Map<string, Map<string, { t: number; o: number; c: number; ctx: number }>>()
|
|
158
|
+
private lastTextPart = new Map<string, string>()
|
|
159
|
+
private sem = 0
|
|
160
|
+
private semMax: number
|
|
161
|
+
private waiters: Array<() => void> = []
|
|
162
|
+
private stoppedAgents = new Set<string>()
|
|
163
|
+
private replay: Map<string, Array<{ result: any; agent: AgentState }>> | undefined
|
|
164
|
+
private startedAt: number
|
|
165
|
+
private resolveModelLabel: (m: ModelRef) => string
|
|
166
|
+
|
|
167
|
+
private deps: EngineDeps
|
|
168
|
+
private runId: string
|
|
169
|
+
constructor(deps: EngineDeps, runId: string) {
|
|
170
|
+
this.deps = deps
|
|
171
|
+
this.runId = runId
|
|
172
|
+
this.semMax = Math.max(1, Math.min(16, (cpus()?.length ?? 4) - 2))
|
|
173
|
+
this.startedAt = Date.now()
|
|
174
|
+
this.resolveModelLabel = (m) => `${m.providerID}/${m.modelID}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get runDir() {
|
|
178
|
+
return runDir(this.deps.runsRoot, this.runId)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
registerSession(agentId: string, sessionId: string) {
|
|
182
|
+
this.sessionToAgent.set(sessionId, agentId)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- public API used by the server plugin -------------------------------
|
|
186
|
+
// Token/cost accounting is per assistant message (deduped by message id):
|
|
187
|
+
// bus `message.updated` events stream it live, and the final prompt response
|
|
188
|
+
// records the last message (same id → overwrite, no double count). Bus
|
|
189
|
+
// `message.part.updated` events drive LIVE tool activity (onPartUpdated).
|
|
190
|
+
onMessageUpdated(msg: any): void {
|
|
191
|
+
this.recordMessage(msg)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
recordMessage(msg: any): void {
|
|
195
|
+
if (!msg || typeof msg.id !== "string" || !msg.sessionID) return
|
|
196
|
+
if (msg.role && msg.role !== "assistant") return
|
|
197
|
+
const agentId = this.sessionToAgent.get(msg.sessionID)
|
|
198
|
+
if (!agentId) return
|
|
199
|
+
const a = this.state.agents[agentId]
|
|
200
|
+
if (!a) return
|
|
201
|
+
const tk = tokenTotal(msg.tokens)
|
|
202
|
+
const entry = {
|
|
203
|
+
t: Math.max(0, tk | 0),
|
|
204
|
+
o: Math.max(0, Number(msg.tokens?.output ?? 0) | 0),
|
|
205
|
+
c: typeof msg.cost === "number" ? msg.cost : 0,
|
|
206
|
+
ctx: Math.max(0, contextTokens(msg.tokens) | 0),
|
|
207
|
+
}
|
|
208
|
+
const per = this.sessionMsgTotals.get(msg.sessionID) ?? new Map<string, { t: number; o: number; c: number; ctx: number }>()
|
|
209
|
+
const prev = per.get(msg.id)
|
|
210
|
+
if (prev && prev.t === entry.t && prev.o === entry.o && prev.c === entry.c && prev.ctx === entry.ctx) return
|
|
211
|
+
per.set(msg.id, entry)
|
|
212
|
+
this.sessionMsgTotals.set(msg.sessionID, per)
|
|
213
|
+
// billed = sum over all calls; context = prompt size of the latest call
|
|
214
|
+
// (message ids are time-ordered, so the greatest id is the newest call;
|
|
215
|
+
// a still-streaming message may report 0 until the provider fills it in,
|
|
216
|
+
// so keep the previous non-zero context in that case)
|
|
217
|
+
let T = 0
|
|
218
|
+
let O = 0
|
|
219
|
+
let C = 0
|
|
220
|
+
let newestId = ""
|
|
221
|
+
let ctx = 0
|
|
222
|
+
for (const [id, e] of per) {
|
|
223
|
+
T += e.t
|
|
224
|
+
O += e.o
|
|
225
|
+
C += e.c
|
|
226
|
+
if (id > newestId && e.ctx > 0) {
|
|
227
|
+
newestId = id
|
|
228
|
+
ctx = e.ctx
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
a.tokens = T
|
|
232
|
+
a.outputTokens = O
|
|
233
|
+
a.contextTokens = ctx
|
|
234
|
+
a.cost = C
|
|
235
|
+
this.recount()
|
|
236
|
+
this.markDirty()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
onPartUpdated(part: any): void {
|
|
240
|
+
const agentId = this.sessionToAgent.get(part?.sessionID)
|
|
241
|
+
if (!agentId) return
|
|
242
|
+
const a = this.state.agents[agentId]
|
|
243
|
+
if (!a) return
|
|
244
|
+
if (part?.type === "reasoning") {
|
|
245
|
+
this.onReasoningPart(a, part)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (part?.type === "text") {
|
|
249
|
+
const txt = typeof part?.text === "string" ? part.text : ""
|
|
250
|
+
if (!txt.trim()) return
|
|
251
|
+
a.liveText = txt.slice(-800)
|
|
252
|
+
const pid = typeof part?.id === "string" ? part.id : ""
|
|
253
|
+
if (pid !== this.lastTextPart.get(agentId)) {
|
|
254
|
+
this.lastTextPart.set(agentId, pid)
|
|
255
|
+
this.pushLiveFeed(a, "text", txt)
|
|
256
|
+
} else {
|
|
257
|
+
// same part still streaming: refresh the newest feed line in place
|
|
258
|
+
const f = a.liveFeed?.[a.liveFeed.length - 1]
|
|
259
|
+
if (f && f.kind === "text") f.text = txt.trim().replace(/\s+/g, " ").slice(0, 160)
|
|
260
|
+
}
|
|
261
|
+
this.markDirty()
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
if (part?.type !== "tool") return
|
|
265
|
+
const st = part.state
|
|
266
|
+
const callId = typeof part?.callID === "string" ? part.callID : typeof part?.id === "string" ? part.id : undefined
|
|
267
|
+
// match the open activity entry by call id first; fall back to "latest
|
|
268
|
+
// open entry of the same tool" for hosts that do not send ids
|
|
269
|
+
const existing =
|
|
270
|
+
(callId && a.activity.find((x) => x.callId === callId)) ||
|
|
271
|
+
a.activity.find((x) => !x.callId && x.tool === part.tool && !x.endedAt)
|
|
272
|
+
const title = toolTitle(part.tool, st)
|
|
273
|
+
if (!existing) {
|
|
274
|
+
a.activity.push({
|
|
275
|
+
callId,
|
|
276
|
+
tool: part.tool,
|
|
277
|
+
title,
|
|
278
|
+
preview: undefined,
|
|
279
|
+
startedAt: Date.now(),
|
|
280
|
+
})
|
|
281
|
+
a.toolCalls = a.activity.filter((x) => x.kind !== "think").length
|
|
282
|
+
this.pushLiveFeed(a, "tool", `${part.tool} ${title !== part.tool ? title : ""}`)
|
|
283
|
+
this.recount()
|
|
284
|
+
this.markDirty()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (existing.title === existing.tool && title !== part.tool) existing.title = title
|
|
288
|
+
if (!existing.endedAt && (st?.status === "completed" || st?.status === "error")) {
|
|
289
|
+
existing.endedAt = Date.now()
|
|
290
|
+
existing.preview = truncate(String(st?.output ?? st?.error ?? ""), 300)
|
|
291
|
+
const tail = st?.status === "error" ? `failed · ${oneLine(String(st?.error ?? ""))}` : "done"
|
|
292
|
+
this.pushLiveFeed(a, "tool", `${existing.tool} ${tail} · ${existing.title !== existing.tool ? existing.title : ""}`)
|
|
293
|
+
this.markDirty()
|
|
294
|
+
} else {
|
|
295
|
+
this.markDirty()
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Thinking blocks become "think" activity rows: one per reasoning part,
|
|
301
|
+
* matched by part id. The row's title is the first line of the thought
|
|
302
|
+
* (empty when the provider hides the text), its duration comes from the
|
|
303
|
+
* part's own time.start/time.end so it is exact even when events lag.
|
|
304
|
+
*/
|
|
305
|
+
private onReasoningPart(a: AgentState, part: any): void {
|
|
306
|
+
const pid = typeof part?.id === "string" ? part.id : undefined
|
|
307
|
+
if (!pid) return
|
|
308
|
+
const txt = typeof part?.text === "string" ? part.text : ""
|
|
309
|
+
const start = typeof part?.time?.start === "number" ? part.time.start : Date.now()
|
|
310
|
+
const end = typeof part?.time?.end === "number" ? part.time.end : undefined
|
|
311
|
+
const title = oneLine(txt).slice(0, 120)
|
|
312
|
+
const feedText = title ? `think · ${title}` : "think"
|
|
313
|
+
let act = a.activity.find((x) => x.kind === "think" && x.callId === pid)
|
|
314
|
+
if (!act) {
|
|
315
|
+
act = { kind: "think", callId: pid, tool: "think", title, startedAt: start }
|
|
316
|
+
a.activity.push(act)
|
|
317
|
+
this.pushLiveFeed(a, "think", feedText)
|
|
318
|
+
} else {
|
|
319
|
+
if (title) act.title = title
|
|
320
|
+
// keep the feed line for this thought fresh while the text streams
|
|
321
|
+
const feed = a.liveFeed ?? []
|
|
322
|
+
const f = [...feed].reverse().find((x) => x.kind === "think")
|
|
323
|
+
if (f && !act.endedAt) f.text = feedLine(feedText)
|
|
324
|
+
}
|
|
325
|
+
if (txt.trim()) act.preview = truncate(txt, 300)
|
|
326
|
+
if (end && !act.endedAt) {
|
|
327
|
+
act.endedAt = end
|
|
328
|
+
const feed = a.liveFeed ?? []
|
|
329
|
+
const f = [...feed].reverse().find((x) => x.kind === "think")
|
|
330
|
+
const dur = fmtSecs(end - act.startedAt)
|
|
331
|
+
if (f) f.text = feedLine(act.title ? `think ${dur} · ${act.title}` : `think ${dur}`)
|
|
332
|
+
}
|
|
333
|
+
this.markDirty()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private pushLiveFeed(a: AgentState, kind: "text" | "tool" | "think", text: string): void {
|
|
337
|
+
const line = feedLine(text)
|
|
338
|
+
if (!line) return
|
|
339
|
+
const feed = a.liveFeed ?? (a.liveFeed = [])
|
|
340
|
+
feed.push({ at: Date.now(), kind, text: line })
|
|
341
|
+
while (feed.length > 10) feed.shift()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- run lifecycle -------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
async run(opts: RunOptions): Promise<{ runId: string; status: RunStatus; name: string; error?: string; result?: string }> {
|
|
347
|
+
mkdirSync(this.runDir, { recursive: true })
|
|
348
|
+
const prior = opts.resume
|
|
349
|
+
if (prior) {
|
|
350
|
+
if (prior.state.runId !== this.runId) throw new Error(`resume: engine runId ${this.runId} does not match prior run ${prior.state.runId}`)
|
|
351
|
+
opts = { ...opts, script: prior.script, scriptPath: undefined, name: undefined, args: opts.args ?? prior.state.args }
|
|
352
|
+
this.replay = prior.replay
|
|
353
|
+
this.startedAt = prior.state.startedAt || this.startedAt
|
|
354
|
+
}
|
|
355
|
+
const parsed = this.resolveScript(opts)
|
|
356
|
+
const meta = parsed.meta
|
|
357
|
+
const total = countAgents(meta)
|
|
358
|
+
|
|
359
|
+
this.state = {
|
|
360
|
+
runId: this.runId,
|
|
361
|
+
status: "running",
|
|
362
|
+
name: meta.name,
|
|
363
|
+
description: meta.description,
|
|
364
|
+
whenToUse: meta.whenToUse,
|
|
365
|
+
phases: (meta.phases ?? []).map((p: any, i: number) => ({ ...p, index: i + 1, agentIds: [], done: 0 })),
|
|
366
|
+
agents: {},
|
|
367
|
+
agentOrder: [],
|
|
368
|
+
logs: [],
|
|
369
|
+
agentCount: 0,
|
|
370
|
+
agentDone: 0,
|
|
371
|
+
startedAt: this.startedAt,
|
|
372
|
+
totalTokens: 0,
|
|
373
|
+
totalContextTokens: 0,
|
|
374
|
+
totalCost: 0,
|
|
375
|
+
scriptPath: opts.scriptPath ?? (opts.name ? this.findSavedScript(opts.name) : undefined),
|
|
376
|
+
directory: join(this.deps.opencodeDir, ".."),
|
|
377
|
+
mainSessionID: this.deps.mainSessionID || undefined,
|
|
378
|
+
defaultModel: this.deps.defaultModel,
|
|
379
|
+
args: opts.args,
|
|
380
|
+
}
|
|
381
|
+
if (prior) {
|
|
382
|
+
// keep the story of the run: earlier logs, then a marker for this resume
|
|
383
|
+
this.state.logs = (prior.state.logs ?? []).slice(-150)
|
|
384
|
+
this.state.scriptPath = prior.state.scriptPath
|
|
385
|
+
this.state.resumedAt = Date.now()
|
|
386
|
+
this.state.resumeCount = (prior.state.resumeCount ?? 0) + 1
|
|
387
|
+
this.logLine("log", `resumed (${prior.replayable} completed agent${prior.replayable === 1 ? "" : "s"} replay from the journal, the rest run again)`)
|
|
388
|
+
this.writeJournal({ type: "run-start", runId: this.runId, name: meta.name, agentEstimate: total, at: Date.now(), resumed: true, replayable: prior.replayable })
|
|
389
|
+
} else {
|
|
390
|
+
this.writeJournal({ type: "run-start", runId: this.runId, name: meta.name, agentEstimate: total, at: this.startedAt })
|
|
391
|
+
}
|
|
392
|
+
this.flushNow()
|
|
393
|
+
|
|
394
|
+
this.heartbeat = setInterval(() => this.markDirty(), 2000)
|
|
395
|
+
this.controlTimer = setInterval(() => this.pollControl(), 300)
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
this.deps.runArgs = opts.args
|
|
399
|
+
const fn = buildScriptFunction(this.primitives(opts.budgetTotal ?? null), parsed.body)
|
|
400
|
+
const result = await fn()
|
|
401
|
+
this.finish(meta, this.stopRequested ? "stopped" : "completed", undefined, result)
|
|
402
|
+
} catch (e: any) {
|
|
403
|
+
const stopped = this.stopRequested
|
|
404
|
+
this.finish(meta, stopped ? "stopped" : "failed", stopped ? undefined : (e?.message ?? String(e)), undefined, stopped ? undefined : e)
|
|
405
|
+
} finally {
|
|
406
|
+
this.cleanupTimers()
|
|
407
|
+
this.flushNow()
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
runId: this.runId,
|
|
411
|
+
status: this.state.status,
|
|
412
|
+
name: meta.name,
|
|
413
|
+
error: this.state.error,
|
|
414
|
+
result: this.state.result,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private finish(meta: { name: string; description: string }, status: RunStatus, error?: string, result?: any, rawError?: unknown): void {
|
|
419
|
+
this.state.status = status
|
|
420
|
+
this.state.endedAt = Date.now()
|
|
421
|
+
this.state.error = this.state.error ?? error
|
|
422
|
+
if (result !== undefined) {
|
|
423
|
+
this.state.result = truncate(typeof result === "string" ? result : JSON.stringify(result, null, 2), 200_000)
|
|
424
|
+
}
|
|
425
|
+
this.logLine(status, error ? `failed: ${error}` : "completed")
|
|
426
|
+
this.writeJournal({ type: "run-end", runId: this.runId, status, at: Date.now() })
|
|
427
|
+
this.deps.log?.(status === "failed" ? "error" : "info", `workflow ${meta.name}: ${status}`, {
|
|
428
|
+
runId: this.runId,
|
|
429
|
+
agents: this.state.agentCount,
|
|
430
|
+
tokens: this.state.totalTokens,
|
|
431
|
+
contextTokens: this.state.totalContextTokens,
|
|
432
|
+
error: rawError ? String(rawError) : undefined,
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private budgetTotal: number | null = null
|
|
437
|
+
private primitives(budgetTotal: number | null): Primitives {
|
|
438
|
+
this.budgetTotal = budgetTotal
|
|
439
|
+
return {
|
|
440
|
+
agent: (prompt, opts) => this.spawnAgent(prompt, opts ?? {}),
|
|
441
|
+
parallel: (thunks) => this.parallel(thunks),
|
|
442
|
+
pipeline: (items, ...stages) => this.pipeline(items, stages),
|
|
443
|
+
phase: (title) => this.setPhase(title),
|
|
444
|
+
log: (message) => this.logLine("log", String(message)),
|
|
445
|
+
args: this.deps.runArgs,
|
|
446
|
+
budget: {
|
|
447
|
+
total: budgetTotal,
|
|
448
|
+
spent: () => this.outputTokensSpent(),
|
|
449
|
+
remaining: () => (budgetTotal == null ? Infinity : Math.max(0, budgetTotal - this.outputTokensSpent())),
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- primitives ----------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
private currentPhase = ""
|
|
457
|
+
|
|
458
|
+
private setPhase(title: string) {
|
|
459
|
+
this.currentPhase = title
|
|
460
|
+
if (!this.state.phases.find((p) => p.title === title)) {
|
|
461
|
+
this.state.phases.push({ title, index: this.state.phases.length + 1, agentIds: [], done: 0 })
|
|
462
|
+
this.recount()
|
|
463
|
+
}
|
|
464
|
+
this.markDirty()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private ensurePhase(title: string): number {
|
|
468
|
+
let p = this.state.phases.find((x) => x.title === title)
|
|
469
|
+
if (!p) {
|
|
470
|
+
p = { title, index: this.state.phases.length + 1, agentIds: [], done: 0 }
|
|
471
|
+
this.state.phases.push(p)
|
|
472
|
+
}
|
|
473
|
+
return p.index
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private async parallel(thunks: Array<() => Promise<any>>): Promise<any[]> {
|
|
477
|
+
if (!Array.isArray(thunks)) throw new Error("parallel() expects an array of thunks")
|
|
478
|
+
if (thunks.length > 4096) throw new Error("parallel() accepts at most 4096 items")
|
|
479
|
+
const out: any[] = new Array(thunks.length)
|
|
480
|
+
await Promise.all(
|
|
481
|
+
thunks.map(async (t, i) => {
|
|
482
|
+
try {
|
|
483
|
+
if (typeof t !== "function") throw new Error(`parallel(${i}) is not a thunk`)
|
|
484
|
+
out[i] = await t()
|
|
485
|
+
} catch (e) {
|
|
486
|
+
this.logLine("log", `parallel item ${i} failed: ${errText(e)}`)
|
|
487
|
+
out[i] = null
|
|
488
|
+
}
|
|
489
|
+
}),
|
|
490
|
+
)
|
|
491
|
+
return out
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private async pipeline(items: any[], stages: Array<(prev: any, item: any, index: number) => Promise<any> | any>): Promise<any[]> {
|
|
495
|
+
if (!Array.isArray(items)) throw new Error("pipeline() expects an items array")
|
|
496
|
+
if (items.length > 4096) throw new Error("pipeline() accepts at most 4096 items")
|
|
497
|
+
if (!stages.length) throw new Error("pipeline() needs at least one stage")
|
|
498
|
+
const results = await Promise.all(
|
|
499
|
+
items.map(async (item, index) => {
|
|
500
|
+
let prev: any = null
|
|
501
|
+
try {
|
|
502
|
+
for (const stage of stages) prev = await stage(prev, item, index)
|
|
503
|
+
} catch (e) {
|
|
504
|
+
this.logLine("log", `pipeline item ${index} dropped: ${errText(e)}`)
|
|
505
|
+
return null
|
|
506
|
+
}
|
|
507
|
+
return prev
|
|
508
|
+
}),
|
|
509
|
+
)
|
|
510
|
+
return results
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private async spawnAgent(prompt: string, opts: AgentOpts): Promise<any> {
|
|
514
|
+
if (this.state.agentCount >= 1000) throw new Error("workflow agent cap (1000) reached")
|
|
515
|
+
const total = this.budgetTotal
|
|
516
|
+
if (total != null && this.outputTokensSpent() >= total)
|
|
517
|
+
throw new Error("token budget exhausted; further agent() calls are blocked")
|
|
518
|
+
|
|
519
|
+
this.agentSeq++
|
|
520
|
+
const id = `ag-${String(this.agentSeq).padStart(3, "0")}`
|
|
521
|
+
const label = opts.label ?? `agent-${this.agentSeq}`
|
|
522
|
+
const phaseTitle = opts.phase ?? this.currentPhase ?? "main"
|
|
523
|
+
const phaseIndex = this.ensurePhase(phaseTitle)
|
|
524
|
+
if (this.currentPhase === "" && !opts.phase) this.currentPhase = phaseTitle
|
|
525
|
+
|
|
526
|
+
const model = this.resolveModel(opts.model)
|
|
527
|
+
const agent: AgentState = {
|
|
528
|
+
id,
|
|
529
|
+
label,
|
|
530
|
+
phase: phaseTitle,
|
|
531
|
+
phaseIndex,
|
|
532
|
+
status: "queued",
|
|
533
|
+
model: model ? this.resolveModelLabel(model) : this.deps.defaultModel ?? "default",
|
|
534
|
+
tokens: 0,
|
|
535
|
+
contextTokens: 0,
|
|
536
|
+
outputTokens: 0,
|
|
537
|
+
cost: 0,
|
|
538
|
+
toolCalls: 0,
|
|
539
|
+
activity: [],
|
|
540
|
+
prompt: String(prompt),
|
|
541
|
+
sessionId: undefined,
|
|
542
|
+
}
|
|
543
|
+
this.state.agents[id] = agent
|
|
544
|
+
this.state.agentOrder.push(id)
|
|
545
|
+
this.state.phases.find((p) => p.title === phaseTitle)?.agentIds.push(id)
|
|
546
|
+
this.state.agentCount++
|
|
547
|
+
this.recount()
|
|
548
|
+
this.markDirty()
|
|
549
|
+
this.writeJournal({ type: "agent-start", id, label, phase: phaseTitle, at: Date.now() })
|
|
550
|
+
|
|
551
|
+
const hit = this.takeReplay(phaseTitle, label, agent.prompt)
|
|
552
|
+
if (hit) {
|
|
553
|
+
const p = hit.agent
|
|
554
|
+
agent.status = "completed"
|
|
555
|
+
agent.replayed = true
|
|
556
|
+
agent.model = p.model || agent.model
|
|
557
|
+
agent.tokens = p.tokens || 0
|
|
558
|
+
agent.contextTokens = p.contextTokens || 0
|
|
559
|
+
agent.outputTokens = p.outputTokens || 0
|
|
560
|
+
agent.cost = p.cost || 0
|
|
561
|
+
agent.toolCalls = p.toolCalls || 0
|
|
562
|
+
agent.activity = Array.isArray(p.activity) ? p.activity : []
|
|
563
|
+
agent.outcome = p.outcome
|
|
564
|
+
agent.outcomeText = p.outcomeText ?? truncate(typeof hit.result === "string" ? hit.result : JSON.stringify(hit.result, null, 2), 4000)
|
|
565
|
+
agent.sessionId = p.sessionId
|
|
566
|
+
agent.startedAt = p.startedAt
|
|
567
|
+
agent.endedAt = p.endedAt ?? Date.now()
|
|
568
|
+
this.onAgentTerminal(agent)
|
|
569
|
+
this.writeJournal({ type: "agent-done", id, status: "completed", replayed: true, result: truncate(JSON.stringify(hit.result ?? null), 100_000), at: Date.now() })
|
|
570
|
+
return hit.result
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
await this.acquireSem()
|
|
574
|
+
try {
|
|
575
|
+
if (this.stopRequested) {
|
|
576
|
+
agent.status = "cancelled"
|
|
577
|
+
agent.error = "run stopped"
|
|
578
|
+
this.onAgentTerminal(agent)
|
|
579
|
+
return null
|
|
580
|
+
}
|
|
581
|
+
await this.waitIfPaused()
|
|
582
|
+
if (this.stopRequested) {
|
|
583
|
+
agent.status = "cancelled"
|
|
584
|
+
agent.error = "run stopped"
|
|
585
|
+
this.onAgentTerminal(agent)
|
|
586
|
+
return null
|
|
587
|
+
}
|
|
588
|
+
agent.status = "running"
|
|
589
|
+
agent.startedAt = Date.now()
|
|
590
|
+
this.markDirty()
|
|
591
|
+
|
|
592
|
+
const session = unwrap(await this.deps.client.session.create({
|
|
593
|
+
body: { parentID: this.deps.mainSessionID, title: `wf/${this.state.name}/${label}` },
|
|
594
|
+
}))
|
|
595
|
+
agent.sessionId = session.id
|
|
596
|
+
this.sessionToAgent.set(session.id, id)
|
|
597
|
+
this.deps.onChildSession?.(id, session.id)
|
|
598
|
+
|
|
599
|
+
let attempts = opts.schema ? 2 : 1
|
|
600
|
+
let lastFailure = ""
|
|
601
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
602
|
+
const body: any = {
|
|
603
|
+
parts: [{ type: "text", text: this.subagentPrompt(prompt, phaseTitle, opts, attempt, lastFailure) }],
|
|
604
|
+
}
|
|
605
|
+
if (model) body.model = { providerID: model.providerID, modelID: model.modelID }
|
|
606
|
+
const res = unwrap(await this.deps.client.session.prompt({ path: { id: session.id }, body }))
|
|
607
|
+
const info = res?.info
|
|
608
|
+
if (!info) throw new Error("empty session response")
|
|
609
|
+
this.recordMessage(info)
|
|
610
|
+
if (info.time?.completed) agent.endedAt = info.time.completed
|
|
611
|
+
this.recount()
|
|
612
|
+
this.markDirty()
|
|
613
|
+
if (info.error) {
|
|
614
|
+
throw new Error(typeof info.error === "string" ? info.error : info.error?.message ?? JSON.stringify(info.error))
|
|
615
|
+
}
|
|
616
|
+
const text = finalText(res?.parts)
|
|
617
|
+
if (!opts.schema) {
|
|
618
|
+
agent.outcomeText = truncate(text, 4000)
|
|
619
|
+
return this.terminate(agent, "completed", text)
|
|
620
|
+
}
|
|
621
|
+
const ex = extractJson(text)
|
|
622
|
+
if (!ex.ok) {
|
|
623
|
+
lastFailure = `previous response was not valid JSON: ${ex.error}`
|
|
624
|
+
agent.activity.push({ tool: "StructuredOutput", title: "invalid output", preview: ex.error, startedAt: Date.now(), endedAt: Date.now() })
|
|
625
|
+
this.markDirty()
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
validateSchema(ex.value, opts.schema)
|
|
630
|
+
agent.outcome = ex.value
|
|
631
|
+
agent.outcomeText = truncate(JSON.stringify(ex.value, null, 2), 4000)
|
|
632
|
+
return this.terminate(agent, "completed", ex.value)
|
|
633
|
+
} catch (e: any) {
|
|
634
|
+
lastFailure = `previous JSON failed schema validation: ${e?.message ?? e}`
|
|
635
|
+
continue
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
agent.status = "failed"
|
|
639
|
+
agent.error = `structured output failed after ${attempts} attempts (${lastFailure})`
|
|
640
|
+
this.onAgentTerminal(agent)
|
|
641
|
+
this.writeJournal({ type: "agent-done", id, status: "failed", error: agent.error, at: Date.now() })
|
|
642
|
+
return null
|
|
643
|
+
} catch (e: any) {
|
|
644
|
+
if (this.stopRequested || (e instanceof RunAbortedError)) {
|
|
645
|
+
agent.status = "cancelled"
|
|
646
|
+
agent.error = "run stopped"
|
|
647
|
+
} else {
|
|
648
|
+
agent.status = "failed"
|
|
649
|
+
agent.error = errText(e)
|
|
650
|
+
}
|
|
651
|
+
this.onAgentTerminal(agent)
|
|
652
|
+
this.writeJournal({ type: "agent-done", id, status: agent.status, error: agent.error, at: Date.now() })
|
|
653
|
+
return null
|
|
654
|
+
} finally {
|
|
655
|
+
this.releaseSem()
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private terminate(agent: AgentState, status: AgentStatus, value: any): any {
|
|
660
|
+
agent.status = status
|
|
661
|
+
agent.endedAt = Date.now()
|
|
662
|
+
this.onAgentTerminal(agent)
|
|
663
|
+
this.writeJournal({
|
|
664
|
+
type: "agent-done",
|
|
665
|
+
id: agent.id,
|
|
666
|
+
status,
|
|
667
|
+
result: truncate(JSON.stringify(value ?? null), 100_000),
|
|
668
|
+
at: Date.now(),
|
|
669
|
+
})
|
|
670
|
+
return value
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** pop the next journaled result for this (phase, label, prompt), if resuming */
|
|
674
|
+
private takeReplay(phase: string, label: string, prompt: string): { result: any; agent: AgentState } | undefined {
|
|
675
|
+
if (!this.replay) return undefined
|
|
676
|
+
const list = this.replay.get(replayKey(phase, label, prompt))
|
|
677
|
+
if (!list?.length) return undefined
|
|
678
|
+
return list.shift()
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private onAgentTerminal(_agent: AgentState): void {
|
|
682
|
+
this.recount()
|
|
683
|
+
this.markDirty()
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// --- model resolution -----------------------------------------------------
|
|
687
|
+
|
|
688
|
+
private resolveModel(requested?: string): ModelRef | undefined {
|
|
689
|
+
if (!requested) return this.parseModel(this.deps.defaultModel)
|
|
690
|
+
const direct = this.parseModel(requested)
|
|
691
|
+
if (direct) {
|
|
692
|
+
if (this.deps.availableModels.has(`${direct.providerID}/${direct.modelID}`)) return direct
|
|
693
|
+
// not in the known set: fall back to session model and note it
|
|
694
|
+
this.logLine("log", `model "${requested}" not found in available models; using session model for agent`)
|
|
695
|
+
return this.parseModel(this.deps.defaultModel)
|
|
696
|
+
}
|
|
697
|
+
// bare name: match any provider/modelID
|
|
698
|
+
const hits: string[] = []
|
|
699
|
+
for (const m of this.deps.availableModels) {
|
|
700
|
+
const i = m.indexOf("/")
|
|
701
|
+
if (m.slice(i + 1) === requested || m === requested) hits.push(m)
|
|
702
|
+
}
|
|
703
|
+
if (hits.length >= 1) return this.parseModel(hits[0])!
|
|
704
|
+
this.logLine("log", `model "${requested}" not found; using session model for agent`)
|
|
705
|
+
return this.parseModel(this.deps.defaultModel)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private parseModel(s?: string): ModelRef | undefined {
|
|
709
|
+
if (!s) return undefined
|
|
710
|
+
const i = s.indexOf("/")
|
|
711
|
+
if (i <= 0 || i === s.length - 1) return undefined
|
|
712
|
+
return { providerID: s.slice(0, i), modelID: s.slice(i + 1), label: s }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// --- prompting -------------------------------------------------------------
|
|
716
|
+
|
|
717
|
+
private subagentPrompt(prompt: string, phase: string, opts: AgentOpts, attempt: number, lastFailure: string): string {
|
|
718
|
+
const parts: string[] = []
|
|
719
|
+
parts.push(`You are a sub-agent of a workflow run (workflow "${this.state.name}", phase "${phase}").`)
|
|
720
|
+
parts.push("Your final message text is the machine return value of this task — treat it as data for the orchestrator, not a message to a human.")
|
|
721
|
+
parts.push("- Be concise and factual. No conversational preamble, no summaries of what you did.")
|
|
722
|
+
if (attempt > 0 && lastFailure) {
|
|
723
|
+
parts.push("")
|
|
724
|
+
parts.push(`IMPORTANT: your previous attempt was rejected — ${lastFailure}. This time respond correctly.`)
|
|
725
|
+
}
|
|
726
|
+
if (opts.schema) {
|
|
727
|
+
parts.push("")
|
|
728
|
+
parts.push("Respond with ONLY a single JSON object that matches this JSON Schema exactly. No markdown fences, no text before or after:")
|
|
729
|
+
parts.push(JSON.stringify(opts.schema))
|
|
730
|
+
} else {
|
|
731
|
+
parts.push("")
|
|
732
|
+
parts.push("Return the raw result text. If the result is code or JSON, a fenced block or bare value is fine.")
|
|
733
|
+
}
|
|
734
|
+
parts.push("")
|
|
735
|
+
parts.push("--- TASK ---")
|
|
736
|
+
parts.push(prompt)
|
|
737
|
+
return parts.join("\n")
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// --- control (pause / stop) -------------------------------------------------
|
|
741
|
+
|
|
742
|
+
private pollControl(): void {
|
|
743
|
+
const p = controlPath(this.deps.runsRoot, this.runId)
|
|
744
|
+
let raw: string | undefined
|
|
745
|
+
try {
|
|
746
|
+
raw = readFileSync(p, "utf8")
|
|
747
|
+
} catch {
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
let ctl: any
|
|
751
|
+
try {
|
|
752
|
+
ctl = JSON.parse(raw)
|
|
753
|
+
} catch {
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
if (!ctl || typeof ctl !== "object") return
|
|
757
|
+
// accept both shapes: {"stop":true} and {"action":"stop"}
|
|
758
|
+
const wantStop = ctl.stop === true || ctl.action === "stop"
|
|
759
|
+
const wantPause = ctl.pause === true || ctl.action === "pause"
|
|
760
|
+
const wantResume = ctl.resume === true || ctl.action === "resume"
|
|
761
|
+
try {
|
|
762
|
+
rmSync(p, { force: true })
|
|
763
|
+
} catch {}
|
|
764
|
+
if (wantPause && !this.paused && !this.stopRequested) {
|
|
765
|
+
this.paused = true
|
|
766
|
+
this.state.status = "paused"
|
|
767
|
+
this.logLine("log", "paused by user")
|
|
768
|
+
this.markDirty()
|
|
769
|
+
}
|
|
770
|
+
if (wantResume && this.paused) {
|
|
771
|
+
this.paused = false
|
|
772
|
+
if (this.state.status === "paused") this.state.status = "running"
|
|
773
|
+
this.logLine("log", "resumed by user")
|
|
774
|
+
this.markDirty()
|
|
775
|
+
}
|
|
776
|
+
if (wantStop && !this.stopRequested) {
|
|
777
|
+
this.stopRequested = true
|
|
778
|
+
this.state.status = "running"
|
|
779
|
+
this.logLine("log", "stop requested by user")
|
|
780
|
+
this.markDirty()
|
|
781
|
+
this.abortAll()
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private abortAll(): void {
|
|
786
|
+
for (const a of Object.values(this.state.agents)) {
|
|
787
|
+
if (a.status === "running" && a.sessionId) {
|
|
788
|
+
this.stoppedAgents.add(a.id)
|
|
789
|
+
this.deps.client.session
|
|
790
|
+
.abort({ path: { id: a.sessionId } })
|
|
791
|
+
.catch(() => {})
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private async waitIfPaused(): Promise<void> {
|
|
797
|
+
while (this.paused && !this.stopRequested) {
|
|
798
|
+
await sleep(300)
|
|
799
|
+
}
|
|
800
|
+
if (this.stopRequested) throw new RunAbortedError()
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// --- concurrency pool --------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
private acquireSem(): Promise<void> {
|
|
806
|
+
if (this.sem < this.semMax) {
|
|
807
|
+
this.sem++
|
|
808
|
+
return Promise.resolve()
|
|
809
|
+
}
|
|
810
|
+
return new Promise((res) => this.waiters.push(res))
|
|
811
|
+
}
|
|
812
|
+
private releaseSem(): void {
|
|
813
|
+
this.sem--
|
|
814
|
+
const next = this.waiters.shift()
|
|
815
|
+
if (next) {
|
|
816
|
+
this.sem++
|
|
817
|
+
next()
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private outputTokensSpent(): number {
|
|
822
|
+
return Object.values(this.state.agents).reduce((s, a) => s + (a.outputTokens || 0), 0)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// --- state persistence ---------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
private recount(): void {
|
|
828
|
+
for (const p of this.state.phases) {
|
|
829
|
+
p.done = p.agentIds.filter((id) => {
|
|
830
|
+
const a = this.state.agents[id]
|
|
831
|
+
return a && a.status !== "queued" && a.status !== "running"
|
|
832
|
+
}).length
|
|
833
|
+
}
|
|
834
|
+
const agents = Object.values(this.state.agents)
|
|
835
|
+
this.state.agentCount = agents.length
|
|
836
|
+
this.state.agentDone = agents.filter((a) => a.status !== "queued" && a.status !== "running").length
|
|
837
|
+
this.state.totalTokens = agents.reduce((s, a) => s + (a.tokens || 0), 0)
|
|
838
|
+
this.state.totalContextTokens = agents.reduce((s, a) => s + (a.contextTokens || 0), 0)
|
|
839
|
+
this.state.totalCost = agents.reduce((s, a) => s + (a.cost || 0), 0)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private markDirty(): void {
|
|
843
|
+
if (this.flushScheduled || !this.state) return
|
|
844
|
+
this.flushScheduled = true
|
|
845
|
+
this.flushTimer = setTimeout(() => {
|
|
846
|
+
this.flushScheduled = false
|
|
847
|
+
this.flushTimer = null
|
|
848
|
+
this.flushNow()
|
|
849
|
+
}, 400)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
flushNow(): void {
|
|
853
|
+
if (!this.state) return
|
|
854
|
+
try {
|
|
855
|
+
mkdirSync(this.runDir, { recursive: true })
|
|
856
|
+
writeFileSync(statePath(this.deps.runsRoot, this.runId), JSON.stringify(this.state, null, 2))
|
|
857
|
+
} catch (e: any) {
|
|
858
|
+
this.deps.log?.("error", `workflow state write failed: ${errText(e)}`)
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private writeJournal(entry: any): void {
|
|
863
|
+
try {
|
|
864
|
+
mkdirSync(this.runDir, { recursive: true })
|
|
865
|
+
appendFileSync(journalPath(this.deps.runsRoot, this.runId), JSON.stringify(entry) + "\n")
|
|
866
|
+
} catch {}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private logLine(kind: string, message: string): void {
|
|
870
|
+
this.state.logs.push({ at: Date.now(), message })
|
|
871
|
+
if (this.state.logs.length > 200) this.state.logs.splice(0, this.state.logs.length - 200)
|
|
872
|
+
this.deps.log?.("info", `[${this.state.name}] ${message}`)
|
|
873
|
+
this.markDirty()
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
private resolveScript(opts: RunOptions): { meta: any; body: string; raw: string } {
|
|
877
|
+
let script: string | undefined = opts.script
|
|
878
|
+
if (!script && opts.scriptPath) script = readFileSync(opts.scriptPath, "utf8")
|
|
879
|
+
if (!script && opts.name) {
|
|
880
|
+
const p = this.findSavedScript(opts.name)
|
|
881
|
+
if (p) script = readFileSync(p, "utf8")
|
|
882
|
+
else throw new Error(`saved workflow "${opts.name}" not found (looked in .opencode/workflows and ~/.config/opencode/workflows)`)
|
|
883
|
+
}
|
|
884
|
+
if (!script) throw new Error("Workflow requires one of: script (inline), scriptPath, or name")
|
|
885
|
+
const parsed = parseScript(script)
|
|
886
|
+
try {
|
|
887
|
+
writeFileSync(join(this.runDir, "script.js"), script)
|
|
888
|
+
} catch {}
|
|
889
|
+
return { ...parsed, raw: script }
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
findSavedScript(name: string): string | undefined {
|
|
893
|
+
const candidates = [
|
|
894
|
+
join(this.deps.opencodeDir, "workflows", `${safeName(name)}.js`),
|
|
895
|
+
join(this.deps.opencodeDir, "workflows", `${safeName(name)}`),
|
|
896
|
+
]
|
|
897
|
+
for (const c of candidates) if (existsSync(c) && statSync(c).isFile()) return c
|
|
898
|
+
return undefined
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** host is shutting down: mark the run stopped so the TUI never shows a zombie "running" */
|
|
902
|
+
shutdown(reason: string): void {
|
|
903
|
+
if (!this.state) return
|
|
904
|
+
if (this.state.status === "completed" || this.state.status === "failed" || this.state.status === "stopped") return
|
|
905
|
+
this.stopRequested = true
|
|
906
|
+
this.abortAll()
|
|
907
|
+
for (const a of Object.values(this.state.agents)) {
|
|
908
|
+
if (a.status === "running" || a.status === "queued") {
|
|
909
|
+
a.status = "cancelled"
|
|
910
|
+
a.error = reason
|
|
911
|
+
a.endedAt = Date.now()
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
this.recount()
|
|
915
|
+
this.state.status = "stopped"
|
|
916
|
+
this.state.endedAt = Date.now()
|
|
917
|
+
this.state.error = this.state.error ?? reason
|
|
918
|
+
this.logLine("stopped", reason)
|
|
919
|
+
this.writeJournal({ type: "run-end", runId: this.runId, status: "stopped", at: Date.now(), reason })
|
|
920
|
+
this.cleanupTimers()
|
|
921
|
+
this.flushNow()
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private cleanupTimers(): void {
|
|
925
|
+
if (this.heartbeat) clearInterval(this.heartbeat)
|
|
926
|
+
if (this.controlTimer) clearInterval(this.controlTimer)
|
|
927
|
+
if (this.flushTimer) clearTimeout(this.flushTimer)
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// --- helpers -----------------------------------------------------------------
|
|
932
|
+
|
|
933
|
+
function tokenTotal(t: any): number {
|
|
934
|
+
if (!t) return 0
|
|
935
|
+
return (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** prompt size of one API call: everything the model read, minus what it wrote */
|
|
939
|
+
function contextTokens(t: any): number {
|
|
940
|
+
if (!t) return 0
|
|
941
|
+
return (t.input ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function finalText(parts: any[]): string {
|
|
945
|
+
if (!parts) return ""
|
|
946
|
+
const texts: string[] = []
|
|
947
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
948
|
+
const p = parts[i]
|
|
949
|
+
if (p?.type === "text" && typeof p.text === "string") {
|
|
950
|
+
texts.unshift(p.text)
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// last text part wins (final answer), fall back to all
|
|
954
|
+
const last = texts.length ? texts[texts.length - 1] : ""
|
|
955
|
+
return last.trim() || texts.join("\n")
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function unwrap(res: any): any {
|
|
959
|
+
if (res && typeof res === "object" && "data" in res) {
|
|
960
|
+
const r = res as any
|
|
961
|
+
if (r.error) {
|
|
962
|
+
const msg = r.error?.data?.message ?? r.error?.message ?? "opencode API error"
|
|
963
|
+
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg))
|
|
964
|
+
}
|
|
965
|
+
return r.data
|
|
966
|
+
}
|
|
967
|
+
return res
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function truncate(s: string, n: number): string {
|
|
971
|
+
if (s.length <= n) return s
|
|
972
|
+
return s.slice(0, n) + `… [truncated ${s.length - n} chars]`
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function errText(e: any): string {
|
|
976
|
+
return e?.message ?? String(e)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function sleep(ms: number): Promise<void> {
|
|
980
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function countAgents(meta: { phases?: any[] }): number {
|
|
984
|
+
const fromPhases = (meta.phases ?? []).reduce((n, p: any) => n + (Array.isArray(p?.agentIds) ? p.agentIds.length : 0), 0)
|
|
985
|
+
return fromPhases || (meta.phases?.length ?? 0)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function safeName(n: string): string {
|
|
989
|
+
return n.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export type { Primitives }
|
|
993
|
+
|
|
994
|
+
/** human title for a tool call: host title → recognisable input field → tool name */
|
|
995
|
+
function toolTitle(tool: string, st: any): string {
|
|
996
|
+
const t = typeof st?.title === "string" ? st.title.trim() : ""
|
|
997
|
+
if (t) return oneLine(t).slice(0, 120)
|
|
998
|
+
const input = st?.input
|
|
999
|
+
if (input && typeof input === "object") {
|
|
1000
|
+
for (const k of ["command", "filePath", "path", "pattern", "query", "url", "description", "prompt", "title"]) {
|
|
1001
|
+
const v = (input as any)[k]
|
|
1002
|
+
if (typeof v === "string" && v.trim()) return oneLine(v).slice(0, 120)
|
|
1003
|
+
}
|
|
1004
|
+
for (const v of Object.values(input)) if (typeof v === "string" && v.trim()) return oneLine(v).slice(0, 120)
|
|
1005
|
+
}
|
|
1006
|
+
return String(tool)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function oneLine(s: string): string {
|
|
1010
|
+
return s.replace(/\s+/g, " ").trim()
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function feedLine(text: string): string {
|
|
1014
|
+
return String(text ?? "").trim().replace(/\s+/g, " ").slice(0, 160)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function fmtSecs(ms: number): string {
|
|
1018
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
1019
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""}`
|
|
1020
|
+
}
|