teapot-coding-agent 0.23.0 → 0.24.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/dist/acp/adapter.js +198 -0
- package/dist/agent/agent.js +263 -12
- package/dist/agent/llm.js +33 -0
- package/dist/agent/skills.js +165 -32
- package/dist/agent/tools.js +632 -27
- package/dist/index.js +74 -27
- package/dist/log/events.js +14 -0
- package/dist/master.js +134 -12
- package/dist/model-meta.js +96 -36
- package/dist/server/api.js +21 -5
- package/package.json +1 -1
- package/public/assets/index-B_XaPTQR.css +1 -0
- package/public/assets/index-Y_KYu5nW.js +30 -0
- package/public/assets/index-Y_KYu5nW.js.map +1 -0
- package/public/assets/{shiki-NOpdigkY.js → shiki-CG8vVdKn.js} +2 -2
- package/public/assets/{shiki-NOpdigkY.js.map → shiki-CG8vVdKn.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-CNmd6GUS.js +0 -30
- package/public/assets/index-CNmd6GUS.js.map +0 -1
- package/public/assets/index-QCwmIYsM.css +0 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readEvents } from "../log/events.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
4
|
+
/** JSON-RPC error codes we use (the standard set plus JSON-RPC's own). */
|
|
5
|
+
const PARSE_ERROR = -32700;
|
|
6
|
+
const METHOD_NOT_FOUND = -32601;
|
|
7
|
+
const INVALID_PARAMS = -32602;
|
|
8
|
+
const INTERNAL_ERROR = -32603;
|
|
9
|
+
/**
|
|
10
|
+
* Run the ACP adapter until stdin closes.
|
|
11
|
+
*
|
|
12
|
+
* Kept as a class rather than free functions so the session map and the pending
|
|
13
|
+
* request bookkeeping have somewhere to live, and so it is directly testable
|
|
14
|
+
* with an in-memory stream pair.
|
|
15
|
+
*/
|
|
16
|
+
export class AcpAdapter {
|
|
17
|
+
master;
|
|
18
|
+
input;
|
|
19
|
+
output;
|
|
20
|
+
defaultCwd;
|
|
21
|
+
sessions = new Map();
|
|
22
|
+
/** id -> notification params, so a prompt turn can stream updates */
|
|
23
|
+
turnUpdates = new Map();
|
|
24
|
+
buffer = "";
|
|
25
|
+
nextSession = 1;
|
|
26
|
+
initialized = false;
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
this.master = opts.master;
|
|
29
|
+
this.input = opts.input ?? process.stdin;
|
|
30
|
+
this.output = opts.output ?? process.stdout;
|
|
31
|
+
this.defaultCwd = opts.defaultCwd ?? process.cwd();
|
|
32
|
+
}
|
|
33
|
+
/** Attach to the stream. Resolves when stdin ends. */
|
|
34
|
+
listen() {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
this.input.on("data", (chunk) => this.onData(String(chunk)));
|
|
37
|
+
this.input.on("end", () => resolve());
|
|
38
|
+
this.input.on("error", () => resolve());
|
|
39
|
+
if (typeof this.input.resume === "function")
|
|
40
|
+
this.input.resume();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/* ---------- transport ---------- */
|
|
44
|
+
onData(chunk) {
|
|
45
|
+
this.buffer += chunk;
|
|
46
|
+
// NDJSON: one JSON object per line
|
|
47
|
+
let nl;
|
|
48
|
+
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
49
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
50
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
51
|
+
if (line)
|
|
52
|
+
this.handleLine(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
handleLine(line) {
|
|
56
|
+
let msg;
|
|
57
|
+
try {
|
|
58
|
+
msg = JSON.parse(line);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// a parse error has no id to answer
|
|
62
|
+
this.send({ jsonrpc: "2.0", id: null, error: { code: PARSE_ERROR, message: "invalid JSON" } });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
void this.dispatch(msg);
|
|
66
|
+
}
|
|
67
|
+
send(res) {
|
|
68
|
+
this.output.write(JSON.stringify(res) + "\n");
|
|
69
|
+
}
|
|
70
|
+
notify(method, params) {
|
|
71
|
+
this.send({ jsonrpc: "2.0", method, params });
|
|
72
|
+
}
|
|
73
|
+
ok(id, result) {
|
|
74
|
+
this.send({ jsonrpc: "2.0", id, result });
|
|
75
|
+
}
|
|
76
|
+
fail(id, code, message) {
|
|
77
|
+
this.send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
78
|
+
}
|
|
79
|
+
/* ---------- dispatch ---------- */
|
|
80
|
+
async dispatch(msg) {
|
|
81
|
+
const { method, id, params } = msg;
|
|
82
|
+
const isNotification = id === undefined || id === null;
|
|
83
|
+
try {
|
|
84
|
+
switch (method) {
|
|
85
|
+
case "initialize":
|
|
86
|
+
return this.ok(id ?? null, this.initialize());
|
|
87
|
+
case "authenticate":
|
|
88
|
+
return this.ok(id ?? null, {});
|
|
89
|
+
case "session/new":
|
|
90
|
+
return this.ok(id ?? null, await this.newSession(params));
|
|
91
|
+
case "session/load":
|
|
92
|
+
return this.ok(id ?? null, await this.loadSession(params));
|
|
93
|
+
case "session/prompt":
|
|
94
|
+
return await this.prompt(id ?? null, params);
|
|
95
|
+
case "session/cancel":
|
|
96
|
+
this.cancel(params);
|
|
97
|
+
if (!isNotification)
|
|
98
|
+
return this.ok(id ?? null, {});
|
|
99
|
+
return;
|
|
100
|
+
default:
|
|
101
|
+
// notifications for unknown methods get NO response at all
|
|
102
|
+
if (isNotification)
|
|
103
|
+
return;
|
|
104
|
+
return this.fail(id ?? null, METHOD_NOT_FOUND, `unknown method: ${method}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (isNotification)
|
|
109
|
+
return; // never answer a notification
|
|
110
|
+
return this.fail(id ?? null, INTERNAL_ERROR, err.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
initialize() {
|
|
114
|
+
this.initialized = true;
|
|
115
|
+
return {
|
|
116
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
117
|
+
// Only capabilities we actually implement. Terminal/fs are deliberately
|
|
118
|
+
// absent: a client calls what we advertise, and a partial implementation
|
|
119
|
+
// would be worse than an honest omission.
|
|
120
|
+
agentCapabilities: { loadSession: true, promptCapabilities: { image: false, audio: false } },
|
|
121
|
+
authMethods: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/* ---------- sessions ---------- */
|
|
125
|
+
async newSession(params) {
|
|
126
|
+
const p = (params ?? {});
|
|
127
|
+
const cwd = p.cwd ? path.resolve(String(p.cwd)) : this.defaultCwd;
|
|
128
|
+
const id = `acp-${this.nextSession++}`;
|
|
129
|
+
const agent = await this.master.addAgent({ id, workspace: cwd }, { persist: false, fresh: true });
|
|
130
|
+
this.sessions.set(id, { id, cwd, agent });
|
|
131
|
+
return { sessionId: id };
|
|
132
|
+
}
|
|
133
|
+
async loadSession(params) {
|
|
134
|
+
const p = (params ?? {});
|
|
135
|
+
const id = String(p.sessionId ?? "");
|
|
136
|
+
const existing = this.sessions.get(id);
|
|
137
|
+
if (existing)
|
|
138
|
+
return null; // already live
|
|
139
|
+
const cwd = p.cwd ? path.resolve(String(p.cwd)) : this.defaultCwd;
|
|
140
|
+
// resuming means the agent's own log is restored, not a fresh session
|
|
141
|
+
const agent = await this.master.addAgent({ id, workspace: cwd }, { persist: false, fresh: false });
|
|
142
|
+
this.sessions.set(id, { id, cwd, agent });
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
cancel(params) {
|
|
146
|
+
const p = (params ?? {});
|
|
147
|
+
const s = p.sessionId ? this.sessions.get(String(p.sessionId)) : undefined;
|
|
148
|
+
s?.agent.stop("cancelled via ACP");
|
|
149
|
+
}
|
|
150
|
+
/* ---------- prompt turn ---------- */
|
|
151
|
+
async prompt(id, params) {
|
|
152
|
+
const p = (params ?? {});
|
|
153
|
+
const s = p.sessionId ? this.sessions.get(String(p.sessionId)) : undefined;
|
|
154
|
+
if (!s)
|
|
155
|
+
return this.fail(id, INVALID_PARAMS, `unknown session: ${String(p.sessionId)}`);
|
|
156
|
+
const text = (p.prompt ?? [])
|
|
157
|
+
.map((c) => String(c?.text ?? ""))
|
|
158
|
+
.join("\n")
|
|
159
|
+
.trim();
|
|
160
|
+
if (!text)
|
|
161
|
+
return this.fail(id, INVALID_PARAMS, "prompt must contain text");
|
|
162
|
+
this.turnUpdates.set(id, { sessionId: s.id });
|
|
163
|
+
try {
|
|
164
|
+
s.agent.enqueuePrompt(text, "user");
|
|
165
|
+
if (s.agent.status !== "running")
|
|
166
|
+
s.agent.start("acp prompt");
|
|
167
|
+
await s.agent.settled();
|
|
168
|
+
const stopReason = s.agent.goal.status === "done" ? "end_turn" : "end_turn";
|
|
169
|
+
return this.ok(id, { stopReason });
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
this.turnUpdates.delete(id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Stream a session's events to the client as session/update notifications. */
|
|
176
|
+
async drainOnce(sessionId, afterSeq = 0) {
|
|
177
|
+
const s = this.sessions.get(sessionId);
|
|
178
|
+
if (!s)
|
|
179
|
+
return afterSeq;
|
|
180
|
+
const events = await readEvents(s.agent.log.filePath).catch(() => []);
|
|
181
|
+
for (const e of events) {
|
|
182
|
+
if (e.seq <= afterSeq)
|
|
183
|
+
continue;
|
|
184
|
+
this.notify("session/update", {
|
|
185
|
+
sessionId,
|
|
186
|
+
update: {
|
|
187
|
+
sessionUpdate: e.type === "message" ? "agent_message_chunk" : "tool_call",
|
|
188
|
+
content: { type: "text", text: String(e.data?.content ?? "") },
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const max = events.reduce((m, e) => Math.max(m, e.seq), afterSeq);
|
|
193
|
+
return max;
|
|
194
|
+
}
|
|
195
|
+
get initialized_() {
|
|
196
|
+
return this.initialized;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/agent/agent.js
CHANGED
|
@@ -11,8 +11,38 @@ import path from "node:path";
|
|
|
11
11
|
import { EventLog, readEvents } from "../log/events.js";
|
|
12
12
|
import { chat, chatStream } from "./llm.js";
|
|
13
13
|
import { executeTool, toolSpecs, currentSkills, safeJoin, isContextOverflow, hasRunningBgShells } from "./tools.js";
|
|
14
|
-
import { skillRootsFingerprint } from "./skills.js";
|
|
14
|
+
import { skillRootsFingerprint, foreignSkillRoots } from "./skills.js";
|
|
15
15
|
import { bus } from "../bus.js";
|
|
16
|
+
/**
|
|
17
|
+
* Harness nudges, named and exported so they can be asserted on directly.
|
|
18
|
+
*
|
|
19
|
+
* Both were fixed for a reason invisible in the prose:
|
|
20
|
+
*
|
|
21
|
+
* - AUTO_CONTINUE_NUDGE (#43) used to say only "continue working". The model
|
|
22
|
+
* was never told HOW TO STOP, so it worked forever: it replied in prose, the
|
|
23
|
+
* loop saw an active goal and no finish(), and nudged again. The sub-agent
|
|
24
|
+
* variant already named finish() — the ROOT one did not.
|
|
25
|
+
* - PROGRESS_REQUEST (#45) used to DESCRIBE the report instead of asking for
|
|
26
|
+
* the tool, so the model answered in prose, then — not knowing that had
|
|
27
|
+
* satisfied the request — sent a voluntary report_progress right after, and
|
|
28
|
+
* every requested report appeared TWICE on the timeline.
|
|
29
|
+
*/
|
|
30
|
+
export const AUTO_CONTINUE_NUDGE = "Continue working toward the current goal. If you are blocked, explain why briefly. " +
|
|
31
|
+
// name the tool explicitly, the way the sub-agent nudge already does
|
|
32
|
+
"When the goal is genuinely met — or truly blocked — call finish() " +
|
|
33
|
+
"(finish(goalComplete=true) when met, finish(goalComplete=false) when blocked) " +
|
|
34
|
+
"instead of replying in prose; prose here does not stop the loop.";
|
|
35
|
+
export const PROGRESS_REQUEST =
|
|
36
|
+
// The leading sentence is kept VERBATIM: it is what existing harness prompts,
|
|
37
|
+
// tests and any operator muscle-memory key off. #45 changed the INSTRUCTION
|
|
38
|
+
// (call the tool, don't answer in prose), not the identification of the request.
|
|
39
|
+
"[harness] Please give a brief progress report now: what you are doing, goal progress, " +
|
|
40
|
+
"what you recently tried, any problems, and your next step. Keep it under 10 lines. " +
|
|
41
|
+
// ask for the TOOL, not prose — a prose answer left the model unsure whether it
|
|
42
|
+
// had satisfied the request, so it sent a voluntary report_progress right after
|
|
43
|
+
// and every requested report appeared TWICE on the timeline (#45)
|
|
44
|
+
"Record it with the report_progress TOOL (doing, goalStatus, recent, problems, next) — " +
|
|
45
|
+
"call that tool; do NOT answer in prose, which would just duplicate this request.";
|
|
16
46
|
/**
|
|
17
47
|
* SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
|
|
18
48
|
* provider prefix caches key on it, so changing it (or injecting per-turn
|
|
@@ -114,6 +144,14 @@ export class Agent {
|
|
|
114
144
|
modelPricing;
|
|
115
145
|
/** messages.length right after the last successful compaction */
|
|
116
146
|
compactedAtLen = 0;
|
|
147
|
+
/**
|
|
148
|
+
* A sub-agent's latest bare text turn, held back until the round ends.
|
|
149
|
+
* Models usually end a sub-agent task with a plain message instead of calling
|
|
150
|
+
* finish(); only finish() forwards a report to the parent, so without this the
|
|
151
|
+
* parent got nothing (#42). Held (not sent immediately) so a mid-task
|
|
152
|
+
* narration never pre-empts a real finish() summary.
|
|
153
|
+
*/
|
|
154
|
+
pendingSubReport = "";
|
|
117
155
|
/** live compaction phase for UI progress ("summarizing"/"harvesting") */
|
|
118
156
|
compactPhase = "";
|
|
119
157
|
/** set by ask_user: the loop is parked until the operator replies */
|
|
@@ -132,6 +170,10 @@ export class Agent {
|
|
|
132
170
|
autoContinue: true,
|
|
133
171
|
continueDelayMs: 15_000,
|
|
134
172
|
autoCompact: true,
|
|
173
|
+
// #47: no effort and no catalogue entry means "provider default" — the
|
|
174
|
+
// effort field is then never sent, which is the safe default
|
|
175
|
+
reasoningEffort: "",
|
|
176
|
+
supportedParameters: [],
|
|
135
177
|
maxConsecutiveToolErrors: 5,
|
|
136
178
|
maxTurnsPerRound: 200,
|
|
137
179
|
onError: "stop",
|
|
@@ -160,6 +202,13 @@ export class Agent {
|
|
|
160
202
|
this.skillRoots = [
|
|
161
203
|
{ dir: path.join(opts.workspace, "skills"), source: "workspace" },
|
|
162
204
|
...(opts.globalSkillsDir ? [{ dir: opts.globalSkillsDir, source: "global" }] : []),
|
|
205
|
+
// OTHER HARNESSES' skill directories (#28). The SKILL.md format is shared,
|
|
206
|
+
// so these are readable as-is — verified against real third-party skills
|
|
207
|
+
// (terminalskills/skills, trailofbits/skills, …). They sit below the
|
|
208
|
+
// workspace and teapot's own global dir so a project always wins, and
|
|
209
|
+
// missing directories are skipped silently. Paths are the documented
|
|
210
|
+
// native locations plus the cross-tool ones several tools also read.
|
|
211
|
+
...foreignSkillRoots(opts.workspace),
|
|
163
212
|
// shipped-with-package skills: lowest priority, always discoverable
|
|
164
213
|
...(opts.bundledSkillsDir ? [{ dir: opts.bundledSkillsDir, source: "bundled" }] : []),
|
|
165
214
|
];
|
|
@@ -192,6 +241,24 @@ export class Agent {
|
|
|
192
241
|
* skill was added, edited or removed (save_skill writes one, so a
|
|
193
242
|
* self-created skill still shows up on the very next turn).
|
|
194
243
|
*/
|
|
244
|
+
/**
|
|
245
|
+
* Render the skill catalogue for the system prompt (#31).
|
|
246
|
+
*
|
|
247
|
+
* Name + description only, one line each — the full SKILL.md is what
|
|
248
|
+
* load_skill() is for, and this block goes into EVERY request of the session,
|
|
249
|
+
* so it has to stay small. The description is what the model matches its task
|
|
250
|
+
* against, so a skill with a poor description is effectively undiscoverable;
|
|
251
|
+
* that is worth knowing when authoring one.
|
|
252
|
+
*/
|
|
253
|
+
skillCatalogueText() {
|
|
254
|
+
if (!this.skillsCache.length)
|
|
255
|
+
return "";
|
|
256
|
+
const lines = this.skillsCache.map((s) => {
|
|
257
|
+
const desc = (s.description || "(no description)").replace(/\s+/g, " ").slice(0, 200);
|
|
258
|
+
return `- ${s.name}: ${desc}`;
|
|
259
|
+
});
|
|
260
|
+
return lines.join("\n");
|
|
261
|
+
}
|
|
195
262
|
async refreshSkills() {
|
|
196
263
|
try {
|
|
197
264
|
const stamp = await skillRootsFingerprint(this.skillRoots);
|
|
@@ -206,7 +273,16 @@ export class Agent {
|
|
|
206
273
|
}
|
|
207
274
|
callLlm(messages, tools, onDelta) {
|
|
208
275
|
const fn = this.opts.chatFn ?? chatStream;
|
|
209
|
-
|
|
276
|
+
// #47: carry the effort setting + the model's advertised capabilities into
|
|
277
|
+
// the request config. chat() applies effortForRequest(), which sends the
|
|
278
|
+
// field ONLY for OpenRouter endpoints whose catalogue lists support.
|
|
279
|
+
return fn({
|
|
280
|
+
...this.opts.llm,
|
|
281
|
+
...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
|
|
282
|
+
...(this.opts.supportedParameters
|
|
283
|
+
? { supportedParameters: this.opts.supportedParameters }
|
|
284
|
+
: {}),
|
|
285
|
+
}, messages, tools, this.abort?.signal, onDelta);
|
|
210
286
|
}
|
|
211
287
|
/** expose id for metrics */
|
|
212
288
|
opts_id() {
|
|
@@ -566,7 +642,13 @@ export class Agent {
|
|
|
566
642
|
* must not be running — editing under a live loop would race its history.
|
|
567
643
|
*/
|
|
568
644
|
async editPromptAt(eventId, text, tail) {
|
|
569
|
-
|
|
645
|
+
// "running" is not the only live state: parkForTool() rewrites the status to
|
|
646
|
+
// "idle" while the loop is still very much alive inside a parked tool
|
|
647
|
+
// (wait_children does this), and queued prompts have not been consumed yet.
|
|
648
|
+
// Editing underneath either of those replaces the very `messages` array the
|
|
649
|
+
// agent is about to resume into, so its old-branch work kept running
|
|
650
|
+
// against the new branch (#38).
|
|
651
|
+
if (this.status === "running" || this.parkedByTool || this.pendingPrompts.length)
|
|
570
652
|
throw new Error("agent is running — stop it before editing history");
|
|
571
653
|
const all = await readEvents(this.log.filePath);
|
|
572
654
|
const target = all.find((e) => e.id === eventId);
|
|
@@ -599,7 +681,17 @@ export class Agent {
|
|
|
599
681
|
}
|
|
600
682
|
msgs.push({ role: "user", content: text });
|
|
601
683
|
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
602
|
-
|
|
684
|
+
// Quiesce BEFORE rewriting history: an in-flight LLM call, a parked tool or
|
|
685
|
+
// a queued prompt would otherwise resume into the NEW messages array and
|
|
686
|
+
// keep running the old branch's work here (#38). Mirrors stop(), which is
|
|
687
|
+
// the only other place that cancels this state.
|
|
688
|
+
await this.quiesceForHistoryEdit();
|
|
689
|
+
// The fork event must be appended while currentBranch is still the OLD
|
|
690
|
+
// one. EventLog links parents PER BRANCH (lastByBranch), so appending it
|
|
691
|
+
// under the new branch gave it parent:null — severing the chain, and
|
|
692
|
+
// lineageOf() then returned only the post-fork events, so a restart rebuilt
|
|
693
|
+
// the history with none of the pre-edit context (#38).
|
|
694
|
+
await this.log.append("fork", this.currentSession, this.currentBranch, {
|
|
603
695
|
fromSession: this.currentSession,
|
|
604
696
|
fromBranch: this.currentBranch,
|
|
605
697
|
fromEvent: kept.at(-1)?.id ?? null,
|
|
@@ -608,12 +700,49 @@ export class Agent {
|
|
|
608
700
|
droppedEvents: dropped.length,
|
|
609
701
|
tailMode: tail,
|
|
610
702
|
});
|
|
703
|
+
// Seed the new branch's parent link to the fork event just written, so its
|
|
704
|
+
// first event chains across instead of starting a disconnected parent:null
|
|
705
|
+
// history (#38).
|
|
706
|
+
this.log.seedBranch(newBranch, this.log.lastEventId(this.currentBranch));
|
|
611
707
|
this.currentBranch = newBranch;
|
|
612
708
|
this.messages = msgs;
|
|
613
709
|
await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
|
|
614
710
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
615
711
|
return { droppedEvents: dropped.length, branch: newBranch };
|
|
616
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Cancel everything that could resume against the history we are about to
|
|
715
|
+
* replace, then wait for the loop to actually stop.
|
|
716
|
+
*
|
|
717
|
+
* An edit rewinds the conversation, so any work still in flight belongs to
|
|
718
|
+
* the branch being discarded: an LLM call whose result would append to the
|
|
719
|
+
* new messages, a parked tool, and queued prompts that were never delivered
|
|
720
|
+
* (#38). stopRequested is set and cleared around the wait so the agent is
|
|
721
|
+
* usable again once the edit lands.
|
|
722
|
+
*/
|
|
723
|
+
async quiesceForHistoryEdit() {
|
|
724
|
+
if (!this.stopRequested) {
|
|
725
|
+
this.stopRequested = true;
|
|
726
|
+
this.abort?.abort(); // interrupt an in-flight LLM call
|
|
727
|
+
this.toolAbort.abort(); // kill subprocesses / interrupt a parked tool
|
|
728
|
+
this.wake?.(); // release wait_children
|
|
729
|
+
try {
|
|
730
|
+
await this.settled();
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
/* the loop is being abandoned; its errors are not ours to report */
|
|
734
|
+
}
|
|
735
|
+
this.parkedByTool = false;
|
|
736
|
+
this.pendingPrompts = []; // undelivered prompts belong to the old branch
|
|
737
|
+
this.stopRequested = false;
|
|
738
|
+
// a fresh signal: toolCtx holds a copy of the reference, so republish
|
|
739
|
+
if (this.toolAbort.signal.aborted) {
|
|
740
|
+
this.toolAbort = new AbortController();
|
|
741
|
+
this.toolCtx.signal = this.toolAbort.signal;
|
|
742
|
+
}
|
|
743
|
+
this.bgExits = []; // old-branch job exits must not be folded in
|
|
744
|
+
}
|
|
745
|
+
}
|
|
617
746
|
parseGoalFile(text) {
|
|
618
747
|
// humans and agents may append their own status lines — latest wins
|
|
619
748
|
const all = [...text.matchAll(/status:\s*(\w+)/gi)];
|
|
@@ -761,6 +890,9 @@ export class Agent {
|
|
|
761
890
|
stats: { ...this.stats, costUsd: this.modelPricing ? this.stats.costUsd : undefined },
|
|
762
891
|
model: this.opts.llm.model,
|
|
763
892
|
provider: this.opts.provider,
|
|
893
|
+
// #47: the effort in force, and whether this model can take one at all
|
|
894
|
+
...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
|
|
895
|
+
effortSupported: (this.opts.supportedParameters ?? []).includes("reasoning_effort"),
|
|
764
896
|
sessionDir: this.opts.sessionDir,
|
|
765
897
|
ctx: {
|
|
766
898
|
usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
|
|
@@ -904,8 +1036,22 @@ export class Agent {
|
|
|
904
1036
|
// suppressed while bg jobs are alive) — its exit IS the wake-up call.
|
|
905
1037
|
// Without this the report would sit unread until the operator poked
|
|
906
1038
|
// the agent. Stopped agents stay stopped (a deliberate human stop).
|
|
907
|
-
|
|
908
|
-
|
|
1039
|
+
//
|
|
1040
|
+
// Checking status once is NOT enough: the exit can land in the window
|
|
1041
|
+
// between the loop's hasRunningBgShells() check (which breaks the round
|
|
1042
|
+
// out) and the loop actually flipping to idle. The callback then sees
|
|
1043
|
+
// "running", skips start(), and the agent goes idle with the report
|
|
1044
|
+
// unread — wedged until a human pokes it. So remember that the loop was
|
|
1045
|
+
// still winding down, and re-check once it has settled.
|
|
1046
|
+
const windingDown = this.status === "running";
|
|
1047
|
+
if (!this.stopRequested && (this.status === "idle" || windingDown)) {
|
|
1048
|
+
// park the decision until the in-flight round has actually settled
|
|
1049
|
+
void this.enqueue(async () => {
|
|
1050
|
+
if (this.stopRequested)
|
|
1051
|
+
return; // a stop raced us — respect it
|
|
1052
|
+
if (this.status === "idle")
|
|
1053
|
+
this.start(`background job ${info.id} finished`);
|
|
1054
|
+
});
|
|
909
1055
|
}
|
|
910
1056
|
}
|
|
911
1057
|
/** fold queued background-exit reports into the next LLM call (once each) */
|
|
@@ -1030,6 +1176,18 @@ export class Agent {
|
|
|
1030
1176
|
});
|
|
1031
1177
|
break;
|
|
1032
1178
|
}
|
|
1179
|
+
// A SUB-AGENT that ended its round on a bare message (no finish call)
|
|
1180
|
+
// still owes the parent a report — nothing else will carry it (#42).
|
|
1181
|
+
// Emitted HERE, after the loop, so a real finish() on a later turn
|
|
1182
|
+
// always wins and this never pre-empts a better summary.
|
|
1183
|
+
if (this.pendingSubReport) {
|
|
1184
|
+
const report = this.pendingSubReport;
|
|
1185
|
+
this.pendingSubReport = "";
|
|
1186
|
+
if (!finished) {
|
|
1187
|
+
await this.handleFinish(JSON.stringify({ goalComplete: true, summary: report }));
|
|
1188
|
+
finished = true; // it IS finished now — don't auto-nudge a reporter
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1033
1191
|
// auto-continue only makes sense with an active goal to continue toward
|
|
1034
1192
|
if (finished ||
|
|
1035
1193
|
!this.opts.autoContinue ||
|
|
@@ -1047,7 +1205,7 @@ export class Agent {
|
|
|
1047
1205
|
const isSub = !!this.opts.parent;
|
|
1048
1206
|
const nudge = isSub
|
|
1049
1207
|
? `[harness] Auto-nudge. Re-anchor on YOUR task — and only that:\n\n${this.goal.text.slice(0, 1500)}\n\nThe inherited conversation is reference only; other agents own any older tasks in it. If your task is complete, call finish() now instead of continuing. If blocked, explain why briefly and finish().`
|
|
1050
|
-
:
|
|
1208
|
+
: AUTO_CONTINUE_NUDGE;
|
|
1051
1209
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
1052
1210
|
source: "harness",
|
|
1053
1211
|
text: nudge,
|
|
@@ -1261,12 +1419,63 @@ export class Agent {
|
|
|
1261
1419
|
this.messages.push(m);
|
|
1262
1420
|
this.turnsSinceProgress++;
|
|
1263
1421
|
this.activityChars += m.content?.length ?? 0;
|
|
1264
|
-
|
|
1422
|
+
// A round that ends with a plain assistant message and NO tool calls is
|
|
1423
|
+
// the end of the agent's work. For a ROOT agent that is just "done" — but
|
|
1424
|
+
// for a SUB-AGENT it means the work is over and the parent is waiting.
|
|
1425
|
+
//
|
|
1426
|
+
// Models overwhelmingly prefer writing their report as the final message
|
|
1427
|
+
// over calling finish(), and only handleFinish() emits the `final: true`
|
|
1428
|
+
// event that onChildEvent() forwards to the parent. So that habit silently
|
|
1429
|
+
// cost the parent the ENTIRE result (#42) — the report existed only in the
|
|
1430
|
+
// child's own log, which the parent's model context never replays.
|
|
1431
|
+
// Harvest it here so both paths share one code path.
|
|
1432
|
+
if (!m.tool_calls?.length) {
|
|
1433
|
+
// A turn with no tool calls and real text is how models overwhelmingly
|
|
1434
|
+
// signal "I'm done" — far more often than by calling finish(), which
|
|
1435
|
+
// the spawn directive asks for but they routinely ignore.
|
|
1436
|
+
//
|
|
1437
|
+
// Two things were wrong (#42):
|
|
1438
|
+
// 1. only handleFinish() emits the `final: true` event that
|
|
1439
|
+
// onChildEvent() forwards to the parent, so a sub-agent that ended
|
|
1440
|
+
// with a plain message sent the parent NOTHING — the report lived
|
|
1441
|
+
// only in the child's log, which the parent's context never
|
|
1442
|
+
// replays;
|
|
1443
|
+
// 2. the round then returned "not finished" with an active goal, so
|
|
1444
|
+
// auto-continue re-nudged and the child looped forever (measured:
|
|
1445
|
+
// 257 consecutive turns) re-reporting the same text.
|
|
1446
|
+
//
|
|
1447
|
+
// A bare message might be a mid-task narration though — sub-agents
|
|
1448
|
+
// often say "here's what I found so far…" and call finish() later with
|
|
1449
|
+
// a better summary. So do NOT report yet: remember it and give the child
|
|
1450
|
+
// ONE more turn to call finish() properly. The harness nudge makes that
|
|
1451
|
+
// turn explicit, so a child that just wanted to narrate ends here (and
|
|
1452
|
+
// its held report is forwarded by the loop's end-of-round path), while
|
|
1453
|
+
// a child that has a real summary delivers it instead. A real finish()
|
|
1454
|
+
// clears the held note, so it is forwarded exactly once (#42).
|
|
1455
|
+
if (this.opts.parent) {
|
|
1456
|
+
if (this.pendingSubReport) {
|
|
1457
|
+
// already offered a turn after a bare message — take the held report
|
|
1458
|
+
this.messages.push({
|
|
1459
|
+
role: "user",
|
|
1460
|
+
content: "[harness] Report delivered. If this IS your final answer, call finish() now with a summary for your parent; otherwise continue the task.",
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
else {
|
|
1464
|
+
this.pendingSubReport = (m.content ?? "").trim();
|
|
1465
|
+
this.messages.push({
|
|
1466
|
+
role: "user",
|
|
1467
|
+
content: "[harness] If you are done, call finish() with a summary for your parent so it receives your report. Otherwise continue the task.",
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
continue; // one more turn before we treat the message as final
|
|
1471
|
+
}
|
|
1265
1472
|
return finished;
|
|
1473
|
+
}
|
|
1266
1474
|
for (const call of m.tool_calls) {
|
|
1267
1475
|
if (this.stopRequested)
|
|
1268
1476
|
return finished;
|
|
1269
1477
|
if (call.function.name === "finish") {
|
|
1478
|
+
this.pendingSubReport = ""; // the real summary supersedes it
|
|
1270
1479
|
await this.handleFinish(call.function.arguments);
|
|
1271
1480
|
// surface still-running children so the operator knows work may
|
|
1272
1481
|
// continue after this agent goes idle
|
|
@@ -1520,9 +1729,16 @@ export class Agent {
|
|
|
1520
1729
|
}
|
|
1521
1730
|
/** AGENTS.md content cached for the session (empty string = none/read-failed) */
|
|
1522
1731
|
agentsMd = "";
|
|
1732
|
+
/** captured once, with the rest of the session-frozen context */
|
|
1523
1733
|
agentsMdLoaded = false;
|
|
1524
1734
|
/** one-time workspace listing injected with the system prompt */
|
|
1525
1735
|
wsSnapshot = "";
|
|
1736
|
+
/**
|
|
1737
|
+
* one-time catalogue of available skills, injected with the system prompt
|
|
1738
|
+
* (#31). Built once alongside wsSnapshot so the prompt stays byte-identical
|
|
1739
|
+
* for the session and the provider prefix cache survives.
|
|
1740
|
+
*/
|
|
1741
|
+
skillCatalogue = "";
|
|
1526
1742
|
systemPrompt() {
|
|
1527
1743
|
if (!this.agentsMdLoaded) {
|
|
1528
1744
|
this.agentsMdLoaded = true;
|
|
@@ -1593,6 +1809,9 @@ export class Agent {
|
|
|
1593
1809
|
this.agentsMd = "";
|
|
1594
1810
|
}
|
|
1595
1811
|
}
|
|
1812
|
+
// #31: capture the skill catalogue in the SAME one-shot block, so the
|
|
1813
|
+
// system prompt stays byte-identical for the session (prefix cache).
|
|
1814
|
+
this.skillCatalogue = this.skillCatalogueText();
|
|
1596
1815
|
}
|
|
1597
1816
|
let sys = SYSTEM_TEMPLATE;
|
|
1598
1817
|
if (this.agentsMd.trim()) {
|
|
@@ -1601,6 +1820,22 @@ export class Agent {
|
|
|
1601
1820
|
if (this.wsSnapshot) {
|
|
1602
1821
|
sys += `\n\n## Workspace snapshot (top level)\n${this.wsSnapshot}\nUse list_dir/read_file for anything deeper — do NOT list again what is already here.`;
|
|
1603
1822
|
}
|
|
1823
|
+
// #31: the catalogue of AVAILABLE SKILLS. The prompt previously named the
|
|
1824
|
+
// skill TOOLS but never the skills themselves, so the only way to discover
|
|
1825
|
+
// one was to spend a tool call on list_skills() — and a model that does not
|
|
1826
|
+
// know a skill exists will never think to look.
|
|
1827
|
+
//
|
|
1828
|
+
// Appended ONCE, in the same one-shot block as AGENTS.md and the workspace
|
|
1829
|
+
// snapshot, so SYSTEM_TEMPLATE stays byte-identical for the session and the
|
|
1830
|
+
// provider prefix cache survives (#14's constraint). list_skills() remains
|
|
1831
|
+
// for the full detail (a skill saved mid-session shows up there).
|
|
1832
|
+
if (this.skillCatalogue) {
|
|
1833
|
+
sys +=
|
|
1834
|
+
`\n\n## Skills available in this workspace\n${this.skillCatalogue}\n` +
|
|
1835
|
+
`Load one with load_skill(name, instructions) when its description matches your task — ` +
|
|
1836
|
+
`pass what you want done so the instructions arrive with the playbook. ` +
|
|
1837
|
+
`list_skills() shows the full detail.`;
|
|
1838
|
+
}
|
|
1604
1839
|
return sys;
|
|
1605
1840
|
}
|
|
1606
1841
|
async handleFinish(argsJson) {
|
|
@@ -1675,7 +1910,11 @@ export class Agent {
|
|
|
1675
1910
|
// the raw content is what matters here, and cap hard: these can be huge
|
|
1676
1911
|
line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
|
|
1677
1912
|
}
|
|
1678
|
-
|
|
1913
|
+
// A generous cap, but not unbounded: one runaway turn must not blow up
|
|
1914
|
+
// the parent's context. The old 1500 char clip cut findings in half —
|
|
1915
|
+
// a review's conclusions routinely live past that mark (#42) — and this
|
|
1916
|
+
// text is bounded by 6 turns, so the worst case is ~120k chars.
|
|
1917
|
+
recent.unshift(line.slice(0, 20_000));
|
|
1679
1918
|
}
|
|
1680
1919
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
1681
1920
|
role: "assistant",
|
|
@@ -1693,8 +1932,7 @@ export class Agent {
|
|
|
1693
1932
|
this.lastProgressAt = Date.now();
|
|
1694
1933
|
this.activityChars = 0;
|
|
1695
1934
|
this.turnsSinceProgress = 0;
|
|
1696
|
-
const request =
|
|
1697
|
-
"what you recently tried, any problems, and your next step. Keep it under 10 lines.";
|
|
1935
|
+
const request = PROGRESS_REQUEST;
|
|
1698
1936
|
// log both sides so a session restore replays this exchange faithfully
|
|
1699
1937
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
1700
1938
|
source: "harness",
|
|
@@ -1938,8 +2176,21 @@ export class Agent {
|
|
|
1938
2176
|
// fallback: drop the oldest half without LLM help so work can continue
|
|
1939
2177
|
mode = "truncate";
|
|
1940
2178
|
summarizedCount = 0;
|
|
1941
|
-
|
|
2179
|
+
// Cut at a safe boundary, but never at index 0 — that would drop the
|
|
2180
|
+
// ENTIRE history (including the prompt just delivered) and leave the agent
|
|
2181
|
+
// with nothing. On a short history floor(oldCount/2) can round to 0, and
|
|
2182
|
+
// the old code then bailed out silently: compaction "ran", reported
|
|
2183
|
+
// nothing, and the context stayed over budget, so the very next provider
|
|
2184
|
+
// call overflowed — the "unstable after compact" report (#8). Always drop
|
|
2185
|
+
// at least one message, and say so.
|
|
2186
|
+
const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
|
|
1942
2187
|
if (halfCut <= 0) {
|
|
2188
|
+
// nothing in the prefix is safe to drop (e.g. it is all tool results).
|
|
2189
|
+
// Report it instead of pretending compaction happened.
|
|
2190
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2191
|
+
event: "context-compaction-skipped",
|
|
2192
|
+
reason: "summarize failed and no safe prefix to truncate",
|
|
2193
|
+
});
|
|
1943
2194
|
this.compactPhase = "";
|
|
1944
2195
|
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
|
|
1945
2196
|
return;
|