Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
50 changes: 38 additions & 12 deletions src/edge/image-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string> {
Expand All @@ -196,22 +209,35 @@ class AtlasBackend implements ImageBackend {

async generate(req: ImageGenRequest): Promise<ImageGenResult> {
const model = req.model?.trim() || this.defaultModel;
const body: Record<string, unknown> = { model, prompt: req.prompt };
if (req.size) body.size = req.size;
return this.submitAndPoll(body, model);
const build = (m: string): Record<string, unknown> => ({ model: m, prompt: req.prompt, ...(req.size ? { size: req.size } : {}) });
return this.withModelFallback(model, ATLAS_BUILTIN_IMAGE_MODEL, build);
}

async editImage(req: ImageEditRequest): Promise<ImageGenResult> {
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<string, unknown> = 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<string, unknown> => 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<string, unknown>): Promise<ImageGenResult> {
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
Expand Down
10 changes: 6 additions & 4 deletions src/memory/memory-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1106,11 +1106,12 @@ async function imageGenerate(args: Record<string, unknown>): Promise<string> {
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<string, unknown>): Promise<string> {
Expand All @@ -1128,12 +1129,13 @@ async function imageEdit(args: Record<string, unknown>): Promise<string> {
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<string, unknown>): Promise<string> {
Expand Down
18 changes: 12 additions & 6 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)' };
Expand Down Expand Up @@ -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 } : {}) };
}

/**
Expand All @@ -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)' };
Expand Down Expand Up @@ -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 } : {}) };
}

/**
Expand Down
17 changes: 15 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9396,7 +9396,13 @@ function IntegrationsSettings({ me }: { me: Member }) {
<datalist id="atlas-image-models">
{atlasModels.image.map((m) => <option key={m.id} value={m.id}>{m.priceUsd != null ? `${m.label} — $${+m.priceUsd.toFixed(3)}/image` : m.label}</option>)}
</datalist>
{(() => { const m = atlasModels.image.find((x) => x.id === imgModel.trim()); return m && m.priceUsd != null ? <p className="mt-1 text-[11px] text-muted-foreground">{m.label} · <strong>${+m.priceUsd.toFixed(3)}</strong> per image</p> : null })()}
{(() => {
const v = imgModel.trim();
const m = atlasModels.image.find((x) => x.id === v);
if (m) return <p className="mt-1 text-[11px] text-muted-foreground">{m.label}{m.priceUsd != null ? <> · <strong>${+m.priceUsd.toFixed(3)}</strong> per image</> : null}</p>;
if (v && atlasModels.image.length) return <p className="mt-1 text-[11px] text-amber-600">⚠ "{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).</p>;
return null;
})()}
</Field>
</div>

Expand All @@ -9414,7 +9420,14 @@ function IntegrationsSettings({ me }: { me: Member }) {
<datalist id="atlas-video-models">
{atlasModels.video.map((m) => <option key={m.id} value={m.id}>{m.priceUsd != null ? `${m.label} — $${+m.priceUsd.toFixed(3)}/sec` : m.label}</option>)}
</datalist>
{(() => { const m = atlasModels.video.find((x) => x.id === vidModel.trim()); return m && m.priceUsd != null ? <p className="mt-1 text-[11px] text-muted-foreground">{m.label} · <strong>${+m.priceUsd.toFixed(3)}</strong> per second of video</p> : null })()}
{(() => {
const v = vidModel.trim();
const m = atlasModels.video.find((x) => x.id === v);
if (m) return <p className="mt-1 text-[11px] text-muted-foreground">{m.label}{m.priceUsd != null ? <> · <strong>${+m.priceUsd.toFixed(3)}</strong> per second of video</> : null}</p>;
// 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 <p className="mt-1 text-[11px] text-amber-600">⚠ "{v}" isn't a known Atlas video model — pick one from the list, or leave blank for the default.</p>;
return null;
})()}
</Field>
<Field label="fal.ai API key (optional)" help="fal.ai → dashboard → Keys. Widest video catalog (Veo, Kling, Seedance…) via one key. When set, fal handles video instead of Atlas.">
<Input
Expand Down
Loading