teapot-coding-agent 0.22.13 → 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/README.md +29 -0
- package/dist/acp/adapter.js +198 -0
- package/dist/agent/agent.js +282 -15
- package/dist/agent/llm.js +33 -0
- package/dist/agent/skills.js +212 -32
- package/dist/agent/tools.js +776 -39
- package/dist/index.js +74 -27
- package/dist/log/events.js +14 -0
- package/dist/master.js +146 -14
- package/dist/model-meta.js +96 -36
- package/dist/server/api.js +44 -8
- package/dist/server/term-size.js +62 -0
- 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-B-DhHuic.js → shiki-CG8vVdKn.js} +2 -2
- package/public/assets/{shiki-B-DhHuic.js.map → shiki-CG8vVdKn.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-B-RJz1_I.css +0 -1
- package/public/assets/index-Bg8Y4MGZ.js +0 -30
- package/public/assets/index-Bg8Y4MGZ.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -11,7 +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, foreignSkillRoots } from "./skills.js";
|
|
14
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.";
|
|
15
46
|
/**
|
|
16
47
|
* SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
|
|
17
48
|
* provider prefix caches key on it, so changing it (or injecting per-turn
|
|
@@ -21,7 +52,7 @@ import { bus } from "../bus.js";
|
|
|
21
52
|
*/
|
|
22
53
|
const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
|
|
23
54
|
|
|
24
|
-
|
|
55
|
+
Most session state is not injected into prompts — fetch it with tools instead:
|
|
25
56
|
- get_goal() → current objective + status. Call at session start, after a
|
|
26
57
|
compaction notice, or whenever you lose the thread.
|
|
27
58
|
- set_goal(text) → change the objective itself (not routine updates).
|
|
@@ -35,10 +66,11 @@ Session state is not injected into prompts — fetch it with tools instead:
|
|
|
35
66
|
- Log significant choices with record_decision(decision, rationale,
|
|
36
67
|
alternatives?) — compaction forgets reasoning, decisions.md doesn't.
|
|
37
68
|
- list_skills() / load_skill(name) / save_skill(...) → reusable playbooks.
|
|
38
|
-
- AGENTS.md in the workspace root (optional) holds project knowledge — read it
|
|
39
|
-
with read_file at session start when present, keep it current.
|
|
40
69
|
|
|
41
70
|
## Rules
|
|
71
|
+
- Project instructions from AGENTS.md (workspace root) are EMBEDDED in this
|
|
72
|
+
prompt below when present — read them here, don't re-read_file it. Keep the
|
|
73
|
+
file current by editing it when it goes stale.
|
|
42
74
|
- Work step by step with tools. Verify results (run tests/builds) before claiming progress.
|
|
43
75
|
- File changes — pick by scope: write_file (one new file / full rewrite) ·
|
|
44
76
|
edit_file (exactly one small unique replacement) · apply_patch (several
|
|
@@ -112,6 +144,14 @@ export class Agent {
|
|
|
112
144
|
modelPricing;
|
|
113
145
|
/** messages.length right after the last successful compaction */
|
|
114
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 = "";
|
|
115
155
|
/** live compaction phase for UI progress ("summarizing"/"harvesting") */
|
|
116
156
|
compactPhase = "";
|
|
117
157
|
/** set by ask_user: the loop is parked until the operator replies */
|
|
@@ -130,6 +170,10 @@ export class Agent {
|
|
|
130
170
|
autoContinue: true,
|
|
131
171
|
continueDelayMs: 15_000,
|
|
132
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: [],
|
|
133
177
|
maxConsecutiveToolErrors: 5,
|
|
134
178
|
maxTurnsPerRound: 200,
|
|
135
179
|
onError: "stop",
|
|
@@ -158,6 +202,13 @@ export class Agent {
|
|
|
158
202
|
this.skillRoots = [
|
|
159
203
|
{ dir: path.join(opts.workspace, "skills"), source: "workspace" },
|
|
160
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),
|
|
161
212
|
// shipped-with-package skills: lowest priority, always discoverable
|
|
162
213
|
...(opts.bundledSkillsDir ? [{ dir: opts.bundledSkillsDir, source: "bundled" }] : []),
|
|
163
214
|
];
|
|
@@ -179,10 +230,42 @@ export class Agent {
|
|
|
179
230
|
}
|
|
180
231
|
skillRoots;
|
|
181
232
|
skillsCache = [];
|
|
182
|
-
/**
|
|
233
|
+
/** fingerprint of the roots that produced skillsCache ("" = not scanned yet) */
|
|
234
|
+
skillsStamp = "";
|
|
235
|
+
/**
|
|
236
|
+
* Rescan skill roots — but only when something actually changed (#14).
|
|
237
|
+
* This used to run a readdir per root plus a read of every SKILL.md at the
|
|
238
|
+
* top of EVERY turn; the cache is only ever read by list_skills, so that
|
|
239
|
+
* was pure I/O on the hot path. A cheap stat fingerprint of the roots and
|
|
240
|
+
* their SKILL.md files gates the real scan, so a turn only pays when a
|
|
241
|
+
* skill was added, edited or removed (save_skill writes one, so a
|
|
242
|
+
* self-created skill still shows up on the very next turn).
|
|
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
|
+
}
|
|
183
262
|
async refreshSkills() {
|
|
184
263
|
try {
|
|
264
|
+
const stamp = await skillRootsFingerprint(this.skillRoots);
|
|
265
|
+
if (stamp === this.skillsStamp)
|
|
266
|
+
return; // unchanged → reuse the cache
|
|
185
267
|
this.skillsCache = await currentSkills(this.toolCtx);
|
|
268
|
+
this.skillsStamp = stamp;
|
|
186
269
|
}
|
|
187
270
|
catch {
|
|
188
271
|
/* keep previous cache */
|
|
@@ -190,7 +273,16 @@ export class Agent {
|
|
|
190
273
|
}
|
|
191
274
|
callLlm(messages, tools, onDelta) {
|
|
192
275
|
const fn = this.opts.chatFn ?? chatStream;
|
|
193
|
-
|
|
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);
|
|
194
286
|
}
|
|
195
287
|
/** expose id for metrics */
|
|
196
288
|
opts_id() {
|
|
@@ -550,7 +642,13 @@ export class Agent {
|
|
|
550
642
|
* must not be running — editing under a live loop would race its history.
|
|
551
643
|
*/
|
|
552
644
|
async editPromptAt(eventId, text, tail) {
|
|
553
|
-
|
|
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)
|
|
554
652
|
throw new Error("agent is running — stop it before editing history");
|
|
555
653
|
const all = await readEvents(this.log.filePath);
|
|
556
654
|
const target = all.find((e) => e.id === eventId);
|
|
@@ -583,7 +681,17 @@ export class Agent {
|
|
|
583
681
|
}
|
|
584
682
|
msgs.push({ role: "user", content: text });
|
|
585
683
|
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
586
|
-
|
|
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, {
|
|
587
695
|
fromSession: this.currentSession,
|
|
588
696
|
fromBranch: this.currentBranch,
|
|
589
697
|
fromEvent: kept.at(-1)?.id ?? null,
|
|
@@ -592,12 +700,49 @@ export class Agent {
|
|
|
592
700
|
droppedEvents: dropped.length,
|
|
593
701
|
tailMode: tail,
|
|
594
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));
|
|
595
707
|
this.currentBranch = newBranch;
|
|
596
708
|
this.messages = msgs;
|
|
597
709
|
await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
|
|
598
710
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
599
711
|
return { droppedEvents: dropped.length, branch: newBranch };
|
|
600
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
|
+
}
|
|
601
746
|
parseGoalFile(text) {
|
|
602
747
|
// humans and agents may append their own status lines — latest wins
|
|
603
748
|
const all = [...text.matchAll(/status:\s*(\w+)/gi)];
|
|
@@ -745,6 +890,9 @@ export class Agent {
|
|
|
745
890
|
stats: { ...this.stats, costUsd: this.modelPricing ? this.stats.costUsd : undefined },
|
|
746
891
|
model: this.opts.llm.model,
|
|
747
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"),
|
|
748
896
|
sessionDir: this.opts.sessionDir,
|
|
749
897
|
ctx: {
|
|
750
898
|
usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
|
|
@@ -888,8 +1036,22 @@ export class Agent {
|
|
|
888
1036
|
// suppressed while bg jobs are alive) — its exit IS the wake-up call.
|
|
889
1037
|
// Without this the report would sit unread until the operator poked
|
|
890
1038
|
// the agent. Stopped agents stay stopped (a deliberate human stop).
|
|
891
|
-
|
|
892
|
-
|
|
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
|
+
});
|
|
893
1055
|
}
|
|
894
1056
|
}
|
|
895
1057
|
/** fold queued background-exit reports into the next LLM call (once each) */
|
|
@@ -1014,6 +1176,18 @@ export class Agent {
|
|
|
1014
1176
|
});
|
|
1015
1177
|
break;
|
|
1016
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
|
+
}
|
|
1017
1191
|
// auto-continue only makes sense with an active goal to continue toward
|
|
1018
1192
|
if (finished ||
|
|
1019
1193
|
!this.opts.autoContinue ||
|
|
@@ -1031,7 +1205,7 @@ export class Agent {
|
|
|
1031
1205
|
const isSub = !!this.opts.parent;
|
|
1032
1206
|
const nudge = isSub
|
|
1033
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().`
|
|
1034
|
-
:
|
|
1208
|
+
: AUTO_CONTINUE_NUDGE;
|
|
1035
1209
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
1036
1210
|
source: "harness",
|
|
1037
1211
|
text: nudge,
|
|
@@ -1245,12 +1419,63 @@ export class Agent {
|
|
|
1245
1419
|
this.messages.push(m);
|
|
1246
1420
|
this.turnsSinceProgress++;
|
|
1247
1421
|
this.activityChars += m.content?.length ?? 0;
|
|
1248
|
-
|
|
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
|
+
}
|
|
1249
1472
|
return finished;
|
|
1473
|
+
}
|
|
1250
1474
|
for (const call of m.tool_calls) {
|
|
1251
1475
|
if (this.stopRequested)
|
|
1252
1476
|
return finished;
|
|
1253
1477
|
if (call.function.name === "finish") {
|
|
1478
|
+
this.pendingSubReport = ""; // the real summary supersedes it
|
|
1254
1479
|
await this.handleFinish(call.function.arguments);
|
|
1255
1480
|
// surface still-running children so the operator knows work may
|
|
1256
1481
|
// continue after this agent goes idle
|
|
@@ -1504,9 +1729,16 @@ export class Agent {
|
|
|
1504
1729
|
}
|
|
1505
1730
|
/** AGENTS.md content cached for the session (empty string = none/read-failed) */
|
|
1506
1731
|
agentsMd = "";
|
|
1732
|
+
/** captured once, with the rest of the session-frozen context */
|
|
1507
1733
|
agentsMdLoaded = false;
|
|
1508
1734
|
/** one-time workspace listing injected with the system prompt */
|
|
1509
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 = "";
|
|
1510
1742
|
systemPrompt() {
|
|
1511
1743
|
if (!this.agentsMdLoaded) {
|
|
1512
1744
|
this.agentsMdLoaded = true;
|
|
@@ -1577,6 +1809,9 @@ export class Agent {
|
|
|
1577
1809
|
this.agentsMd = "";
|
|
1578
1810
|
}
|
|
1579
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();
|
|
1580
1815
|
}
|
|
1581
1816
|
let sys = SYSTEM_TEMPLATE;
|
|
1582
1817
|
if (this.agentsMd.trim()) {
|
|
@@ -1585,6 +1820,22 @@ export class Agent {
|
|
|
1585
1820
|
if (this.wsSnapshot) {
|
|
1586
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.`;
|
|
1587
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
|
+
}
|
|
1588
1839
|
return sys;
|
|
1589
1840
|
}
|
|
1590
1841
|
async handleFinish(argsJson) {
|
|
@@ -1659,7 +1910,11 @@ export class Agent {
|
|
|
1659
1910
|
// the raw content is what matters here, and cap hard: these can be huge
|
|
1660
1911
|
line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
|
|
1661
1912
|
}
|
|
1662
|
-
|
|
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));
|
|
1663
1918
|
}
|
|
1664
1919
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
1665
1920
|
role: "assistant",
|
|
@@ -1677,8 +1932,7 @@ export class Agent {
|
|
|
1677
1932
|
this.lastProgressAt = Date.now();
|
|
1678
1933
|
this.activityChars = 0;
|
|
1679
1934
|
this.turnsSinceProgress = 0;
|
|
1680
|
-
const request =
|
|
1681
|
-
"what you recently tried, any problems, and your next step. Keep it under 10 lines.";
|
|
1935
|
+
const request = PROGRESS_REQUEST;
|
|
1682
1936
|
// log both sides so a session restore replays this exchange faithfully
|
|
1683
1937
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
1684
1938
|
source: "harness",
|
|
@@ -1922,8 +2176,21 @@ export class Agent {
|
|
|
1922
2176
|
// fallback: drop the oldest half without LLM help so work can continue
|
|
1923
2177
|
mode = "truncate";
|
|
1924
2178
|
summarizedCount = 0;
|
|
1925
|
-
|
|
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)));
|
|
1926
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
|
+
});
|
|
1927
2194
|
this.compactPhase = "";
|
|
1928
2195
|
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
|
|
1929
2196
|
return;
|
package/dist/agent/llm.js
CHANGED
|
@@ -7,6 +7,37 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import OpenAI from "openai";
|
|
9
9
|
const clients = new WeakMap();
|
|
10
|
+
/**
|
|
11
|
+
* Reasoning effort for a request body (#47).
|
|
12
|
+
*
|
|
13
|
+
* Kept local and deliberately conservative: the setting is only honoured when
|
|
14
|
+
* the endpoint is OpenRouter, the value is one OpenRouter documents, and the
|
|
15
|
+
* catalogue says this model advertises `reasoning_effort`. Support is far from
|
|
16
|
+
* universal (verified against the live /models endpoint: 196 of 462 models),
|
|
17
|
+
* so an unsent field is much safer than a wrong one.
|
|
18
|
+
*
|
|
19
|
+
* Implemented here rather than via model-meta to avoid an import cycle —
|
|
20
|
+
* model-meta already imports providerHeaders from this file.
|
|
21
|
+
*/
|
|
22
|
+
export function effortForRequest(cfg, supported = cfg.supportedParameters) {
|
|
23
|
+
const e = cfg.reasoningEffort;
|
|
24
|
+
if (!e || !REASONING_EFFORTS.includes(e))
|
|
25
|
+
return {};
|
|
26
|
+
if (!isOpenRouterEndpoint(cfg.baseUrl))
|
|
27
|
+
return {};
|
|
28
|
+
if (!supported?.includes("reasoning_effort"))
|
|
29
|
+
return {};
|
|
30
|
+
return { reasoning_effort: e };
|
|
31
|
+
}
|
|
32
|
+
function isOpenRouterEndpoint(baseUrl) {
|
|
33
|
+
try {
|
|
34
|
+
return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
|
|
10
41
|
/**
|
|
11
42
|
* OpenRouter app attribution (https://openrouter.ai/docs/app-attribution):
|
|
12
43
|
* `HTTP-Referer` is the required identifier that puts teapot's usage on the
|
|
@@ -103,6 +134,7 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
103
134
|
model: cfg.model,
|
|
104
135
|
messages: sanitize(messages),
|
|
105
136
|
...(tools.length ? { tools } : {}),
|
|
137
|
+
...effortForRequest(cfg),
|
|
106
138
|
}, { signal });
|
|
107
139
|
const raw = res.choices?.[0];
|
|
108
140
|
const rm = raw?.message;
|
|
@@ -188,6 +220,7 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
188
220
|
model: cfg.model,
|
|
189
221
|
messages: sanitize(messages),
|
|
190
222
|
...(tools.length ? { tools } : {}),
|
|
223
|
+
...effortForRequest(cfg),
|
|
191
224
|
stream: true,
|
|
192
225
|
// ask providers to include the final usage chunk in streaming mode
|
|
193
226
|
// (OpenAI-compatible; harmless where unsupported)
|