Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
364 changes: 336 additions & 28 deletions api/routers/agent.py

Large diffs are not rendered by default.

465 changes: 465 additions & 0 deletions api/tests/test_agent_client_tools.py

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions docs/superpowers/specs/2026-11-12-agent-side-panel-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Agent side panel: design

## Intent

The agent today lives in the "chat" mode of the Generate page's left panel. It loses its conversation whenever that panel or the page changes. The user wants the agent available **all the time**, in a panel on the right that **pushes the whole app**, so that the app can be driven entirely through the agent.

## Goals

- **Global panel.** A right-side panel lives in `MainLayout`, next to `<main>`, and pushes the content rather than overlaying it.
- A TopBar button toggles it, and so does `Ctrl+L` (my call).
- Its width can be resized by dragging (min 320, max 640, default 380; my call).
- Its open state and width are remembered across restarts.
- **Chat moves out of Generate.**
- `WorkflowPanel` loses its basic/chat toggle and keeps basic mode only.
- `ChatPanel` moves to `src/shared/components/agent/` as the panel body, keeping its current behaviour (streaming, thinking, actions card, mesh undo, workflow progress, image attachments).
- **The agent acts on the whole app without navigating.** It works on the page's state through stores, IPC and the API, never by changing `currentPage`, and confirms what it did in the panel.
- **Destructive actions ask first.** The panel shows a Confirm / Cancel card and the agent waits for the answer. This applies to uninstalling an extension, deleting model weights, deleting or overwriting a workflow, and changing a storage folder.
- **Conversations are persisted.**
- Each conversation is a JSON file in `<userData>/agent/conversations/{id}.json`.
- The panel lists past conversations (title, date) and offers New, Open and Delete.
- Images attached to messages are stored inline as data URLs (my call; simple, and good enough at this scale).
- **Context everywhere.** Every request sends the current page, the mesh (path, triangles), workflows, installed extensions, downloaded models and storage paths.

## New agent tools

**Server-side tools** run in `execute_tool` and call the local API:

| Tool | Does |
|---|---|
| `generate_from_image(model_id?)` | Starts a generation from the image attached to the message. The frontend runs it, as with `run_workflow`. |
| `list_extensions` | Lists installed extensions (from context). |
| `get_settings` | Returns the storage paths and a few UI options (from context). |

**Client-side tools** need Electron IPC or renderer stores, so the frontend runs them:

| Tool | Confirm? |
|---|---|
| `install_extension(github_url)` | no |
| `uninstall_extension(extension_id)` | **yes** |
| `reload_extensions` | no |
| `download_model_weights(extension_id, variant?)` | no |
| `delete_model_weights(extension_id, variant?)` | **yes** |
| `delete_workflow(workflow_id)` | **yes** |
| `open_workflow(workflow_id)` | no (opens it in the workflows store, does not navigate) |
| `set_setting(key, value)`, with key in `uiScale` / `useAtkinsonFont` / `showRamIndicator` | no |
| `set_storage_folder(kind, path)` | **yes** |
| `undo_mesh` / `redo_mesh` | no |
| `navigate(page)` | no; only when the user explicitly asks to go to a page |

## Client tool bridge

This is the core mechanism.

1. The LLM calls a client tool. `_chat_events` does not run it. It yields a `client_tool` event `{id, tool, arguments, confirm}`, registers an `asyncio.Future` under the call id, and awaits it.
2. The panel runs the tool through a frontend registry (`clientTools.ts`, mapping each tool name to an async handler that returns a result string). When `confirm` is set, it first shows the Confirm / Cancel card. Cancel resolves with "The user declined."
3. The panel sends `POST /agent/tool-result {call_id, result, error?}`. The future resolves, the result goes into `messages` as a real `role: tool` message, and the loop continues in the same turn.
4. Timeouts work differently depending on whether the user is involved:
- Execution is bounded by `tool_timeout`.
- Waiting for the user to confirm is not bounded by it, but is capped at 10 minutes.
- If the stream disconnects, the futures are cancelled.
5. Which tools run on the client, and which of those need confirmation, is declared once on the backend in a `CLIENT_TOOLS` dict. The frontend just obeys `confirm`.

