diff --git a/CHANGELOG.md b/CHANGELOG.md index 8407db5..f8b8b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,14 @@ new version heading in the same commit. ## [Unreleased] -## [0.138.4] — 2026-07-13 -### Fixed +## [0.139.0] — 2026-07-13 +### Added +- **`image_edit` gains a `remove-background` preset.** Alongside prompt-guided edit and upscale, agents can + now cut out an image's subject with `image_edit({ image, operation: "remove-background" })` — no prompt + needed. It returns a **transparent PNG** saved as a new Library image (source untouched), via Atlas's + dedicated `youchuan/v8.1/remove-background` model. Same governed `generateImage` submit+poll as the other + edit modes (`operation` takes precedence over `scale`/`prompt`), classified `image.edit` (money-capped), + audited `image.edited` (op=remove-background). Verified live (→ 822 KB transparent PNG). - **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 diff --git a/package-lock.json b/package-lock.json index bc98e86..a4de3ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.138.4", + "version": "0.139.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.138.4", + "version": "0.139.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 5a3b487..a7200ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.138.4", + "version": "0.139.0", "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 04fe9c7..8d0f8e7 100644 --- a/src/edge/image-gen.ts +++ b/src/edge/image-gen.ts @@ -41,15 +41,17 @@ export interface ImageGenRequest { n: number; } -/** Edit or upscale an EXISTING image. `images` are already-resolved inputs the vendor accepts inline - * (data: URLs or http URLs). `scale` set (>1) ⇒ upscale mode (uses the upscaler model, `prompt` ignored); - * otherwise it's a prompt-guided edit (image-to-image). */ +/** Edit, upscale, or run a named preset on an EXISTING image. `images` are already-resolved inputs the + * vendor accepts inline (data: URLs or http URLs). Mode precedence: `operation` (a named preset like + * 'remove-background') ⇒ that preset; else `scale` (>1) ⇒ upscale (uses the upscaler model, `prompt` + * ignored); else a prompt-guided edit (image-to-image). */ export interface ImageEditRequest { images: string[]; prompt?: string; model?: string; size?: string; scale?: number; + operation?: 'remove-background'; } export interface ImageBackend { @@ -184,6 +186,7 @@ interface AtlasPrediction { 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'; +const ATLAS_BG_REMOVE_MODEL = 'youchuan/v8.1/remove-background'; /** 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. */ @@ -215,12 +218,16 @@ class AtlasBackend implements ImageBackend { 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 build = (m: string): Record => upscale + const bgRemove = req.operation === 'remove-background'; + const upscale = !bgRemove && typeof req.scale === 'number' && req.scale > 1; + const model = req.model?.trim() || (bgRemove ? ATLAS_BG_REMOVE_MODEL : upscale ? this.upscaleModel : this.editModel); + const fallback = bgRemove ? ATLAS_BG_REMOVE_MODEL : upscale ? ATLAS_UPSCALE_MODEL : ATLAS_BUILTIN_EDIT_MODEL; + // All three modes ride the SAME generateImage submit+poll — only the body shape differs: an upscaler + // and background-removal take a single `image` (bg-remove returns a transparent PNG, no prompt); an + // image-to-image edit takes `images[]` + a `prompt`. + const build = (m: string): Record => bgRemove + ? { model: m, image: req.images[0] } + : 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); diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index ae845ad..bc03d70 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -172,19 +172,21 @@ const IMAGE_GENERATE_TOOL = { const IMAGE_EDIT_TOOL = { name: 'image_edit', description: - 'Edit or upscale an EXISTING image (image-to-image). Use this to transform an image you already have — ' + - 'change its style/content ("make the sky purple", "add a hat"), or upscale it — WITHOUT redrawing from ' + - 'scratch. The source `image` can be a Library artifact id (e.g. from a prior image_generate), a file path ' + - 'in your working folder (a file you wrote OR one uploaded into the session), or an http(s) image URL. ' + - 'The result is saved as a NEW image in the Library (the source is never changed) and the tool returns the ' + - 'new artifact id. Pass `prompt` to describe the edit, OR `scale` (2 or 4) to upscale. Governed ' + + 'Edit, upscale, or run a named preset on an EXISTING image. Use this to transform an image you already ' + + 'have — change its style/content ("make the sky purple", "add a hat"), upscale it, or remove its ' + + 'background — WITHOUT redrawing from scratch. The source `image` can be a Library artifact id (e.g. from a ' + + 'prior image_generate), a file path in your working folder (a file you wrote OR one uploaded into the ' + + 'session), or an http(s) image URL. The result is saved as a NEW image in the Library (the source is never ' + + 'changed) and the tool returns the new artifact id. Pass `prompt` to describe an edit, `scale` (2 or 4) to ' + + 'upscale, OR `operation` for a named preset ("remove-background" → a transparent PNG cutout). Governed ' + '(cost-metered + audited). Requires an Atlas Cloud key.', inputSchema: { type: 'object', properties: { image: { type: 'string', description: 'The image to edit: a Library artifact id, a file path in your working folder (written or uploaded), or an http(s) image URL.' }, - prompt: { type: 'string', description: 'How to change the image (e.g. "turn it into a watercolor painting", "remove the background"). Required unless upscaling.' }, + prompt: { type: 'string', description: 'How to change the image (e.g. "turn it into a watercolor painting"). Required unless upscaling or using an `operation` preset.' }, scale: { type: 'number', description: 'Upscale factor (2 or 4). When set, upscales the image and `prompt` is ignored.' }, + operation: { type: 'string', enum: ['remove-background'], description: 'A named preset. "remove-background" removes the background and returns a transparent PNG (no `prompt` needed). Takes precedence over `scale`/`prompt`.' }, model: { type: 'string', description: 'Optional model id override (an Atlas image-to-image / upscaler model). Omit for the default.' }, }, required: ['image'], @@ -1117,10 +1119,12 @@ async function imageGenerate(args: Record): Promise { async function imageEdit(args: Record): Promise { const image = String(args.image ?? '').trim(); if (!image) return 'An input `image` is required (a Library artifact id, a working-folder file path, or an image URL).'; + const operation = args.operation === 'remove-background' ? 'remove-background' : undefined; const scale = args.scale !== undefined ? Number(args.scale) : undefined; const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : ''; - if (!(scale && scale > 1) && !prompt) return 'Describe the edit in `prompt`, or pass `scale` (2 or 4) to upscale.'; + if (!operation && !(scale && scale > 1) && !prompt) return 'Describe the edit in `prompt`, pass `scale` (2 or 4) to upscale, or set `operation` (e.g. "remove-background").'; const body: Record = { session: SESSION, image }; + if (operation) body.operation = operation; if (prompt) body.prompt = prompt; if (scale !== undefined) body.scale = scale; if (typeof args.model === 'string' && args.model.trim()) body.model = args.model.trim(); @@ -1133,7 +1137,7 @@ async function imageEdit(args: Record): Promise { 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'; + const what = operation === 'remove-background' ? 'Removed background' : scale && scale > 1 ? `Upscaled ${scale}×` : 'Edited'; const warn = d.warning ? ` ⚠ ${d.warning}` : ''; return `${what} with ${d.model ?? 'the default model'}${cost}. New image saved to the Library: ${list}.${warn}`; } diff --git a/src/server.ts b/src/server.ts index cfcdb90..92f8937 100644 --- a/src/server.ts +++ b/src/server.ts @@ -885,6 +885,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: prompt: b.prompt ? String(b.prompt) : undefined, scale: b.scale !== undefined ? Number(b.scale) : undefined, model: b.model ? String(b.model) : undefined, + operation: b.operation === 'remove-background' ? 'remove-background' : undefined, }); return sendJson(res, out.ok ? 200 : 400, out); } diff --git a/src/terminal.ts b/src/terminal.ts index 801729c..b4c1006 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -2392,16 +2392,18 @@ export class TerminalManager { * `generateImage`: classified `image.edit` with an estimated `amountUsd` (money-cap applies), the * result is `ingest`ed as a NEW `image` artifact (the source is never mutated) + an owner-scoped inbox * card, audited `image.edited`. The source image is any ref `resolveImageRef` accepts — a Library - * 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). + * artifact id, a working-folder file (written or terminal-uploaded), or a URL. Mode precedence: + * `operation` (a named preset — 'remove-background', a transparent-PNG cutout, no prompt) ⇒ that preset; + * else `scale` (>1) upscales (prompt ignored); else `prompt` drives an image-to-image edit. Atlas-only. */ - 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 }> { + async editImage(sessionId: string, input: { image: string; prompt?: string; scale?: number; model?: string; operation?: 'remove-background' }): 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)' }; - const upscale = typeof input.scale === 'number' && input.scale > 1; + const bgRemove = input.operation === 'remove-background'; + const upscale = !bgRemove && typeof input.scale === 'number' && input.scale > 1; const prompt = (input.prompt || '').trim(); - if (!upscale && !prompt) return { ok: false, error: 'describe the edit in `prompt` (or pass `scale` to upscale)' }; + if (!bgRemove && !upscale && !prompt) return { ok: false, error: 'describe the edit in `prompt`, pass `scale` to upscale, or set `operation` (e.g. "remove-background")' }; const imageRef = (input.image || '').trim(); if (!imageRef) return { ok: false, error: 'an input `image` is required — a Library artifact id, a working-folder path, or an image URL' }; const resolved = this.resolveImageRef(agent, imageRef); @@ -2415,15 +2417,15 @@ export class TerminalManager { if (!backend) return { ok: false, error: 'image editing is not configured — set an Atlas Cloud key in Settings → Integrations' }; const estimateUsd = DEFAULT_IMAGE_COST_USD; - const op = upscale ? 'upscale' : 'edit'; - const model = input.model?.trim() || (upscale ? 'atlascloud/image-upscaler' : op); - const gate = this.gate(sessionId, agent, 'image.edit', { prompt, model, upscale, amountUsd: estimateUsd }, `${op} an image with ${model}`); + const op = bgRemove ? 'remove-background' : upscale ? 'upscale' : 'edit'; + const model = input.model?.trim() || (bgRemove ? 'youchuan/v8.1/remove-background' : upscale ? 'atlascloud/image-upscaler' : op); + const gate = this.gate(sessionId, agent, 'image.edit', { prompt, model, op, amountUsd: estimateUsd }, `${op} an image with ${model}`); if (gate.decision === 'deny') return { ok: false, error: 'blocked by policy' }; if (gate.decision === 'pending') return { ok: false, error: 'this edit needs human approval — an approval request was filed; retry once it is approved' }; let result; try { - result = await backend.editImage({ images: [resolved.url], prompt, model: input.model, scale: input.scale }); + result = await backend.editImage({ images: [resolved.url], prompt, model: input.model, scale: input.scale, operation: input.operation }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); this.audit(sessionId, agent, 'image.failed', { model, op, error: msg }); @@ -2432,7 +2434,8 @@ export class TerminalManager { const srow = this.db.prepare('SELECT spawned_by, run_as FROM term_sessions WHERE id = ?').get<{ spawned_by: string | null; run_as: string | null }>(sessionId); const source = srow?.run_as ?? srow?.spawned_by ?? undefined; - const title = upscale ? `Upscaled ${input.scale}×` : (prompt.length > 60 ? prompt.slice(0, 57) + '…' : prompt); + const title = bgRemove ? 'Background removed' : upscale ? `Upscaled ${input.scale}×` : (prompt.length > 60 ? prompt.slice(0, 57) + '…' : prompt); + const description = bgRemove ? `Background removed from ${imageRef}` : upscale ? `Upscaled ${input.scale}× from ${imageRef}` : prompt; const stamp = Date.now(); const costUsd = result.costUsd ?? estimateUsd; const perImageUsd = +(costUsd / result.images.length).toFixed(6); @@ -2440,7 +2443,7 @@ export class TerminalManager { result.images.forEach((img, i) => { const filename = `${op}-${stamp}${result.images.length > 1 ? `-${i + 1}` : ''}.${img.ext}`; const r = this.os.artifacts.ingest({ - sessionId, agent, source, title, description: upscale ? `Upscaled ${input.scale}× from ${imageRef}` : prompt, + sessionId, agent, source, title, description, folder: 'edited-images', filename, bytes: img.bytes, kind: 'image', costUsd: perImageUsd, }); if (!r.ok) return;