teapot-coding-agent 0.8.0 → 0.10.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 +19 -9
- package/dist/agent/agent.js +320 -95
- package/dist/agent/llm.js +8 -2
- package/dist/agent/skills.js +13 -0
- package/dist/agent/tools.js +577 -24
- package/dist/bus.js +1 -1
- package/dist/master.js +34 -2
- package/dist/scheduler/cron.js +15 -0
- package/dist/server/api.js +40 -4
- package/package.json +3 -1
- package/public/assets/index-CIc04lSi.css +1 -0
- package/public/assets/index-CqCUyqTK.js +13 -0
- package/public/index.html +2 -2
- package/public/assets/index-DIs0rfrk.css +0 -1
- package/public/assets/index-JRXeHcww.js +0 -8
package/README.md
CHANGED
|
@@ -108,12 +108,17 @@ master (Hono server, src/master.ts + src/server/api.ts)
|
|
|
108
108
|
- **LLM** (`src/agent/llm.ts`): official `openai` npm client against any
|
|
109
109
|
OpenAI-compatible endpoint (OpenRouter, vLLM, Ollama...). Model is config,
|
|
110
110
|
never hard-coded.
|
|
111
|
-
- **Tools** (`src/agent/tools.ts`): provider-agnostic JSON-schema function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
- **Tools** (`src/agent/tools.ts`): provider-agnostic JSON-schema function specs —
|
|
112
|
+
file work via `read_file` (numbered lines, grep-style `pattern` mode, negative
|
|
113
|
+
offsets), `write_file`, `edit_file` (unique replacement, `replace_all`,
|
|
114
|
+
whitespace-tolerant fallbacks with recovery hints), `apply_patch`
|
|
115
|
+
(Codex-style multi-file add/update/rename/delete patches, validated
|
|
116
|
+
atomically before writing) and `list_dir`; `bash` for git/builds/tests and
|
|
117
|
+
quick bulk transforms; `read_url` fetches a web page's readable content
|
|
118
|
+
(Mozilla Readability + happy-dom); plus meta tools `finish` /
|
|
119
|
+
`report_progress` / `get_goal` / `set_goal` / `read_memory` / `set_memory` /
|
|
120
|
+
`list_skills`. Paths are confined to the workspace; bash runs detached in its
|
|
121
|
+
own process group and the whole group is SIGKILLed on timeout or shutdown.
|
|
117
122
|
- **Cache-friendly prompt design** — the system prompt is byte-identical on
|
|
118
123
|
every turn; session state (goal, memory, skills) is fetched via tools, never
|
|
119
124
|
injected. Combined with the append-only message history this keeps provider
|
|
@@ -168,8 +173,10 @@ description: Steps to cut a release safely
|
|
|
168
173
|
- The system prompt lists every discovered skill (name + description);
|
|
169
174
|
workspace skills override same-named global ones.
|
|
170
175
|
- The agent calls `load_skill(name)` when a task matches and follows it.
|
|
171
|
-
- The agent calls `save_skill(name, description, content)` to distill a
|
|
172
|
-
reusable procedure
|
|
176
|
+
- The agent calls `save_skill(name, description, content, files?)` to distill a
|
|
177
|
+
reusable procedure — `files` bundles helper scripts next to `SKILL.md`
|
|
178
|
+
(made executable automatically), and `load_skill` lists them as runnable
|
|
179
|
+
workspace paths. Available from the next turn, forever.
|
|
173
180
|
|
|
174
181
|
### Session log format (JSONL)
|
|
175
182
|
|
|
@@ -213,10 +220,13 @@ GET /api/agents list snapshots
|
|
|
213
220
|
GET /api/agents/:id one snapshot
|
|
214
221
|
POST /api/agents/:id/prompt {text, start?}
|
|
215
222
|
POST /api/agents/:id/start | /stop
|
|
216
|
-
POST /api/agents/:id/
|
|
223
|
+
POST /api/agents/:id/load lazy session restore (stopped → idle)
|
|
224
|
+
POST /api/agents/:id/goal {text, notify?} or {status}
|
|
217
225
|
POST /api/agents/:id/fork {} → new branch, same session log
|
|
226
|
+
POST /api/agents/:id/edit-prompt {eventId, text, tail: "discard"|"summarize"} → fork & resend
|
|
218
227
|
GET /api/agents/:id/events?limit&branch&session
|
|
219
228
|
GET /api/agents/:id/branches
|
|
229
|
+
GET /api/tasks scheduled tasks with computed next-fire times
|
|
220
230
|
GET /api/metrics master rss/heap/load + per-agent stats
|
|
221
231
|
GET /api/events SSE updates (push, no polling)
|
|
222
232
|
GET /brew 418 I'm a teapot (RFC 2324)
|
package/dist/agent/agent.js
CHANGED
|
@@ -33,7 +33,17 @@ Session state is not injected into prompts — fetch it with tools instead:
|
|
|
33
33
|
|
|
34
34
|
## Rules
|
|
35
35
|
- Work step by step with tools. Verify results (run tests/builds) before claiming progress.
|
|
36
|
+
- File changes — pick by scope: write_file (one new file / full rewrite) ·
|
|
37
|
+
edit_file (exactly one small unique replacement) · apply_patch (several
|
|
38
|
+
edits, renames or deletes across one or more files, applied atomically).
|
|
39
|
+
read_file numbers lines and can grep via its pattern option. bash
|
|
40
|
+
text-munging (sed/awk/heredoc) remains available for quick bulk transforms
|
|
41
|
+
when that is genuinely faster.
|
|
36
42
|
- When a loaded skill matches your task, follow its playbook.
|
|
43
|
+
- Turn proven procedures into skills: once something non-trivial worked well,
|
|
44
|
+
save_skill(name, description, content, files=[{name, content}]) so future
|
|
45
|
+
sessions can load_skill them — helper scripts go through files and are made
|
|
46
|
+
executable automatically.
|
|
37
47
|
- When you make meaningful progress, call report_progress.
|
|
38
48
|
- Be frugal: prefer small precise edits, avoid runaway loops.`;
|
|
39
49
|
export class Agent {
|
|
@@ -46,6 +56,8 @@ export class Agent {
|
|
|
46
56
|
currentBranch = "br0";
|
|
47
57
|
goal = { text: "", status: "active", updatedAt: new Date().toISOString() };
|
|
48
58
|
latestProgress = null;
|
|
59
|
+
/** set once the conversation has been restored (lazy: on first interaction) */
|
|
60
|
+
readyPromise = null;
|
|
49
61
|
stats = {
|
|
50
62
|
turns: 0,
|
|
51
63
|
toolCalls: 0,
|
|
@@ -56,19 +68,34 @@ export class Agent {
|
|
|
56
68
|
};
|
|
57
69
|
opts;
|
|
58
70
|
messages = [];
|
|
71
|
+
/**
|
|
72
|
+
* User prompts waiting for the next turn boundary. Deliberately NOT queued
|
|
73
|
+
* on runChain: that chain holds the long-running loop, so queueing behind
|
|
74
|
+
* it would delay both the log entry (UI) and delivery until the round —
|
|
75
|
+
* sometimes the whole goal — finished.
|
|
76
|
+
*/
|
|
77
|
+
pendingPrompts = [];
|
|
59
78
|
stopRequested = false;
|
|
60
79
|
wake = null;
|
|
61
80
|
abort = null;
|
|
81
|
+
/** aborted only by dispose(): kills in-flight subprocess groups instantly */
|
|
82
|
+
toolAbort = new AbortController();
|
|
62
83
|
runChain = Promise.resolve();
|
|
63
84
|
lastProgressAt = Date.now();
|
|
85
|
+
/** real assistant output since the last progress report (chars / turns) */
|
|
86
|
+
activityChars = 0;
|
|
87
|
+
turnsSinceProgress = 0;
|
|
64
88
|
consecutiveToolErrors = 0;
|
|
65
89
|
constructor(opts) {
|
|
66
90
|
this.opts = {
|
|
67
91
|
progressIntervalMs: 10 * 60_000,
|
|
92
|
+
progressMinChars: 4_000,
|
|
93
|
+
progressMaxQuietTurns: 40,
|
|
68
94
|
autoContinue: true,
|
|
69
95
|
continueDelayMs: 15_000,
|
|
70
96
|
maxConsecutiveToolErrors: 5,
|
|
71
97
|
contextTokenBudget: 96_000,
|
|
98
|
+
contextWindowTokens: 0,
|
|
72
99
|
restoreSession: true,
|
|
73
100
|
globalSkillsDir: "",
|
|
74
101
|
provider: "",
|
|
@@ -84,6 +111,7 @@ export class Agent {
|
|
|
84
111
|
defaultTimeoutMs: 120_000,
|
|
85
112
|
maxOutputBytes: 60_000,
|
|
86
113
|
skillRoots: this.skillRoots,
|
|
114
|
+
signal: this.toolAbort.signal,
|
|
87
115
|
};
|
|
88
116
|
// the session id IS the directory name — one directory per incarnation
|
|
89
117
|
this.mainSession = path.basename(opts.sessionDir);
|
|
@@ -127,9 +155,35 @@ export class Agent {
|
|
|
127
155
|
await this.migrateGoalFromWorkspace();
|
|
128
156
|
const stored = await this.readGoalStore();
|
|
129
157
|
this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
|
|
130
|
-
if (this.opts.restoreSession)
|
|
131
|
-
await this.restoreFromLog();
|
|
132
158
|
await this.refreshSkills();
|
|
159
|
+
// the conversation is NOT restored here: boot cost stays O(agents), not
|
|
160
|
+
// O(history). It is rebuilt lazily by ensureReady() on first interaction.
|
|
161
|
+
if (this.opts.restoreSession) {
|
|
162
|
+
this.status = "stopped";
|
|
163
|
+
this.statusReason = "session not loaded — select it or send a prompt";
|
|
164
|
+
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Restore the conversation from the JSONL log exactly once, on demand.
|
|
169
|
+
* Everything that touches history (prompts, start, fork, UI selection)
|
|
170
|
+
* funnels through here; boot stays cheap no matter how many sessions exist.
|
|
171
|
+
*/
|
|
172
|
+
ensureReady() {
|
|
173
|
+
if (!this.readyPromise) {
|
|
174
|
+
this.readyPromise = (async () => {
|
|
175
|
+
if (this.opts.restoreSession) {
|
|
176
|
+
await this.restoreFromLog();
|
|
177
|
+
if (this.status === "stopped")
|
|
178
|
+
this.setStatus("idle", "session loaded");
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
}
|
|
182
|
+
return this.readyPromise;
|
|
183
|
+
}
|
|
184
|
+
/** Explicit load (e.g. the user clicked the agent in the UI): stopped → idle. */
|
|
185
|
+
async load() {
|
|
186
|
+
await this.ensureReady();
|
|
133
187
|
}
|
|
134
188
|
/** harness-managed files inside the session directory */
|
|
135
189
|
get goalFile() {
|
|
@@ -179,83 +233,11 @@ export class Agent {
|
|
|
179
233
|
const events = await readEvents(this.log.filePath);
|
|
180
234
|
if (events.length === 0)
|
|
181
235
|
return;
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
|
|
188
|
-
if (seen.has(cur.id))
|
|
189
|
-
break;
|
|
190
|
-
seen.add(cur.id);
|
|
191
|
-
lineage.push(cur);
|
|
192
|
-
}
|
|
193
|
-
lineage.reverse();
|
|
194
|
-
// skip the trailing fork event itself (it is bookkeeping, not conversation)
|
|
195
|
-
while (lineage.length && lineage[0].type === "fork")
|
|
196
|
-
lineage.shift();
|
|
197
|
-
const msgs = [];
|
|
198
|
-
for (const e of lineage) {
|
|
199
|
-
const d = e.data;
|
|
200
|
-
if (e.type === "prompt" && typeof d.text === "string") {
|
|
201
|
-
msgs.push({ role: "user", content: d.text });
|
|
202
|
-
}
|
|
203
|
-
else if (e.type === "message") {
|
|
204
|
-
const role = d.role === "assistant" ? "assistant" : "user";
|
|
205
|
-
const m = { role, content: typeof d.content === "string" ? d.content : "" };
|
|
206
|
-
if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
|
|
207
|
-
m.tool_calls = d.toolCalls.map((c) => ({
|
|
208
|
-
id: c.id,
|
|
209
|
-
type: "function",
|
|
210
|
-
function: { name: c.name, arguments: "{}" },
|
|
211
|
-
}));
|
|
212
|
-
}
|
|
213
|
-
msgs.push(m);
|
|
214
|
-
}
|
|
215
|
-
else if (e.type === "tool_call") {
|
|
216
|
-
// enrich the preceding assistant tool_calls with real arguments
|
|
217
|
-
const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
|
|
218
|
-
const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
|
|
219
|
-
if (tc)
|
|
220
|
-
tc.function.arguments = JSON.stringify(d.args ?? {});
|
|
221
|
-
}
|
|
222
|
-
else if (e.type === "tool_result") {
|
|
223
|
-
msgs.push({
|
|
224
|
-
role: "tool",
|
|
225
|
-
tool_call_id: String(d.callId ?? ""),
|
|
226
|
-
content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
else if (e.type === "progress") {
|
|
230
|
-
// progress events may follow an assistant report_progress call that
|
|
231
|
-
// has no logged tool result — patch it in when present
|
|
232
|
-
const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
|
|
233
|
-
if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
|
|
234
|
-
for (const t of lastAssistant.tool_calls) {
|
|
235
|
-
if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
|
|
236
|
-
msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
// every assistant tool_call must be answered by a tool message, or the
|
|
243
|
-
// API rejects the sequence — close any holes left by meta tools (finish)
|
|
244
|
-
for (let i = 0; i < msgs.length; i++) {
|
|
245
|
-
const m = msgs[i];
|
|
246
|
-
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
247
|
-
for (const t of m.tool_calls) {
|
|
248
|
-
if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
|
|
249
|
-
msgs.splice(i + 1, 0, {
|
|
250
|
-
role: "tool",
|
|
251
|
-
tool_call_id: t.id,
|
|
252
|
-
content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
|
|
253
|
-
});
|
|
254
|
-
i++;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
236
|
+
const lineage = lineageOf(events);
|
|
237
|
+
if (!lineage.length)
|
|
238
|
+
return;
|
|
239
|
+
const last = lineage[lineage.length - 1];
|
|
240
|
+
const msgs = rebuildMessagesFrom(lineage);
|
|
259
241
|
if (msgs.length > 0) {
|
|
260
242
|
this.messages = msgs;
|
|
261
243
|
this.currentBranch = last.branch;
|
|
@@ -266,6 +248,61 @@ export class Agent {
|
|
|
266
248
|
});
|
|
267
249
|
}
|
|
268
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Edit a previously-sent prompt: fork the conversation at that point,
|
|
253
|
+
* replace its text, and optionally fold everything that happened after it
|
|
254
|
+
* into a summary note on the new branch (ChatGPT-edit style). The agent
|
|
255
|
+
* must not be running — editing under a live loop would race its history.
|
|
256
|
+
*/
|
|
257
|
+
async editPromptAt(eventId, text, tail) {
|
|
258
|
+
if (this.status === "running")
|
|
259
|
+
throw new Error("agent is running — stop it before editing history");
|
|
260
|
+
const all = await readEvents(this.log.filePath);
|
|
261
|
+
const target = all.find((e) => e.id === eventId);
|
|
262
|
+
if (!target || target.type !== "prompt")
|
|
263
|
+
throw new Error("event not found on this session (or not a prompt)");
|
|
264
|
+
const lineage = lineageOf(all);
|
|
265
|
+
const tIdx = lineage.findIndex((e) => e.id === eventId);
|
|
266
|
+
if (tIdx === -1)
|
|
267
|
+
throw new Error("prompt is not on this agent's current lineage");
|
|
268
|
+
const kept = lineage.slice(0, tIdx);
|
|
269
|
+
const dropped = lineage.slice(tIdx); // includes the original prompt itself
|
|
270
|
+
const msgs = rebuildMessagesFrom(kept);
|
|
271
|
+
if (tail === "summarize" && dropped.length > 0) {
|
|
272
|
+
try {
|
|
273
|
+
const droppedMsgs = rebuildMessagesFrom(dropped);
|
|
274
|
+
if (droppedMsgs.length) {
|
|
275
|
+
const summary = await this.summarize(droppedMsgs);
|
|
276
|
+
if (summary.trim()) {
|
|
277
|
+
msgs.push({
|
|
278
|
+
role: "user",
|
|
279
|
+
content: "[harness] The conversation continued past this point on another timeline. " +
|
|
280
|
+
`Notes from what happened there:\n\n${summary}`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// summarization is best-effort; the fork proceeds without notes
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
msgs.push({ role: "user", content: text });
|
|
290
|
+
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
291
|
+
await this.log.append("fork", this.currentSession, newBranch, {
|
|
292
|
+
fromSession: this.currentSession,
|
|
293
|
+
fromBranch: this.currentBranch,
|
|
294
|
+
fromEvent: kept.at(-1)?.id ?? null,
|
|
295
|
+
newBranch,
|
|
296
|
+
reason: "prompt-edited",
|
|
297
|
+
droppedEvents: dropped.length,
|
|
298
|
+
tailMode: tail,
|
|
299
|
+
});
|
|
300
|
+
this.currentBranch = newBranch;
|
|
301
|
+
this.messages = msgs;
|
|
302
|
+
await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
|
|
303
|
+
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
304
|
+
return { droppedEvents: dropped.length, branch: newBranch };
|
|
305
|
+
}
|
|
269
306
|
parseGoalFile(text) {
|
|
270
307
|
// humans and agents may append their own status lines — latest wins
|
|
271
308
|
const all = [...text.matchAll(/status:\s*(\w+)/gi)];
|
|
@@ -316,14 +353,35 @@ export class Agent {
|
|
|
316
353
|
model: this.opts.llm.model,
|
|
317
354
|
provider: this.opts.provider,
|
|
318
355
|
sessionDir: this.opts.sessionDir,
|
|
356
|
+
ctx: {
|
|
357
|
+
usedTokens: this.estimateTokens(),
|
|
358
|
+
compactAt: this.opts.contextTokenBudget,
|
|
359
|
+
window: this.opts.contextWindowTokens || 0,
|
|
360
|
+
},
|
|
361
|
+
pendingPrompts: this.pendingPrompts.length,
|
|
319
362
|
};
|
|
320
363
|
}
|
|
321
|
-
/**
|
|
364
|
+
/**
|
|
365
|
+
* Queue a user prompt. Returns immediately: the event is logged right away
|
|
366
|
+
* (so every connected UI sees it instantly) and the text is handed to the
|
|
367
|
+
* model at the next turn boundary — never mid-turn, and never blocked by
|
|
368
|
+
* the running loop. The very first prompt on a fresh boot also triggers the
|
|
369
|
+
* lazy session restore (before the mailbox is filled, so no duplicates).
|
|
370
|
+
*/
|
|
322
371
|
enqueuePrompt(text, source = "user") {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
this.
|
|
326
|
-
|
|
372
|
+
void this.ensureReady()
|
|
373
|
+
.then(() => {
|
|
374
|
+
this.pendingPrompts.push({ source, text });
|
|
375
|
+
return this.log.append("prompt", this.currentSession, this.currentBranch, { source, text });
|
|
376
|
+
})
|
|
377
|
+
.then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
|
|
378
|
+
.catch(() => { });
|
|
379
|
+
}
|
|
380
|
+
/** Hand queued user prompts to the model at a turn boundary. */
|
|
381
|
+
drainPendingPrompts() {
|
|
382
|
+
for (const p of this.pendingPrompts.splice(0)) {
|
|
383
|
+
this.messages.push({ role: "user", content: p.text });
|
|
384
|
+
}
|
|
327
385
|
}
|
|
328
386
|
/** Resolves when all queued work (including a running loop) has settled. */
|
|
329
387
|
settled() {
|
|
@@ -340,6 +398,7 @@ export class Agent {
|
|
|
340
398
|
return;
|
|
341
399
|
this.stopRequested = false;
|
|
342
400
|
void this.enqueue(async () => {
|
|
401
|
+
await this.ensureReady(); // lazy restore before the loop touches history
|
|
343
402
|
this.setStatus("running", reason);
|
|
344
403
|
this.stats.startedAt ??= new Date().toISOString();
|
|
345
404
|
});
|
|
@@ -372,9 +431,16 @@ export class Agent {
|
|
|
372
431
|
while (!this.stopRequested) {
|
|
373
432
|
try {
|
|
374
433
|
const finished = await this.runTurnsUntilIdle();
|
|
375
|
-
if (
|
|
434
|
+
if (this.stopRequested)
|
|
376
435
|
break;
|
|
377
|
-
|
|
436
|
+
// fresh user input arrived while we were finishing up — another round now
|
|
437
|
+
if (this.pendingPrompts.length)
|
|
438
|
+
continue;
|
|
439
|
+
// auto-continue only makes sense with an active goal to continue toward
|
|
440
|
+
if (finished ||
|
|
441
|
+
!this.opts.autoContinue ||
|
|
442
|
+
this.goal.status !== "active" ||
|
|
443
|
+
!this.goal.text.trim())
|
|
378
444
|
break;
|
|
379
445
|
// auto-continue: wait quietly, then nudge with a fresh round
|
|
380
446
|
await this.sleepInterruptible(this.opts.continueDelayMs);
|
|
@@ -458,6 +524,8 @@ export class Agent {
|
|
|
458
524
|
for (let guard = 0; guard < 200; guard++) {
|
|
459
525
|
if (this.stopRequested)
|
|
460
526
|
return finished;
|
|
527
|
+
// deliver prompts queued while the previous turn was running
|
|
528
|
+
this.drainPendingPrompts();
|
|
461
529
|
// skills may have been created last turn — refresh the prompt listing
|
|
462
530
|
await this.refreshSkills();
|
|
463
531
|
// periodic progress report at turn boundary (no mid-turn interruption)
|
|
@@ -471,14 +539,40 @@ export class Agent {
|
|
|
471
539
|
turn: ++this.stats.turns,
|
|
472
540
|
});
|
|
473
541
|
// stream the assistant reply live to connected clients
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
542
|
+
let res;
|
|
543
|
+
try {
|
|
544
|
+
res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
|
|
545
|
+
bus.emit("update", {
|
|
546
|
+
kind: "llm-delta",
|
|
547
|
+
agentId: this.opts.id,
|
|
548
|
+
text: s.text,
|
|
549
|
+
reasoning: s.reasoning,
|
|
550
|
+
});
|
|
480
551
|
});
|
|
481
|
-
}
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
// user stop mid-stream: persist the partial output so the timeline
|
|
555
|
+
// keeps what was already visible (otherwise it silently vanishes)
|
|
556
|
+
const partial = err.partial;
|
|
557
|
+
if (this.stopRequested && partial && (partial.text || partial.reasoning)) {
|
|
558
|
+
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
559
|
+
role: "assistant",
|
|
560
|
+
content: partial.text ?? "",
|
|
561
|
+
reasoning: partial.reasoning,
|
|
562
|
+
interrupted: true,
|
|
563
|
+
});
|
|
564
|
+
this.messages.push({ role: "assistant", content: partial.text ?? "" });
|
|
565
|
+
}
|
|
566
|
+
else if (this.stopRequested) {
|
|
567
|
+
// nothing had streamed — leave an explicit marker so the log shows
|
|
568
|
+
// why this prompt has no reply
|
|
569
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
570
|
+
event: "turn-interrupted",
|
|
571
|
+
detail: "stopped before any output arrived",
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
throw err;
|
|
575
|
+
}
|
|
482
576
|
if (res.usage) {
|
|
483
577
|
this.stats.inputTokens += res.usage.inputTokens ?? 0;
|
|
484
578
|
this.stats.outputTokens += res.usage.outputTokens ?? 0;
|
|
@@ -492,6 +586,8 @@ export class Agent {
|
|
|
492
586
|
reasoning: res.reasoning,
|
|
493
587
|
});
|
|
494
588
|
this.messages.push(m);
|
|
589
|
+
this.turnsSinceProgress++;
|
|
590
|
+
this.activityChars += m.content?.length ?? 0;
|
|
495
591
|
if (!m.tool_calls?.length)
|
|
496
592
|
return finished;
|
|
497
593
|
for (const call of m.tool_calls) {
|
|
@@ -608,9 +704,14 @@ export class Agent {
|
|
|
608
704
|
});
|
|
609
705
|
}
|
|
610
706
|
async maybeRequestProgress() {
|
|
611
|
-
|
|
612
|
-
|
|
707
|
+
const elapsedOk = Date.now() - this.lastProgressAt >= this.opts.progressIntervalMs;
|
|
708
|
+
const activityOk = this.activityChars >= this.opts.progressMinChars ||
|
|
709
|
+
this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
|
|
710
|
+
if (!elapsedOk || !activityOk)
|
|
711
|
+
return; // stalling provider → don't waste a turn asking
|
|
613
712
|
this.lastProgressAt = Date.now();
|
|
713
|
+
this.activityChars = 0;
|
|
714
|
+
this.turnsSinceProgress = 0;
|
|
614
715
|
const request = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
|
|
615
716
|
"what you recently tried, any problems, and your next step. Keep it under 10 lines.";
|
|
616
717
|
// log both sides so a session restore replays this exchange faithfully
|
|
@@ -746,6 +847,10 @@ export class Agent {
|
|
|
746
847
|
next: str(a.next) || undefined,
|
|
747
848
|
ts: new Date().toISOString(),
|
|
748
849
|
};
|
|
850
|
+
// a report (voluntary or requested) restarts the progress gates
|
|
851
|
+
this.lastProgressAt = Date.now();
|
|
852
|
+
this.activityChars = 0;
|
|
853
|
+
this.turnsSinceProgress = 0;
|
|
749
854
|
await this.log.append("progress", this.currentSession, this.currentBranch, this.latestProgress);
|
|
750
855
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
751
856
|
}
|
|
@@ -764,6 +869,9 @@ export class Agent {
|
|
|
764
869
|
}
|
|
765
870
|
async dispose() {
|
|
766
871
|
this.stop("disposed");
|
|
872
|
+
// kill any in-flight subprocess group NOW so shutdown never waits out a
|
|
873
|
+
// long-running command (up to 10 min otherwise)
|
|
874
|
+
this.toolAbort.abort();
|
|
767
875
|
await this.runChain.catch(() => { });
|
|
768
876
|
await this.log.close();
|
|
769
877
|
}
|
|
@@ -890,3 +998,120 @@ function safeParse(json) {
|
|
|
890
998
|
return {};
|
|
891
999
|
}
|
|
892
1000
|
}
|
|
1001
|
+
/** Walk parent links backwards from the newest event, then flip forward. */
|
|
1002
|
+
function lineageOf(events) {
|
|
1003
|
+
if (events.length === 0)
|
|
1004
|
+
return [];
|
|
1005
|
+
const byId = new Map(events.map((e) => [e.id, e]));
|
|
1006
|
+
const last = events[events.length - 1];
|
|
1007
|
+
const lineage = [];
|
|
1008
|
+
const seen = new Set();
|
|
1009
|
+
for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
|
|
1010
|
+
if (seen.has(cur.id))
|
|
1011
|
+
break;
|
|
1012
|
+
seen.add(cur.id);
|
|
1013
|
+
lineage.push(cur);
|
|
1014
|
+
}
|
|
1015
|
+
lineage.reverse();
|
|
1016
|
+
// the trailing fork event itself is bookkeeping, not conversation
|
|
1017
|
+
while (lineage.length && lineage[0].type === "fork")
|
|
1018
|
+
lineage.shift();
|
|
1019
|
+
return lineage;
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Replay ordered events into ChatMessages (shared by session restore and
|
|
1023
|
+
* prompt-edit forks). Prompts logged inside an open tool batch are buffered
|
|
1024
|
+
* until it closes, so user messages never split a tool_call/tool_result pair.
|
|
1025
|
+
*/
|
|
1026
|
+
function rebuildMessagesFrom(list) {
|
|
1027
|
+
const msgs = [];
|
|
1028
|
+
const META_TOOLS = new Set([
|
|
1029
|
+
"finish", "report_progress", "set_goal", "get_goal",
|
|
1030
|
+
"read_memory", "set_memory", "list_skills",
|
|
1031
|
+
]);
|
|
1032
|
+
const openCalls = new Map(); // real tool_call id -> name
|
|
1033
|
+
const bufferedUsers = [];
|
|
1034
|
+
const flushUsers = () => {
|
|
1035
|
+
if (openCalls.size === 0) {
|
|
1036
|
+
for (const text of bufferedUsers.splice(0))
|
|
1037
|
+
msgs.push({ role: "user", content: text });
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
for (const e of list) {
|
|
1041
|
+
const d = e.data;
|
|
1042
|
+
if (e.type === "prompt" && typeof d.text === "string") {
|
|
1043
|
+
if (openCalls.size > 0)
|
|
1044
|
+
bufferedUsers.push(d.text);
|
|
1045
|
+
else
|
|
1046
|
+
msgs.push({ role: "user", content: d.text });
|
|
1047
|
+
}
|
|
1048
|
+
else if (e.type === "message") {
|
|
1049
|
+
const role = d.role === "assistant" ? "assistant" : "user";
|
|
1050
|
+
const m = { role, content: typeof d.content === "string" ? d.content : "" };
|
|
1051
|
+
if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
|
|
1052
|
+
m.tool_calls = d.toolCalls.map((c) => ({
|
|
1053
|
+
id: c.id,
|
|
1054
|
+
type: "function",
|
|
1055
|
+
function: { name: c.name, arguments: "{}" },
|
|
1056
|
+
}));
|
|
1057
|
+
// meta tools are answered inline by the harness (no logged result);
|
|
1058
|
+
// the hole-filling pass below synthesizes theirs where they belong
|
|
1059
|
+
for (const t of m.tool_calls)
|
|
1060
|
+
if (!META_TOOLS.has(t.function.name))
|
|
1061
|
+
openCalls.set(t.id, t.function.name);
|
|
1062
|
+
}
|
|
1063
|
+
msgs.push(m);
|
|
1064
|
+
}
|
|
1065
|
+
else if (e.type === "tool_call") {
|
|
1066
|
+
// enrich the preceding assistant tool_calls with real arguments
|
|
1067
|
+
const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
|
|
1068
|
+
const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
|
|
1069
|
+
if (tc)
|
|
1070
|
+
tc.function.arguments = JSON.stringify(d.args ?? {});
|
|
1071
|
+
}
|
|
1072
|
+
else if (e.type === "tool_result") {
|
|
1073
|
+
msgs.push({
|
|
1074
|
+
role: "tool",
|
|
1075
|
+
tool_call_id: String(d.callId ?? ""),
|
|
1076
|
+
content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
|
|
1077
|
+
});
|
|
1078
|
+
openCalls.delete(String(d.callId ?? ""));
|
|
1079
|
+
flushUsers();
|
|
1080
|
+
}
|
|
1081
|
+
else if (e.type === "progress") {
|
|
1082
|
+
// progress events may follow an assistant report_progress call that
|
|
1083
|
+
// has no logged tool result — patch it in when present
|
|
1084
|
+
const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
|
|
1085
|
+
if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
|
|
1086
|
+
for (const t of lastAssistant.tool_calls) {
|
|
1087
|
+
if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
|
|
1088
|
+
msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
|
|
1089
|
+
}
|
|
1090
|
+
openCalls.delete(t.id);
|
|
1091
|
+
}
|
|
1092
|
+
flushUsers();
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// every assistant tool_call must be answered by a tool message, or the
|
|
1097
|
+
// API rejects the sequence — close any holes left by meta tools (finish)
|
|
1098
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
1099
|
+
const m = msgs[i];
|
|
1100
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
1101
|
+
for (const t of m.tool_calls) {
|
|
1102
|
+
if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
|
|
1103
|
+
msgs.splice(i + 1, 0, {
|
|
1104
|
+
role: "tool",
|
|
1105
|
+
tool_call_id: t.id,
|
|
1106
|
+
content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
|
|
1107
|
+
});
|
|
1108
|
+
i++;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// prompts that were still waiting on a hole-filled tail land here
|
|
1114
|
+
for (const text of bufferedUsers.splice(0))
|
|
1115
|
+
msgs.push({ role: "user", content: text });
|
|
1116
|
+
return msgs;
|
|
1117
|
+
}
|
package/dist/agent/llm.js
CHANGED
|
@@ -96,6 +96,9 @@ export async function chat(cfg, messages, tools, signal) {
|
|
|
96
96
|
*/
|
|
97
97
|
export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
98
98
|
let gotChunk = false;
|
|
99
|
+
// hoisted so the abort handler below can attach whatever streamed so far
|
|
100
|
+
let text = "";
|
|
101
|
+
let reasoning = "";
|
|
99
102
|
try {
|
|
100
103
|
const stream = await client(cfg).chat.completions.create({
|
|
101
104
|
model: cfg.model,
|
|
@@ -103,8 +106,6 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
103
106
|
...(tools.length ? { tools } : {}),
|
|
104
107
|
stream: true,
|
|
105
108
|
}, { signal });
|
|
106
|
-
let text = "";
|
|
107
|
-
let reasoning = "";
|
|
108
109
|
const calls = {};
|
|
109
110
|
let finishReason;
|
|
110
111
|
let usage;
|
|
@@ -155,6 +156,11 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
155
156
|
// provider may not support streaming at all — one clean fallback
|
|
156
157
|
if (!gotChunk && !signal?.aborted)
|
|
157
158
|
return chat(cfg, messages, tools, signal);
|
|
159
|
+
// user interrupt: hand back whatever streamed so far so the harness can
|
|
160
|
+
// keep the partial output visible instead of losing it
|
|
161
|
+
if (signal?.aborted && (text || reasoning)) {
|
|
162
|
+
err.partial = { text, reasoning };
|
|
163
|
+
}
|
|
158
164
|
throw err;
|
|
159
165
|
}
|
|
160
166
|
}
|
package/dist/agent/skills.js
CHANGED
|
@@ -64,11 +64,24 @@ export async function discoverSkills(roots) {
|
|
|
64
64
|
const name = parsed.meta.name || e.name;
|
|
65
65
|
if (byName.has(name))
|
|
66
66
|
continue; // higher-priority root already defined it
|
|
67
|
+
// collect bundled files (helper scripts, templates, ...) next to SKILL.md
|
|
68
|
+
let files = [];
|
|
69
|
+
try {
|
|
70
|
+
const siblings = await fs.readdir(path.join(root.dir, e.name), { withFileTypes: true });
|
|
71
|
+
files = siblings
|
|
72
|
+
.filter((f) => f.isFile() && f.name !== SKILL_FILE && !f.name.startsWith("."))
|
|
73
|
+
.map((f) => f.name)
|
|
74
|
+
.sort();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* unreadable dir → no bundled files */
|
|
78
|
+
}
|
|
67
79
|
byName.set(name, {
|
|
68
80
|
name,
|
|
69
81
|
description: parsed.meta.description || "",
|
|
70
82
|
source: root.source,
|
|
71
83
|
filePath,
|
|
84
|
+
files,
|
|
72
85
|
});
|
|
73
86
|
}
|
|
74
87
|
}
|