teapot-coding-agent 0.23.0 → 0.24.1
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 +305 -20
- 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 +96 -0
- package/dist/master.js +134 -12
- package/dist/model-meta.js +96 -36
- package/dist/server/api.js +42 -9
- package/package.json +1 -1
- package/public/assets/index-ClyCarLt.css +1 -0
- package/public/assets/{index-CNmd6GUS.js → index-sfdzqjpM.js} +20 -20
- package/public/assets/index-sfdzqjpM.js.map +1 -0
- package/public/assets/{shiki-NOpdigkY.js → shiki-D1TWrY4w.js} +2 -2
- package/public/assets/{shiki-NOpdigkY.js.map → shiki-D1TWrY4w.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-CNmd6GUS.js.map +0 -1
- package/public/assets/index-QCwmIYsM.css +0 -1
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) {
|
|
@@ -1629,18 +1864,37 @@ export class Agent {
|
|
|
1629
1864
|
const text = String(res.message.content ?? "");
|
|
1630
1865
|
const approved = /^APPROVED\b/i.test(text.trim());
|
|
1631
1866
|
const feedback = text.replace(/^(APPROVED|CHANGES-REQUIRED)\s*[:—-]?\s*/i, "").trim();
|
|
1632
|
-
|
|
1867
|
+
// #51: storing the literal "(no detail)" made the UI draw a card
|
|
1868
|
+
// whose whole content was the absence of a reason, and that same
|
|
1869
|
+
// string landed in goal.md for the right panel. An empty string is
|
|
1870
|
+
// the honest value: the auditor returned a verdict and no reason,
|
|
1871
|
+
// which every consumer can already render as "label only".
|
|
1872
|
+
await this.setGoalAudit(approved ? "approved" : "changes-required", feedback);
|
|
1873
|
+
// `operatorFacing: true` — the verdict is written for the TIMELINE
|
|
1874
|
+
// only. The audit ran in its own side conversation (a tools-less call
|
|
1875
|
+
// over buildMessages() + an audit prompt), so it was never in
|
|
1876
|
+
// `this.messages` and must not come back through a restore: doing so
|
|
1877
|
+
// injected an assistant turn that answers nothing into the model's
|
|
1878
|
+
// context, which providers answer with a 200 and no choices (#50).
|
|
1879
|
+
// The rejection feedback reaches the agent as a real prompt below.
|
|
1633
1880
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
1634
1881
|
role: "assistant",
|
|
1882
|
+
operatorFacing: true,
|
|
1635
1883
|
content: approved
|
|
1636
1884
|
? `✅ completion audit: APPROVED — ${feedback}`
|
|
1637
1885
|
: `🔍 completion audit: CHANGES REQUIRED — ${feedback}`,
|
|
1638
1886
|
});
|
|
1639
1887
|
if (!approved) {
|
|
1640
|
-
// hand the gaps back to the worker as its next instruction
|
|
1888
|
+
// hand the gaps back to the worker as its next instruction.
|
|
1889
|
+
// #51: with the "(no detail)" placeholder gone, an empty verdict
|
|
1890
|
+
// would leave this prompt saying "address these gaps" and then
|
|
1891
|
+
// naming none — so restate the contract as the thing to re-check.
|
|
1892
|
+
const gaps = feedback.trim()
|
|
1893
|
+
? feedback.slice(0, 2000)
|
|
1894
|
+
: `The auditor returned no specific gaps. Re-read the verification contract above and re-run it yourself, item by item, before finishing again:\n\n${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
|
|
1641
1895
|
this.pendingPrompts.push({
|
|
1642
1896
|
source: "harness",
|
|
1643
|
-
text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${
|
|
1897
|
+
text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`,
|
|
1644
1898
|
});
|
|
1645
1899
|
}
|
|
1646
1900
|
}
|
|
@@ -1675,7 +1929,11 @@ export class Agent {
|
|
|
1675
1929
|
// the raw content is what matters here, and cap hard: these can be huge
|
|
1676
1930
|
line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
|
|
1677
1931
|
}
|
|
1678
|
-
|
|
1932
|
+
// A generous cap, but not unbounded: one runaway turn must not blow up
|
|
1933
|
+
// the parent's context. The old 1500 char clip cut findings in half —
|
|
1934
|
+
// a review's conclusions routinely live past that mark (#42) — and this
|
|
1935
|
+
// text is bounded by 6 turns, so the worst case is ~120k chars.
|
|
1936
|
+
recent.unshift(line.slice(0, 20_000));
|
|
1679
1937
|
}
|
|
1680
1938
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
1681
1939
|
role: "assistant",
|
|
@@ -1693,8 +1951,7 @@ export class Agent {
|
|
|
1693
1951
|
this.lastProgressAt = Date.now();
|
|
1694
1952
|
this.activityChars = 0;
|
|
1695
1953
|
this.turnsSinceProgress = 0;
|
|
1696
|
-
const request =
|
|
1697
|
-
"what you recently tried, any problems, and your next step. Keep it under 10 lines.";
|
|
1954
|
+
const request = PROGRESS_REQUEST;
|
|
1698
1955
|
// log both sides so a session restore replays this exchange faithfully
|
|
1699
1956
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
1700
1957
|
source: "harness",
|
|
@@ -1938,8 +2195,21 @@ export class Agent {
|
|
|
1938
2195
|
// fallback: drop the oldest half without LLM help so work can continue
|
|
1939
2196
|
mode = "truncate";
|
|
1940
2197
|
summarizedCount = 0;
|
|
1941
|
-
|
|
2198
|
+
// Cut at a safe boundary, but never at index 0 — that would drop the
|
|
2199
|
+
// ENTIRE history (including the prompt just delivered) and leave the agent
|
|
2200
|
+
// with nothing. On a short history floor(oldCount/2) can round to 0, and
|
|
2201
|
+
// the old code then bailed out silently: compaction "ran", reported
|
|
2202
|
+
// nothing, and the context stayed over budget, so the very next provider
|
|
2203
|
+
// call overflowed — the "unstable after compact" report (#8). Always drop
|
|
2204
|
+
// at least one message, and say so.
|
|
2205
|
+
const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
|
|
1942
2206
|
if (halfCut <= 0) {
|
|
2207
|
+
// nothing in the prefix is safe to drop (e.g. it is all tool results).
|
|
2208
|
+
// Report it instead of pretending compaction happened.
|
|
2209
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2210
|
+
event: "context-compaction-skipped",
|
|
2211
|
+
reason: "summarize failed and no safe prefix to truncate",
|
|
2212
|
+
});
|
|
1943
2213
|
this.compactPhase = "";
|
|
1944
2214
|
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
|
|
1945
2215
|
return;
|
|
@@ -2435,13 +2705,28 @@ function rebuildMessagesFrom(list) {
|
|
|
2435
2705
|
msgs.push(mk());
|
|
2436
2706
|
}
|
|
2437
2707
|
else if (e.type === "message") {
|
|
2438
|
-
//
|
|
2439
|
-
// them in
|
|
2440
|
-
|
|
2708
|
+
// Operator-facing rows are TIMELINE content, not conversation: the live
|
|
2709
|
+
// loop never put them in `messages`, so a restore must not either.
|
|
2710
|
+
// `final` is the finish() summary; `operatorFacing` covers harness notes
|
|
2711
|
+
// such as the completion audit verdict. Replaying either produced a
|
|
2712
|
+
// history shape no live run emits, and a provider given that answers
|
|
2713
|
+
// 200 with zero choices (#50).
|
|
2714
|
+
if (d.final === true || d.operatorFacing === true)
|
|
2441
2715
|
continue;
|
|
2442
2716
|
const role = d.role === "assistant" ? "assistant" : "user";
|
|
2443
|
-
const
|
|
2444
|
-
|
|
2717
|
+
const content = typeof d.content === "string" ? d.content : "";
|
|
2718
|
+
const hasCalls = Array.isArray(d.toolCalls) && d.toolCalls.length > 0;
|
|
2719
|
+
// A blank assistant turn that issued NO tool calls is an artefact of a
|
|
2720
|
+
// kill mid-stream: the model streamed nothing, so the live run never had
|
|
2721
|
+
// this turn, yet the restore replays it as a real utterance. sanitize()
|
|
2722
|
+
// then rewrites blank assistant content to the literal "(no content)",
|
|
2723
|
+
// so the agent is told it previously said a sentence it never said — a
|
|
2724
|
+
// history shape no live run produces (#50). A blank turn that DID call
|
|
2725
|
+
// tools is legitimate and must stay, or its results are orphaned.
|
|
2726
|
+
if (role === "assistant" && !content && !hasCalls)
|
|
2727
|
+
continue;
|
|
2728
|
+
const m = { role, content };
|
|
2729
|
+
if (hasCalls) {
|
|
2445
2730
|
m.tool_calls = d.toolCalls.map((c) => ({
|
|
2446
2731
|
id: c.id,
|
|
2447
2732
|
type: "function",
|
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)
|