diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a52c1..8407db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ new version heading in the same commit. ## [Unreleased] +## [0.138.4] — 2026-07-13 +### Fixed +- **An invalid/partial default image model no longer silently breaks generation.** A half-typed default + (e.g. `google/` left in the Settings field) used to fail every `image_generate`/`image_edit` with a + cryptic Atlas "not found". Now guarded two ways: (1) **console warning** — the default-model fields flag + a value that isn't a known Atlas catalog id ("⚠ isn't a known Atlas model…"), for both image and video; + (2) **graceful fallback** — if Atlas rejects a model id as not-found/invalid, the image backend retries + once with the built-in default (`google/nano-banana-2/text-to-image` for generate, `…/edit` for edit, + the upscaler for upscale) and the tool response + audit note which bad model was replaced (`fallbackFrom`), + so the run succeeds and the operator sees a clear "fix the default" message instead of a dead end. + (`AtlasBackend.withModelFallback` in `src/edge/image-gen.ts`; verified live — bad `google/` → auto-retry + → success.) + ## [0.138.3] — 2026-07-13 ### Added - **Docs: three new end-user Docs pages covering recently shipped surfaces.** The console **Docs** diff --git a/package-lock.json b/package-lock.json index 38f6ddc..bc98e86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.138.3", + "version": "0.138.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.138.3", + "version": "0.138.4", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 71af3b9..5a3b487 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.138.3", + "version": "0.138.4", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/edge/image-gen.ts b/src/edge/image-gen.ts index a170ef5..04fe9c7 100644 --- a/src/edge/image-gen.ts +++ b/src/edge/image-gen.ts @@ -31,6 +31,7 @@ export interface ImageGenResult { images: GeneratedImage[]; model: string; // the model actually used (as reported/requested) costUsd?: number; // real USD cost when the vendor reports it (OpenRouter usage.cost); else undefined + fallbackFrom?: string; // set when the requested model was rejected and we retried with a built-in default } export interface ImageGenRequest { @@ -178,16 +179,28 @@ interface AtlasPrediction { error?: string; } +// A built-in, known-good default per operation — the anchor we fall back to when a configured/passed +// model id is rejected by Atlas (e.g. a half-typed "google/" default silently breaking every run). +const ATLAS_BUILTIN_IMAGE_MODEL = 'google/nano-banana-2/text-to-image'; +const ATLAS_BUILTIN_EDIT_MODEL = 'google/nano-banana-2/edit'; +const ATLAS_UPSCALE_MODEL = 'atlascloud/image-upscaler'; + +/** Does this error look like "the model id is wrong" (vs a transient/other failure)? Only these are + * worth retrying with the built-in default — a real content/timeout error should surface as-is. */ +function isModelRejected(err: unknown): boolean { + const m = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return /not found|no such model|unknown model|invalid model|model.*(not found|invalid|unsupported)|does not exist/.test(m); +} + class AtlasBackend implements ImageBackend { readonly name = 'atlas' as const; readonly defaultModel: string; - readonly editModel: string; // image-to-image default when an edit names no model - readonly upscaleModel = 'atlascloud/image-upscaler'; + readonly editModel = ATLAS_BUILTIN_EDIT_MODEL; // image-to-image default when an edit names no model + readonly upscaleModel = ATLAS_UPSCALE_MODEL; private readonly base: string; constructor(private readonly key: string, baseUrl?: string, defaultModel?: string) { this.base = (baseUrl?.trim() || 'https://api.atlascloud.ai').replace(/\/+$/, ''); - this.defaultModel = defaultModel?.trim() || 'google/nano-banana-2/text-to-image'; - this.editModel = 'google/nano-banana-2/edit'; + this.defaultModel = defaultModel?.trim() || ATLAS_BUILTIN_IMAGE_MODEL; } private headers(): Record { @@ -196,22 +209,35 @@ class AtlasBackend implements ImageBackend { async generate(req: ImageGenRequest): Promise { const model = req.model?.trim() || this.defaultModel; - const body: Record = { model, prompt: req.prompt }; - if (req.size) body.size = req.size; - return this.submitAndPoll(body, model); + const build = (m: string): Record => ({ model: m, prompt: req.prompt, ...(req.size ? { size: req.size } : {}) }); + return this.withModelFallback(model, ATLAS_BUILTIN_IMAGE_MODEL, build); } async editImage(req: ImageEditRequest): Promise { if (!req.images.length) throw new Error('an input image is required'); const upscale = typeof req.scale === 'number' && req.scale > 1; const model = req.model?.trim() || (upscale ? this.upscaleModel : this.editModel); + const fallback = upscale ? ATLAS_UPSCALE_MODEL : ATLAS_BUILTIN_EDIT_MODEL; // Edit and upscale ride the SAME generateImage submit+poll — only the body shape differs: an upscaler // takes a single `image` + `outscale`; an image-to-image edit takes `images[]` + a `prompt`. - const body: Record = upscale - ? { model, image: req.images[0], outscale: req.scale, output_format: 'jpeg' } - : { model, prompt: req.prompt ?? '', images: req.images }; - if (!upscale && req.size) body.size = req.size; - return this.submitAndPoll(body, model); + const build = (m: string): Record => upscale + ? { model: m, image: req.images[0], outscale: req.scale, output_format: 'jpeg' } + : { model: m, prompt: req.prompt ?? '', images: req.images, ...(req.size ? { size: req.size } : {}) }; + return this.withModelFallback(model, fallback, build); + } + + /** Run `build(model)` through submit+poll; if the model id is REJECTED and differs from the known-good + * `fallback`, retry once with the fallback and tag the result so the caller can warn the operator. */ + private async withModelFallback(model: string, fallback: string, build: (m: string) => Record): Promise { + try { + return await this.submitAndPoll(build(model), model); + } catch (e) { + if (isModelRejected(e) && model !== fallback) { + const res = await this.submitAndPoll(build(fallback), fallback); + return { ...res, fallbackFrom: model }; + } + throw e; + } } /** Submit a generateImage body then poll the prediction to completion (image renders in seconds, so a diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index bab928e..ae845ad 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -1106,11 +1106,12 @@ async function imageGenerate(args: Record): Promise { headers: H({ 'content-type': 'application/json' }), body: JSON.stringify(body), }); - const d = (await res.json()) as { ok?: boolean; error?: string; artifacts?: { id: string; filename: string }[]; model?: string; costUsd?: number }; + const d = (await res.json()) as { ok?: boolean; error?: string; artifacts?: { id: string; filename: string }[]; model?: string; costUsd?: number; warning?: string }; if (!d.ok) return `Could not generate image: ${d.error ?? 'unknown error'}`; const list = (d.artifacts ?? []).map((a) => `${a.id} (${a.filename})`).join(', '); const cost = typeof d.costUsd === 'number' ? ` · ~$${d.costUsd.toFixed(3)}` : ''; - return `Generated ${d.artifacts?.length ?? 0} image(s) with ${d.model ?? 'the default model'}${cost}. Saved to the Library: ${list}.`; + const warn = d.warning ? ` ⚠ ${d.warning}` : ''; + return `Generated ${d.artifacts?.length ?? 0} image(s) with ${d.model ?? 'the default model'}${cost}. Saved to the Library: ${list}.${warn}`; } async function imageEdit(args: Record): Promise { @@ -1128,12 +1129,13 @@ async function imageEdit(args: Record): Promise { headers: H({ 'content-type': 'application/json' }), body: JSON.stringify(body), }); - const d = (await res.json()) as { ok?: boolean; error?: string; artifacts?: { id: string; filename: string }[]; model?: string; costUsd?: number }; + const d = (await res.json()) as { ok?: boolean; error?: string; artifacts?: { id: string; filename: string }[]; model?: string; costUsd?: number; warning?: string }; if (!d.ok) return `Could not edit image: ${d.error ?? 'unknown error'}`; const list = (d.artifacts ?? []).map((a) => `${a.id} (${a.filename})`).join(', '); const cost = typeof d.costUsd === 'number' ? ` · ~$${d.costUsd.toFixed(3)}` : ''; const what = scale && scale > 1 ? `Upscaled ${scale}×` : 'Edited'; - return `${what} with ${d.model ?? 'the default model'}${cost}. New image saved to the Library: ${list}.`; + const warn = d.warning ? ` ⚠ ${d.warning}` : ''; + return `${what} with ${d.model ?? 'the default model'}${cost}. New image saved to the Library: ${list}.${warn}`; } async function videoGenerate(args: Record): Promise { diff --git a/src/terminal.ts b/src/terminal.ts index afc6390..801729c 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -2323,7 +2323,7 @@ export class TerminalManager { * 3. each image lands as an `image` artifact + an owner-scoped inbox card, and the run is audited * with the REAL cost when the backend reports it (OpenRouter `usage.cost`), else the estimate. */ - async generateImage(sessionId: string, input: { prompt: string; model?: string; size?: string; n?: number }): Promise<{ ok: boolean; artifacts?: { id: string; filename: string; mime: string }[]; model?: string; costUsd?: number; error?: string }> { + async generateImage(sessionId: string, input: { prompt: string; model?: string; size?: string; n?: number }): Promise<{ ok: boolean; artifacts?: { id: string; filename: string; mime: string }[]; model?: string; costUsd?: number; warning?: string; error?: string }> { const agent = this.sessionAgent(sessionId); if (!agent) return { ok: false, error: 'unknown session' }; if (!this.os.artifacts.enabled) return { ok: false, error: 'artifacts store is disabled (no data home)' }; @@ -2380,8 +2380,11 @@ export class TerminalManager { }); if (!out.length) return { ok: false, error: 'generation succeeded but no image could be stored' }; - this.audit(sessionId, agent, 'image.generated', { model: result.model, backend: backend.name, count: out.length, costUsd, costSource: result.costUsd != null ? 'actual' : 'estimate', artifactIds: out.map((o) => o.id), prompt: shortPrompt }); - return { ok: true, artifacts: out, model: result.model, costUsd }; + const warning = result.fallbackFrom + ? `Model "${result.fallbackFrom}" was rejected by Atlas — used "${result.model}" instead. Fix the default in Settings → Integrations (or name a valid model).` + : undefined; + this.audit(sessionId, agent, 'image.generated', { model: result.model, backend: backend.name, count: out.length, costUsd, costSource: result.costUsd != null ? 'actual' : 'estimate', artifactIds: out.map((o) => o.id), prompt: shortPrompt, ...(result.fallbackFrom ? { fallbackFrom: result.fallbackFrom } : {}) }); + return { ok: true, artifacts: out, model: result.model, costUsd, ...(warning ? { warning } : {}) }; } /** @@ -2392,7 +2395,7 @@ export class TerminalManager { * artifact id, a working-folder file (written or terminal-uploaded), or a URL. `scale` (>1) upscales * (prompt ignored); otherwise `prompt` drives an image-to-image edit. Atlas-only (OpenRouter throws). */ - async editImage(sessionId: string, input: { image: string; prompt?: string; scale?: number; model?: string }): Promise<{ ok: boolean; artifacts?: { id: string; filename: string; mime: string }[]; model?: string; costUsd?: number; error?: string }> { + async editImage(sessionId: string, input: { image: string; prompt?: string; scale?: number; model?: string }): Promise<{ ok: boolean; artifacts?: { id: string; filename: string; mime: string }[]; model?: string; costUsd?: number; warning?: string; error?: string }> { const agent = this.sessionAgent(sessionId); if (!agent) return { ok: false, error: 'unknown session' }; if (!this.os.artifacts.enabled) return { ok: false, error: 'artifacts store is disabled (no data home)' }; @@ -2451,8 +2454,11 @@ export class TerminalManager { }); if (!out.length) return { ok: false, error: 'edit succeeded but no image could be stored' }; - this.audit(sessionId, agent, 'image.edited', { model: result.model, backend: backend.name, op, count: out.length, costUsd, costSource: result.costUsd != null ? 'actual' : 'estimate', artifactIds: out.map((o) => o.id) }); - return { ok: true, artifacts: out, model: result.model, costUsd }; + const warning = result.fallbackFrom + ? `Model "${result.fallbackFrom}" was rejected by Atlas — used "${result.model}" instead. Name a valid model or omit it.` + : undefined; + this.audit(sessionId, agent, 'image.edited', { model: result.model, backend: backend.name, op, count: out.length, costUsd, costSource: result.costUsd != null ? 'actual' : 'estimate', artifactIds: out.map((o) => o.id), ...(result.fallbackFrom ? { fallbackFrom: result.fallbackFrom } : {}) }); + return { ok: true, artifacts: out, model: result.model, costUsd, ...(warning ? { warning } : {}) }; } /** diff --git a/web/src/App.tsx b/web/src/App.tsx index a29dea2..387dbae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9396,7 +9396,13 @@ function IntegrationsSettings({ me }: { me: Member }) { {atlasModels.image.map((m) => )} - {(() => { const m = atlasModels.image.find((x) => x.id === imgModel.trim()); return m && m.priceUsd != null ?

