teapot-coding-agent 0.37.60 → 0.37.62

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.
@@ -749,9 +749,39 @@ export class Agent {
749
749
  });
750
750
  return { ran };
751
751
  }
752
- async editPromptAt(eventId, text, tail) {
752
+ /**
753
+ * Rewrite an earlier prompt and fork from there.
754
+ *
755
+ * The events after the edited prompt are DISCARDED, never summarized. A `summarize` mode used to
756
+ * call the model before forking, which made the edit block on a full round trip (the reported lag)
757
+ * and left `stopRequested` unset for the whole of it — so a second edit submitted while the first
758
+ * was summarizing read the same lineage and collided. Neither Pi nor OpenCode summarizes here, and
759
+ * the operator never used it, so the mode is gone rather than made asynchronous.
760
+ */
761
+ async editPromptAt(eventId, text) {
753
762
  if (this.status === "running" || this.parkedByTool || this.pendingPrompts.length)
754
763
  throw new Error("agent is running — stop it before editing history");
764
+ /**
765
+ * A LATCH, because the status check above cannot see a second call.
766
+ *
767
+ * `readEvents` and the log writes are awaits, so two requests arriving together both passed the
768
+ * check, both read the same lineage, and both forked — MEASURED in tyb2-9-8155867c while a
769
+ * `summarize` was in flight, and reachable without it by double-submitting the dialog. The latch
770
+ * is released in `finally`, so a failed edit does not wedge the agent.
771
+ */
772
+ if (this.editingHistory)
773
+ throw new Error("an edit is already in progress — wait for it to finish");
774
+ this.editingHistory = true;
775
+ try {
776
+ return await this.editPromptAtInner(eventId, text);
777
+ }
778
+ finally {
779
+ this.editingHistory = false;
780
+ }
781
+ }
782
+ /** true while an `editPromptAt` call owns the log; see the latch in the public method */
783
+ editingHistory = false;
784
+ async editPromptAtInner(eventId, text) {
755
785
  const all = await readEvents(this.log.filePath);
756
786
  const target = all.find((e) => e.id === eventId);
757
787
  if (!target || target.type !== "prompt")
@@ -763,23 +793,6 @@ export class Agent {
763
793
  const kept = lineage.slice(0, tIdx);
764
794
  const dropped = lineage.slice(tIdx);
765
795
  const msgs = rebuildMessagesFrom(kept);
766
- if (tail === "summarize" && dropped.length > 0) {
767
- try {
768
- const droppedMsgs = rebuildMessagesFrom(dropped);
769
- if (droppedMsgs.length) {
770
- const summary = await this.summarize(droppedMsgs);
771
- if (summary.trim()) {
772
- msgs.push({
773
- role: "user",
774
- content: "[harness] The conversation continued past this point on another timeline. " +
775
- `Notes from what happened there:\n\n${summary}`,
776
- });
777
- }
778
- }
779
- }
780
- catch {
781
- }
782
- }
783
796
  msgs.push({ role: "user", content: text });
784
797
  const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
785
798
  await this.quiesceForHistoryEdit();
@@ -790,7 +803,6 @@ export class Agent {
790
803
  newBranch,
791
804
  reason: "prompt-edited",
792
805
  droppedEvents: dropped.length,
793
- tailMode: tail,
794
806
  });
795
807
  const cutPoint = kept.at(-1)?.id ?? null;
796
808
  this.log.seedBranch(newBranch, cutPoint);
@@ -1001,7 +1013,15 @@ export class Agent {
1001
1013
  model: this.opts.llm.model,
1002
1014
  provider: this.opts.provider,
1003
1015
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
1004
- effortSupported: effortChoosable(this.opts.supportedParameters),
1016
+ /**
1017
+ * Offer the control unless the catalog SAYS the model cannot reason.
1018
+ *
1019
+ * `effortChoosable(undefined)` is `true` on purpose: a model missing from models.dev is
1020
+ * UNKNOWN, not unsupported, and the wire recovers from a rejected `reasoning_effort`
1021
+ * (`isEffortUnsupported`). The previous rule gated this on OpenRouter alone, which hid the
1022
+ * control for every other provider whose model did support it.
1023
+ */
1024
+ effortSupported: this.opts.supportedEfforts?.length ? true : effortChoosable(undefined),
1005
1025
  ...(this.opts.supportedEfforts?.length ? { effortOptions: this.opts.supportedEfforts } : {}),
1006
1026
  sessionDir: this.opts.sessionDir,
1007
1027
  ctx: {
package/dist/agent/llm.js CHANGED
@@ -34,7 +34,7 @@ export function effortChoosable(supported) {
34
34
  return true;
35
35
  return supported.includes("reasoning_effort");
36
36
  }
37
- function isOpenRouterEndpoint(baseUrl) {
37
+ export function isOpenRouterEndpoint(baseUrl) {
38
38
  try {
39
39
  return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
40
40
  }
package/dist/master.js CHANGED
@@ -659,9 +659,6 @@ export class Master {
659
659
  ...(ac.parent ? { parent: ac.parent } : {}),
660
660
  ...(ac.chatFn ? { chatFn: ac.chatFn } : {}),
661
661
  ...(ac.reasoningEffort ? { reasoningEffort: ac.reasoningEffort } : {}),
662
- ...(modelMeta?.supportedParameters
663
- ? { supportedParameters: modelMeta.supportedParameters }
664
- : {}),
665
662
  ...(modelMeta?.supportedEfforts?.length ? { supportedEfforts: modelMeta.supportedEfforts } : {}),
666
663
  });
667
664
  void fetchModelList(llm.baseUrl, llm.apiKey)
@@ -1390,7 +1387,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1390
1387
  if (cfgEntry)
1391
1388
  cfgEntry.reasoningEffort = opts.opts.reasoningEffort || undefined;
1392
1389
  }
1393
- opts.opts.supportedParameters = meta?.supportedParameters;
1394
1390
  opts.opts.supportedEfforts = meta?.supportedEfforts;
1395
1391
  if (!llm.model)
1396
1392
  throw new Error("no model resolved (pass model or set one on the provider)");
@@ -1,84 +1,180 @@
1
+ /**
2
+ * Model capabilities, from models.dev.
3
+ *
4
+ * WHY models.dev and not the provider's own `/models`: MEASURED against the operator's four
5
+ * configured providers, only OpenRouter publishes anything about capabilities. Command Code (93
6
+ * models), Google (38) and orcarouter all return just an id, a name and a context length — no
7
+ * `modalities`, no `reasoning`, no `supported_parameters`. That is why this file used to conclude
8
+ * "cannot be known", which was WRONG: models.dev carries the same information for 232 providers,
9
+ * including `commandcode` (71 models) and `opencode` (130), keyed by the provider's own API host.
10
+ *
11
+ * The shape mirrors what Pi (and its forks) do — models.dev is the authority, `reasoning_options`
12
+ * becomes the selectable effort list, `modalities.input` becomes the accepted input kinds. Keeping a
13
+ * second parser for each provider's ad-hoc `/models` JSON was the "wasteful and a source of bugs"
14
+ * the operator called out, so it is gone.
15
+ */
1
16
  import { providerHeaders } from "./agent/provider-headers.js";
2
- const cache = new Map();
3
- const inFlight = new Map();
4
- const TTL = 10 * 60_000;
5
- const NEGATIVE_TTL = 30_000;
6
- function fresh(entry) {
7
- return Date.now() - entry.at < (entry.negative ? NEGATIVE_TTL : TTL);
8
- }
9
- export function peekModelList(baseUrl) {
10
- const root = baseUrl.replace(/\/+$/, "");
11
- const hit = cache.get(root);
12
- if (!hit)
13
- return undefined;
14
- if (!fresh(hit))
17
+ const DB_URL = "https://models.dev/api.json";
18
+ /** the whole DB is ~6MB; it changes on the order of days, so an hour is generous and cheap. */
19
+ const TTL = 60 * 60_000;
20
+ /** a failed fetch must not re-run per agent (15s × every agent at boot was the old bug) */
21
+ const NEGATIVE_TTL = 5 * 60_000;
22
+ let db;
23
+ let inFlight;
24
+ const hostOf = (baseUrl) => {
25
+ try {
26
+ return new URL(String(baseUrl ?? "")).host.toLowerCase();
27
+ }
28
+ catch {
29
+ return "";
30
+ }
31
+ };
32
+ const fresh = (s) => !!s && Date.now() - s.at < (s.negative ? NEGATIVE_TTL : TTL);
33
+ /**
34
+ * Find the models.dev provider for a baseUrl.
35
+ *
36
+ * HOST FIRST, then the path as a tie-break, because both are needed:
37
+ *
38
+ * - a host match is what makes `commandcode` work: FIVE config entries share
39
+ * `api.commandcode.ai`, and models.dev lists that host once;
40
+ * - a host match alone is ambiguous for `opencode.ai`, which models.dev splits into `opencode`
41
+ * (130 models, `/inference/openai/v1`) and `opencode-go` (37, `/inference/go/openai/v1`).
42
+ * MEASURED: host-only picked whichever came first and resolved the session's provider to the
43
+ * wrong catalog — 37 models, and its model not found.
44
+ *
45
+ * The operator's `https://opencode.ai/zen/v1` matches NEITHER path exactly, so the path only
46
+ * disambiguates when it actually agrees; otherwise the host match stands.
47
+ */
48
+ export function providerFor(dbProviders, baseUrl) {
49
+ const host = hostOf(baseUrl);
50
+ if (!host)
15
51
  return undefined;
16
- return hit.negative ? [] : hit.list;
52
+ const sameHost = [...dbProviders.values()].filter((p) => hostOf(p.api ?? undefined) === host);
53
+ if (sameHost.length <= 1)
54
+ return sameHost[0];
55
+ const path = (() => {
56
+ try {
57
+ return new URL(String(baseUrl ?? "")).pathname;
58
+ }
59
+ catch {
60
+ return "";
61
+ }
62
+ })();
63
+ // prefer the entry whose path is a prefix/suffix relation with ours, longest overlap wins
64
+ const scored = sameHost
65
+ .map((p) => {
66
+ const apiPath = (() => {
67
+ try {
68
+ return new URL(String(p.api ?? "")).pathname;
69
+ }
70
+ catch {
71
+ return "";
72
+ }
73
+ })();
74
+ let common = 0;
75
+ for (let i = 0; i < Math.min(apiPath.length, path.length) && apiPath[i] === path[i]; i++)
76
+ common++;
77
+ /**
78
+ * When the shared prefix is only the leading `/`, the paths disagree entirely — `zen/v1` is
79
+ * neither `inference/openai/v1` nor `inference/go/openai/v1`. In that case prefer the entry
80
+ * with MORE models: a real provider catalog is the general one, and the narrower variant is a
81
+ * strict subset (opencode: 130 vs opencode-go: 37, MEASURED).
82
+ */
83
+ const models = Object.keys(p.models ?? {}).length;
84
+ return { p, common, models };
85
+ })
86
+ .sort((a, b) => b.common - a.common || b.models - a.models);
87
+ return scored[0]?.p ?? sameHost[0];
17
88
  }
18
- export async function fetchModelList(baseUrl, apiKey) {
19
- const root = baseUrl.replace(/\/+$/, "");
20
- const hit = cache.get(root);
21
- if (hit && fresh(hit))
22
- return hit.list;
23
- const inflight = inFlight.get(root);
24
- if (inflight)
25
- return inflight;
89
+ async function loadDb() {
90
+ if (fresh(db))
91
+ return db;
92
+ if (inFlight)
93
+ return inFlight;
26
94
  const p = (async () => {
27
95
  try {
28
- const res = await fetch(`${root}/models`, {
29
- headers: {
30
- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
31
- ...providerHeaders(baseUrl),
32
- },
33
- signal: AbortSignal.timeout(3_000),
96
+ const res = await fetch(DB_URL, {
97
+ headers: providerHeaders(DB_URL),
98
+ signal: AbortSignal.timeout(8_000),
34
99
  });
35
100
  if (!res.ok)
36
101
  throw new Error(`HTTP ${res.status}`);
37
102
  const j = (await res.json());
38
- const list = (j.data ?? [])
39
- .map((m) => ({
40
- id: typeof m.id === "string" ? m.id : "",
41
- contextLength: typeof m.context_length === "number" ? m.context_length : undefined,
42
- pricing: m.pricing && (m.pricing.prompt !== undefined || m.pricing.completion !== undefined)
43
- ? {
44
- prompt: Number(m.pricing.prompt ?? 0),
45
- completion: Number(m.pricing.completion ?? 0),
46
- }
47
- : undefined,
48
- modalities: Array.isArray(m.architecture?.input_modalities) && m.architecture.input_modalities.length
49
- ? {
50
- input: m.architecture.input_modalities,
51
- output: m.architecture.output_modalities ?? ["text"],
52
- }
53
- : undefined,
54
- supportedParameters: Array.isArray(m.supported_parameters)
55
- ? m.supported_parameters.filter((p) => typeof p === "string")
56
- : undefined,
57
- supportedEfforts: Array.isArray(m.reasoning?.supported_efforts)
58
- ? m.reasoning.supported_efforts.filter((e) => typeof e === "string")
59
- : undefined,
60
- ...(typeof m.reasoning?.default_effort === "string"
61
- ? { defaultEffort: m.reasoning.default_effort }
62
- : {}),
63
- ...(m.reasoning?.mandatory === true ? { reasoningMandatory: true } : {}),
64
- }))
65
- .filter((m) => m.id);
66
- cache.set(root, { at: Date.now(), list });
67
- return list;
103
+ const providers = new Map();
104
+ for (const [key, value] of Object.entries(j)) {
105
+ if (value && typeof value === "object")
106
+ providers.set(key, value);
107
+ }
108
+ const snap = { at: Date.now(), providers };
109
+ db = snap;
110
+ return snap;
68
111
  }
69
112
  catch {
70
- cache.set(root, { at: Date.now(), list: [], negative: true });
71
- return [];
113
+ /**
114
+ * Remember the FAILURE briefly, and always return a usable snapshot.
115
+ *
116
+ * Without the negative cache every agent paid an 8s timeout at boot — the same shape as the
117
+ * old per-endpoint fetch. An empty map is not an error to the caller: it means "no data", and
118
+ * every consumer treats absent metadata as UNKNOWN rather than as "unsupported".
119
+ */
120
+ const snap = { at: Date.now(), providers: new Map(), negative: true };
121
+ db = snap;
122
+ return snap;
72
123
  }
73
124
  })();
74
- inFlight.set(root, p);
125
+ inFlight = p;
75
126
  try {
76
127
  return await p;
77
128
  }
78
129
  finally {
79
- inFlight.delete(root);
130
+ inFlight = undefined;
80
131
  }
81
132
  }
133
+ /** the effort levels a models.dev record publishes, flattened out of `reasoning_options` */
134
+ export function effortsFrom(m) {
135
+ const values = (m?.reasoning_options ?? [])
136
+ .filter((o) => o?.type === "effort")
137
+ .flatMap((o) => (Array.isArray(o.values) ? o.values : []))
138
+ .filter((v) => typeof v === "string");
139
+ return values.length ? [...new Set(values)] : undefined;
140
+ }
141
+ const strings = (v) => {
142
+ const out = (Array.isArray(v) ? v : []).filter((x) => typeof x === "string");
143
+ return out.length ? out : undefined;
144
+ };
145
+ /** one models.dev record → our shape; unknown fields stay absent, never invented */
146
+ export function metaFrom(m) {
147
+ const input = strings(m.modalities?.input);
148
+ const output = strings(m.modalities?.output);
149
+ const efforts = effortsFrom(m);
150
+ return {
151
+ id: String(m.id ?? ""),
152
+ ...(typeof m.name === "string" ? { name: m.name } : {}),
153
+ ...(typeof m.limit?.context === "number" ? { contextLength: m.limit.context } : {}),
154
+ ...(m.cost && (typeof m.cost.input === "number" || typeof m.cost.output === "number")
155
+ ? { pricing: { prompt: Number(m.cost.input ?? 0), completion: Number(m.cost.output ?? 0) } }
156
+ : {}),
157
+ ...(input ? { modalities: { input, output: output ?? ["text"] } } : {}),
158
+ ...(efforts ? { supportedEfforts: efforts } : {}),
159
+ ...(m.reasoning === true ? { reasoning: true } : {}),
160
+ };
161
+ }
162
+ /** every model models.dev lists for this baseUrl's provider, or [] when the provider is unknown */
163
+ export async function fetchModelList(baseUrl, _apiKey) {
164
+ const snap = await loadDb();
165
+ const provider = providerFor(snap.providers, baseUrl);
166
+ if (!provider?.models)
167
+ return [];
168
+ return Object.values(provider.models).map(metaFrom).filter((m) => m.id);
169
+ }
170
+ export function peekModelList(baseUrl) {
171
+ if (!fresh(db))
172
+ return undefined;
173
+ const provider = providerFor(db.providers, baseUrl);
174
+ if (!provider?.models)
175
+ return undefined;
176
+ return Object.values(provider.models).map(metaFrom).filter((m) => m.id);
177
+ }
82
178
  export function contextLengthFor(list, model) {
83
179
  return list.find((m) => m.id === model)?.contextLength;
84
180
  }
@@ -86,23 +182,23 @@ export const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
86
182
  export function isReasoningEffort(v) {
87
183
  return typeof v === "string" && REASONING_EFFORTS.includes(v);
88
184
  }
89
- export function isOpenRouter(baseUrl) {
90
- try {
91
- return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
92
- }
93
- catch {
94
- return false;
95
- }
96
- }
97
- export function resolveReasoningEffort(opts) {
98
- if (!isReasoningEffort(opts.effort))
99
- return undefined;
100
- if (!isOpenRouter(opts.baseUrl))
101
- return undefined;
185
+ /**
186
+ * Should the UI offer a reasoning-effort control for this model?
187
+ *
188
+ * THREE answers, not two. `true` when models.dev says the model reasons; `false` when it says it
189
+ * does not; and TRUE when the model is simply not in the database — because the previous rule
190
+ * ("only OpenRouter") hid the control for every other provider even though the request would have
191
+ * worked. A wire that rejects `reasoning_effort` is recovered from at runtime
192
+ * (`isEffortUnsupported`), so an optimistic default costs one retry at worst, while a pessimistic one
193
+ * silently removes a feature the model does have.
194
+ */
195
+ export function effortSupportedFor(opts) {
102
196
  const meta = opts.list?.find((m) => m.id === opts.model);
103
- if (!meta?.supportedParameters)
104
- return undefined;
105
- if (!meta.supportedParameters.includes("reasoning_effort"))
106
- return undefined;
107
- return opts.effort;
197
+ if (!meta)
198
+ return true; // not in models.dev → unknown, not "unsupported"
199
+ return meta.reasoning === true || !!meta.supportedEfforts?.length;
200
+ }
201
+ /** the effort levels to offer, or undefined to let the caller use its default ladder */
202
+ export function effortsFor(opts) {
203
+ return opts.list?.find((m) => m.id === opts.model)?.supportedEfforts;
108
204
  }
@@ -1152,7 +1152,9 @@ export function buildApp(master) {
1152
1152
  if (!body?.eventId || !body.text?.trim())
1153
1153
  return c.json({ error: "eventId and text required" }, 400);
1154
1154
  try {
1155
- const r = await a.editPromptAt(body.eventId, body.text, body.tail === "summarize" ? "summarize" : "discard");
1155
+ // a `tail` in the body is ignored on purpose: the events after the edit are discarded, so an
1156
+ // older client still sending `tail: "summarize"` gets the fast path rather than an error
1157
+ const r = await a.editPromptAt(body.eventId, body.text);
1156
1158
  a.start("edited prompt resent");
1157
1159
  return c.json({ ok: true, ...r });
1158
1160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.60",
3
+ "version": "0.37.62",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",