jeo-code 0.7.56 → 0.7.57
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/CHANGELOG.md +12 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/config-schema.ts +14 -0
- package/src/agent/context-files.ts +25 -0
- package/src/agent/engine.ts +4 -2
- package/src/agent/prompt-router.ts +178 -28
- package/src/agent/state.ts +22 -1
- package/src/agent/subagents.ts +37 -3
- package/src/agent/tools.ts +169 -0
- package/src/ai/model-catalog.ts +8 -1
- package/src/ai/model-discovery.ts +5 -1
- package/src/ai/model-manager.ts +17 -4
- package/src/auth/callback-server.ts +13 -3
- package/src/commands/doctor.ts +32 -6
- package/src/commands/launch.ts +1 -1
- package/src/tui/app.ts +2 -2
- package/src/tui/components/status.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.7.57] - 2026-07-07
|
|
10
|
+
_"동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`routing.crossProviderPool`** (opt-in, default off) — session-STABLE selection across every credentialed provider's EQUIVALENT-class model for a tier (`sizeClassFor`: provider-declared size suffixes — haiku/flash/mini=small, sonnet/pro=mid, opus/fable=large — computed live off `MODEL_CATALOG`, with a strength-tercile fallback for the ~50% of the catalog with no size-suffix naming). The same session always resolves to the same model (preserves provider-side prompt-cache warmth turn-to-turn); different sessions spread across different providers. Purely additive — unset, every existing config's behavior is byte-identical to v0.7.56.
|
|
14
|
+
- **`CatalogModel.limitedAvailability`** — a real bug this work surfaced: invite-only models (`claude-mythos-5`) were reachable by auto-select/pool even though a provider credential doesn't imply access to that specific model. Now excluded from all auto-select paths; still targetable explicitly by id for approved accounts.
|
|
15
|
+
- **`jeo doctor` routing preview** extended with an `"auto-selected: cross-provider pool"` source label when `crossProviderPool` resolved the shown model.
|
|
16
|
+
|
|
17
|
+
### Verified
|
|
18
|
+
- `bun run typecheck` clean; full suite 2692/2692 pass across 280 files.
|
|
19
|
+
- End-to-end against the real 4-provider production config: `tierModelPool("trivial")` spans gemini/openai/anthropic/antigravity; 5 distinct sessions distribute across 3 different providers while each individual session stays stable across repeated turns.
|
|
20
|
+
|
|
9
21
|
## [0.7.56] - 2026-07-07
|
|
10
22
|
_"서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale._
|
|
11
23
|
|
package/README.ja.md
CHANGED
|
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
259
259
|
## 変更履歴 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
|
|
262
263
|
- **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
|
|
263
264
|
- **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
|
|
264
265
|
- **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
|
|
265
266
|
- **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
|
|
266
|
-
- **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -260,11 +260,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
260
260
|
## 변경 이력 (Changelog)
|
|
261
261
|
|
|
262
262
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
263
|
+
- **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
|
|
263
264
|
- **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
|
|
264
265
|
- **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
|
|
265
266
|
- **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
|
|
266
267
|
- **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
|
|
267
|
-
- **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
|
|
268
268
|
|
|
269
269
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
270
270
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -264,11 +264,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
264
264
|
## Changelog
|
|
265
265
|
|
|
266
266
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
267
|
+
- **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
|
|
267
268
|
- **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
|
|
268
269
|
- **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
|
|
269
270
|
- **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
|
|
270
271
|
- **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
|
|
271
|
-
- **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
|
|
272
272
|
|
|
273
273
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
274
274
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
259
259
|
## 更新日志 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
|
|
262
263
|
- **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
|
|
263
264
|
- **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
|
|
264
265
|
- **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
|
|
265
266
|
- **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
|
|
266
|
-
- **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -186,6 +186,9 @@ export const ConfigSchema = z
|
|
|
186
186
|
roles: z
|
|
187
187
|
.object({
|
|
188
188
|
smol: z.string().optional(),
|
|
189
|
+
medium: z.string().optional(),
|
|
190
|
+
high: z.string().optional(),
|
|
191
|
+
xhigh: z.string().optional(),
|
|
189
192
|
slow: z.string().optional(),
|
|
190
193
|
plan: z.string().optional(),
|
|
191
194
|
})
|
|
@@ -203,9 +206,11 @@ export const ConfigSchema = z
|
|
|
203
206
|
.object({
|
|
204
207
|
trivial: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
205
208
|
standard: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
209
|
+
high: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
206
210
|
complex: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
207
211
|
})
|
|
208
212
|
.optional(),
|
|
213
|
+
crossProviderPool: z.boolean().optional(),
|
|
209
214
|
})
|
|
210
215
|
.optional(),
|
|
211
216
|
gitAutoCommit: z.boolean().optional(),
|
|
@@ -320,6 +325,15 @@ export function parseConfig(raw: unknown): { ok: true; config: ValidatedConfig }
|
|
|
320
325
|
if (config.roles.smol) {
|
|
321
326
|
config.roles.smol = normalizeModelId(config.roles.smol, aliases);
|
|
322
327
|
}
|
|
328
|
+
if (config.roles.medium) {
|
|
329
|
+
config.roles.medium = normalizeModelId(config.roles.medium, aliases);
|
|
330
|
+
}
|
|
331
|
+
if (config.roles.high) {
|
|
332
|
+
config.roles.high = normalizeModelId(config.roles.high, aliases);
|
|
333
|
+
}
|
|
334
|
+
if (config.roles.xhigh) {
|
|
335
|
+
config.roles.xhigh = normalizeModelId(config.roles.xhigh, aliases);
|
|
336
|
+
}
|
|
323
337
|
if (config.roles.slow) {
|
|
324
338
|
config.roles.slow = normalizeModelId(config.roles.slow, aliases);
|
|
325
339
|
}
|
|
@@ -264,6 +264,31 @@ export async function loadProjectContext(cwd = process.cwd()): Promise<ProjectCo
|
|
|
264
264
|
const home = process.env.HOME ? path.resolve(process.env.HOME) : null;
|
|
265
265
|
const collectedItems: ContextItem[] = [];
|
|
266
266
|
|
|
267
|
+
// 0. User-global context files (~/.jeo/agent/AGENTS.md) — lowest precedence.
|
|
268
|
+
// Native user files come first in the result so project files later take precedence.
|
|
269
|
+
// Only jeo's own user config applies; foreign-provider user files are excluded (e.g.
|
|
270
|
+
// ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md stay out — only ~/.jeo/* is loaded).
|
|
271
|
+
//
|
|
272
|
+
// This is optional: if the user file doesn't exist or is unreadable, we silently skip.
|
|
273
|
+
// Symlinks are followed (fs.stat resolves them), so ~/.jeo/agent/AGENTS.md can be a link.
|
|
274
|
+
if (home) {
|
|
275
|
+
const userAgentFile = path.join(home, ".jeo", "agent", "AGENTS.md");
|
|
276
|
+
try {
|
|
277
|
+
const stat = await fs.stat(userAgentFile);
|
|
278
|
+
// Check: must be a regular file (not directory) and non-empty.
|
|
279
|
+
// fs.stat follows symlinks, so this works transparently with linked files.
|
|
280
|
+
if (stat.isFile() && stat.size > 0) {
|
|
281
|
+
const file = await readContextFile(userAgentFile, "~/.jeo/agent/AGENTS.md", BASE_CONTEXT_CHARS - baseChars);
|
|
282
|
+
if (file) {
|
|
283
|
+
result.push(file);
|
|
284
|
+
baseChars += file.content.length;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// User-global file not found, unreadable, or permission denied; silently continue.
|
|
289
|
+
// User config is optional; its absence is not an error condition.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
267
292
|
// 1. CWD 및 부모 walk
|
|
268
293
|
let curr = resolvedCwd;
|
|
269
294
|
let distance = 0;
|
package/src/agent/engine.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as path from "node:path";
|
|
|
12
12
|
import type { Message } from "./loop";
|
|
13
13
|
import { extractJsonObject } from "./json";
|
|
14
14
|
import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
|
|
15
|
-
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
|
|
15
|
+
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, bisectTool, type ToolResult } from "./tools";
|
|
16
16
|
import { webSearchTool, setWebSearchActiveModel } from "./web-search";
|
|
17
17
|
import { executeComputerAction } from "../commands/computer";
|
|
18
18
|
import { computerSupervisor } from "./computer-supervisor";
|
|
@@ -105,6 +105,7 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
|
|
|
105
105
|
lsp_rename: createLspRenameTool(),
|
|
106
106
|
debug: createDebugTool(),
|
|
107
107
|
browser: createBrowserTool(),
|
|
108
|
+
bisect: (a, cwd) => bisectTool(a.good, a.bad, a.run, !!a.invert, a.maxSteps, a.stepTimeoutMs, cwd),
|
|
108
109
|
};
|
|
109
110
|
/** Tool-protocol description injected into the system prompt. */
|
|
110
111
|
export const TOOL_PROTOCOL = [
|
|
@@ -126,7 +127,8 @@ export const TOOL_PROTOCOL = [
|
|
|
126
127
|
"15. lsp_rename {file, line, symbol?, new_name, apply?} — cross-file TypeScript/JavaScript rename (apply defaults true; apply:false previews only)",
|
|
127
128
|
"16. debug {action, ...} — Node.js debugging (launch/set_breakpoint/continue/step_over/step_in/step_out/pause/evaluate/stack_trace/scopes/variables/threads/output/terminate); one active session, 'launch' spawns 'node --inspect-brk' and pauses at start",
|
|
128
129
|
"17. browser {action:\"open\"|\"close\"|\"run\"|\"act\", name?, url?, actions?} — headless Chromium automation (Playwright); 'act' runs structured steps (navigate/click/type/fill/select/press/scroll/back/wait/observe/extract/screenshot) addressing elements by numeric id from 'observe' or a CSS selector; 'run' executes JS with page/browser/tab/display/assert/wait in scope",
|
|
129
|
-
"18.
|
|
130
|
+
"18. bisect {good, bad?, run, invert?, maxSteps?, stepTimeoutMs?} — binary-search git history to find the commit that introduced a regression; 'good' is a known-good commit, 'bad' defaults to HEAD, 'run' is a test command (exit 0=good, non-zero=bad, 125=skip), 'invert' finds a FIX instead of a regression",
|
|
131
|
+
"19. done {reason?} — call when the task is fully implemented AND verified",
|
|
130
132
|
"",
|
|
131
133
|
"Reply with STRICT JSON only — no code fences. You MAY include an optional leading",
|
|
132
134
|
'"reasoning" string (one short sentence on your plan) before "tool":',
|
|
@@ -18,17 +18,34 @@
|
|
|
18
18
|
* `config.roles`/`config.routing.tiers` so a user who already configured
|
|
19
19
|
* `roles.smol`/`roles.slow` (for `--smol`/`--slow` role-tier resolution
|
|
20
20
|
* elsewhere) gets working routing with zero additional config. An unconfigured
|
|
21
|
-
* trivial/complex tier does NOT simply collapse to `defaultModel` (that
|
|
22
|
-
* defeat cross-provider routing for every user who hasn't hand-tuned
|
|
23
|
-
* `routing.tiers`) — it auto-selects the cheapest (trivial) /
|
|
24
|
-
* (complex) model jeo has a stored
|
|
25
|
-
* `MODEL_CATALOG`/`pricing.ts` on every
|
|
26
|
-
* catalog drift: when jeo ships a
|
|
27
|
-
* picks it up on the next call
|
|
28
|
-
* hand-maintained tier→model table to
|
|
29
|
-
* exception: it always falls through to
|
|
30
|
-
* configured, since routine day-to-day work
|
|
31
|
-
* deliberately chosen default rather than being
|
|
21
|
+
* trivial/high/complex tier does NOT simply collapse to `defaultModel` (that
|
|
22
|
+
* would defeat cross-provider routing for every user who hasn't hand-tuned
|
|
23
|
+
* `routing.tiers`) — it auto-selects the cheapest (trivial) / a strong
|
|
24
|
+
* mid-class model (high) / the most capable (complex) model jeo has a stored
|
|
25
|
+
* credential for, computed LIVE off `MODEL_CATALOG`/`pricing.ts` on every
|
|
26
|
+
* call. This is the "long-term" fix for catalog drift: when jeo ships a
|
|
27
|
+
* new/cheaper/stronger model, auto-select picks it up on the next call
|
|
28
|
+
* automatically — no user config edit, no hand-maintained tier→model table to
|
|
29
|
+
* go stale. "standard" is the one exception: it always falls through to
|
|
30
|
+
* `defaultModel` unless explicitly configured, since routine day-to-day work
|
|
31
|
+
* should stay on the user's deliberately chosen default rather than being
|
|
32
|
+
* silently reassigned (unless `routing.crossProviderPool` opts it into the
|
|
33
|
+
* session-stable cross-provider pool below).
|
|
34
|
+
*
|
|
35
|
+
* Four content-based tiers map onto the four `roles.*` price points
|
|
36
|
+
* (smol/medium/high/xhigh) so automatic routing can actually reach all of
|
|
37
|
+
* them: `trivial`→smol, `standard`→medium, `high`→roles.high (a single
|
|
38
|
+
* borderline-complex signal — e.g. one deep-work keyword alone — no longer
|
|
39
|
+
* jumps straight to the most expensive `complex`/xhigh tier), `complex`→xhigh.
|
|
40
|
+
*
|
|
41
|
+
* `routing.crossProviderPool` (opt-in, default off) activates
|
|
42
|
+
* `tierModelPool`/`selectFromPool` for the `standard`/`high` tiers: instead of
|
|
43
|
+
* a single deterministic pick, a SESSION-STABLE hash spreads different
|
|
44
|
+
* sessions across every credentialed provider's equivalent-class model, so
|
|
45
|
+
* jeo actually captures different providers' respective strengths on
|
|
46
|
+
* comparably-priced work instead of converging every session onto one
|
|
47
|
+
* provider. Off by default to preserve the "safe no-op absent configuration"
|
|
48
|
+
* contract for users who never opted in.
|
|
32
49
|
*/
|
|
33
50
|
import { callLlm } from "./loop";
|
|
34
51
|
import { resolveRoleModel } from "../ai/model-manager";
|
|
@@ -40,7 +57,7 @@ import type { ThinkLevel } from "../ai/model-catalog";
|
|
|
40
57
|
import type { ProviderName } from "../ai/types";
|
|
41
58
|
import type { AuthProvider } from "../auth";
|
|
42
59
|
|
|
43
|
-
export type PromptTier = "trivial" | "standard" | "complex";
|
|
60
|
+
export type PromptTier = "trivial" | "standard" | "high" | "complex";
|
|
44
61
|
|
|
45
62
|
export interface HeuristicResult {
|
|
46
63
|
tier: PromptTier;
|
|
@@ -62,6 +79,12 @@ export interface RouteDecision {
|
|
|
62
79
|
export interface RoutePromptOptions {
|
|
63
80
|
hasImages?: boolean;
|
|
64
81
|
signal?: AbortSignal;
|
|
82
|
+
/** Threads through to `selectFromPool` for session-stable cross-provider pool
|
|
83
|
+
* selection — the same session always lands on the same pool member (keeps
|
|
84
|
+
* provider-side prompt caching warm turn-to-turn), while different sessions
|
|
85
|
+
* spread across the pool. Omitted (e.g. a one-shot `jeo chat` call) ->
|
|
86
|
+
* deterministic index 0. */
|
|
87
|
+
sessionId?: string;
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
// --- Frozen, bilingual-validated classification signals (do not re-derive). ---
|
|
@@ -143,7 +166,10 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
|
143
166
|
return { tier: "standard", confidence: 0.35, signals }; // conflicting signals -> let escalation decide
|
|
144
167
|
}
|
|
145
168
|
if (complexScore >= 2) return { tier: "complex", confidence: 0.85, signals };
|
|
146
|
-
|
|
169
|
+
// A single complex signal alone is treated as "high" (elevated but not full complex) —
|
|
170
|
+
// price efficiency: one weak/borderline signal must not jump straight to the priciest
|
|
171
|
+
// xhigh tier (that's reserved for >=2 corroborating complex signals).
|
|
172
|
+
if (complexScore === 1) return { tier: "high", confidence: 0.65, signals };
|
|
147
173
|
if (trivialScore >= 2) return { tier: "trivial", confidence: 0.85, signals };
|
|
148
174
|
if (trivialScore === 1) return { tier: "trivial", confidence: 0.65, signals };
|
|
149
175
|
return { tier: "standard", confidence: 0.9, signals }; // no signals at all -> common case, high confidence
|
|
@@ -185,12 +211,95 @@ function isCloudProvider(p: ProviderName): p is AuthProvider {
|
|
|
185
211
|
return p !== "ollama" && p !== "lmstudio";
|
|
186
212
|
}
|
|
187
213
|
|
|
214
|
+
/** Size class jeo infers for a catalog model, used to group EQUIVALENT models
|
|
215
|
+
* across DIFFERENT providers for pool-based routing (see `tierModelPool`) — the
|
|
216
|
+
* cross-provider counterpart to `cheapestCredentialed`/`strongestCredentialed`'s
|
|
217
|
+
* single-winner selection. Matches PROVIDER-DECLARED size-tier suffixes
|
|
218
|
+
* (Anthropic's haiku/sonnet/opus, Google's flash/pro, OpenAI's mini) against
|
|
219
|
+
* hyphen/dot-delimited SEGMENTS of the canonical id — NOT a raw substring match,
|
|
220
|
+
* which would false-positive on "gemini" containing "mini" as a substring.
|
|
221
|
+
* `null` for ids with no size-tier suffix at all (gpt-5.5, o3, grok-4.3, kimi-*,
|
|
222
|
+
* glm-*, deepseek-*, minimax-* — providers whose naming doesn't encode size);
|
|
223
|
+
* `tierModelPool` falls those back to a strength-tercile split instead. */
|
|
224
|
+
function sizeClassFor(canonical: string): "small" | "mid" | "large" | null {
|
|
225
|
+
const id = canonical.toLowerCase();
|
|
226
|
+
const bare = id.includes("/") ? id.slice(id.indexOf("/") + 1) : id;
|
|
227
|
+
const segments = bare.split(/[-.]/);
|
|
228
|
+
if (segments.includes("haiku") || segments.includes("flash") || segments.includes("mini") || segments.includes("nano")) return "small";
|
|
229
|
+
if (segments.includes("opus") || segments.includes("ultra") || segments.includes("fable") || segments.includes("mythos")) return "large";
|
|
230
|
+
if (segments.includes("sonnet") || segments.includes("pro")) return "mid";
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const TIER_TO_SIZE_CLASS: Record<PromptTier, "small" | "mid" | "large"> = { trivial: "small", standard: "mid", high: "mid", complex: "large" };
|
|
235
|
+
|
|
236
|
+
/** Multi-key strength comparator (ascending: weakest first) — same ranking signals
|
|
237
|
+
* as `strongestCredentialed`'s single-winner tiebreak, reused here to bucket
|
|
238
|
+
* UNCLASSIFIED (no size-suffix) models into a tercile fallback. */
|
|
239
|
+
function compareStrengthAscending(a: CatalogModel, b: CatalogModel): number {
|
|
240
|
+
const xhighA = a.thinking.includes("xhigh") ? 1 : 0;
|
|
241
|
+
const xhighB = b.thinking.includes("xhigh") ? 1 : 0;
|
|
242
|
+
if (xhighA !== xhighB) return xhighA - xhighB;
|
|
243
|
+
if (a.maxOutputTokens !== b.maxOutputTokens) return a.maxOutputTokens - b.maxOutputTokens;
|
|
244
|
+
if (a.contextTokens !== b.contextTokens) return a.contextTokens - b.contextTokens;
|
|
245
|
+
return a.canonical < b.canonical ? -1 : a.canonical > b.canonical ? 1 : 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Cross-provider EQUIVALENCE pool for `tier` — every credentialed cloud model
|
|
249
|
+
* jeo classifies as the same size class (trivial→small, standard→mid,
|
|
250
|
+
* complex→large), computed LIVE off `MODEL_CATALOG` so it stays correct as the
|
|
251
|
+
* catalog evolves. Models with no size-tier-suffix (gpt-5.5, o3, grok-4.3,
|
|
252
|
+
* kimi-*, glm-*, …) fall back to a strength-tercile bucket instead of being
|
|
253
|
+
* silently excluded from every pool. Returns `[]` when zero credentialed
|
|
254
|
+
* models qualify (caller falls back to `defaultModel`). Sorted by canonical id
|
|
255
|
+
* for deterministic pool ordering (required by `selectFromPool`'s index math). */
|
|
256
|
+
export function tierModelPool(tier: PromptTier, config: RoutingConfig): string[] {
|
|
257
|
+
const targetClass = TIER_TO_SIZE_CLASS[tier];
|
|
258
|
+
const credentialed = MODEL_CATALOG.filter(m => isCloudProvider(m.provider) && !m.limitedAvailability && (config.providers?.[m.provider] || config.oauth?.[m.provider]));
|
|
259
|
+
|
|
260
|
+
const suffixPool = credentialed.filter(m => sizeClassFor(m.canonical) === targetClass);
|
|
261
|
+
|
|
262
|
+
const unclassified = credentialed.filter(m => sizeClassFor(m.canonical) === null).sort(compareStrengthAscending);
|
|
263
|
+
const third = Math.ceil(unclassified.length / 3);
|
|
264
|
+
// "high" shares "standard"'s unclassified-model tercile share: the catalog's
|
|
265
|
+
// size-suffix classification only has 3 real buckets (small/mid/large — see
|
|
266
|
+
// sizeClassFor), so there is no distinct 4th tercile to carve out for "high".
|
|
267
|
+
// resolveTierModel's "high" branch does not currently rely on this tercile
|
|
268
|
+
// share (it prefers a deterministic strongest-mid-class pick — see
|
|
269
|
+
// strongestMidTierCredentialed below); kept here only so this `Record<PromptTier,…>`
|
|
270
|
+
// stays exhaustive for external callers of `tierModelPool("high", …)`.
|
|
271
|
+
const tercileByTier: Record<PromptTier, CatalogModel[]> = {
|
|
272
|
+
trivial: unclassified.slice(0, third),
|
|
273
|
+
standard: unclassified.slice(third, third * 2),
|
|
274
|
+
high: unclassified.slice(third, third * 2),
|
|
275
|
+
complex: unclassified.slice(third * 2),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const pool = [...suffixPool, ...tercileByTier[tier]];
|
|
279
|
+
return pool.map(m => m.canonical).sort();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Deterministic, SESSION-STABLE pick from `pool` — the same `sessionId` always
|
|
283
|
+
* resolves to the same index within one pool (so a session's provider-side
|
|
284
|
+
* prompt cache stays warm turn-to-turn, per this file's `deriveCacheSessionKey`
|
|
285
|
+
* cache-correlation contract), while DIFFERENT sessions spread across the pool's
|
|
286
|
+
* providers (the actual cross-provider distribution this feature exists for).
|
|
287
|
+
* No `sessionId` (e.g. a one-shot `jeo chat` call) deterministically picks index
|
|
288
|
+
* 0 (the pool is canonical-id-sorted, so this is still stable, just unvaried). */
|
|
289
|
+
export function selectFromPool(pool: readonly string[], sessionId: string | undefined): string {
|
|
290
|
+
if (pool.length === 0) throw new Error("selectFromPool: empty pool");
|
|
291
|
+
if (pool.length === 1 || !sessionId) return pool[0];
|
|
292
|
+
let h = 0x811c9dc5 | 0;
|
|
293
|
+
for (let i = 0; i < sessionId.length; i++) h = Math.imul(h ^ sessionId.charCodeAt(i), 0x01000193);
|
|
294
|
+
return pool[(h >>> 0) % pool.length];
|
|
295
|
+
}
|
|
296
|
+
|
|
188
297
|
/** Cheapest cloud model jeo has a credential for, computed LIVE off
|
|
189
298
|
* `MODEL_CATALOG`/`priceForModel` — picks up new/repriced catalog entries
|
|
190
299
|
* automatically, never a hand-maintained id. `null` when no credentialed
|
|
191
300
|
* model has a known price (caller falls back to `defaultModel`). Tiebreak:
|
|
192
301
|
* canonical id (deterministic across otherwise-equal candidates). */
|
|
193
|
-
function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
302
|
+
export function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
194
303
|
let best: CatalogModel | null = null;
|
|
195
304
|
let bestCost = Infinity;
|
|
196
305
|
for (const m of MODEL_CATALOG) {
|
|
@@ -199,7 +308,7 @@ function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
|
199
308
|
// wins (unlike provider-status.ts's `configuredCredential` precedence). The
|
|
200
309
|
// real veto gate in launch.ts's `runTurn` re-verifies full readiness (including
|
|
201
310
|
// live OAuth expiry) before a turn actually uses the model.
|
|
202
|
-
if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
311
|
+
if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
203
312
|
const price = priceForModel(m.canonical);
|
|
204
313
|
if (!price) continue;
|
|
205
314
|
const cost = price.inPerM + price.outPerM;
|
|
@@ -216,14 +325,19 @@ function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
|
216
325
|
* output tokens, then context window, so a newly catalogued frontier model
|
|
217
326
|
* is picked up automatically without a hand-maintained id. `null` when no
|
|
218
327
|
* credentialed model qualifies (caller falls back to `defaultModel`).
|
|
219
|
-
* Tiebreak: canonical id (deterministic
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
328
|
+
* Tiebreak: canonical id (deterministic). Excludes `limitedAvailability`
|
|
329
|
+
* models (e.g. `claude-mythos-5`) — a provider credential doesn't imply
|
|
330
|
+
* access to a specific invite-only model; explicit `/model`/`routing.tiers`
|
|
331
|
+
* can still target one by id for approved accounts. */
|
|
332
|
+
export function strongestCredentialed(
|
|
333
|
+
config: RoutingConfig,
|
|
334
|
+
filter?: (m: CatalogModel) => boolean
|
|
335
|
+
): string | null {
|
|
223
336
|
let best: CatalogModel | null = null;
|
|
224
337
|
for (const m of MODEL_CATALOG) {
|
|
225
338
|
// "ANY stored credential" is enough (see cheapestCredentialed's comment above).
|
|
226
|
-
if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
339
|
+
if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
340
|
+
if (filter && !filter(m)) continue;
|
|
227
341
|
if (!best) { best = m; continue; }
|
|
228
342
|
const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
|
|
229
343
|
const xhighBest = best.thinking.includes("xhigh") ? 1 : 0;
|
|
@@ -235,24 +349,59 @@ function strongestCredentialed(config: RoutingConfig): string | null {
|
|
|
235
349
|
return best?.canonical ?? null;
|
|
236
350
|
}
|
|
237
351
|
|
|
352
|
+
/** Strongest credentialed model restricted to the SAME "mid" size class
|
|
353
|
+
* `tierModelPool` uses for `standard` (sonnet/pro-suffixed families) — the
|
|
354
|
+
* auto-select fallback for the `high` tier. Deliberately narrower than
|
|
355
|
+
* `strongestCredentialed`'s catalog-wide search: an unrestricted search would
|
|
356
|
+
* frequently tie with (or even prefer) `complex`'s own pick, since several
|
|
357
|
+
* "unclassified" frontier ids (gpt-5.5, grok-4.3, …) publish flagship-tier
|
|
358
|
+
* specs under a name with no size-suffix at all and would otherwise win over
|
|
359
|
+
* a genuinely mid-class model like `claude-sonnet-4-6`. `null` when no
|
|
360
|
+
* mid-class-suffixed model is credentialed (caller falls through to
|
|
361
|
+
* `roles.medium`/`defaultModel` — never to `complex`'s flagship pick). */
|
|
362
|
+
function strongestMidTierCredentialed(config: RoutingConfig): string | null {
|
|
363
|
+
const midClass = MODEL_CATALOG.filter(
|
|
364
|
+
m => isCloudProvider(m.provider) && !m.limitedAvailability && sizeClassFor(m.canonical) === "mid" && (config.providers?.[m.provider] || config.oauth?.[m.provider]),
|
|
365
|
+
);
|
|
366
|
+
if (midClass.length === 0) return null;
|
|
367
|
+
return midClass.sort(compareStrengthAscending).at(-1)!.canonical;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** `routing.crossProviderPool` (opt-in, default off) fallback for ALL FOUR tiers:
|
|
371
|
+
* a SESSION-STABLE pick from `tierModelPool(tier, config)` instead of each
|
|
372
|
+
* tier's deterministic single-winner pick (`cheapestCredentialed`/
|
|
373
|
+
* `strongestMidTierCredentialed`/`strongestCredentialed`). Off (or an empty
|
|
374
|
+
* pool) returns `null` so the caller's next fallback applies unchanged — this
|
|
375
|
+
* is purely additive: a user who has not set the flag keeps v0.7.56's exact
|
|
376
|
+
* single-winner behavior; setting it distributes qualifying tiers across
|
|
377
|
+
* every credentialed provider's equivalent-class model instead of always
|
|
378
|
+
* resolving to the one deterministic winner. */
|
|
379
|
+
function crossProviderPoolPick(tier: PromptTier, config: RoutingConfig, sessionId: string | undefined): string | null {
|
|
380
|
+
if (!config.routing?.crossProviderPool) return null;
|
|
381
|
+
const pool = tierModelPool(tier, config);
|
|
382
|
+
return pool.length > 0 ? selectFromPool(pool, sessionId) : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
238
385
|
// --- Tier -> model/thinking resolution (inline off config.roles/config.routing.tiers; no static map). ---
|
|
239
386
|
|
|
240
387
|
/** Exported for `jeo doctor`'s routing-preview diagnostic (same resolution the real
|
|
241
388
|
* `routePrompt` uses — no duplicated logic between "what WILL routing pick" and
|
|
242
389
|
* "what does doctor SHOW the user routing will pick"). */
|
|
243
|
-
export function resolveTierModel(tier: PromptTier, config: RoutingConfig): string {
|
|
390
|
+
export function resolveTierModel(tier: PromptTier, config: RoutingConfig, sessionId?: string): string {
|
|
244
391
|
const configured = config.routing?.tiers?.[tier]?.model;
|
|
245
392
|
if (configured) return configured;
|
|
246
|
-
if (tier === "trivial") return config.roles?.smol || cheapestCredentialed(config) || config.defaultModel;
|
|
247
|
-
if (tier === "
|
|
248
|
-
return config.
|
|
393
|
+
if (tier === "trivial") return config.roles?.smol || crossProviderPoolPick(tier, config, sessionId) || cheapestCredentialed(config) || config.defaultModel;
|
|
394
|
+
if (tier === "standard") return config.roles?.medium || config.roles?.high || crossProviderPoolPick(tier, config, sessionId) || config.defaultModel;
|
|
395
|
+
if (tier === "high") return config.roles?.high || config.roles?.medium || crossProviderPoolPick(tier, config, sessionId) || strongestMidTierCredentialed(config) || config.defaultModel;
|
|
396
|
+
if (tier === "complex") return config.roles?.xhigh || config.roles?.slow || crossProviderPoolPick(tier, config, sessionId) || strongestCredentialed(config) || config.defaultModel;
|
|
397
|
+
return config.defaultModel;
|
|
249
398
|
}
|
|
250
399
|
|
|
251
400
|
function resolveTierThinking(tier: PromptTier, config: RoutingConfig): ThinkLevel | undefined {
|
|
252
401
|
return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
|
|
253
402
|
}
|
|
254
403
|
|
|
255
|
-
const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "complex"];
|
|
404
|
+
const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "high", "complex"];
|
|
256
405
|
function isPromptTier(value: unknown): value is PromptTier {
|
|
257
406
|
return typeof value === "string" && (VALID_TIERS as readonly string[]).includes(value);
|
|
258
407
|
}
|
|
@@ -266,8 +415,9 @@ function buildClassifierPrompt(prompt: string): string {
|
|
|
266
415
|
return (
|
|
267
416
|
`Classify the complexity of the following user request for a coding assistant into exactly one ` +
|
|
268
417
|
`tier: "trivial" (a quick factual lookup, no repo work), "standard" (routine coding/editing work), ` +
|
|
269
|
-
`
|
|
270
|
-
`
|
|
418
|
+
`"high" (a single moderately complex change — one non-trivial refactor/debug in one area, not yet ` +
|
|
419
|
+
`multi-file or architectural), or "complex" (multi-file, architectural, or root-cause debugging work). ` +
|
|
420
|
+
`Respond with ONLY a JSON object of the shape {"tier":"trivial"|"standard"|"high"|"complex"}.\n\n` +
|
|
271
421
|
`Request:\n${capped}`
|
|
272
422
|
);
|
|
273
423
|
}
|
|
@@ -336,7 +486,7 @@ export async function routePrompt(
|
|
|
336
486
|
}
|
|
337
487
|
}
|
|
338
488
|
|
|
339
|
-
const model = resolveTierModel(tier, config);
|
|
489
|
+
const model = resolveTierModel(tier, config, opts.sessionId);
|
|
340
490
|
if (opts.hasImages && catalogMetadata(model)?.images === false) return null;
|
|
341
491
|
|
|
342
492
|
const decision: RouteDecision = {
|
package/src/agent/state.ts
CHANGED
|
@@ -130,7 +130,14 @@ export interface Config {
|
|
|
130
130
|
* Model role tiers (gjc `--smol`/`--slow`/`--plan` parity). Each falls back to
|
|
131
131
|
* `defaultModel`. Env `JEO_SMOL_MODEL`/`JEO_SLOW_MODEL`/`JEO_PLAN_MODEL` fill gaps.
|
|
132
132
|
*/
|
|
133
|
-
roles?: {
|
|
133
|
+
roles?: {
|
|
134
|
+
smol?: string;
|
|
135
|
+
medium?: string;
|
|
136
|
+
high?: string;
|
|
137
|
+
xhigh?: string;
|
|
138
|
+
slow?: string;
|
|
139
|
+
plan?: string;
|
|
140
|
+
};
|
|
134
141
|
/** Prompt-content-based per-turn model routing (PromptRouter) — see prompt-router.ts.
|
|
135
142
|
* Opt-in (undefined/false = off). Never affects subagent/role-tier model resolution. */
|
|
136
143
|
routing?: {
|
|
@@ -139,8 +146,19 @@ export interface Config {
|
|
|
139
146
|
tiers?: {
|
|
140
147
|
trivial?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
|
|
141
148
|
standard?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
|
|
149
|
+
/** A single borderline-complex signal (see prompt-router.ts's `classifyPromptHeuristically`)
|
|
150
|
+
* — routes to `roles.high` by default instead of jumping straight to `complex`/xhigh. */
|
|
151
|
+
high?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
|
|
142
152
|
complex?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
|
|
143
153
|
};
|
|
154
|
+
/** Opt-in (default off): for `standard`/`high` tiers left unconfigured (no
|
|
155
|
+
* `routing.tiers.*.model`, no `roles.medium`/`roles.high`), pick a SESSION-STABLE
|
|
156
|
+
* model from the cross-provider equivalence pool (`tierModelPool`/`selectFromPool`)
|
|
157
|
+
* instead of falling straight to `defaultModel` — spreads different sessions across
|
|
158
|
+
* every credentialed provider's comparable-tier model so jeo actually captures each
|
|
159
|
+
* provider's respective strengths on comparably-priced work, rather than every
|
|
160
|
+
* session converging on one provider. */
|
|
161
|
+
crossProviderPool?: boolean;
|
|
144
162
|
};
|
|
145
163
|
gitAutoCommit?: boolean;
|
|
146
164
|
hooks?: HookConfig;
|
|
@@ -284,6 +302,9 @@ function withEnvOverlay(cfg: Config): Config {
|
|
|
284
302
|
lmstudioBaseUrl: cfg.lmstudioBaseUrl || process.env.LMSTUDIO_BASE_URL || "http://localhost:1234/v1",
|
|
285
303
|
roles: {
|
|
286
304
|
smol: cfg.roles?.smol || jeoEnv("SMOL_MODEL"),
|
|
305
|
+
medium: cfg.roles?.medium || jeoEnv("MEDIUM_MODEL"),
|
|
306
|
+
high: cfg.roles?.high || jeoEnv("HIGH_MODEL"),
|
|
307
|
+
xhigh: cfg.roles?.xhigh || jeoEnv("XHIGH_MODEL"),
|
|
287
308
|
slow: cfg.roles?.slow || jeoEnv("SLOW_MODEL"),
|
|
288
309
|
plan: cfg.roles?.plan || jeoEnv("PLAN_MODEL"),
|
|
289
310
|
},
|
package/src/agent/subagents.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { DEFAULT_TOOLS, TOOL_PROTOCOL, READONLY_TOOL_PROTOCOL, WORKING_DISCIPLINE, type ToolHandler } from "./engine";
|
|
13
13
|
import type { Config } from "./state";
|
|
14
|
+
import type { CatalogModel } from "../ai/model-catalog";
|
|
14
15
|
import architectPrompt from "../prompts/agents/architect.md" with { type: "text" };
|
|
15
16
|
import criticPrompt from "../prompts/agents/critic.md" with { type: "text" };
|
|
16
17
|
import executorPrompt from "../prompts/agents/executor.md" with { type: "text" };
|
|
@@ -158,9 +159,42 @@ export function defaultSubagentRole(): SubagentRole {
|
|
|
158
159
|
export type SubagentConfig = NonNullable<Config["subagents"]>;
|
|
159
160
|
|
|
160
161
|
/** Per-role model override → falls back to the global default model. */
|
|
161
|
-
export function resolveSubagentModel(
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
export function resolveSubagentModel(
|
|
163
|
+
roleId: string,
|
|
164
|
+
config: {
|
|
165
|
+
defaultModel: string;
|
|
166
|
+
subagents?: Config["subagents"];
|
|
167
|
+
roles?: Config["roles"];
|
|
168
|
+
providers?: Config["providers"];
|
|
169
|
+
oauth?: Config["oauth"];
|
|
170
|
+
},
|
|
171
|
+
): string {
|
|
172
|
+
const normalized = normalizeRoleId(roleId);
|
|
173
|
+
const entry = config.subagents?.[normalized];
|
|
174
|
+
if (entry?.model) return entry.model;
|
|
175
|
+
|
|
176
|
+
// Exception: load strongestCredentialed dynamically using require to prevent eager loading and circular dependency issues that break test mocking.
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
178
|
+
const { strongestCredentialed } = require("./prompt-router");
|
|
179
|
+
if (normalized === "executor") {
|
|
180
|
+
return config.roles?.xhigh || config.roles?.slow || strongestCredentialed(config) || config.defaultModel;
|
|
181
|
+
}
|
|
182
|
+
if (normalized === "architect") {
|
|
183
|
+
return config.roles?.xhigh || strongestCredentialed(config, (m: CatalogModel) => m.thinking.includes("xhigh") || m.thinking.includes("high")) || config.defaultModel;
|
|
184
|
+
}
|
|
185
|
+
if (normalized === "planner") {
|
|
186
|
+
return config.roles?.high || strongestCredentialed(config) || config.defaultModel;
|
|
187
|
+
}
|
|
188
|
+
if (normalized === "critic") {
|
|
189
|
+
return (
|
|
190
|
+
config.roles?.medium ||
|
|
191
|
+
config.roles?.smol ||
|
|
192
|
+
strongestCredentialed(config, (m: CatalogModel) => m.canonical === "o3-mini") ||
|
|
193
|
+
strongestCredentialed(config, (m: CatalogModel) => m.canonical === "gemini-2.5-flash") ||
|
|
194
|
+
config.defaultModel
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return config.defaultModel;
|
|
164
198
|
}
|
|
165
199
|
|
|
166
200
|
/** Per-role step budget → config override, else the role default, else 15. */
|
package/src/agent/tools.ts
CHANGED
|
@@ -1130,3 +1130,172 @@ export async function deleteTool(
|
|
|
1130
1130
|
return { success: false, output: "", error: err.message };
|
|
1131
1131
|
}
|
|
1132
1132
|
}
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Binary-search git history to find the commit that introduced a regression.
|
|
1137
|
+
* Uses `git bisect run <predicate>` to automate the search.
|
|
1138
|
+
* Exit codes: 0=good, non-zero=bad, 125=skip.
|
|
1139
|
+
*/
|
|
1140
|
+
export async function bisectTool(
|
|
1141
|
+
good: string,
|
|
1142
|
+
bad: string = "HEAD",
|
|
1143
|
+
run: string = "",
|
|
1144
|
+
invert: boolean = false,
|
|
1145
|
+
maxSteps: number = 40,
|
|
1146
|
+
stepTimeoutMs: number = 600000,
|
|
1147
|
+
cwd: string = process.cwd()
|
|
1148
|
+
): Promise<ToolResult> {
|
|
1149
|
+
try {
|
|
1150
|
+
if (!good || !good.trim()) {
|
|
1151
|
+
return { success: false, output: "", error: 'bisect requires "good" (a known-good commit)' };
|
|
1152
|
+
}
|
|
1153
|
+
if (!run || !run.trim()) {
|
|
1154
|
+
return { success: false, output: "", error: 'bisect requires "run" (a shell command to test)' };
|
|
1155
|
+
}
|
|
1156
|
+
if (maxSteps <= 0) {
|
|
1157
|
+
return { success: false, output: "", error: 'maxSteps must be > 0' };
|
|
1158
|
+
}
|
|
1159
|
+
if (stepTimeoutMs <= 0) {
|
|
1160
|
+
return { success: false, output: "", error: 'stepTimeoutMs must be > 0' };
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Helper to run a command with timeout support and resilient error handling
|
|
1164
|
+
const runCmd = async (args: string[], timeoutMs?: number): Promise<{ code: number; out: string; err: string }> => {
|
|
1165
|
+
const timeout = timeoutMs || stepTimeoutMs;
|
|
1166
|
+
let timeoutOccurred = false;
|
|
1167
|
+
const timeoutHandle = setTimeout(() => {
|
|
1168
|
+
timeoutOccurred = true;
|
|
1169
|
+
}, timeout);
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
const proc = Bun.spawn(args, {
|
|
1173
|
+
cwd,
|
|
1174
|
+
stdout: "pipe",
|
|
1175
|
+
stderr: "pipe",
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
let out = "";
|
|
1179
|
+
let err = "";
|
|
1180
|
+
|
|
1181
|
+
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
1182
|
+
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
|
|
1183
|
+
|
|
1184
|
+
// Read stdout with resilience: stream errors don't fail the entire call.
|
|
1185
|
+
// If a reader encounters an error, we continue with partial output, as
|
|
1186
|
+
// git bisect can still function with partial stdout/stderr.
|
|
1187
|
+
try {
|
|
1188
|
+
while (!timeoutOccurred) {
|
|
1189
|
+
const { done, value } = await stdoutReader.read();
|
|
1190
|
+
if (done) break;
|
|
1191
|
+
out += new TextDecoder().decode(value);
|
|
1192
|
+
}
|
|
1193
|
+
} catch (e) {
|
|
1194
|
+
// Stream read error; continue with partial output.
|
|
1195
|
+
// This is acceptable because git bisect may still be usable.
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Read stderr with resilience: same strategy as stdout.
|
|
1199
|
+
try {
|
|
1200
|
+
while (!timeoutOccurred) {
|
|
1201
|
+
const { done, value } = await stderrReader.read();
|
|
1202
|
+
if (done) break;
|
|
1203
|
+
err += new TextDecoder().decode(value);
|
|
1204
|
+
}
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
// Stream read error; continue with partial output.
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (timeoutOccurred) {
|
|
1210
|
+
return { code: 124, out, err: `Command timeout after ${timeout}ms` };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const code = await proc.exited;
|
|
1214
|
+
return { code: typeof code === 'number' ? code : 0, out, err };
|
|
1215
|
+
} finally {
|
|
1216
|
+
clearTimeout(timeoutHandle);
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
// Validate commits exist (quick validation with shorter timeout)
|
|
1221
|
+
const goodRes = await runCmd(["git", "rev-parse", good], 10000);
|
|
1222
|
+
const badRes = await runCmd(["git", "rev-parse", bad], 10000);
|
|
1223
|
+
|
|
1224
|
+
if (goodRes.code !== 0) {
|
|
1225
|
+
return { success: false, output: "", error: `Invalid or non-existent commit: ${good}` };
|
|
1226
|
+
}
|
|
1227
|
+
if (badRes.code !== 0) {
|
|
1228
|
+
return { success: false, output: "", error: `Invalid or non-existent commit: ${bad}` };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const goodSha = goodRes.out.trim();
|
|
1232
|
+
const badSha = badRes.out.trim();
|
|
1233
|
+
|
|
1234
|
+
// Start bisect session
|
|
1235
|
+
const initRes = await runCmd(["sh", "-c", `git bisect start "${badSha}" "${goodSha}"`], 10000);
|
|
1236
|
+
if (initRes.code !== 0) {
|
|
1237
|
+
return { success: false, output: "", error: `Failed to start bisect: ${initRes.err || initRes.out}` };
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Run bisect with the predicate command.
|
|
1241
|
+
// Shell escaping: single quotes in sh -c context need special care.
|
|
1242
|
+
// 'text' -> '\''text'\'' (end quote, escaped quote, start quote, content, end quote, escaped quote, start quote)
|
|
1243
|
+
const escapedRun = run.replace(/'/g, "'\''");
|
|
1244
|
+
const runCmd2 = invert
|
|
1245
|
+
? `git bisect run sh -c '${escapedRun} && exit 1 || exit 0'`
|
|
1246
|
+
: `git bisect run sh -c '${escapedRun}'`;
|
|
1247
|
+
|
|
1248
|
+
const runRes = await runCmd(["sh", "-c", runCmd2]);
|
|
1249
|
+
|
|
1250
|
+
// Parse result: look for "XXXX is the first bad/good commit"
|
|
1251
|
+
const badCommitMatch = runRes.out.match(/^([0-9a-f]{7,40}) is the first (bad|good) commit$/m);
|
|
1252
|
+
let culprit: string | null = null;
|
|
1253
|
+
let concluded = false;
|
|
1254
|
+
|
|
1255
|
+
if (badCommitMatch) {
|
|
1256
|
+
culprit = badCommitMatch[1];
|
|
1257
|
+
concluded = true;
|
|
1258
|
+
} else if (runRes.out.includes("only 'skip'ped commits left")) {
|
|
1259
|
+
concluded = false;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Cleanup: always attempt to reset bisect and working tree (best-effort).
|
|
1263
|
+
// Errors here are non-critical; if reset fails, the user can manually run
|
|
1264
|
+
// 'git bisect reset' to recover. We don't throw on cleanup errors.
|
|
1265
|
+
try {
|
|
1266
|
+
await runCmd(["git", "bisect", "reset"], 10000);
|
|
1267
|
+
} catch {}
|
|
1268
|
+
try {
|
|
1269
|
+
await runCmd(["git", "reset", "--hard"], 10000);
|
|
1270
|
+
} catch {}
|
|
1271
|
+
|
|
1272
|
+
if (!concluded) {
|
|
1273
|
+
return {
|
|
1274
|
+
success: false,
|
|
1275
|
+
output: `Bisect did not converge. Output:\n${runRes.out}`,
|
|
1276
|
+
error: "",
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (culprit) {
|
|
1281
|
+
// Get commit details (use shorter timeout for metadata lookup)
|
|
1282
|
+
const detailsRes = await runCmd(["git", "show", "-s", "--format=%aN|%ai|%s", culprit], 10000);
|
|
1283
|
+
const [author, date, subject] = detailsRes.out.trim().split("|");
|
|
1284
|
+
const message = [
|
|
1285
|
+
invert ? `First fixing commit: ${culprit}` : `First bad commit: ${culprit}`,
|
|
1286
|
+
author ? `Author: ${author}` : "",
|
|
1287
|
+
date ? `Date: ${date}` : "",
|
|
1288
|
+
subject ? `Subject: ${subject}` : "",
|
|
1289
|
+
]
|
|
1290
|
+
.filter(Boolean)
|
|
1291
|
+
.join("\n");
|
|
1292
|
+
|
|
1293
|
+
return { success: true, output: message };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
return { success: false, output: "Bisect completed but found no culprit.", error: "" };
|
|
1297
|
+
} catch (err: any) {
|
|
1298
|
+
return { success: false, output: "", error: err.message || String(err) };
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface CatalogModel {
|
|
|
29
29
|
images: boolean;
|
|
30
30
|
/** Optional company override. */
|
|
31
31
|
company?: string;
|
|
32
|
+
/** True for models requiring separate approval/waitlist beyond the provider's
|
|
33
|
+
* base credential (e.g. Anthropic's invite-only Project Glasswing models) — a
|
|
34
|
+
* user with a valid API key/OAuth for the PROVIDER may still lack access to
|
|
35
|
+
* this SPECIFIC model. Auto-select (prompt-router.ts's pool/single-winner
|
|
36
|
+
* selection) excludes these; explicit `/model` or `routing.tiers.*.model`
|
|
37
|
+
* can still target them by id for approved accounts. */
|
|
38
|
+
limitedAvailability?: boolean;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
const FULL: ThinkLevel[] = ["low", "medium", "high", "xhigh"];
|
|
@@ -73,7 +80,7 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
|
|
|
73
80
|
// Fable 5 = Anthropic's most capable widely-released model (adaptive thinking always on);
|
|
74
81
|
// Mythos 5 is limited-availability (Project Glasswing) but callable by id for approved accounts.
|
|
75
82
|
{ canonical: "claude-fable-5", provider: "anthropic", providerModel: "claude-fable-5", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true },
|
|
76
|
-
{ canonical: "claude-mythos-5", provider: "anthropic", providerModel: "claude-mythos-5", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true },
|
|
83
|
+
{ canonical: "claude-mythos-5", provider: "anthropic", providerModel: "claude-mythos-5", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true, limitedAvailability: true },
|
|
77
84
|
// OpenAI
|
|
78
85
|
{ canonical: "gpt-4o", provider: "openai", providerModel: "gpt-4o", contextTokens: 128_000, maxOutputTokens: 16_384, thinking: [], images: true },
|
|
79
86
|
{ canonical: "gpt-4o-mini", provider: "openai", providerModel: "gpt-4o-mini", contextTokens: 128_000, maxOutputTokens: 16_384, thinking: [], images: true },
|
|
@@ -60,7 +60,11 @@ const ANTIGRAVITY_MODEL_DENYLIST = new Set([
|
|
|
60
60
|
|
|
61
61
|
function anthropicHeaders(cred: Credential): Record<string, string> {
|
|
62
62
|
if (cred.kind === "oauth") {
|
|
63
|
-
|
|
63
|
+
// NOTE: The oauth-2025-04-20 beta is for the messages endpoint, not the models endpoint.
|
|
64
|
+
// The models endpoint does not support this beta, so we omit it here to avoid
|
|
65
|
+
// potential 400/403 errors. The fallback to catalog ensures Anthropic models
|
|
66
|
+
// are always available even if the live endpoint fails.
|
|
67
|
+
return { authorization: `Bearer ${cred.token}`, "anthropic-version": "2023-06-01" };
|
|
64
68
|
}
|
|
65
69
|
if (cred.kind === "api_key") {
|
|
66
70
|
return { "x-api-key": cred.token, "anthropic-version": "2023-06-01" };
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -146,10 +146,23 @@ export async function describeModel(
|
|
|
146
146
|
return { input, resolved, provider: resolveProvider(resolved) };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
export type ModelRole = "smol" | "slow" | "plan";
|
|
150
|
-
|
|
151
|
-
/** Resolve a model role tier (smol/slow/plan) → configured tier model, else defaultModel. */
|
|
152
|
-
export function resolveRoleModel(
|
|
149
|
+
export type ModelRole = "smol" | "medium" | "high" | "xhigh" | "slow" | "plan";
|
|
150
|
+
|
|
151
|
+
/** Resolve a model role tier (smol/medium/high/xhigh/slow/plan) → configured tier model, else defaultModel. */
|
|
152
|
+
export function resolveRoleModel(
|
|
153
|
+
role: ModelRole,
|
|
154
|
+
config: {
|
|
155
|
+
defaultModel: string;
|
|
156
|
+
roles?: {
|
|
157
|
+
smol?: string;
|
|
158
|
+
medium?: string;
|
|
159
|
+
high?: string;
|
|
160
|
+
xhigh?: string;
|
|
161
|
+
slow?: string;
|
|
162
|
+
plan?: string;
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
): string {
|
|
153
166
|
return config.roles?.[role] || config.defaultModel;
|
|
154
167
|
}
|
|
155
168
|
|
|
@@ -58,13 +58,15 @@ h1.ok{color:#22d3ee}h1.fail{color:#f472b6}
|
|
|
58
58
|
p{margin:.25rem 0;color:#8b8b96;font-size:.92rem}
|
|
59
59
|
button{margin-top:1.5rem;padding:.55rem 1.6rem;border-radius:999px;border:1px solid #2a2a35;background:transparent;color:#e7e7ec;font:inherit;font-size:.88rem;cursor:pointer}
|
|
60
60
|
button:hover{background:#1b1c27}
|
|
61
|
-
.hint{margin-top:.9rem;font-size:.78rem;color:#5f5f6b}
|
|
61
|
+
.hint{margin-top:.9rem;font-size:.78rem;color:#5f5f6b}
|
|
62
|
+
.closed{display:none}</style></head>
|
|
62
63
|
<body><div class="card">
|
|
63
64
|
<img class="wordmark" alt="jeo" src="${JEO_WORDMARK_DATA_URI}">
|
|
64
65
|
<h1 class="__STATUS__">__TITLE__</h1>
|
|
65
66
|
<p>__MSG__</p>
|
|
66
67
|
<button id="jeo-close" type="button">Close</button>
|
|
67
68
|
<p class="hint">Closing automatically in <span id="jeo-countdown">__SECONDS__</span>s…</p>
|
|
69
|
+
<p class="hint closed" id="jeo-closed-msg">Window closed. You can close this tab.</p>
|
|
68
70
|
</div>
|
|
69
71
|
<script>__SCRIPT__</script>
|
|
70
72
|
</body></html>`;
|
|
@@ -78,8 +80,16 @@ const AUTO_CLOSE_SECONDS = 5;
|
|
|
78
80
|
|
|
79
81
|
function renderHtml(ok: boolean, msg: string): string {
|
|
80
82
|
const script = `(function(){
|
|
81
|
-
var n=${AUTO_CLOSE_SECONDS},el=document.getElementById("jeo-countdown"),btn=document.getElementById("jeo-close"),t;
|
|
82
|
-
function closeNow(){
|
|
83
|
+
var n=${AUTO_CLOSE_SECONDS},el=document.getElementById("jeo-countdown"),btn=document.getElementById("jeo-close"),closedMsg=document.getElementById("jeo-closed-msg"),t;
|
|
84
|
+
function closeNow(){
|
|
85
|
+
clearInterval(t);
|
|
86
|
+
var closed=window.close();
|
|
87
|
+
if(!closed){
|
|
88
|
+
btn.style.display="none";
|
|
89
|
+
el.parentElement.style.display="none";
|
|
90
|
+
closedMsg.classList.remove("closed");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
83
93
|
t=setInterval(function(){n-=1;if(el)el.textContent=String(Math.max(n,0));if(n<=0)closeNow();},1000);
|
|
84
94
|
if(btn)btn.addEventListener("click",closeNow);
|
|
85
95
|
})();`;
|
package/src/commands/doctor.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { meter } from "../tui/components/meter";
|
|
|
8
8
|
import { size } from "../tui/terminal";
|
|
9
9
|
import chalk from "chalk";
|
|
10
10
|
import { extractChatgptAccountId, CODEX_RESPONSES_URL } from "../ai/providers/openai-responses";
|
|
11
|
-
import { resolveTierModel } from "../agent/prompt-router";
|
|
11
|
+
import { resolveTierModel, tierModelPool } from "../agent/prompt-router";
|
|
12
12
|
|
|
13
13
|
interface ProbeResult {
|
|
14
14
|
status: "ok" | "fail" | "skipped";
|
|
@@ -197,8 +197,9 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
197
197
|
if (routingEnabled) {
|
|
198
198
|
const tierCandidates: { tier: string; model: string | undefined }[] = [
|
|
199
199
|
{ tier: "trivial", model: config.routing?.tiers?.trivial?.model || config.roles?.smol },
|
|
200
|
-
{ tier: "standard", model: config.routing?.tiers?.standard?.model },
|
|
201
|
-
{ tier: "
|
|
200
|
+
{ tier: "standard", model: config.routing?.tiers?.standard?.model || config.roles?.medium || config.roles?.high },
|
|
201
|
+
{ tier: "high", model: config.routing?.tiers?.high?.model || config.roles?.high || config.roles?.medium },
|
|
202
|
+
{ tier: "complex", model: config.routing?.tiers?.complex?.model || config.roles?.xhigh || config.roles?.slow },
|
|
202
203
|
];
|
|
203
204
|
for (const { tier, model } of tierCandidates) {
|
|
204
205
|
if (!model) continue;
|
|
@@ -220,11 +221,36 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
220
221
|
// informational — never affects ready/--strict, same contract as routingNotes.
|
|
221
222
|
const routingPreview: { tier: string; model: string; provider: string; source: string }[] = [];
|
|
222
223
|
if (routingEnabled) {
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
// Legacy role-tier fallback per tier, mirroring resolveTierModel's OWN precedence
|
|
225
|
+
// (not just the id it happens to already show up under `routing.tiers.*.model`) —
|
|
226
|
+
// so e.g. a `roles.medium`-only config correctly shows "configured", not
|
|
227
|
+
// "auto-selected", for `standard`.
|
|
228
|
+
|
|
229
|
+
for (const tier of ["trivial", "standard", "high", "complex"] as const) {
|
|
230
|
+
const legacyRole =
|
|
231
|
+
tier === "trivial" ? config.roles?.smol
|
|
232
|
+
: tier === "standard" ? config.roles?.medium || config.roles?.high
|
|
233
|
+
: tier === "high" ? config.roles?.high || config.roles?.medium
|
|
234
|
+
: config.roles?.xhigh || config.roles?.slow;
|
|
235
|
+
const explicit = config.routing?.tiers?.[tier]?.model || legacyRole;
|
|
225
236
|
const model = resolveTierModel(tier, config);
|
|
226
237
|
const provider = resolveProvider(model);
|
|
227
|
-
|
|
238
|
+
// crossProviderPoolPick (prompt-router.ts) is tried BEFORE every tier's own
|
|
239
|
+
// cheapest/strongest/mid-tier fallback, so a non-empty pool always wins over
|
|
240
|
+
// those once the flag is on — `.length > 0` alone is enough to know the pool,
|
|
241
|
+
// not the tier-specific fallback, produced `model`.
|
|
242
|
+
const pooled = !explicit && model !== config.defaultModel && !!config.routing?.crossProviderPool && tierModelPool(tier, config).length > 0;
|
|
243
|
+
const source = explicit
|
|
244
|
+
? "configured"
|
|
245
|
+
: model === config.defaultModel
|
|
246
|
+
? "defaultModel"
|
|
247
|
+
: pooled
|
|
248
|
+
? "auto-selected: cross-provider pool"
|
|
249
|
+
: tier === "trivial"
|
|
250
|
+
? "auto-selected: cheapest credentialed"
|
|
251
|
+
: tier === "high"
|
|
252
|
+
? "auto-selected: strongest mid-tier credentialed"
|
|
253
|
+
: "auto-selected: strongest credentialed";
|
|
228
254
|
routingPreview.push({ tier, model, provider, source });
|
|
229
255
|
}
|
|
230
256
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -885,7 +885,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
885
885
|
// selection must stay session-local: running sessions never influence each other.
|
|
886
886
|
const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
|
|
887
887
|
let routed = routingEnabled && !sessionModel
|
|
888
|
-
? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length }).catch(() => null)
|
|
888
|
+
? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length, sessionId }).catch(() => null)
|
|
889
889
|
: null;
|
|
890
890
|
// Fail-open credential gate: `routePrompt` only resolves a tier's model from
|
|
891
891
|
// config (routing.tiers/roles.smol/roles.slow/defaultModel) — it deliberately
|
package/src/tui/app.ts
CHANGED
|
@@ -73,7 +73,7 @@ export interface LaunchTuiOptions {
|
|
|
73
73
|
* gate fell back). Renders a persistent `⚡tier` marker in the status bar so the
|
|
74
74
|
* routed model stays visible without depending on the transient `[route]`
|
|
75
75
|
* console notice. See `StatusBarData.routedTier` in components/status.ts. */
|
|
76
|
-
routedTier?: "trivial" | "standard" | "complex";
|
|
76
|
+
routedTier?: "trivial" | "standard" | "high" | "complex";
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
export interface AgentEventsLike {
|
|
@@ -375,7 +375,7 @@ export class LaunchTui {
|
|
|
375
375
|
private readonly thinkingLevel?: string;
|
|
376
376
|
// PromptRouter's resolved tier when routing chose this turn's model; undefined when
|
|
377
377
|
// routing didn't engage. Renders the persistent `⚡tier` marker in the status bar.
|
|
378
|
-
private readonly routedTier?: "trivial" | "standard" | "complex";
|
|
378
|
+
private readonly routedTier?: "trivial" | "standard" | "high" | "complex";
|
|
379
379
|
|
|
380
380
|
constructor(opts: LaunchTuiOptions) {
|
|
381
381
|
// Backpressure-aware wrapper around the underlying writer (real stdout, or the
|
|
@@ -24,7 +24,7 @@ export interface StatusBarData {
|
|
|
24
24
|
* marker so the routed model stays visible without depending on the transient
|
|
25
25
|
* `[route] …` console notice, which intentionally stays silent for routine
|
|
26
26
|
* unescalated `standard`-tier turns. */
|
|
27
|
-
routedTier?: "trivial" | "standard" | "complex";
|
|
27
|
+
routedTier?: "trivial" | "standard" | "high" | "complex";
|
|
28
28
|
branch?: string;
|
|
29
29
|
/** Uncommitted-change count for the `?N` dirty flag; omit/0 = clean. */
|
|
30
30
|
dirtyCount?: number;
|