teapot-coding-agent 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.
@@ -0,0 +1,730 @@
1
+ /**
2
+ * Agent: an event-driven loop over one workspace.
3
+ *
4
+ * CPU discipline: the agent only consumes CPU while waiting on LLM/tool I/O
5
+ * promises; when idle it holds zero timers and zero polling loops. All
6
+ * periodic behaviour (progress reports, scheduled tasks) is driven by the
7
+ * master's single low-frequency scheduler tick or by turn boundaries.
8
+ */
9
+ import { promises as fs } from "node:fs";
10
+ import path from "node:path";
11
+ import { EventLog, readEvents } from "../log/events.js";
12
+ import { chat } from "./llm.js";
13
+ import { executeTool, toolSpecs, currentSkills } from "./tools.js";
14
+ import { bus } from "../bus.js";
15
+ const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
16
+
17
+ ## Persistent context files (human-readable, git-tracked)
18
+ - AGENTS.md : project knowledge/conventions written for agents (read it first)
19
+ - GOAL.md : your current long-term goal and its status
20
+ - MEMORY.md : durable notes you write for yourself
21
+
22
+ Keep these files updated with edit_file/write_file. They survive restarts.
23
+
24
+ ## Rules
25
+ - Work step by step with tools. Verify results (run tests/builds) before claiming progress.
26
+ - When a task matches an available skill's description, load_skill it first and follow the playbook.
27
+ - When you develop a reusable procedure, save_skill it — skills persist and are offered to future sessions.
28
+ - When you make meaningful progress, call report_progress.
29
+ - When the goal is fully achieved, call finish(goalComplete=true) with a short summary.
30
+ - Be frugal: prefer small precise edits, avoid runaway loops.`;
31
+ export class Agent {
32
+ log;
33
+ toolCtx;
34
+ status = "idle";
35
+ statusReason = "";
36
+ mainSession;
37
+ currentSession;
38
+ currentBranch = "br0";
39
+ goal = { text: "", status: "active", updatedAt: new Date().toISOString() };
40
+ latestProgress = null;
41
+ stats = {
42
+ turns: 0,
43
+ toolCalls: 0,
44
+ inputTokens: 0,
45
+ outputTokens: 0,
46
+ compactions: 0,
47
+ startedAt: null,
48
+ };
49
+ opts;
50
+ messages = [];
51
+ stopRequested = false;
52
+ wake = null;
53
+ abort = null;
54
+ runChain = Promise.resolve();
55
+ lastProgressAt = Date.now();
56
+ consecutiveToolErrors = 0;
57
+ constructor(opts) {
58
+ this.opts = {
59
+ progressIntervalMs: 10 * 60_000,
60
+ autoContinue: true,
61
+ continueDelayMs: 15_000,
62
+ maxConsecutiveToolErrors: 5,
63
+ contextTokenBudget: 96_000,
64
+ restoreSession: true,
65
+ globalSkillsDir: "",
66
+ ...opts,
67
+ };
68
+ this.log = new EventLog(opts.logFile, opts.id);
69
+ this.skillRoots = [
70
+ { dir: path.join(opts.workspace, "skills"), source: "workspace" },
71
+ ...(opts.globalSkillsDir ? [{ dir: opts.globalSkillsDir, source: "global" }] : []),
72
+ ];
73
+ this.toolCtx = {
74
+ cwd: opts.workspace,
75
+ defaultTimeoutMs: 120_000,
76
+ maxOutputBytes: 60_000,
77
+ skillRoots: this.skillRoots,
78
+ };
79
+ this.mainSession = `sess-${opts.id}-main`;
80
+ this.currentSession = this.mainSession;
81
+ }
82
+ skillRoots;
83
+ skillsCache = [];
84
+ /** Rescan skill roots (cheap: a readdir per root, done once per turn). */
85
+ async refreshSkills() {
86
+ try {
87
+ this.skillsCache = await currentSkills(this.toolCtx);
88
+ }
89
+ catch {
90
+ /* keep previous cache */
91
+ }
92
+ }
93
+ skillsListing() {
94
+ if (this.skillsCache.length === 0) {
95
+ return ("## Skills\n" +
96
+ "No skills exist yet. When you develop a reusable procedure worth keeping " +
97
+ "(build steps, checklists, project conventions), distill it into a durable playbook " +
98
+ "with save_skill so future sessions can load it via load_skill.");
99
+ }
100
+ return ("## Skills (reusable playbooks)\n" +
101
+ "When the current task matches a description below, call load_skill(name) first and follow it.\n" +
102
+ this.skillsCache.map((s) => `- ${s.name}: ${s.description || "(no description)"}`).join("\n"));
103
+ }
104
+ callLlm(messages, tools) {
105
+ const fn = this.opts.chatFn ?? chat;
106
+ return fn(this.opts.llm, messages, tools, this.abort?.signal);
107
+ }
108
+ /** expose id for metrics */
109
+ opts_id() {
110
+ return this.opts.id;
111
+ }
112
+ get workspace() {
113
+ return this.opts.workspace;
114
+ }
115
+ async init() {
116
+ await this.log.load();
117
+ await fs.mkdir(this.workspace, { recursive: true });
118
+ // seed persistent context files if missing
119
+ await this.seed("AGENTS.md", "# Project knowledge\n\n(Describe conventions, build commands, and gotchas here.)\n");
120
+ await this.seed("MEMORY.md", "# Memory\n");
121
+ const goalText = await this.readGoalFile();
122
+ if (goalText !== null)
123
+ this.goal = this.parseGoalFile(goalText);
124
+ else
125
+ await this.writeGoalFile();
126
+ if (this.opts.restoreSession)
127
+ await this.restoreFromLog();
128
+ await this.refreshSkills();
129
+ }
130
+ /**
131
+ * Rebuild the in-memory conversation from the JSONL event log so a restart
132
+ * continues where the agent left off instead of starting blank.
133
+ *
134
+ * Strategy: find the most recent event overall (its branch is where the
135
+ * agent was), walk the `parent` chain backwards (crossing fork points),
136
+ * then replay events forward into ChatMessages. Meta-tool calls
137
+ * (finish / report_progress) never produced logged tool results, so we
138
+ * synthesize their responses to keep the message sequence valid.
139
+ */
140
+ async restoreFromLog() {
141
+ const events = await readEvents(this.log.filePath);
142
+ if (events.length === 0)
143
+ return;
144
+ const byId = new Map(events.map((e) => [e.id, e]));
145
+ let last = events[events.length - 1];
146
+ // walk backwards to the root via parent links (fork-safe)
147
+ const lineage = [];
148
+ const seen = new Set();
149
+ for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
150
+ if (seen.has(cur.id))
151
+ break;
152
+ seen.add(cur.id);
153
+ lineage.push(cur);
154
+ }
155
+ lineage.reverse();
156
+ // skip the trailing fork event itself (it is bookkeeping, not conversation)
157
+ while (lineage.length && lineage[0].type === "fork")
158
+ lineage.shift();
159
+ const msgs = [];
160
+ for (const e of lineage) {
161
+ const d = e.data;
162
+ if (e.type === "prompt" && typeof d.text === "string") {
163
+ msgs.push({ role: "user", content: d.text });
164
+ }
165
+ else if (e.type === "message") {
166
+ const role = d.role === "assistant" ? "assistant" : "user";
167
+ const m = { role, content: typeof d.content === "string" ? d.content : "" };
168
+ if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
169
+ m.tool_calls = d.toolCalls.map((c) => ({
170
+ id: c.id,
171
+ type: "function",
172
+ function: { name: c.name, arguments: "{}" },
173
+ }));
174
+ }
175
+ msgs.push(m);
176
+ }
177
+ else if (e.type === "tool_call") {
178
+ // enrich the preceding assistant tool_calls with real arguments
179
+ const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
180
+ const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
181
+ if (tc)
182
+ tc.function.arguments = JSON.stringify(d.args ?? {});
183
+ }
184
+ else if (e.type === "tool_result") {
185
+ msgs.push({
186
+ role: "tool",
187
+ tool_call_id: String(d.callId ?? ""),
188
+ content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
189
+ });
190
+ }
191
+ else if (e.type === "progress") {
192
+ // progress events may follow an assistant report_progress call that
193
+ // has no logged tool result — patch it in when present
194
+ const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
195
+ if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
196
+ for (const t of lastAssistant.tool_calls) {
197
+ if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
198
+ msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ // every assistant tool_call must be answered by a tool message, or the
205
+ // API rejects the sequence — close any holes left by meta tools (finish)
206
+ for (let i = 0; i < msgs.length; i++) {
207
+ const m = msgs[i];
208
+ if (m.role === "assistant" && m.tool_calls?.length) {
209
+ for (const t of m.tool_calls) {
210
+ if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
211
+ msgs.splice(i + 1, 0, {
212
+ role: "tool",
213
+ tool_call_id: t.id,
214
+ content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
215
+ });
216
+ i++;
217
+ }
218
+ }
219
+ }
220
+ }
221
+ if (msgs.length > 0) {
222
+ this.messages = msgs;
223
+ this.currentBranch = last.branch;
224
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
225
+ event: "session-restored",
226
+ branch: last.branch,
227
+ messages: msgs.length,
228
+ });
229
+ }
230
+ }
231
+ async seed(file, content) {
232
+ const p = path.join(this.workspace, file);
233
+ try {
234
+ await fs.access(p);
235
+ }
236
+ catch {
237
+ await fs.writeFile(p, content, "utf8");
238
+ }
239
+ }
240
+ readGoalFile() {
241
+ return fs.readFile(path.join(this.workspace, "GOAL.md"), "utf8").catch(() => null);
242
+ }
243
+ parseGoalFile(text) {
244
+ const m = text.match(/status:\s*(\w+)/i);
245
+ const status = m?.[1] === "done" ? "done" : m?.[1] === "paused" ? "paused" : "active";
246
+ return { text: text.trim(), status, updatedAt: new Date().toISOString() };
247
+ }
248
+ async writeGoalFile() {
249
+ const body = `${this.goal.text}\n\nstatus: ${this.goal.status}\nupdated: ${this.goal.updatedAt}\n`;
250
+ await fs.writeFile(path.join(this.workspace, "GOAL.md"), body, "utf8");
251
+ }
252
+ async setGoal(text) {
253
+ this.goal = { text, status: "active", updatedAt: new Date().toISOString() };
254
+ await this.writeGoalFile();
255
+ await this.log.append("goal", this.currentSession, this.currentBranch, { event: "set", text });
256
+ }
257
+ async setGoalStatus(status) {
258
+ this.goal = { ...this.goal, status, updatedAt: new Date().toISOString() };
259
+ await this.writeGoalFile();
260
+ await this.log.append("goal", this.currentSession, this.currentBranch, { event: "status", status });
261
+ }
262
+ snapshot() {
263
+ return {
264
+ id: this.opts.id,
265
+ status: this.status,
266
+ statusReason: this.statusReason,
267
+ workspace: this.workspace,
268
+ session: this.currentSession,
269
+ branch: this.currentBranch,
270
+ goal: { status: this.goal.status, text: this.goal.text.slice(0, 400) },
271
+ latestProgress: this.latestProgress,
272
+ stats: { ...this.stats },
273
+ model: this.opts.llm.model,
274
+ };
275
+ }
276
+ /** Queue a user prompt; wakes the loop if needed. Returns immediately. */
277
+ enqueuePrompt(text, source = "user") {
278
+ return this.enqueue(async () => {
279
+ await this.log.append("prompt", this.currentSession, this.currentBranch, { source, text });
280
+ this.messages.push({ role: "user", content: text });
281
+ });
282
+ }
283
+ /** Resolves when all queued work (including a running loop) has settled. */
284
+ settled() {
285
+ return this.runChain.catch(() => { });
286
+ }
287
+ enqueue(fn) {
288
+ const p = this.runChain.then(fn);
289
+ this.runChain = p.then(() => { }, () => { });
290
+ return p;
291
+ }
292
+ /** Start (or resume) autonomous operation toward the goal. */
293
+ start(reason = "start") {
294
+ if (this.status === "running")
295
+ return;
296
+ this.stopRequested = false;
297
+ void this.enqueue(async () => {
298
+ this.setStatus("running", reason);
299
+ this.stats.startedAt ??= new Date().toISOString();
300
+ });
301
+ void this.enqueue(() => this.loop());
302
+ }
303
+ stop(reason = "stopped by user") {
304
+ this.stopRequested = true;
305
+ // interrupt an in-flight LLM call immediately instead of waiting it out
306
+ this.abort?.abort();
307
+ this.wake?.();
308
+ void this.enqueue(() => {
309
+ this.setStatus("stopped", reason);
310
+ return Promise.resolve();
311
+ });
312
+ }
313
+ setStatus(s, reason = "") {
314
+ if (this.status !== s) {
315
+ void this.log.append("state", this.currentSession, this.currentBranch, {
316
+ from: this.status,
317
+ to: s,
318
+ reason,
319
+ });
320
+ }
321
+ this.status = s;
322
+ this.statusReason = reason;
323
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
324
+ }
325
+ /** Main auto-run loop: turn -> tools -> turn ... -> done/idle/stop. */
326
+ async loop() {
327
+ while (!this.stopRequested) {
328
+ try {
329
+ const finished = await this.runTurnsUntilIdle();
330
+ if (finished || this.stopRequested)
331
+ break;
332
+ if (!this.opts.autoContinue || this.goal.status !== "active")
333
+ break;
334
+ // auto-continue: wait quietly, then nudge with a fresh round
335
+ await this.sleepInterruptible(this.opts.continueDelayMs);
336
+ if (this.stopRequested)
337
+ break;
338
+ const nudge = "Continue working toward the goal in GOAL.md. If you are blocked, explain why briefly.";
339
+ await this.log.append("prompt", this.currentSession, this.currentBranch, {
340
+ source: "harness",
341
+ text: nudge,
342
+ });
343
+ this.messages.push({ role: "user", content: nudge });
344
+ }
345
+ catch (err) {
346
+ const name = err.name;
347
+ // a stop (user abort or pre-call guard) is control flow, not a failure
348
+ if (this.stopRequested || name === "AbortError" || name === "StopRequested")
349
+ break;
350
+ const msg = err.message ?? String(err);
351
+ await this.log.append("error", this.currentSession, this.currentBranch, { message: msg });
352
+ this.setStatus("error", msg.slice(0, 300));
353
+ return;
354
+ }
355
+ }
356
+ if (!this.stopRequested)
357
+ this.setStatus("idle", "round complete");
358
+ }
359
+ /**
360
+ * One LLM call with a fresh abort controller so stop() can interrupt it
361
+ * immediately, plus loop-level retries for provider flakiness (the SDK
362
+ * already backsoff 429/5xx; this covers exhausted rate limits and 400s).
363
+ */
364
+ async llmCall(messages, tools) {
365
+ const maxAttempts = 3;
366
+ for (let attempt = 1;; attempt++) {
367
+ if (this.stopRequested)
368
+ throw Object.assign(new Error("stopped"), { name: "StopRequested" });
369
+ this.abort = new AbortController();
370
+ try {
371
+ return await this.callLlm(messages, tools);
372
+ }
373
+ catch (err) {
374
+ this.abort = null;
375
+ const name = err.name;
376
+ if (name === "StopRequested" || name === "AbortError" || this.stopRequested)
377
+ throw err;
378
+ if (attempt >= maxAttempts)
379
+ throw err;
380
+ const waitMs = attempt * 20_000;
381
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
382
+ event: "llm-retry",
383
+ attempt,
384
+ waitMs,
385
+ error: String(err.message).slice(0, 300),
386
+ });
387
+ await this.sleepInterruptible(waitMs);
388
+ }
389
+ }
390
+ }
391
+ /**
392
+ * One "round": alternate LLM turns and tool executions until the model
393
+ * produces a final answer without tool calls. Returns true if the agent
394
+ * called finish().
395
+ */
396
+ async runTurnsUntilIdle() {
397
+ let finished = false;
398
+ for (let guard = 0; guard < 200; guard++) {
399
+ if (this.stopRequested)
400
+ return finished;
401
+ // skills may have been created last turn — refresh the prompt listing
402
+ await this.refreshSkills();
403
+ // periodic progress report at turn boundary (no mid-turn interruption)
404
+ await this.maybeRequestProgress();
405
+ // keep the context window bounded before spending tokens on a turn
406
+ await this.maybeCompact();
407
+ await this.log.append("state", this.currentSession, this.currentBranch, {
408
+ from: this.status,
409
+ to: this.status,
410
+ detail: "llm turn start",
411
+ turn: ++this.stats.turns,
412
+ });
413
+ const res = await this.llmCall(this.buildMessages(), allToolSpecs());
414
+ if (res.usage) {
415
+ this.stats.inputTokens += res.usage.inputTokens ?? 0;
416
+ this.stats.outputTokens += res.usage.outputTokens ?? 0;
417
+ await this.log.append("usage", this.currentSession, this.currentBranch, res.usage);
418
+ }
419
+ const m = res.message;
420
+ await this.log.append("message", this.currentSession, this.currentBranch, {
421
+ role: "assistant",
422
+ content: m.content ?? "",
423
+ toolCalls: m.tool_calls?.map((c) => ({ id: c.id, name: c.function.name })),
424
+ });
425
+ this.messages.push(m);
426
+ if (!m.tool_calls?.length)
427
+ return finished;
428
+ for (const call of m.tool_calls) {
429
+ if (this.stopRequested)
430
+ return finished;
431
+ if (call.function.name === "finish") {
432
+ await this.handleFinish(call.function.arguments);
433
+ // answer the tool_call so a follow-up round stays API-valid
434
+ this.messages.push({
435
+ role: "tool",
436
+ tool_call_id: call.id,
437
+ content: this.goal.status === "done" ? "(goal complete)" : "(round ended)",
438
+ });
439
+ finished = true;
440
+ continue;
441
+ }
442
+ if (call.function.name === "report_progress") {
443
+ await this.recordProgress(call.function.arguments);
444
+ this.messages.push({
445
+ role: "tool",
446
+ tool_call_id: call.id,
447
+ content: "progress recorded",
448
+ });
449
+ continue;
450
+ }
451
+ await this.log.append("tool_call", this.currentSession, this.currentBranch, {
452
+ callId: call.id,
453
+ name: call.function.name,
454
+ args: safeParse(call.function.arguments),
455
+ });
456
+ const t0 = Date.now();
457
+ const result = await executeTool(call.function.name, call.function.arguments, this.toolCtx);
458
+ this.stats.toolCalls++;
459
+ await this.log.append("tool_result", this.currentSession, this.currentBranch, {
460
+ callId: call.id,
461
+ name: call.function.name,
462
+ ok: result.ok,
463
+ durationMs: Date.now() - t0,
464
+ result: result.result.slice(0, 8000),
465
+ });
466
+ this.messages.push({ role: "tool", tool_call_id: call.id, content: result.result });
467
+ this.consecutiveToolErrors = result.ok ? 0 : this.consecutiveToolErrors + 1;
468
+ if (this.consecutiveToolErrors >= this.opts.maxConsecutiveToolErrors) {
469
+ throw new Error(`runaway detection: ${this.consecutiveToolErrors} consecutive tool failures`);
470
+ }
471
+ }
472
+ if (finished)
473
+ return true;
474
+ }
475
+ throw new Error("runaway detection: too many turns in one round (>200)");
476
+ }
477
+ buildMessages() {
478
+ const sys = [SYSTEM_TEMPLATE];
479
+ if (this.goal.text) {
480
+ sys.push(`## Current goal (${this.goal.status})\n${this.goal.text}`);
481
+ }
482
+ sys.push(this.skillsListing());
483
+ const hasSystem = this.messages[0]?.role === "system";
484
+ const head = [{ role: "system", content: sys.join("\n\n") }];
485
+ return hasSystem ? [...head, ...this.messages.slice(1)] : [...head, ...this.messages];
486
+ }
487
+ async handleFinish(argsJson) {
488
+ const args = safeParse(argsJson);
489
+ if (args.goalComplete === true)
490
+ await this.setGoalStatus("done");
491
+ await this.log.append("message", this.currentSession, this.currentBranch, {
492
+ role: "assistant",
493
+ final: true,
494
+ content: String(args.summary ?? ""),
495
+ });
496
+ }
497
+ async maybeRequestProgress() {
498
+ if (Date.now() - this.lastProgressAt < this.opts.progressIntervalMs)
499
+ return;
500
+ this.lastProgressAt = Date.now();
501
+ const request = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
502
+ "what you recently tried, any problems, and your next step. Keep it under 10 lines.";
503
+ // log both sides so a session restore replays this exchange faithfully
504
+ await this.log.append("prompt", this.currentSession, this.currentBranch, {
505
+ source: "harness",
506
+ text: request,
507
+ });
508
+ this.messages.push({ role: "user", content: request });
509
+ const res = await this.llmCall(this.buildMessages(), []); // no tools: pure report
510
+ await this.recordProgress(JSON.stringify({ freeform: res.message.content }));
511
+ await this.log.append("message", this.currentSession, this.currentBranch, {
512
+ role: "assistant",
513
+ content: res.message.content ?? "",
514
+ });
515
+ this.messages.push(res.message);
516
+ }
517
+ /* ---------- context compaction ---------- */
518
+ /** rough token estimate (~4 chars/token); good enough to trigger before overflow */
519
+ estimateTokens() {
520
+ let chars = 0;
521
+ for (const m of this.messages) {
522
+ chars += (m.content?.length ?? 0) + JSON.stringify(m.tool_calls ?? "").length;
523
+ }
524
+ return Math.ceil(chars / 4);
525
+ }
526
+ /**
527
+ * Latest index whose message may START the kept tail of a compacted
528
+ * history. Anything except a dangling tool result is safe: a kept
529
+ * assistant-with-tool_calls always has its tool responses after it (they
530
+ * are never cut apart — we only remove a prefix).
531
+ */
532
+ safeCut(maxCut) {
533
+ for (let i = Math.min(maxCut, this.messages.length - 1); i >= 1; i--) {
534
+ if (this.messages[i].role !== "tool")
535
+ return i;
536
+ }
537
+ return -1;
538
+ }
539
+ /** Compact history when the estimated token count exceeds the budget. */
540
+ async maybeCompact() {
541
+ const before = this.estimateTokens();
542
+ if (before < this.opts.contextTokenBudget)
543
+ return;
544
+ // keep roughly the most recent quarter of the budget as live context
545
+ const keepCharBudget = (this.opts.contextTokenBudget / 4) * 4;
546
+ let keepChars = 0;
547
+ let cut = -1;
548
+ for (let i = this.messages.length - 1; i >= 1; i--) {
549
+ keepChars += (this.messages[i].content?.length ?? 0) + JSON.stringify(this.messages[i].tool_calls ?? "").length;
550
+ if (keepChars > keepCharBudget)
551
+ break;
552
+ cut = i;
553
+ }
554
+ cut = this.safeCut(cut);
555
+ if (cut <= 0)
556
+ return; // nothing safely compactable (should not happen)
557
+ const old = this.messages.slice(0, cut);
558
+ const oldCount = old.length;
559
+ let summary = "";
560
+ let mode = "summarize";
561
+ let summarizedCount = oldCount;
562
+ let droppedCount = 0;
563
+ try {
564
+ summary = await this.summarize(old);
565
+ }
566
+ catch (err) {
567
+ await this.log.append("error", this.currentSession, this.currentBranch, {
568
+ message: `compaction summarize failed, falling back to truncation: ${err.message}`,
569
+ });
570
+ }
571
+ if (!summary) {
572
+ // fallback: drop the oldest half without LLM help so work can continue
573
+ mode = "truncate";
574
+ summarizedCount = 0;
575
+ const halfCut = this.safeCut(Math.floor(oldCount / 2));
576
+ if (halfCut <= 0)
577
+ return;
578
+ droppedCount = halfCut;
579
+ this.messages = this.messages.slice(halfCut);
580
+ }
581
+ else {
582
+ this.messages = [
583
+ {
584
+ role: "user",
585
+ content: `[harness] Context was compacted: ${oldCount} earlier messages were summarized. ` +
586
+ "Persistent files (GOAL.md / AGENTS.md / MEMORY.md) are still on disk — re-read them when needed.\n\n" +
587
+ `## Summary of earlier conversation\n${summary}`,
588
+ },
589
+ ...this.messages.slice(cut),
590
+ ];
591
+ }
592
+ const after = this.estimateTokens();
593
+ this.stats.compactions++;
594
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
595
+ event: "context-compacted",
596
+ tokensBefore: before,
597
+ tokensAfter: after,
598
+ summarized: summarizedCount,
599
+ dropped: droppedCount,
600
+ mode,
601
+ });
602
+ }
603
+ /** Ask the model for dense continuation notes over the compacted range. */
604
+ async summarize(old) {
605
+ const transcript = old
606
+ .map((m) => {
607
+ const who = m.role === "tool" ? "tool" : m.role;
608
+ const tc = m.tool_calls?.map((t) => `\n[calls ${t.function.name}(${t.function.arguments})]`).join("");
609
+ return `${who}: ${m.content ?? ""}${tc}`;
610
+ })
611
+ .join("\n\n")
612
+ .slice(-120_000);
613
+ const fn = this.opts.chatFn ?? chat;
614
+ const res = await fn(this.opts.llm, [
615
+ {
616
+ role: "system",
617
+ content: "You compress a coding agent's conversation into dense notes for it to continue working. " +
618
+ "Preserve: current goal state, key decisions, files created/changed, important command results, " +
619
+ "open problems, and the next step. Be terse bullet points, no prose flourishes.",
620
+ },
621
+ { role: "user", content: `Conversation:\n\n${transcript}\n\nWrite the continuation notes now.` },
622
+ ], [], undefined);
623
+ return res.message.content ?? "";
624
+ }
625
+ async recordProgress(argsJson) {
626
+ const a = safeParse(argsJson);
627
+ this.latestProgress = {
628
+ doing: str(a.doing) || str(a.freeform),
629
+ goalStatus: str(a.goalStatus),
630
+ recent: str(a.recent),
631
+ problems: str(a.problems) || undefined,
632
+ next: str(a.next) || undefined,
633
+ ts: new Date().toISOString(),
634
+ };
635
+ await this.log.append("progress", this.currentSession, this.currentBranch, this.latestProgress);
636
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
637
+ }
638
+ sleepInterruptible(ms) {
639
+ return new Promise((resolve) => {
640
+ const timer = setTimeout(() => {
641
+ this.wake = null;
642
+ resolve();
643
+ }, ms);
644
+ this.wake = () => {
645
+ clearTimeout(timer);
646
+ this.wake = null;
647
+ resolve();
648
+ };
649
+ });
650
+ }
651
+ async dispose() {
652
+ this.stop("disposed");
653
+ await this.runChain.catch(() => { });
654
+ await this.log.close();
655
+ }
656
+ /* ---------- fork / branch management (session layer) ---------- */
657
+ /**
658
+ * Fork the conversation: copy the message history into a fresh branch of the
659
+ * SAME log file, recording lineage so reconstruction stays possible.
660
+ */
661
+ async fork(fromEventId) {
662
+ const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
663
+ const parentBranch = this.currentBranch;
664
+ await this.log.append("fork", this.currentSession, newBranch, {
665
+ fromSession: this.currentSession,
666
+ fromBranch: parentBranch,
667
+ fromEvent: fromEventId ?? this.log.lastEventId(parentBranch),
668
+ newBranch,
669
+ });
670
+ this.currentBranch = newBranch;
671
+ this.messages = [...this.messages]; // independent history copy
672
+ return { session: this.currentSession, branch: newBranch };
673
+ }
674
+ branchCount() {
675
+ return this.branchCountCache++;
676
+ }
677
+ branchCountCache = 0;
678
+ switchTo(branch) {
679
+ this.currentBranch = branch;
680
+ }
681
+ }
682
+ /** workspace tools + agent-meta tools (finish / report_progress) */
683
+ function allToolSpecs() {
684
+ return [
685
+ ...toolSpecs(),
686
+ {
687
+ type: "function",
688
+ function: {
689
+ name: "finish",
690
+ description: "End the current round. Call with goalComplete=true only when the goal in GOAL.md is fully achieved.",
691
+ parameters: {
692
+ type: "object",
693
+ properties: {
694
+ goalComplete: { type: "boolean" },
695
+ summary: { type: "string" },
696
+ },
697
+ },
698
+ },
699
+ },
700
+ {
701
+ type: "function",
702
+ function: {
703
+ name: "report_progress",
704
+ description: "Report current progress to humans: doing, goalStatus, recent attempts, problems, next step.",
705
+ parameters: {
706
+ type: "object",
707
+ properties: {
708
+ doing: { type: "string" },
709
+ goalStatus: { type: "string" },
710
+ recent: { type: "string" },
711
+ problems: { type: "string" },
712
+ next: { type: "string" },
713
+ },
714
+ required: ["doing", "goalStatus", "recent"],
715
+ },
716
+ },
717
+ },
718
+ ];
719
+ }
720
+ function str(v) {
721
+ return typeof v === "string" ? v : "";
722
+ }
723
+ function safeParse(json) {
724
+ try {
725
+ return JSON.parse(json);
726
+ }
727
+ catch {
728
+ return {};
729
+ }
730
+ }