teapot-coding-agent 0.9.0 → 0.11.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 +13 -3
- package/dist/agent/agent.js +339 -123
- package/dist/agent/llm.js +20 -4
- package/dist/agent/skills.js +13 -0
- package/dist/agent/tools.js +51 -4
- package/dist/master.js +42 -3
- package/dist/scheduler/cron.js +15 -0
- package/dist/server/api.js +54 -2
- package/package.json +1 -1
- package/public/assets/index-BJB4iFSq.js +14 -0
- package/public/assets/index-C4cJD5q_.css +1 -0
- package/public/index.html +2 -2
- package/public/assets/index-BE9Nw_-t.css +0 -1
- package/public/assets/index-DxB6uahH.js +0 -8
package/README.md
CHANGED
|
@@ -133,6 +133,11 @@ master (Hono server, src/master.ts + src/server/api.ts)
|
|
|
133
133
|
through tools (`get_goal` / `set_goal` / `read_memory` / `set_memory`);
|
|
134
134
|
`AGENTS.md` is optional project knowledge in the workspace root that agents
|
|
135
135
|
are told to read at session start. Nothing is seeded into your project.
|
|
136
|
+
- **Task list** — `todo.md` in the session dir, editable by BOTH sides:
|
|
137
|
+
humans write it from the web UI (✅ tasks panel) or
|
|
138
|
+
`POST /api/agents/:id/todo {text, notify?}`, the agent reads and updates it
|
|
139
|
+
via `get_todo` / `set_todo`. Saving with notify queues a harness prompt so
|
|
140
|
+
the agent picks up changes at the next turn boundary.
|
|
136
141
|
|
|
137
142
|
### Web UI
|
|
138
143
|
|
|
@@ -173,8 +178,10 @@ description: Steps to cut a release safely
|
|
|
173
178
|
- The system prompt lists every discovered skill (name + description);
|
|
174
179
|
workspace skills override same-named global ones.
|
|
175
180
|
- The agent calls `load_skill(name)` when a task matches and follows it.
|
|
176
|
-
- The agent calls `save_skill(name, description, content)` to distill a
|
|
177
|
-
reusable procedure
|
|
181
|
+
- The agent calls `save_skill(name, description, content, files?)` to distill a
|
|
182
|
+
reusable procedure — `files` bundles helper scripts next to `SKILL.md`
|
|
183
|
+
(made executable automatically), and `load_skill` lists them as runnable
|
|
184
|
+
workspace paths. Available from the next turn, forever.
|
|
178
185
|
|
|
179
186
|
### Session log format (JSONL)
|
|
180
187
|
|
|
@@ -218,10 +225,13 @@ GET /api/agents list snapshots
|
|
|
218
225
|
GET /api/agents/:id one snapshot
|
|
219
226
|
POST /api/agents/:id/prompt {text, start?}
|
|
220
227
|
POST /api/agents/:id/start | /stop
|
|
221
|
-
POST /api/agents/:id/
|
|
228
|
+
POST /api/agents/:id/load lazy session restore (stopped → idle)
|
|
229
|
+
POST /api/agents/:id/goal {text, notify?} or {status}
|
|
222
230
|
POST /api/agents/:id/fork {} → new branch, same session log
|
|
231
|
+
POST /api/agents/:id/edit-prompt {eventId, text, tail: "discard"|"summarize"} → fork & resend
|
|
223
232
|
GET /api/agents/:id/events?limit&branch&session
|
|
224
233
|
GET /api/agents/:id/branches
|
|
234
|
+
GET /api/tasks scheduled tasks with computed next-fire times
|
|
225
235
|
GET /api/metrics master rss/heap/load + per-agent stats
|
|
226
236
|
GET /api/events SSE updates (push, no polling)
|
|
227
237
|
GET /brew 418 I'm a teapot (RFC 2324)
|
package/dist/agent/agent.js
CHANGED
|
@@ -27,6 +27,8 @@ Session state is not injected into prompts — fetch it with tools instead:
|
|
|
27
27
|
- set_goal(text) → change the objective itself (not routine updates).
|
|
28
28
|
- finish(goalComplete=true, summary) → goal fully achieved.
|
|
29
29
|
- read_memory() / set_memory(content) → your durable notes (memory.md).
|
|
30
|
+
- get_todo() / set_todo(content) → the operator-maintained task list
|
|
31
|
+
(todo.md); check it when picking up work, keep it current as you go.
|
|
30
32
|
- list_skills() / load_skill(name) / save_skill(...) → reusable playbooks.
|
|
31
33
|
- AGENTS.md in the workspace root (optional) holds project knowledge — read it
|
|
32
34
|
with read_file at session start when present, keep it current.
|
|
@@ -40,6 +42,10 @@ Session state is not injected into prompts — fetch it with tools instead:
|
|
|
40
42
|
text-munging (sed/awk/heredoc) remains available for quick bulk transforms
|
|
41
43
|
when that is genuinely faster.
|
|
42
44
|
- When a loaded skill matches your task, follow its playbook.
|
|
45
|
+
- Turn proven procedures into skills: once something non-trivial worked well,
|
|
46
|
+
save_skill(name, description, content, files=[{name, content}]) so future
|
|
47
|
+
sessions can load_skill them — helper scripts go through files and are made
|
|
48
|
+
executable automatically.
|
|
43
49
|
- When you make meaningful progress, call report_progress.
|
|
44
50
|
- Be frugal: prefer small precise edits, avoid runaway loops.`;
|
|
45
51
|
export class Agent {
|
|
@@ -52,10 +58,15 @@ export class Agent {
|
|
|
52
58
|
currentBranch = "br0";
|
|
53
59
|
goal = { text: "", status: "active", updatedAt: new Date().toISOString() };
|
|
54
60
|
latestProgress = null;
|
|
61
|
+
/** operator-maintained task list (todo.md) — humans edit, agent reads */
|
|
62
|
+
todo = "";
|
|
63
|
+
/** set once the conversation has been restored (lazy: on first interaction) */
|
|
64
|
+
readyPromise = null;
|
|
55
65
|
stats = {
|
|
56
66
|
turns: 0,
|
|
57
67
|
toolCalls: 0,
|
|
58
68
|
inputTokens: 0,
|
|
69
|
+
cachedInputTokens: 0,
|
|
59
70
|
outputTokens: 0,
|
|
60
71
|
compactions: 0,
|
|
61
72
|
startedAt: null,
|
|
@@ -76,19 +87,31 @@ export class Agent {
|
|
|
76
87
|
toolAbort = new AbortController();
|
|
77
88
|
runChain = Promise.resolve();
|
|
78
89
|
lastProgressAt = Date.now();
|
|
90
|
+
/** real assistant output since the last progress report (chars / turns) */
|
|
91
|
+
activityChars = 0;
|
|
92
|
+
turnsSinceProgress = 0;
|
|
79
93
|
consecutiveToolErrors = 0;
|
|
80
94
|
constructor(opts) {
|
|
81
95
|
this.opts = {
|
|
82
96
|
progressIntervalMs: 10 * 60_000,
|
|
97
|
+
progressMinChars: 4_000,
|
|
98
|
+
progressMaxQuietTurns: 40,
|
|
83
99
|
autoContinue: true,
|
|
84
100
|
continueDelayMs: 15_000,
|
|
85
101
|
maxConsecutiveToolErrors: 5,
|
|
86
102
|
contextTokenBudget: 96_000,
|
|
103
|
+
contextWindowTokens: 0,
|
|
87
104
|
restoreSession: true,
|
|
88
105
|
globalSkillsDir: "",
|
|
89
106
|
provider: "",
|
|
90
107
|
...opts,
|
|
91
108
|
};
|
|
109
|
+
// a declared window implies its own budget: compact at ~75% of the
|
|
110
|
+
// model's real context unless the config set an explicit one (the 96k
|
|
111
|
+
// default only makes sense for unknown-window models)
|
|
112
|
+
if (this.opts.contextWindowTokens && !opts.contextTokenBudget) {
|
|
113
|
+
this.opts.contextTokenBudget = Math.round(this.opts.contextWindowTokens * 0.75);
|
|
114
|
+
}
|
|
92
115
|
this.log = new EventLog(path.join(opts.sessionDir, "chat.jsonl"), opts.id);
|
|
93
116
|
this.skillRoots = [
|
|
94
117
|
{ dir: path.join(opts.workspace, "skills"), source: "workspace" },
|
|
@@ -143,9 +166,37 @@ export class Agent {
|
|
|
143
166
|
await this.migrateGoalFromWorkspace();
|
|
144
167
|
const stored = await this.readGoalStore();
|
|
145
168
|
this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
|
|
146
|
-
|
|
147
|
-
|
|
169
|
+
// operator-maintained task list lives beside goal.md
|
|
170
|
+
this.todo = await fs.readFile(this.todoFile, "utf8").catch(() => "");
|
|
148
171
|
await this.refreshSkills();
|
|
172
|
+
// the conversation is NOT restored here: boot cost stays O(agents), not
|
|
173
|
+
// O(history). It is rebuilt lazily by ensureReady() on first interaction.
|
|
174
|
+
if (this.opts.restoreSession) {
|
|
175
|
+
this.status = "stopped";
|
|
176
|
+
this.statusReason = "session not loaded — select it or send a prompt";
|
|
177
|
+
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Restore the conversation from the JSONL log exactly once, on demand.
|
|
182
|
+
* Everything that touches history (prompts, start, fork, UI selection)
|
|
183
|
+
* funnels through here; boot stays cheap no matter how many sessions exist.
|
|
184
|
+
*/
|
|
185
|
+
ensureReady() {
|
|
186
|
+
if (!this.readyPromise) {
|
|
187
|
+
this.readyPromise = (async () => {
|
|
188
|
+
if (this.opts.restoreSession) {
|
|
189
|
+
await this.restoreFromLog();
|
|
190
|
+
if (this.status === "stopped")
|
|
191
|
+
this.setStatus("idle", "session loaded");
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
}
|
|
195
|
+
return this.readyPromise;
|
|
196
|
+
}
|
|
197
|
+
/** Explicit load (e.g. the user clicked the agent in the UI): stopped → idle. */
|
|
198
|
+
async load() {
|
|
199
|
+
await this.ensureReady();
|
|
149
200
|
}
|
|
150
201
|
/** harness-managed files inside the session directory */
|
|
151
202
|
get goalFile() {
|
|
@@ -154,6 +205,9 @@ export class Agent {
|
|
|
154
205
|
get memoryFile() {
|
|
155
206
|
return path.join(this.opts.sessionDir, "memory.md");
|
|
156
207
|
}
|
|
208
|
+
get todoFile() {
|
|
209
|
+
return path.join(this.opts.sessionDir, "todo.md");
|
|
210
|
+
}
|
|
157
211
|
async readGoalStoreRaw() {
|
|
158
212
|
return fs.readFile(this.goalFile, "utf8").catch(() => null);
|
|
159
213
|
}
|
|
@@ -195,114 +249,11 @@ export class Agent {
|
|
|
195
249
|
const events = await readEvents(this.log.filePath);
|
|
196
250
|
if (events.length === 0)
|
|
197
251
|
return;
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
|
|
204
|
-
if (seen.has(cur.id))
|
|
205
|
-
break;
|
|
206
|
-
seen.add(cur.id);
|
|
207
|
-
lineage.push(cur);
|
|
208
|
-
}
|
|
209
|
-
lineage.reverse();
|
|
210
|
-
// skip the trailing fork event itself (it is bookkeeping, not conversation)
|
|
211
|
-
while (lineage.length && lineage[0].type === "fork")
|
|
212
|
-
lineage.shift();
|
|
213
|
-
const msgs = [];
|
|
214
|
-
// Prompts are logged the moment the user hits send — which can be while a
|
|
215
|
-
// tool batch is still open. Replaying them verbatim would slot a user
|
|
216
|
-
// message BETWEEN an assistant tool_call and its tool_result (API-invalid),
|
|
217
|
-
// so hold them until the open batch is answered, like live delivery does.
|
|
218
|
-
const META_TOOLS = new Set([
|
|
219
|
-
"finish", "report_progress", "set_goal", "get_goal",
|
|
220
|
-
"read_memory", "set_memory", "list_skills",
|
|
221
|
-
]);
|
|
222
|
-
const openCalls = new Map(); // real tool_call id -> name
|
|
223
|
-
const bufferedUsers = [];
|
|
224
|
-
const flushUsers = () => {
|
|
225
|
-
if (openCalls.size === 0) {
|
|
226
|
-
for (const text of bufferedUsers.splice(0))
|
|
227
|
-
msgs.push({ role: "user", content: text });
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
|
-
for (const e of lineage) {
|
|
231
|
-
const d = e.data;
|
|
232
|
-
if (e.type === "prompt" && typeof d.text === "string") {
|
|
233
|
-
if (openCalls.size > 0)
|
|
234
|
-
bufferedUsers.push(d.text);
|
|
235
|
-
else
|
|
236
|
-
msgs.push({ role: "user", content: d.text });
|
|
237
|
-
}
|
|
238
|
-
else if (e.type === "message") {
|
|
239
|
-
const role = d.role === "assistant" ? "assistant" : "user";
|
|
240
|
-
const m = { role, content: typeof d.content === "string" ? d.content : "" };
|
|
241
|
-
if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
|
|
242
|
-
m.tool_calls = d.toolCalls.map((c) => ({
|
|
243
|
-
id: c.id,
|
|
244
|
-
type: "function",
|
|
245
|
-
function: { name: c.name, arguments: "{}" },
|
|
246
|
-
}));
|
|
247
|
-
// meta tools are answered inline by the harness (no logged result);
|
|
248
|
-
// the hole-filling pass below synthesizes theirs where they belong
|
|
249
|
-
for (const t of m.tool_calls)
|
|
250
|
-
if (!META_TOOLS.has(t.function.name))
|
|
251
|
-
openCalls.set(t.id, t.function.name);
|
|
252
|
-
}
|
|
253
|
-
msgs.push(m);
|
|
254
|
-
}
|
|
255
|
-
else if (e.type === "tool_call") {
|
|
256
|
-
// enrich the preceding assistant tool_calls with real arguments
|
|
257
|
-
const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
|
|
258
|
-
const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
|
|
259
|
-
if (tc)
|
|
260
|
-
tc.function.arguments = JSON.stringify(d.args ?? {});
|
|
261
|
-
}
|
|
262
|
-
else if (e.type === "tool_result") {
|
|
263
|
-
msgs.push({
|
|
264
|
-
role: "tool",
|
|
265
|
-
tool_call_id: String(d.callId ?? ""),
|
|
266
|
-
content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
|
|
267
|
-
});
|
|
268
|
-
openCalls.delete(String(d.callId ?? ""));
|
|
269
|
-
flushUsers();
|
|
270
|
-
}
|
|
271
|
-
else if (e.type === "progress") {
|
|
272
|
-
// progress events may follow an assistant report_progress call that
|
|
273
|
-
// has no logged tool result — patch it in when present
|
|
274
|
-
const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
|
|
275
|
-
if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
|
|
276
|
-
for (const t of lastAssistant.tool_calls) {
|
|
277
|
-
if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
|
|
278
|
-
msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
|
|
279
|
-
}
|
|
280
|
-
openCalls.delete(t.id);
|
|
281
|
-
}
|
|
282
|
-
flushUsers();
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
// every assistant tool_call must be answered by a tool message, or the
|
|
287
|
-
// API rejects the sequence — close any holes left by meta tools (finish)
|
|
288
|
-
for (let i = 0; i < msgs.length; i++) {
|
|
289
|
-
const m = msgs[i];
|
|
290
|
-
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
291
|
-
for (const t of m.tool_calls) {
|
|
292
|
-
if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
|
|
293
|
-
msgs.splice(i + 1, 0, {
|
|
294
|
-
role: "tool",
|
|
295
|
-
tool_call_id: t.id,
|
|
296
|
-
content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
|
|
297
|
-
});
|
|
298
|
-
i++;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
// prompts that were still waiting on a hole-filled tail land here
|
|
304
|
-
for (const text of bufferedUsers.splice(0))
|
|
305
|
-
msgs.push({ role: "user", content: text });
|
|
252
|
+
const lineage = lineageOf(events);
|
|
253
|
+
if (!lineage.length)
|
|
254
|
+
return;
|
|
255
|
+
const last = lineage[lineage.length - 1];
|
|
256
|
+
const msgs = rebuildMessagesFrom(lineage);
|
|
306
257
|
if (msgs.length > 0) {
|
|
307
258
|
this.messages = msgs;
|
|
308
259
|
this.currentBranch = last.branch;
|
|
@@ -313,6 +264,61 @@ export class Agent {
|
|
|
313
264
|
});
|
|
314
265
|
}
|
|
315
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Edit a previously-sent prompt: fork the conversation at that point,
|
|
269
|
+
* replace its text, and optionally fold everything that happened after it
|
|
270
|
+
* into a summary note on the new branch (ChatGPT-edit style). The agent
|
|
271
|
+
* must not be running — editing under a live loop would race its history.
|
|
272
|
+
*/
|
|
273
|
+
async editPromptAt(eventId, text, tail) {
|
|
274
|
+
if (this.status === "running")
|
|
275
|
+
throw new Error("agent is running — stop it before editing history");
|
|
276
|
+
const all = await readEvents(this.log.filePath);
|
|
277
|
+
const target = all.find((e) => e.id === eventId);
|
|
278
|
+
if (!target || target.type !== "prompt")
|
|
279
|
+
throw new Error("event not found on this session (or not a prompt)");
|
|
280
|
+
const lineage = lineageOf(all);
|
|
281
|
+
const tIdx = lineage.findIndex((e) => e.id === eventId);
|
|
282
|
+
if (tIdx === -1)
|
|
283
|
+
throw new Error("prompt is not on this agent's current lineage");
|
|
284
|
+
const kept = lineage.slice(0, tIdx);
|
|
285
|
+
const dropped = lineage.slice(tIdx); // includes the original prompt itself
|
|
286
|
+
const msgs = rebuildMessagesFrom(kept);
|
|
287
|
+
if (tail === "summarize" && dropped.length > 0) {
|
|
288
|
+
try {
|
|
289
|
+
const droppedMsgs = rebuildMessagesFrom(dropped);
|
|
290
|
+
if (droppedMsgs.length) {
|
|
291
|
+
const summary = await this.summarize(droppedMsgs);
|
|
292
|
+
if (summary.trim()) {
|
|
293
|
+
msgs.push({
|
|
294
|
+
role: "user",
|
|
295
|
+
content: "[harness] The conversation continued past this point on another timeline. " +
|
|
296
|
+
`Notes from what happened there:\n\n${summary}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// summarization is best-effort; the fork proceeds without notes
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
msgs.push({ role: "user", content: text });
|
|
306
|
+
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
307
|
+
await this.log.append("fork", this.currentSession, newBranch, {
|
|
308
|
+
fromSession: this.currentSession,
|
|
309
|
+
fromBranch: this.currentBranch,
|
|
310
|
+
fromEvent: kept.at(-1)?.id ?? null,
|
|
311
|
+
newBranch,
|
|
312
|
+
reason: "prompt-edited",
|
|
313
|
+
droppedEvents: dropped.length,
|
|
314
|
+
tailMode: tail,
|
|
315
|
+
});
|
|
316
|
+
this.currentBranch = newBranch;
|
|
317
|
+
this.messages = msgs;
|
|
318
|
+
await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
|
|
319
|
+
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
320
|
+
return { droppedEvents: dropped.length, branch: newBranch };
|
|
321
|
+
}
|
|
316
322
|
parseGoalFile(text) {
|
|
317
323
|
// humans and agents may append their own status lines — latest wins
|
|
318
324
|
const all = [...text.matchAll(/status:\s*(\w+)/gi)];
|
|
@@ -344,6 +350,12 @@ export class Agent {
|
|
|
344
350
|
await this.writeGoalFile();
|
|
345
351
|
await this.log.append("goal", this.currentSession, this.currentBranch, { event: "set", text });
|
|
346
352
|
}
|
|
353
|
+
/** Persist the operator-maintained task list (todo.md). */
|
|
354
|
+
async setTodo(text, by = "human") {
|
|
355
|
+
this.todo = text;
|
|
356
|
+
await fs.writeFile(this.todoFile, text, "utf8");
|
|
357
|
+
await this.log.append("todo", this.currentSession, this.currentBranch, { event: "set", by });
|
|
358
|
+
}
|
|
347
359
|
async setGoalStatus(status) {
|
|
348
360
|
this.goal = { ...this.goal, status, updatedAt: new Date().toISOString() };
|
|
349
361
|
await this.writeGoalFile();
|
|
@@ -363,19 +375,28 @@ export class Agent {
|
|
|
363
375
|
model: this.opts.llm.model,
|
|
364
376
|
provider: this.opts.provider,
|
|
365
377
|
sessionDir: this.opts.sessionDir,
|
|
378
|
+
ctx: {
|
|
379
|
+
usedTokens: this.estimateTokens(),
|
|
380
|
+
compactAt: this.opts.contextTokenBudget,
|
|
381
|
+
window: this.opts.contextWindowTokens || 0,
|
|
382
|
+
},
|
|
366
383
|
pendingPrompts: this.pendingPrompts.length,
|
|
384
|
+
todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
|
|
367
385
|
};
|
|
368
386
|
}
|
|
369
387
|
/**
|
|
370
388
|
* Queue a user prompt. Returns immediately: the event is logged right away
|
|
371
389
|
* (so every connected UI sees it instantly) and the text is handed to the
|
|
372
390
|
* model at the next turn boundary — never mid-turn, and never blocked by
|
|
373
|
-
* the running loop.
|
|
391
|
+
* the running loop. The very first prompt on a fresh boot also triggers the
|
|
392
|
+
* lazy session restore (before the mailbox is filled, so no duplicates).
|
|
374
393
|
*/
|
|
375
394
|
enqueuePrompt(text, source = "user") {
|
|
376
|
-
this.
|
|
377
|
-
|
|
378
|
-
|
|
395
|
+
void this.ensureReady()
|
|
396
|
+
.then(() => {
|
|
397
|
+
this.pendingPrompts.push({ source, text });
|
|
398
|
+
return this.log.append("prompt", this.currentSession, this.currentBranch, { source, text });
|
|
399
|
+
})
|
|
379
400
|
.then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
|
|
380
401
|
.catch(() => { });
|
|
381
402
|
}
|
|
@@ -400,6 +421,7 @@ export class Agent {
|
|
|
400
421
|
return;
|
|
401
422
|
this.stopRequested = false;
|
|
402
423
|
void this.enqueue(async () => {
|
|
424
|
+
await this.ensureReady(); // lazy restore before the loop touches history
|
|
403
425
|
this.setStatus("running", reason);
|
|
404
426
|
this.stats.startedAt ??= new Date().toISOString();
|
|
405
427
|
});
|
|
@@ -540,16 +562,43 @@ export class Agent {
|
|
|
540
562
|
turn: ++this.stats.turns,
|
|
541
563
|
});
|
|
542
564
|
// stream the assistant reply live to connected clients
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
565
|
+
let res;
|
|
566
|
+
try {
|
|
567
|
+
res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
|
|
568
|
+
bus.emit("update", {
|
|
569
|
+
kind: "llm-delta",
|
|
570
|
+
agentId: this.opts.id,
|
|
571
|
+
text: s.text,
|
|
572
|
+
reasoning: s.reasoning,
|
|
573
|
+
});
|
|
549
574
|
});
|
|
550
|
-
}
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
// user stop mid-stream: persist the partial output so the timeline
|
|
578
|
+
// keeps what was already visible (otherwise it silently vanishes)
|
|
579
|
+
const partial = err.partial;
|
|
580
|
+
if (this.stopRequested && partial && (partial.text || partial.reasoning)) {
|
|
581
|
+
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
582
|
+
role: "assistant",
|
|
583
|
+
content: partial.text ?? "",
|
|
584
|
+
reasoning: partial.reasoning,
|
|
585
|
+
interrupted: true,
|
|
586
|
+
});
|
|
587
|
+
this.messages.push({ role: "assistant", content: partial.text ?? "" });
|
|
588
|
+
}
|
|
589
|
+
else if (this.stopRequested) {
|
|
590
|
+
// nothing had streamed — leave an explicit marker so the log shows
|
|
591
|
+
// why this prompt has no reply
|
|
592
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
593
|
+
event: "turn-interrupted",
|
|
594
|
+
detail: "stopped before any output arrived",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
throw err;
|
|
598
|
+
}
|
|
551
599
|
if (res.usage) {
|
|
552
600
|
this.stats.inputTokens += res.usage.inputTokens ?? 0;
|
|
601
|
+
this.stats.cachedInputTokens += res.usage.cachedInputTokens ?? 0;
|
|
553
602
|
this.stats.outputTokens += res.usage.outputTokens ?? 0;
|
|
554
603
|
await this.log.append("usage", this.currentSession, this.currentBranch, res.usage);
|
|
555
604
|
}
|
|
@@ -561,6 +610,8 @@ export class Agent {
|
|
|
561
610
|
reasoning: res.reasoning,
|
|
562
611
|
});
|
|
563
612
|
this.messages.push(m);
|
|
613
|
+
this.turnsSinceProgress++;
|
|
614
|
+
this.activityChars += m.content?.length ?? 0;
|
|
564
615
|
if (!m.tool_calls?.length)
|
|
565
616
|
return finished;
|
|
566
617
|
for (const call of m.tool_calls) {
|
|
@@ -606,6 +657,25 @@ export class Agent {
|
|
|
606
657
|
});
|
|
607
658
|
continue;
|
|
608
659
|
}
|
|
660
|
+
if (call.function.name === "get_todo") {
|
|
661
|
+
this.messages.push({
|
|
662
|
+
role: "tool",
|
|
663
|
+
tool_call_id: call.id,
|
|
664
|
+
content: this.todo.trim() || "(todo.md is empty — no task list yet)",
|
|
665
|
+
});
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (call.function.name === "set_todo") {
|
|
669
|
+
const a = safeParse(call.function.arguments);
|
|
670
|
+
const content = String(a.content ?? "").slice(0, 32_000);
|
|
671
|
+
await this.setTodo(content, "agent");
|
|
672
|
+
this.messages.push({
|
|
673
|
+
role: "tool",
|
|
674
|
+
tool_call_id: call.id,
|
|
675
|
+
content: "task list updated (visible to the operator)",
|
|
676
|
+
});
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
609
679
|
if (call.function.name === "read_memory") {
|
|
610
680
|
const mem = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
|
|
611
681
|
this.messages.push({
|
|
@@ -677,9 +747,14 @@ export class Agent {
|
|
|
677
747
|
});
|
|
678
748
|
}
|
|
679
749
|
async maybeRequestProgress() {
|
|
680
|
-
|
|
681
|
-
|
|
750
|
+
const elapsedOk = Date.now() - this.lastProgressAt >= this.opts.progressIntervalMs;
|
|
751
|
+
const activityOk = this.activityChars >= this.opts.progressMinChars ||
|
|
752
|
+
this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
|
|
753
|
+
if (!elapsedOk || !activityOk)
|
|
754
|
+
return; // stalling provider → don't waste a turn asking
|
|
682
755
|
this.lastProgressAt = Date.now();
|
|
756
|
+
this.activityChars = 0;
|
|
757
|
+
this.turnsSinceProgress = 0;
|
|
683
758
|
const request = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
|
|
684
759
|
"what you recently tried, any problems, and your next step. Keep it under 10 lines.";
|
|
685
760
|
// log both sides so a session restore replays this exchange faithfully
|
|
@@ -815,6 +890,10 @@ export class Agent {
|
|
|
815
890
|
next: str(a.next) || undefined,
|
|
816
891
|
ts: new Date().toISOString(),
|
|
817
892
|
};
|
|
893
|
+
// a report (voluntary or requested) restarts the progress gates
|
|
894
|
+
this.lastProgressAt = Date.now();
|
|
895
|
+
this.activityChars = 0;
|
|
896
|
+
this.turnsSinceProgress = 0;
|
|
818
897
|
await this.log.append("progress", this.currentSession, this.currentBranch, this.latestProgress);
|
|
819
898
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
820
899
|
}
|
|
@@ -921,6 +1000,26 @@ function allToolSpecs() {
|
|
|
921
1000
|
parameters: { type: "object", properties: {} },
|
|
922
1001
|
},
|
|
923
1002
|
},
|
|
1003
|
+
{
|
|
1004
|
+
type: "function",
|
|
1005
|
+
function: {
|
|
1006
|
+
name: "get_todo",
|
|
1007
|
+
description: "Fetch the operator-maintained task list (todo.md). Check it when picking up work or when unsure what to do next.",
|
|
1008
|
+
parameters: { type: "object", properties: {} },
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
type: "function",
|
|
1013
|
+
function: {
|
|
1014
|
+
name: "set_todo",
|
|
1015
|
+
description: "Replace the operator-visible task list (todo.md) — e.g. check off finished items or restate what remains. Keep it terse.",
|
|
1016
|
+
parameters: {
|
|
1017
|
+
type: "object",
|
|
1018
|
+
properties: { content: { type: "string" } },
|
|
1019
|
+
required: ["content"],
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
924
1023
|
{
|
|
925
1024
|
type: "function",
|
|
926
1025
|
function: {
|
|
@@ -962,3 +1061,120 @@ function safeParse(json) {
|
|
|
962
1061
|
return {};
|
|
963
1062
|
}
|
|
964
1063
|
}
|
|
1064
|
+
/** Walk parent links backwards from the newest event, then flip forward. */
|
|
1065
|
+
function lineageOf(events) {
|
|
1066
|
+
if (events.length === 0)
|
|
1067
|
+
return [];
|
|
1068
|
+
const byId = new Map(events.map((e) => [e.id, e]));
|
|
1069
|
+
const last = events[events.length - 1];
|
|
1070
|
+
const lineage = [];
|
|
1071
|
+
const seen = new Set();
|
|
1072
|
+
for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
|
|
1073
|
+
if (seen.has(cur.id))
|
|
1074
|
+
break;
|
|
1075
|
+
seen.add(cur.id);
|
|
1076
|
+
lineage.push(cur);
|
|
1077
|
+
}
|
|
1078
|
+
lineage.reverse();
|
|
1079
|
+
// the trailing fork event itself is bookkeeping, not conversation
|
|
1080
|
+
while (lineage.length && lineage[0].type === "fork")
|
|
1081
|
+
lineage.shift();
|
|
1082
|
+
return lineage;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Replay ordered events into ChatMessages (shared by session restore and
|
|
1086
|
+
* prompt-edit forks). Prompts logged inside an open tool batch are buffered
|
|
1087
|
+
* until it closes, so user messages never split a tool_call/tool_result pair.
|
|
1088
|
+
*/
|
|
1089
|
+
function rebuildMessagesFrom(list) {
|
|
1090
|
+
const msgs = [];
|
|
1091
|
+
const META_TOOLS = new Set([
|
|
1092
|
+
"finish", "report_progress", "set_goal", "get_goal",
|
|
1093
|
+
"read_memory", "set_memory", "list_skills", "get_todo", "set_todo",
|
|
1094
|
+
]);
|
|
1095
|
+
const openCalls = new Map(); // real tool_call id -> name
|
|
1096
|
+
const bufferedUsers = [];
|
|
1097
|
+
const flushUsers = () => {
|
|
1098
|
+
if (openCalls.size === 0) {
|
|
1099
|
+
for (const text of bufferedUsers.splice(0))
|
|
1100
|
+
msgs.push({ role: "user", content: text });
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
for (const e of list) {
|
|
1104
|
+
const d = e.data;
|
|
1105
|
+
if (e.type === "prompt" && typeof d.text === "string") {
|
|
1106
|
+
if (openCalls.size > 0)
|
|
1107
|
+
bufferedUsers.push(d.text);
|
|
1108
|
+
else
|
|
1109
|
+
msgs.push({ role: "user", content: d.text });
|
|
1110
|
+
}
|
|
1111
|
+
else if (e.type === "message") {
|
|
1112
|
+
const role = d.role === "assistant" ? "assistant" : "user";
|
|
1113
|
+
const m = { role, content: typeof d.content === "string" ? d.content : "" };
|
|
1114
|
+
if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
|
|
1115
|
+
m.tool_calls = d.toolCalls.map((c) => ({
|
|
1116
|
+
id: c.id,
|
|
1117
|
+
type: "function",
|
|
1118
|
+
function: { name: c.name, arguments: "{}" },
|
|
1119
|
+
}));
|
|
1120
|
+
// meta tools are answered inline by the harness (no logged result);
|
|
1121
|
+
// the hole-filling pass below synthesizes theirs where they belong
|
|
1122
|
+
for (const t of m.tool_calls)
|
|
1123
|
+
if (!META_TOOLS.has(t.function.name))
|
|
1124
|
+
openCalls.set(t.id, t.function.name);
|
|
1125
|
+
}
|
|
1126
|
+
msgs.push(m);
|
|
1127
|
+
}
|
|
1128
|
+
else if (e.type === "tool_call") {
|
|
1129
|
+
// enrich the preceding assistant tool_calls with real arguments
|
|
1130
|
+
const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
|
|
1131
|
+
const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
|
|
1132
|
+
if (tc)
|
|
1133
|
+
tc.function.arguments = JSON.stringify(d.args ?? {});
|
|
1134
|
+
}
|
|
1135
|
+
else if (e.type === "tool_result") {
|
|
1136
|
+
msgs.push({
|
|
1137
|
+
role: "tool",
|
|
1138
|
+
tool_call_id: String(d.callId ?? ""),
|
|
1139
|
+
content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
|
|
1140
|
+
});
|
|
1141
|
+
openCalls.delete(String(d.callId ?? ""));
|
|
1142
|
+
flushUsers();
|
|
1143
|
+
}
|
|
1144
|
+
else if (e.type === "progress") {
|
|
1145
|
+
// progress events may follow an assistant report_progress call that
|
|
1146
|
+
// has no logged tool result — patch it in when present
|
|
1147
|
+
const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
|
|
1148
|
+
if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
|
|
1149
|
+
for (const t of lastAssistant.tool_calls) {
|
|
1150
|
+
if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
|
|
1151
|
+
msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
|
|
1152
|
+
}
|
|
1153
|
+
openCalls.delete(t.id);
|
|
1154
|
+
}
|
|
1155
|
+
flushUsers();
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
// every assistant tool_call must be answered by a tool message, or the
|
|
1160
|
+
// API rejects the sequence — close any holes left by meta tools (finish)
|
|
1161
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
1162
|
+
const m = msgs[i];
|
|
1163
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
1164
|
+
for (const t of m.tool_calls) {
|
|
1165
|
+
if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
|
|
1166
|
+
msgs.splice(i + 1, 0, {
|
|
1167
|
+
role: "tool",
|
|
1168
|
+
tool_call_id: t.id,
|
|
1169
|
+
content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
|
|
1170
|
+
});
|
|
1171
|
+
i++;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
// prompts that were still waiting on a hole-filled tail land here
|
|
1177
|
+
for (const text of bufferedUsers.splice(0))
|
|
1178
|
+
msgs.push({ role: "user", content: text });
|
|
1179
|
+
return msgs;
|
|
1180
|
+
}
|