This replaces nothing that exists today. `mesh_update`, `create_workflow` and `run_workflow` payloads keep working.

## Non-goals

- No page navigation as a side effect of an action.
- No change to the providers or to Settings > Agent.
- No change to the MCP server.

## Anti-goals

- The agent performing a destructive action without an explicit Confirm click.
- Losing the conversation when the page changes.

## Left to the builder

Visual details of the panel and the conversation list, as long as they match the existing zinc/violet style.
171 changes: 171 additions & 0 deletions electron/main/agent-conversations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { mkdtemp, readdir, rename, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import {
ORPHAN_TMP_AGE_MS,
createConversationStorage,
isConversation,
isValidConversationId,
sortSummaries,
} from './agent-conversations'

async function withDir(fn: (dir: string) => Promise<void>): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'modly-conv-'))
try {
await fn(join(root, 'agent', 'conversations'))
} finally {
await rm(root, { recursive: true, force: true })
}
}

const conv = (id: string, updatedAt: number, title = id) =>
({ id, title, createdAt: 1, updatedAt, messages: [{ id: 'u', role: 'user', content: title }] })

test('conversation ids: uuids pass, path tricks and odd characters do not', () => {
assert.equal(isValidConversationId('3f2a9c1e-8b7d-4e6f-a5b4-c3d2e1f0a9b8'), true)
assert.equal(isValidConversationId('a'), true)
assert.equal(isValidConversationId('a'.repeat(64)), true)
assert.equal(isValidConversationId('a'.repeat(65)), false)
assert.equal(isValidConversationId(''), false)
assert.equal(isValidConversationId('../secrets'), false)
assert.equal(isValidConversationId('..\\secrets'), false)
assert.equal(isValidConversationId('a/b'), false)
assert.equal(isValidConversationId('ABC'), false)
assert.equal(isValidConversationId('abc.json'), false)
assert.equal(isValidConversationId('abc\n'), false)
assert.equal(isValidConversationId(42), false)
})

test('isConversation checks the stored shape', () => {
assert.equal(isConversation(conv('abc-1', 2)), true)
assert.equal(isConversation({ ...conv('abc-1', 2), id: '../x' }), false)
assert.equal(isConversation({ ...conv('abc-1', 2), messages: 'nope' }), false)
assert.equal(isConversation({ ...conv('abc-1', 2), updatedAt: '2' }), false)
assert.equal(isConversation(null), false)
})

test('sortSummaries puts the newest first', () => {
const sorted = sortSummaries([conv('a', 10), conv('b', 30), conv('c', 20)])
assert.deepEqual(sorted.map((s) => s.id), ['b', 'c', 'a'])
})

test('list is empty before anything is saved', async () => {
await withDir(async (dir) => {
assert.deepEqual(await createConversationStorage(dir).list(), [])
})
})

test('save, read, list newest first, remove', async () => {
await withDir(async (dir) => {
const storage = createConversationStorage(dir)
await storage.save(conv('old', 10, 'Old'))
await storage.save(conv('new', 20, 'New'))
assert.deepEqual(await storage.read('old'), conv('old', 10, 'Old'))
assert.deepEqual(await storage.list(), [
{ id: 'new', title: 'New', updatedAt: 20 },
{ id: 'old', title: 'Old', updatedAt: 10 },
])
// No temp file is left behind.
assert.deepEqual((await readdir(dir)).sort(), ['new.json', 'old.json'])

await storage.remove('old')
assert.equal(await storage.read('old'), null)
assert.deepEqual((await storage.list()).map((s) => s.id), ['new'])
})
})