{m.label} · ${+m.priceUsd.toFixed(3)} per image

: null })()} + {(() => { + const v = imgModel.trim(); + const m = atlasModels.image.find((x) => x.id === v); + if (m) return

{m.label}{m.priceUsd != null ? <> · ${+m.priceUsd.toFixed(3)} per image : null}

; + if (v && atlasModels.image.length) return

⚠ "{v}" isn't a known Atlas image model — pick one from the list, or leave blank for the default. An invalid id fails at generation (we fall back to the built-in default).

; + return null; + })()} @@ -9414,7 +9420,14 @@ function IntegrationsSettings({ me }: { me: Member }) { {atlasModels.video.map((m) => )} - {(() => { const m = atlasModels.video.find((x) => x.id === vidModel.trim()); return m && m.priceUsd != null ?

{m.label} · ${+m.priceUsd.toFixed(3)} per second of video

: null })()} + {(() => { + const v = vidModel.trim(); + const m = atlasModels.video.find((x) => x.id === v); + if (m) return

{m.label}{m.priceUsd != null ? <> · ${+m.priceUsd.toFixed(3)} per second of video : null}

; + // fal ids won't be in the Atlas catalog, so only warn for Atlas-looking ids (a "/" path) when fal isn't the backend. + if (v && atlasModels.video.length && video.backend !== 'fal') return

⚠ "{v}" isn't a known Atlas video model — pick one from the list, or leave blank for the default.

; + return null; + })()}