test('saving again overwrites the file', async () => {
await withDir(async (dir) => {
const storage = createConversationStorage(dir)
await storage.save(conv('a', 1, 'First'))
await storage.save(conv('a', 2, 'Second'))
assert.equal((await storage.read('a'))?.title, 'Second')
})
})

test('invalid ids and shapes are refused', async () => {
await withDir(async (dir) => {
const storage = createConversationStorage(dir)
await assert.rejects(storage.save({ ...conv('a', 1), id: '../escape' }))
await assert.rejects(storage.save({ id: 'a' }))
await assert.rejects(storage.remove('../escape'))
assert.equal(await storage.read('../escape'), null)
})
})

test('corrupted or mismatched files are skipped', async () => {
await withDir(async (dir) => {
const storage = createConversationStorage(dir)
await storage.save(conv('good', 5))
await writeFile(join(dir, 'broken.json'), '{not json', 'utf-8')
await writeFile(join(dir, 'other.json'), JSON.stringify(conv('different', 9)), 'utf-8')
await writeFile(join(dir, 'notes.txt'), 'hi', 'utf-8')
assert.deepEqual((await storage.list()).map((s) => s.id), ['good'])
assert.equal(await storage.read('broken'), null)
assert.equal(await storage.read('other'), null)
})
})

const errno = (code: string): NodeJS.ErrnoException => Object.assign(new Error(code), { code })

test('a rename locked for a moment (antivirus, indexer) is retried', async () => {
await withDir(async (dir) => {
let calls = 0
const storage = createConversationStorage(dir, {
retryDelayMs: 1,
rename: async (from, to) => {
calls++
if (calls < 3) throw errno(calls === 1 ? 'EPERM' : 'EBUSY')
await rename(from, to)
},
})
await storage.save(conv('a', 1, 'Locked'))
assert.equal(calls, 3)
assert.equal((await storage.read('a'))?.title, 'Locked')
assert.deepEqual(await readdir(dir), ['a.json'])
})
})

test('a rename still locked after the retries fails and leaves no temp file', async () => {
await withDir(async (dir) => {
let calls = 0
const storage = createConversationStorage(dir, {
retryDelayMs: 1,
rename: async () => { calls++; throw errno('EACCES') },
})
await assert.rejects(storage.save(conv('a', 1)), { code: 'EACCES' })
assert.equal(calls, 3)
assert.deepEqual(await readdir(dir), [])
})
})

test('other rename errors are not retried', async () => {
await withDir(async (dir) => {
let calls = 0
const storage = createConversationStorage(dir, {
retryDelayMs: 1,
rename: async () => { calls++; throw errno('ENOSPC') },
})
await assert.rejects(storage.save(conv('a', 1)), { code: 'ENOSPC' })
assert.equal(calls, 1)
})
})

test('list removes orphan temp files, but not ones a write may still own', async () => {
await withDir(async (dir) => {
const storage = createConversationStorage(dir)
await storage.save(conv('a', 1))
const old = join(dir, 'a.json.1111.tmp')
const fresh = join(dir, 'a.json.2222.tmp')
await writeFile(old, '{', 'utf-8')
await writeFile(fresh, '{', 'utf-8')
const past = (Date.now() - ORPHAN_TMP_AGE_MS - 10_000) / 1000
await utimes(old, past, past)

assert.deepEqual((await storage.list()).map((s) => s.id), ['a'])
assert.deepEqual((await readdir(dir)).sort(), ['a.json', 'a.json.2222.tmp'])
})
})
140 changes: 140 additions & 0 deletions electron/main/agent-conversations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Agent conversations on disk: one JSON file per conversation in
// <userData>/agent/conversations/{id}.json.

import { join } from 'path'
import { randomUUID } from 'crypto'
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises'

export interface ConversationSummary {
id: string
title: string
updatedAt: number
}

export interface Conversation extends ConversationSummary {
createdAt: number
messages: unknown[]
}

export const CONVERSATION_ID_RE = /^[a-z0-9-]{1,64}$/

/** Ids name files on disk: anything else could escape the conversations folder. */
export function isValidConversationId(id: unknown): id is string {
return typeof id === 'string' && CONVERSATION_ID_RE.test(id)
}

/** Shape check for a conversation read from disk or received over IPC. */
export function isConversation(value: unknown): value is Conversation {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const c = value as Record<string, unknown>
return isValidConversationId(c.id)
&& typeof c.title === 'string'
&& typeof c.createdAt === 'number'
&& typeof c.updatedAt === 'number'
&& Array.isArray(c.messages)
}

/** Newest first; ties keep a stable order by id. */
export function sortSummaries<T extends ConversationSummary>(list: readonly T[]): T[] {
return [...list].sort((a, b) => (b.updatedAt - a.updatedAt) || a.id.localeCompare(b.id))
}

/** Windows antivirus or indexers briefly lock a freshly written file. */
const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
const RENAME_ATTEMPTS = 3
const RENAME_RETRY_MS = 50
/** A temp file this old belongs to no write in flight: a crash left it behind. */
export const ORPHAN_TMP_AGE_MS = 60_000

export interface ConversationStorageOptions {
/** Injectable for tests. */
rename?: (from: string, to: string) => Promise<void>
retryDelayMs?: number
now?: () => number
}

const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))

export function createConversationStorage(dir: string, options: ConversationStorageOptions = {}) {
const renameFile = options.rename ?? rename
const retryDelayMs = options.retryDelayMs ?? RENAME_RETRY_MS
const now = options.now ?? Date.now
const fileFor = (id: string): string => join(dir, `${id}.json`)

async function readConversation(id: string): Promise<Conversation | null> {
if (!isValidConversationId(id)) return null
try {
const parsed: unknown = JSON.parse(await readFile(fileFor(id), 'utf-8'))
return isConversation(parsed) && parsed.id === id ? parsed : null
} catch {
return null
}
}

async function renameWithRetry(from: string, to: string): Promise<void> {
for (let attempt = 1; ; attempt++) {
try {
await renameFile(from, to)
return
} catch (err) {
const code = (err as NodeJS.ErrnoException | null)?.code
if (attempt >= RENAME_ATTEMPTS || !code || !TRANSIENT_RENAME_CODES.has(code)) throw err
await sleep(retryDelayMs * attempt)
}
}
}

/** Best effort: temp files left by a crash between write and rename. */
async function removeOrphanTmpFiles(files: readonly string[]): Promise<void> {
for (const file of files) {
if (!file.endsWith('.tmp')) continue
const path = join(dir, file)
try {
if (now() - (await stat(path)).mtimeMs >= ORPHAN_TMP_AGE_MS) await rm(path, { force: true })
} catch {
// Gone already, or locked: the next listing tries again.
}
}
}

return {
async list(): Promise<ConversationSummary[]> {
let files: string[]
try {
files = await readdir(dir)
} catch {
return []
}
await removeOrphanTmpFiles(files)
const summaries: ConversationSummary[] = []
for (const file of files) {
if (!file.endsWith('.json')) continue
const c = await readConversation(file.slice(0, -'.json'.length))
if (c) summaries.push({ id: c.id, title: c.title, updatedAt: c.updatedAt })
}
return sortSummaries(summaries)
},

read: readConversation,

async save(conversation: unknown): Promise<void> {
if (!isConversation(conversation)) throw new Error('Invalid conversation')
await mkdir(dir, { recursive: true })
const target = fileFor(conversation.id)
// Write then rename: a crash mid-write never leaves a truncated file.
const tmp = `${target}.${randomUUID()}.tmp`
await writeFile(tmp, JSON.stringify(conversation), 'utf-8')
try {
await renameWithRetry(tmp, target)
} catch (err) {
await rm(tmp, { force: true })
throw err
}
},

async remove(id: string): Promise<void> {
if (!isValidConversationId(id)) throw new Error('Invalid conversation id')
await rm(fileFor(id), { force: true })
},
}
}
Loading
Loading