diff --git a/approval-gate/Cargo.lock b/approval-gate/Cargo.lock index effdfce82..57275b4fa 100644 --- a/approval-gate/Cargo.lock +++ b/approval-gate/Cargo.lock @@ -545,12 +545,13 @@ dependencies = [ [[package]] name = "harness" -version = "1.7.0" +version = "1.8.1" dependencies = [ "anyhow", "async-trait", "clap", "globset", + "iii-console-ui", "iii-helpers", "iii-sdk", "jsonschema", @@ -804,6 +805,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.8" diff --git a/browser/src/functions/mod.rs b/browser/src/functions/mod.rs index df555fc19..7e130b68c 100644 --- a/browser/src/functions/mod.rs +++ b/browser/src/functions/mod.rs @@ -660,7 +660,8 @@ fn register_screenshot(iii: &Arc, sessions: &Arc) { }) } }) - .description(SCREENSHOT_DESC), + .description(SCREENSHOT_DESC) + .metadata(json!({ "display": true })), ); } diff --git a/browser/ui/page.tsx b/browser/ui/page.tsx index 8d5a4bb21..69cc8c25c 100644 --- a/browser/ui/page.tsx +++ b/browser/ui/page.tsx @@ -5,22 +5,22 @@ * asset: ../styles.css ships over `console:style` as browser/styles.css — * the console mounts and link-swaps it, styles-before-scripts on boot. * - * `setup(host)` registers two contributions: + * `setup(host)` registers three contributions: * - src/page/ — the `#/ext/browser` page: the session rail, a screencast-fed * live viewport, and the console/network feeds for the selected session; * drill-in flow (list ⇄ session workspace) when the pane is narrow. * - src/function-trigger-message/ — how every `browser::*` call renders in * chat and the traces span tab (per-function terminal cards). - * - * No config form is registered: the browser worker's configuration is plain - * scalar fields, so the console's schema-generated form is sufficient. + * - src/configuration/ — the full-width, purpose-built browser settings + * editor used by the Console's worker configuration dialog. * * Registrations go through `host` so the loader disposes them on hot reload / * worker disconnect. */ import type { Host } from '@iii-dev/console-ui' -import { createBrowserRenderer } from './src/function-trigger-message' +import { BrowserConfigForm } from './src/configuration' +import { createBrowserRenderer, createBrowserScreenshotRenderer } from './src/function-trigger-message' import { BrowserPage } from './src/page' export default function setup(host: Host) { @@ -30,5 +30,11 @@ export default function setup(host: Host) { render: (props) => , }) + host.configForms.register('browser', BrowserConfigForm, { layout: 'full' }) + + // A captured page is a first-class chat artifact. Register its focused + // renderer first; the general browser renderer still owns errors/running + // states and every other browser::* function. + host.functionTriggers.register(createBrowserScreenshotRenderer()) host.functionTriggers.register(createBrowserRenderer(host)) } diff --git a/browser/ui/src/configuration/index.tsx b/browser/ui/src/configuration/index.tsx new file mode 100644 index 000000000..ea3684d16 --- /dev/null +++ b/browser/ui/src/configuration/index.tsx @@ -0,0 +1,755 @@ +/** + * Purpose-built configuration editor for the browser worker. The Console + * retains ownership of dirty tracking, validation, save, reset, and the + * unsaved-change guard; this component only edits the JSON draft. + */ + +import { type ConfigFormProps, Input, type JsonValue, StatusPanel } from '@iii-dev/console-ui' +import { type ReactNode, useEffect, useRef, useState } from 'react' +import { ChevronLeftIcon, GlobeIcon, useContainerNarrow } from '../lib/widgets' + +type JsonObject = { [key: string]: JsonValue } +type SectionId = 'launch' | 'viewport' | 'limits' | 'behavior' + +const CONFIG_NARROW_BELOW = 660 +const DEFAULTS = { + executable: '', + user_data_dir: '', + headless: true, + max_sessions: 4, + console_buffer: 500, + network_buffer: 500, + viewport_width: 1280, + viewport_height: 800, + default_timeout_ms: 30_000, + max_timeout_ms: 120_000, + idle_stop_ms: 300_000, + screenshot_quality: 60, + allowed_schemes: ['http', 'https'], + max_snapshot_nodes: 2_000, + allow_attach: false, +} as const + +const FIELD_SECTION: Record = { + executable: 'launch', + user_data_dir: 'launch', + headless: 'launch', + max_sessions: 'launch', + allow_attach: 'launch', + viewport_width: 'viewport', + viewport_height: 'viewport', + screenshot_quality: 'viewport', + console_buffer: 'limits', + network_buffer: 'limits', + max_snapshot_nodes: 'limits', + default_timeout_ms: 'behavior', + max_timeout_ms: 'behavior', + idle_stop_ms: 'behavior', + allowed_schemes: 'behavior', +} + +function asObject(value: JsonValue | undefined): JsonObject { + return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {} +} + +function stringValue(value: JsonValue | undefined, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +function numberValue(value: JsonValue | undefined, fallback: number): number { + return typeof value === 'number' ? value : fallback +} + +function booleanValue(value: JsonValue | undefined, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback +} + +function pointer(field: string) { + return `/${field.replaceAll('~', '~0').replaceAll('/', '~1')}` +} + +function fieldError(errors: ConfigFormProps['errors'], field: string) { + const base = pointer(field) + return errors?.get(base) ?? [...(errors?.entries() ?? [])].find(([path]) => path.startsWith(`${base}/`))?.[1] +} + +function formatCount(value: number) { + return new Intl.NumberFormat('en-US').format(value) +} + +function formatDuration(ms: number) { + if (ms === 0) return 'off' + if (ms < 1_000) return `${ms}ms` + const seconds = Math.round(ms / 1_000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainder = seconds % 60 + return remainder ? `${minutes}m ${remainder}s` : `${minutes}m` +} + +function Field({ + label, + hint, + error, + children, +}: { + label: ReactNode + hint?: ReactNode + error?: string + children: ReactNode +}) { + return ( +
+
{label}
+ {children} + {error ? ( +

+ {error} +

+ ) : null} + {hint ?

{hint}

: null} +
+ ) +} + +function TextField({ + field, + label, + value, + placeholder, + hint, + error, + onChange, +}: { + field: string + label: string + value: string + placeholder?: string + hint?: ReactNode + error?: string + onChange: (value: string) => void +}) { + const id = `br-cfg-${field}` + return ( + {label}} hint={hint} error={error}> + + + ) +} + +function NumberField({ + field, + label, + value, + placeholder, + min = 0, + max, + hint, + error, + onChange, +}: { + field: string + label: string + value: JsonValue | undefined + placeholder: number + min?: number + max?: number + hint?: ReactNode + error?: string + onChange: (raw: string) => void +}) { + const id = `br-cfg-${field}` + return ( + {label}} hint={hint} error={error}> + + + ) +} + +function CheckField({ + field, + label, + hint, + checked, + onChange, +}: { + field: string + label: string + hint: ReactNode + checked: boolean + onChange: (checked: boolean) => void +}) { + const id = `br-cfg-${field}` + return ( +
+ +

{hint}

+
+ ) +} + +function SchemesField({ + value, + error, + onChange, +}: { + value: string[] + error?: string + onChange: (value: string[]) => void +}) { + const canonical = value.join(', ') + const [draft, setDraft] = useState(canonical) + + useEffect(() => setDraft(canonical), [canonical]) + + const commit = () => { + onChange( + draft + .split(',') + .map((scheme) => scheme.trim()) + .filter(Boolean), + ) + } + + return ( + Allowed URL schemes} + hint="Enter a comma-separated list without ://. Keep this list as narrow as your workflows allow." + error={error} + > + { + if (event.key !== 'Enter') return + event.preventDefault() + commit() + event.currentTarget.blur() + }} + /> + + ) +} + +function SectionHeader({ title, description }: { title: string; description: string }) { + return ( +
+
+

{title}

+

{description}

+
+
+ ) +} + +function ConfigNav({ + value, + selection, + onSelect, +}: { + value: JsonObject + selection: SectionId + onSelect: (section: SectionId) => void +}) { + const width = numberValue(value.viewport_width, DEFAULTS.viewport_width) + const height = numberValue(value.viewport_height, DEFAULTS.viewport_height) + const maxSessions = numberValue(value.max_sessions, DEFAULTS.max_sessions) + const headless = booleanValue(value.headless, DEFAULTS.headless) + const consoleBuffer = numberValue(value.console_buffer, DEFAULTS.console_buffer) + const networkBuffer = numberValue(value.network_buffer, DEFAULTS.network_buffer) + const timeout = numberValue(value.default_timeout_ms, DEFAULTS.default_timeout_ms) + const idle = numberValue(value.idle_stop_ms, DEFAULTS.idle_stop_ms) + + const sections: Array<{ + id: SectionId + label: string + description: string + summary: string + }> = [ + { + id: 'launch', + label: 'Launch', + description: 'Process and sessions', + summary: `${headless ? 'headless' : 'headful'} · ${maxSessions} max`, + }, + { + id: 'viewport', + label: 'Viewport', + description: 'Canvas and screenshots', + summary: `${width} × ${height}`, + }, + { + id: 'limits', + label: 'Capture limits', + description: 'Buffers and snapshots', + summary: `${formatCount(consoleBuffer)} / ${formatCount(networkBuffer)}`, + }, + { + id: 'behavior', + label: 'Behavior', + description: 'Timeouts and navigation', + summary: `${formatDuration(timeout)} · idle ${formatDuration(idle)}`, + }, + ] + + return ( + + ) +} + +function EditorHeader({ + title, + description, + narrow, + onBack, +}: { + title: string + description: string + narrow: boolean + onBack: () => void +}) { + return ( +
+ {narrow ? ( + + ) : null} + +
+

{title}

+

{description}

+
+
+ ) +} + +function ConfigEditor({ + selection, + value, + errors, + narrow, + onBack, + onChange, +}: { + selection: SectionId + value: JsonObject + errors: ConfigFormProps['errors'] + narrow: boolean + onBack: () => void + onChange: (value: JsonObject) => void +}) { + const setString = (field: string, raw: string) => { + const next = { ...value } + if (raw === '') delete next[field] + else next[field] = raw + onChange(next) + } + + const setNumber = (field: string, raw: string) => { + const next = { ...value } + if (raw.trim() === '') delete next[field] + else { + const parsed = Number(raw) + if (!Number.isInteger(parsed) || parsed < 0) return + next[field] = parsed + } + onChange(next) + } + + const setBoolean = (field: string, checked: boolean) => { + onChange({ ...value, [field]: checked }) + } + + const titles: Record = { + launch: { + title: 'Launch and sessions', + description: 'Choose how Chromium starts and how many sessions can run.', + }, + viewport: { + title: 'Viewport and screenshots', + description: 'Set the canvas used by new sessions and image capture.', + }, + limits: { + title: 'Capture limits', + description: 'Bound live history and serialized page snapshots.', + }, + behavior: { + title: 'Runtime behavior', + description: 'Control timeouts, idle cleanup, and allowed destinations.', + }, + } + + return ( +
+ +
+ {selection === 'launch' ? ( + <> +
+ + setString('executable', next)} + /> + setString('user_data_dir', next)} + /> +
+
+ + setNumber('max_sessions', next)} + /> +
+ setBoolean('headless', next)} + /> + setBoolean('allow_attach', next)} + /> +
+ {booleanValue(value.allow_attach, DEFAULTS.allow_attach) ? ( +
+ Attach mode is enabled. Only connect to browser instances you trust. +
+ ) : null} +
+ + ) : null} + + {selection === 'viewport' ? ( + <> +
+ +
+ setNumber('viewport_width', next)} + /> + setNumber('viewport_height', next)} + /> +
+
+
+ + {numberValue(value.viewport_width, DEFAULTS.viewport_width)} ×{' '} + {numberValue(value.viewport_height, DEFAULTS.viewport_height)} + +
+

Aspect-ratio preview for newly launched sessions.

+
+
+
+ + setNumber('screenshot_quality', next)} + /> +
+ + ) : null} + + {selection === 'limits' ? ( + <> +
+ +
+ setNumber('console_buffer', next)} + /> + setNumber('network_buffer', next)} + /> +
+
+
+ + setNumber('max_snapshot_nodes', next)} + /> +
+ + ) : null} + + {selection === 'behavior' ? ( + <> +
+ +
+ setNumber('default_timeout_ms', next)} + /> + setNumber('max_timeout_ms', next)} + /> +
+ setNumber('idle_stop_ms', next)} + /> +
+
+ + typeof scheme === 'string') + : [...DEFAULTS.allowed_schemes] + } + error={fieldError(errors, 'allowed_schemes')} + onChange={(schemes) => onChange({ ...value, allowed_schemes: schemes })} + /> +
+ + ) : null} +
+
+ ) +} + +export function BrowserConfigForm(props: ConfigFormProps) { + const value = asObject(props.value) + const [rootRef, narrow] = useContainerNarrow(CONFIG_NARROW_BELOW) + const [selection, setSelection] = useState('launch') + const [narrowPane, setNarrowPane] = useState<'nav' | 'editor'>('nav') + const domRef = useRef(null) + const focusKey = props.focusField?.join('/') ?? '' + + const setRoot = (node: HTMLDivElement | null) => { + rootRef(node) + domRef.current = node + } + + const choose = (section: SectionId) => { + setSelection(section) + setNarrowPane('editor') + } + + useEffect(() => { + const field = props.focusField?.[0] + if (!field) return + setSelection(FIELD_SECTION[field] ?? 'launch') + setNarrowPane('editor') + }, [focusKey]) + + useEffect(() => { + if (!focusKey || !domRef.current) return + const field = props.focusField?.[0] ?? focusKey + const target = domRef.current.querySelector(`[data-field="${CSS.escape(field)}"]`) + target?.focus() + target?.scrollIntoView({ block: 'center' }) + }, [focusKey, selection]) + + const showNav = !narrow || narrowPane === 'nav' + const showEditor = !narrow || narrowPane === 'editor' + + return ( +
+
+ {showNav ? : null} + {showEditor ? ( + setNarrowPane('nav')} + onChange={props.onChange} + /> + ) : null} +
+ + {props.errors && props.errors.size > 0 ? ( + + ) : null} +
+ ) +} diff --git a/browser/ui/src/function-trigger-message/index.tsx b/browser/ui/src/function-trigger-message/index.tsx index d405de7fe..a458c528e 100644 --- a/browser/ui/src/function-trigger-message/index.tsx +++ b/browser/ui/src/function-trigger-message/index.tsx @@ -80,6 +80,23 @@ function ScreenshotBody({ output }: { output: unknown }) { ) } +function renderScreenshot( + message: FunctionTriggerMessage, +): React.ReactNode | null { + if ( + message.functionId !== 'browser::screenshot' || + message.pendingApproval || + message.running || + message.output == null + ) { + return null + } + if (parseInfraErrorDisplay(message.output)) return null + const screenshot = parseScreenshotOutput(message.output) + if (!screenshot?.dataUrl) return null + return +} + /** * Per-function pretty body; null when the function is unknown or its * payload doesn't parse, in which case the caller falls back to the @@ -195,3 +212,20 @@ export function createBrowserRenderer(_host: Host): FunctionTriggerRenderer { FunctionIdLabel, } } + +/** + * Focused renderer that promotes successful screenshots into the chat flow. + * Keeping it separate means other browser calls retain the compact card and + * a malformed/error response safely falls through to the general renderer. + */ +export function createBrowserScreenshotRenderer(): FunctionTriggerRenderer { + return { + id: 'browser/page.js#screenshot-display', + isMatch: (functionId) => functionId === 'browser::screenshot', + tryRender: renderScreenshot, + tryRenderRunning: () => null, + tryRenderPreview: () => null, + FunctionIdLabel, + metadata: { display: true }, + } +} diff --git a/browser/ui/src/lib/browser.ts b/browser/ui/src/lib/browser.ts index d73cd97f0..fa2eccc8e 100644 --- a/browser/ui/src/lib/browser.ts +++ b/browser/ui/src/lib/browser.ts @@ -16,6 +16,8 @@ export const BROWSER_SESSIONS_START_FUNCTION_ID = 'browser::sessions::start' export const BROWSER_SESSIONS_LIST_FUNCTION_ID = 'browser::sessions::list' export const BROWSER_SESSIONS_STOP_FUNCTION_ID = 'browser::sessions::stop' export const BROWSER_NAVIGATE_FUNCTION_ID = 'browser::navigate' +export const BROWSER_HISTORY_FUNCTION_ID = 'browser::history' +export const BROWSER_DOCTOR_FUNCTION_ID = 'browser::doctor' export const BROWSER_SCREENSHOT_FUNCTION_ID = 'browser::screenshot' export const BROWSER_ACT_FUNCTION_ID = 'browser::act' export const BROWSER_CONSOLE_READ_FUNCTION_ID = 'browser::console::read' @@ -62,6 +64,18 @@ export const sessionInfoSchema = z.object({ }) export type BrowserSessionInfo = z.infer +const doctorSchema = z.object({ + chromium_version: z.string().nullable().optional(), +}) +export type BrowserDoctorInfo = z.infer + +const historySchema = z.object({ + ok: z.boolean(), + url: z.string(), + moved: z.boolean(), +}) +export type BrowserHistoryResult = z.infer + const sessionListSchema = z.object({ sessions: z.array(z.unknown()).optional(), }) @@ -462,6 +476,27 @@ export async function navigateBrowser( }) } +export async function controlBrowserHistory( + iii: ExtensionIii, + sessionId: string, + action: 'back' | 'forward' | 'reload', +): Promise { + const res = await iii.trigger(BROWSER_HISTORY_FUNCTION_ID, { + session_id: sessionId, + action, + }) + const parsed = historySchema.safeParse(res) + return parsed.success ? parsed.data : null +} + +export async function readBrowserDoctor( + iii: ExtensionIii, +): Promise { + const res = await iii.trigger(BROWSER_DOCTOR_FUNCTION_ID, {}) + const parsed = doctorSchema.safeParse(res) + return parsed.success ? parsed.data : null +} + export async function takeBrowserScreenshot( iii: ExtensionIii, sessionId: string, diff --git a/browser/ui/src/lib/widgets.tsx b/browser/ui/src/lib/widgets.tsx index 72b9dcb12..46fbe0928 100644 --- a/browser/ui/src/lib/widgets.tsx +++ b/browser/ui/src/lib/widgets.tsx @@ -1,6 +1,39 @@ /** Small shared UI pieces + inline icons for the browser page. */ import { StatusDot } from '@iii-dev/console-ui' +import { useCallback, useRef, useState } from 'react' + +/** + * Container-driven responsive state for injected surfaces. The Console can + * place worker UI inside panes of any width, so viewport media queries are + * not a reliable signal for either the page or the configuration editor. + */ +export function useContainerNarrow(threshold: number): [(node: HTMLDivElement | null) => void, boolean] { + const [narrow, setNarrow] = useState(false) + const observerRef = useRef(null) + const ref = useCallback( + (node: HTMLDivElement | null) => { + observerRef.current?.disconnect() + observerRef.current = null + if (!node) return + + const width = node.getBoundingClientRect().width + if (width > 0) setNarrow(width < threshold) + + const observer = new ResizeObserver((entries) => { + const next = entries[0]?.contentRect.width + if (typeof next === 'number' && next > 0) { + setNarrow(next < threshold) + } + }) + observer.observe(node) + observerRef.current = observer + }, + [threshold], + ) + + return [ref, narrow] +} /** Header live / polling indicator for the session feed. */ export function LivePill({ live }: { live: boolean }) { @@ -20,21 +53,9 @@ export function LivePill({ live }: { live: boolean }) { } /** Narrow-mode drill-out affordance (session list ← workspace). */ -export function BackButton({ - onClick, - label, -}: { - onClick: () => void - label: string -}) { +export function BackButton({ onClick, label }: { onClick: () => void; label: string }) { return ( - ) @@ -61,9 +82,7 @@ export function RefreshButton({ title={label} disabled={disabled} > - + ) } diff --git a/browser/ui/src/page/ConsolePanel.tsx b/browser/ui/src/page/ConsolePanel.tsx index 22a619ce1..ebe6cfa35 100644 --- a/browser/ui/src/page/ConsolePanel.tsx +++ b/browser/ui/src/page/ConsolePanel.tsx @@ -1,14 +1,15 @@ -import { Input, type Host } from '@iii-dev/console-ui' +import { type Host, Input } from '@iii-dev/console-ui' import { useEffect, useRef, useState } from 'react' import { BROWSER_CONSOLE_EVENT_TRIGGER, type BrowserConsoleEntry, errorMessage, + formatTime, parseConsoleEvent, readBrowserConsole, } from '../lib/browser' +import { cn } from '../lib/cn' import { useBrowserSessionEvent } from '../lib/events' -import { ConsoleEntryRow } from '../function-trigger-message/BrowserViews' /** * Live console feed for the selected session: seeded from @@ -24,6 +25,8 @@ const MAX_ENTRIES = 500 const PATTERN_DEBOUNCE_MS = 300 const CONSOLE_FEED_FN = 'iii::browser-ui::console-feed' +const CONSOLE_LEVELS = ['all', 'debug', 'info', 'warning', 'error'] as const +type ConsoleLevel = (typeof CONSOLE_LEVELS)[number] function matchesPattern(text: string, pattern: string): boolean { if (!pattern) return true @@ -34,6 +37,26 @@ function matchesPattern(text: string, pattern: string): boolean { } } +function matchesLevel(entryLevel: string, level: ConsoleLevel): boolean { + if (level === 'all') return true + if (level === 'error') return entryLevel === 'error' || entryLevel === 'exception' + return entryLevel === level +} + +function ConsoleLiveRow({ entry }: { entry: BrowserConsoleEntry }) { + const tone = + entry.level === 'error' || entry.level === 'exception' ? 'error' : entry.level === 'warning' ? 'warning' : 'info' + return ( +
  • + + {formatTime(entry.timestamp)} + [{entry.level}] + {entry.text} + {entry.source ?? '—'} +
  • + ) +} + interface ConsolePanelProps { host: Host sessionId: string @@ -43,18 +66,18 @@ interface ConsolePanelProps { export function ConsolePanel({ host, sessionId, enabled }: ConsolePanelProps) { const [pattern, setPattern] = useState('') const [debouncedPattern, setDebouncedPattern] = useState('') + const [level, setLevel] = useState('all') const [entries, setEntries] = useState([]) const [dropped, setDropped] = useState(0) const [error, setError] = useState(null) const lastSeqRef = useRef(0) const patternRef = useRef('') patternRef.current = debouncedPattern + const levelRef = useRef('all') + levelRef.current = level useEffect(() => { - const id = window.setTimeout( - () => setDebouncedPattern(pattern.trim()), - PATTERN_DEBOUNCE_MS, - ) + const id = window.setTimeout(() => setDebouncedPattern(pattern.trim()), PATTERN_DEBOUNCE_MS) return () => window.clearTimeout(id) }, [pattern]) @@ -65,6 +88,7 @@ export function ConsolePanel({ host, sessionId, enabled }: ConsolePanelProps) { setEntries([]) void readBrowserConsole(host.iii, sessionId, { pattern: debouncedPattern || undefined, + level: level === 'all' ? undefined : level, limit: SEED_LIMIT, }) .then((res) => { @@ -81,7 +105,7 @@ export function ConsolePanel({ host, sessionId, enabled }: ConsolePanelProps) { return () => { cancelled = true } - }, [host, enabled, sessionId, debouncedPattern]) + }, [host, enabled, sessionId, debouncedPattern, level]) useBrowserSessionEvent({ host, @@ -95,6 +119,7 @@ export function ConsolePanel({ host, sessionId, enabled }: ConsolePanelProps) { if (evt.entry.seq <= lastSeqRef.current) return lastSeqRef.current = evt.entry.seq if (!matchesPattern(evt.entry.text, patternRef.current)) return + if (!matchesLevel(evt.entry.level, levelRef.current)) return setEntries((cur) => [...cur.slice(-(MAX_ENTRIES - 1)), evt.entry]) }, }) @@ -102,7 +127,10 @@ export function ConsolePanel({ host, sessionId, enabled }: ConsolePanelProps) { return (
    + {sessionId} + - {dropped > 0 ? ( - - {dropped} older entries dropped from the buffer - - ) : null} + + + {entries.length} {entries.length === 1 ? 'entry' : 'entries'} + + {dropped > 0 ? {dropped} older entries dropped from the buffer : null} +
    {error ? (

    {error}

    ) : entries.length === 0 ? ( -

    no console entries yet

    +

    No console entries yet.

    ) : ( // Column-reverse with the newest entry first in the DOM pins the // scroll position to the bottom, terminal-style.
      {[...entries].reverse().map((entry) => ( - + ))}
    )} diff --git a/browser/ui/src/page/NetworkPanel.tsx b/browser/ui/src/page/NetworkPanel.tsx index 73a446fe7..62e7d88f5 100644 --- a/browser/ui/src/page/NetworkPanel.tsx +++ b/browser/ui/src/page/NetworkPanel.tsx @@ -1,4 +1,4 @@ -import type { Host } from '@iii-dev/console-ui' +import { type Host, Input } from '@iii-dev/console-ui' import { useEffect, useRef, useState } from 'react' import { BROWSER_NETWORK_EVENT_TRIGGER, @@ -20,6 +20,7 @@ import { useBrowserSessionEvent } from '../lib/events' const SEED_LIMIT = 200 const MAX_ENTRIES = 500 +const PATTERN_DEBOUNCE_MS = 300 const NETWORK_FEED_FN = 'iii::browser-ui::network-feed' @@ -30,6 +31,8 @@ interface NetworkPanelProps { } export function NetworkPanel({ host, sessionId, enabled }: NetworkPanelProps) { + const [pattern, setPattern] = useState('') + const [debouncedPattern, setDebouncedPattern] = useState('') const [failedOnly, setFailedOnly] = useState(false) const [entries, setEntries] = useState([]) const [dropped, setDropped] = useState(0) @@ -37,13 +40,24 @@ export function NetworkPanel({ host, sessionId, enabled }: NetworkPanelProps) { const lastSeqRef = useRef(0) const failedOnlyRef = useRef(false) failedOnlyRef.current = failedOnly + const patternRef = useRef('') + patternRef.current = debouncedPattern + + useEffect(() => { + const id = window.setTimeout(() => setDebouncedPattern(pattern.trim()), PATTERN_DEBOUNCE_MS) + return () => window.clearTimeout(id) + }, [pattern]) useEffect(() => { if (!enabled) return let cancelled = false lastSeqRef.current = 0 setEntries([]) - void readBrowserNetwork(host.iii, sessionId, { failedOnly, limit: SEED_LIMIT }) + void readBrowserNetwork(host.iii, sessionId, { + pattern: debouncedPattern || undefined, + failedOnly, + limit: SEED_LIMIT, + }) .then((res) => { if (cancelled || !res) return setEntries(res.entries) @@ -58,7 +72,7 @@ export function NetworkPanel({ host, sessionId, enabled }: NetworkPanelProps) { return () => { cancelled = true } - }, [host, enabled, sessionId, failedOnly]) + }, [host, enabled, sessionId, failedOnly, debouncedPattern]) useBrowserSessionEvent({ host, @@ -72,6 +86,13 @@ export function NetworkPanel({ host, sessionId, enabled }: NetworkPanelProps) { if (evt.entry.seq <= lastSeqRef.current) return lastSeqRef.current = evt.entry.seq if (failedOnlyRef.current && !evt.entry.failed) return + if (patternRef.current) { + try { + if (!new RegExp(patternRef.current, 'i').test(evt.entry.url)) return + } catch { + if (!evt.entry.url.toLowerCase().includes(patternRef.current.toLowerCase())) return + } + } setEntries((cur) => [...cur.slice(-(MAX_ENTRIES - 1)), evt.entry]) }, }) @@ -79,51 +100,72 @@ export function NetworkPanel({ host, sessionId, enabled }: NetworkPanelProps) { return (
    + {sessionId} + + + + {entries.length} {entries.length === 1 ? 'request' : 'requests'} + {dropped > 0 ? ( - - {dropped} older requests dropped from the buffer - + {dropped} older requests dropped from the buffer ) : null} +
    {error ? (

    {error}

    ) : entries.length === 0 ? ( -

    - {failedOnly ? 'no failed requests' : 'no requests yet'} -

    +

    {failedOnly ? 'No failed requests.' : 'No requests yet.'}

    ) : ( -
      - {[...entries].reverse().map((entry) => ( -
    • - - {formatTime(entry.timestamp)} - - - {entry.status ?? (entry.failed ? 'err' : '...')} - - {entry.method} - - {entry.url} - {entry.error ? ( - · {entry.error} - ) : null} - - {entry.mime_type ? ( - {entry.mime_type} - ) : null} -
    • - ))} -
    +
    +
    + Time + Status + Method + Request + Type +
    +
      + {[...entries].reverse().map((entry) => ( +
    • + {formatTime(entry.timestamp)} + + {entry.status ?? (entry.failed ? 'err' : '...')} + + {entry.method} + + {entry.url} + {entry.error ? · {entry.error} : null} + + {entry.mime_type ?? '—'} +
    • + ))} +
    +
    )}
    ) diff --git a/browser/ui/src/page/SessionRail.tsx b/browser/ui/src/page/SessionRail.tsx index 0cee43f49..20e399ed9 100644 --- a/browser/ui/src/page/SessionRail.tsx +++ b/browser/ui/src/page/SessionRail.tsx @@ -29,12 +29,7 @@ function hostOf(url: string): string { } } -export function SessionRail({ - sessions, - selectedId, - loading, - onSelect, -}: SessionRailProps) { +export function SessionRail({ sessions, selectedId, loading, onSelect }: SessionRailProps) { if (sessions.length === 0) { if (loading) { return ( @@ -51,10 +46,7 @@ export function SessionRail({ return (

    No sessions yet.

    -

    - Sessions started by agents appear in this list live; new session - starts one now. -

    +

    Sessions started by agents appear in this list live; new session starts one now.

    ) } @@ -77,13 +69,13 @@ export function SessionRail({ {session.title?.trim() || hostOf(session.url) || 'about:blank'} + {session.headless ? 'headless' : 'headful'} {session.url} - {session.session_id} - · - {session.headless ? 'headless' : 'headful'} - · + + live + · {formatMtime(Math.floor(session.last_used_ms / 1000))} diff --git a/browser/ui/src/page/SessionView.tsx b/browser/ui/src/page/SessionView.tsx index f7dc59734..7dc495f63 100644 --- a/browser/ui/src/page/SessionView.tsx +++ b/browser/ui/src/page/SessionView.tsx @@ -26,6 +26,7 @@ import { type BrowserPickedEvent, type BrowserSessionInfo, clickBrowserAt, + controlBrowserHistory, errorMessage, formatPickedElement, hintBrowserPick, @@ -42,8 +43,9 @@ import { } from '../lib/browser' import { cn } from '../lib/cn' import { useBrowserSessionEvent } from '../lib/events' -import { Crosshair, Square, X } from '../lib/icons' -import { BackButton } from '../lib/widgets' +import { formatMtime } from '../lib/format' +import { Crosshair, ExternalLink, Globe, RefreshCw, X } from '../lib/icons' +import { BackButton, ChevronLeftIcon } from '../lib/widgets' import { ConsolePanel } from './ConsolePanel' import { NetworkPanel } from './NetworkPanel' import { useLiveFrames } from './useLiveFrames' @@ -87,6 +89,7 @@ function hostOf(url: string): string { interface SessionViewProps { host: Host session: BrowserSessionInfo + chromiumVersion: string | null enabled: boolean narrow: boolean /** Stable workspace-tab id — namespaces persisted UI state. */ @@ -99,6 +102,7 @@ interface SessionViewProps { export function SessionView({ host, session, + chromiumVersion, enabled, narrow, tabId, @@ -117,9 +121,22 @@ export function SessionView({ const [dockPane, setDockPaneState] = useState(() => readStored(dockStoreKey) === 'network' ? 'network' : 'console', ) + const dockCollapsedStoreKey = `browser-ui:${tabId || 'page'}:dock-collapsed` + const [dockCollapsed, setDockCollapsedState] = useState(() => readStored(dockCollapsedStoreKey) === 'true') const setDockPane = (pane: FeedPane) => { setDockPaneState(pane) writeStored(dockStoreKey, pane) + if (dockCollapsed) { + setDockCollapsedState(false) + writeStored(dockCollapsedStoreKey, 'false') + } + } + const toggleDock = () => { + setDockCollapsedState((current) => { + const next = !current + writeStored(dockCollapsedStoreKey, String(next)) + return next + }) } // The screencast subscription is gated on the viewport actually being @@ -147,7 +164,6 @@ export function SessionView({ lastSessionUrlRef.current = session.url if (!urlFocusedRef.current) setUrlDraft(session.url) }, [session.url]) - // biome-ignore lint/correctness/useExhaustiveDependencies: reset the draft when the selected session changes, not when its url does useEffect(() => { setUrlDraft(session.url) lastSessionUrlRef.current = session.url @@ -165,6 +181,23 @@ export function SessionView({ }) }, [host, urlDraft, sessionId, runAction, onSessionsRefresh]) + const handleHistory = useCallback( + (action: 'back' | 'forward' | 'reload') => { + void runAction(async () => { + const result = await controlBrowserHistory(host.iii, sessionId, action) + if (result?.url) setUrlDraft(result.url) + onSessionsRefresh() + }) + }, + [host, sessionId, runAction, onSessionsRefresh], + ) + + const openCurrentPage = useCallback(() => { + let url = urlDraft.trim() || session.url + if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) url = `https://${url}` + if (url) window.open(url, '_blank', 'noopener,noreferrer') + }, [session.url, urlDraft]) + // Pick-to-clipboard. The worker auto-exits inspect mode after one pick, so // a received event only flips local state; explicit toggles and unmounts // send pick::stop. @@ -219,9 +252,7 @@ export function SessionView({ setLastPicked(evt) // No composer slot in injected UI: copy the summary for the user to // paste into chat. - void navigator.clipboard - ?.writeText(formatPickedElement(evt)) - .catch(() => {}) + void navigator.clipboard?.writeText(formatPickedElement(evt)).catch(() => {}) setPicking(false) }, }) @@ -310,33 +341,37 @@ export function SessionView({ }) }, [host, sessionId, runAction, onSessionsRefresh, onStopped]) - const displayName = - session.title?.trim() || hostOf(session.url) || 'about:blank' - const feedPane: FeedPane = narrow - ? narrowPane === 'network' - ? 'network' - : 'console' - : dockPane + const displayName = session.title?.trim() || hostOf(session.url) || 'about:blank' + const feedPane: FeedPane = narrow ? (narrowPane === 'network' ? 'network' : 'console') : dockPane + const browserMajor = chromiumVersion?.match(/\d+/)?.[0] + const browserLabel = browserMajor ? `Chromium ${browserMajor}` : null return ( -
    +
    - {narrow ? ( - - ) : null} + {narrow ? : null}
    - - {displayName} - - {!narrow ? ( - - {sessionId} · {session.headless ? 'headless' : 'headful'} ·{' '} - {session.url} +
    + + {displayName} - ) : null} + {session.headless ? 'headless' : 'headful'} + {!narrow && browserLabel ? {browserLabel} : null} +
    + + {session.url} + · + + + live + + {!narrow ? ( + <> + · + started {formatMtime(Math.floor(session.created_ms / 1000))} + + ) : null} +
    -
    -
    - { - urlFocusedRef.current = true - }} - onBlur={() => { - urlFocusedRef.current = false - }} - onKeyDown={(e) => { - if (e.key === 'Enter') submitUrl() - }} - className="br-ui-url-input" - /> - -
    - {lastPicked ? (
    picked - + {lastPicked.element.ref} - - {pickedSelector(lastPicked.element)} - + {pickedSelector(lastPicked.element)}
    @@ -432,7 +434,7 @@ export function SessionView({ aria-pressed={narrowPane === pane} onClick={() => setNarrowPane(pane)} > - {pane} + {pane === 'console' ? 'Console' : pane === 'network' ? 'Network' : 'Viewport'} ))}
    @@ -441,18 +443,87 @@ export function SessionView({ {viewportShown ? (
    - +
    +
    { + event.preventDefault() + submitUrl() + }} + > +
    + + + +
    +
    + + { + urlFocusedRef.current = true + }} + onBlur={() => { + urlFocusedRef.current = false + }} + className="br-ui-url-input" + /> +
    + + +
    + +
    ) : (
    @@ -465,7 +536,7 @@ export function SessionView({ )} {!narrow ? ( -
    +
    {/* biome-ignore lint/a11y/useSemanticElements: segmented control of buttons; fieldset chrome (min-content sizing) breaks the row */}
    @@ -477,51 +548,63 @@ export function SessionView({ aria-pressed={dockPane === pane} onClick={() => setDockPane(pane)} > - {pane} + {pane === 'console' ? 'Console' : 'Network'} ))}
    +
    -
    - {dockPane === 'console' ? ( - - ) : ( - - )} -
    + {!dockCollapsed ? ( +
    + {dockPane === 'console' ? ( + + ) : ( + + )} +
    + ) : null}
    ) : null}
    - {sessionId} {live.frame ? ( - {live.frame.width}x{live.frame.height} + Viewport: {live.frame.width}×{live.frame.height} - ) : null} - - {session.headless ? 'headless' : 'headful'} + ) : ( + Viewport: — + )} + {session.headless ? 'Headless' : 'Headful'} + {browserLabel ? {browserLabel} : null} + + + live {viewportShown ? ( picking ? ( - - pick mode: click an element to copy it — esc cancels - + pick mode: click an element to copy it — esc cancels ) : ( <> - - click to focus — clicks, scroll and typing forward to the page - - shift+esc leaves the surface + Click to focus + Scroll or type to interact + Shift+Esc to release ) ) : null} diff --git a/browser/ui/src/page/Viewport.tsx b/browser/ui/src/page/Viewport.tsx index da4e071f8..b89b49cbf 100644 --- a/browser/ui/src/page/Viewport.tsx +++ b/browser/ui/src/page/Viewport.tsx @@ -1,9 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { - type BrowserClickOptions, - type BrowserPickHint, - elementLabel, -} from '../lib/browser' +import { type BrowserClickOptions, type BrowserPickHint, elementLabel } from '../lib/browser' import { cn } from '../lib/cn' import type { LiveFrame } from './useLiveFrames' @@ -51,6 +47,35 @@ interface HintDisplay { dims: string } +interface RenderedImageRect { + left: number + top: number + width: number + height: number +} + +/** + * `object-fit: contain` paints the screenshot inside the image element's + * content box and can leave horizontal or vertical letterboxing. DOM APIs + * only expose the element box, so derive the centered painted rect from the + * frame dimensions before translating pointer coordinates. + */ +function renderedImageRect(img: HTMLImageElement, frameWidth: number, frameHeight: number): RenderedImageRect | null { + if (frameWidth <= 0 || frameHeight <= 0) return null + const box = img.getBoundingClientRect() + if (box.width <= 0 || box.height <= 0) return null + + const scale = Math.min(box.width / frameWidth, box.height / frameHeight) + const width = frameWidth * scale + const height = frameHeight * scale + return { + left: box.left + (box.width - width) / 2, + top: box.top + (box.height - height) / 2, + width, + height, + } +} + interface ViewportProps { frame: LiveFrame | null loading: boolean @@ -87,25 +112,22 @@ export function Viewport({ onScrollAtRef.current = onScrollAt /** Client point -> page-viewport point, null outside the rendered image. */ - const mapToPage = useCallback( - (clientX: number, clientY: number): { x: number; y: number } | null => { - const current = frameRef.current - const img = imgRef.current - if (!current || !img || current.width <= 0 || current.height <= 0) { - return null - } - const rect = img.getBoundingClientRect() - if (rect.width <= 0 || rect.height <= 0) return null - const relX = (clientX - rect.left) / rect.width - const relY = (clientY - rect.top) / rect.height - if (relX < 0 || relX > 1 || relY < 0 || relY > 1) return null - return { - x: Math.round(relX * current.width), - y: Math.round(relY * current.height), - } - }, - [], - ) + const mapToPage = useCallback((clientX: number, clientY: number): { x: number; y: number } | null => { + const current = frameRef.current + const img = imgRef.current + if (!current || !img || current.width <= 0 || current.height <= 0) { + return null + } + const rect = renderedImageRect(img, current.width, current.height) + if (!rect) return null + const relX = (clientX - rect.left) / rect.width + const relY = (clientY - rect.top) / rect.height + if (relX < 0 || relX > 1 || relY < 0 || relY > 1) return null + return { + x: Math.min(current.width - 1, Math.round(relX * current.width)), + y: Math.min(current.height - 1, Math.round(relY * current.height)), + } + }, []) // Single vs double click: a first click waits out the double-click window // so a dblclick can replace it with one click_count:2 act. Pick mode skips @@ -269,9 +291,9 @@ export function Viewport({ setHint(null) return } - const imgRect = img.getBoundingClientRect() + const imgRect = renderedImageRect(img, current.width, current.height) const surfaceRect = surface.getBoundingClientRect() - if (imgRect.width <= 0 || imgRect.height <= 0) { + if (!imgRect) { setHint(null) return } @@ -329,11 +351,7 @@ export function Viewport({ /> ) : (

    - {error - ? `live view failed: ${error}` - : loading - ? 'waiting for the first frame...' - : 'no frame yet'} + {error ? `live view failed: ${error}` : loading ? 'waiting for the first frame...' : 'no frame yet'}

    )} {hint ? ( @@ -347,12 +365,7 @@ export function Viewport({ height: hint.height, }} > - = 22 ? 'above' : 'below', - )} - > + = 22 ? 'above' : 'below')}> {hint.label} {hint.dims} diff --git a/browser/ui/src/page/index.tsx b/browser/ui/src/page/index.tsx index fac5781d3..8ea9dc71a 100644 --- a/browser/ui/src/page/index.tsx +++ b/browser/ui/src/page/index.tsx @@ -20,17 +20,12 @@ * SessionView), so a narrow pane parked on the list streams nothing. */ -import { - Button, - type Host, - PageHeader, - type PageRenderProps, - PageShell, -} from '@iii-dev/console-ui' +import { Button, type Host, PageHeader, type PageRenderProps, PageShell } from '@iii-dev/console-ui' +import type { ComponentType } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { errorMessage, startBrowserSession } from '../lib/browser' +import { errorMessage, readBrowserDoctor, startBrowserSession } from '../lib/browser' import { Plus } from '../lib/icons' -import { GlobeIcon, LivePill, RefreshButton } from '../lib/widgets' +import { GlobeIcon, LivePill, RefreshButton, useContainerNarrow } from '../lib/widgets' import { SessionRail } from './SessionRail' import { SessionView } from './SessionView' import { useBrowserSessionsLive } from './useBrowserSessionsLive' @@ -39,44 +34,35 @@ import { useBrowserSessionsLive } from './useBrowserSessionsLive' * session-list ⇄ workspace flow. */ const NARROW_BELOW = 720 -/** Observe the page body's own width. Returns a callback ref to put on the - * body row plus whether it is currently narrower than `threshold` — - * container-driven, so the same page adapts inside any pane the console - * gives it. Measures synchronously on mount to avoid a wide-mode flash; - * zero widths (display:none) are ignored so a hidden page keeps its last - * real layout. */ -function useContainerNarrow(threshold: number): [(node: HTMLDivElement | null) => void, boolean] { - const [narrow, setNarrow] = useState(false) - const observerRef = useRef(null) - const refCb = useCallback( - (node: HTMLDivElement | null) => { - observerRef.current?.disconnect() - observerRef.current = null - if (!node) return - const width = node.getBoundingClientRect().width - if (width > 0) setNarrow(width < threshold) - const observer = new ResizeObserver((entries) => { - const next = entries[0]?.contentRect.width - if (typeof next === 'number' && next > 0) setNarrow(next < threshold) - }) - observer.observe(node) - observerRef.current = observer - }, - [threshold], - ) - return [refCb, narrow] -} - export function BrowserPage({ host, panelSide = 'left', tabId = '', onRequestClose, }: { host: Host } & Partial) { - const { sessions, loading, error, live, refresh } = useBrowserSessionsLive( - host, - true, - ) + const [configOpen, setConfigOpen] = useState(false) + const ConfigurationDialog = host.components.WorkerConfigurationDialog as + | ComponentType<{ + configurationId: string | null + onClose: () => void + }> + | undefined + const { sessions, loading, error, live, refresh } = useBrowserSessionsLive(host, true) + const [chromiumVersion, setChromiumVersion] = useState(null) + + useEffect(() => { + let cancelled = false + void readBrowserDoctor(host.iii) + .then((doctor) => { + if (!cancelled) setChromiumVersion(doctor?.chromium_version ?? null) + }) + .catch(() => { + // Version is useful context, not a requirement for operating a session. + }) + return () => { + cancelled = true + } + }, [host]) const [selectedId, setSelectedId] = useState(null) const [rootRef, narrow] = useContainerNarrow(NARROW_BELOW) @@ -105,10 +91,7 @@ export function BrowserPage({ }) }, [loading, sessions]) - const selected = useMemo( - () => sessions.find((s) => s.session_id === selectedId) ?? null, - [sessions, selectedId], - ) + const selected = useMemo(() => sessions.find((s) => s.session_id === selectedId) ?? null, [sessions, selectedId]) // The drilled-into session can die underneath us (stopped from chat or // another tab): drill back out to the list rather than silently showing @@ -154,9 +137,18 @@ export function BrowserPage({ } - title="browser" + title="Browser" description="live Chromium sessions you can watch and drive" - actions={} + actions={ +
    + + {ConfigurationDialog ? ( + + ) : null} +
    + } onClose={onRequestClose} /> @@ -172,46 +164,30 @@ export function BrowserPage({
    ) : null} -
    +
    {railVisible ? ( ) : null} @@ -224,6 +200,7 @@ export function BrowserPage({ key={selected.session_id} host={host} session={selected} + chromiumVersion={chromiumVersion} enabled narrow={narrow} tabId={tabId} @@ -238,17 +215,19 @@ export function BrowserPage({
    -

    no browser sessions

    +

    No browser sessions

    - sessions started by agents appear here automatically. start - one yourself with new session, or ask an agent to call - browser::sessions::start. + Sessions started by agents appear here automatically. Start one from the session rail, or ask an agent + to call browser::sessions::start.

    ) ) : null}
    + {ConfigurationDialog ? ( + setConfigOpen(false)} /> + ) : null} ) } diff --git a/browser/ui/styles.css b/browser/ui/styles.css index 83fe6797b..8d02bdacc 100644 --- a/browser/ui/styles.css +++ b/browser/ui/styles.css @@ -47,6 +47,12 @@ font-family: var(--font-sans, system-ui, sans-serif); } +[data-iii-ui="browser"] .br-ui-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + /* live / polling indicator (header actions) */ [data-iii-ui="browser"] .br-ui-live { display: inline-flex; @@ -126,7 +132,7 @@ /* ── navigation rail ────────────────────────────────────────────────── */ [data-iii-ui="browser"] .br-ui-rail { - width: 280px; + width: 300px; flex-shrink: 0; display: flex; flex-direction: column; @@ -152,10 +158,43 @@ display: flex; flex-direction: column; gap: 8px; - padding: 12px; + padding: 14px; flex-shrink: 0; border-bottom: 1px solid var(--color-edge); } + +[data-iii-ui="browser"] .br-ui-rail-intro { + display: flex; + align-items: flex-start; + gap: 10px; +} + +[data-iii-ui="browser"] .br-ui-rail-intro-copy { + flex: 1; + min-width: 0; +} + +[data-iii-ui="browser"] .br-ui-rail-intro h2, +[data-iii-ui="browser"] .br-ui-rail-intro p { + margin: 0; +} + +[data-iii-ui="browser"] .br-ui-rail-intro h2 { + color: var(--color-ink); + font-size: 14px; + font-weight: 600; +} + +[data-iii-ui="browser"] .br-ui-rail-intro p { + padding-top: 3px; + color: var(--color-ink-faint); + font-size: 10.5px; + line-height: 1.45; +} + +[data-iii-ui="browser"] .br-ui-rail-intro button { + flex-shrink: 0; +} [data-iii-ui="browser"] .br-ui-rail-err { margin: 0; font-size: 12px; @@ -223,7 +262,7 @@ flex: 1; min-height: 0; overflow-y: auto; - padding: 4px 6px 12px; + padding: 10px 10px 14px; } /* session rows — the whole row is the button */ @@ -233,24 +272,26 @@ padding: 0; display: flex; flex-direction: column; - gap: 1px; + gap: 8px; } [data-iii-ui="browser"] .br-ui-rail-row { position: relative; display: flex; flex-direction: column; - gap: 3px; + gap: 6px; width: 100%; - padding: 8px 10px 8px 14px; + padding: 11px 12px; text-align: left; background: transparent; - border: 0; - border-radius: 6px; + border: 1px solid var(--color-edge); + border-radius: 8px; + background: color-mix(in srgb, var(--color-panel-raised) 58%, transparent); color: var(--color-ink); cursor: pointer; font-family: inherit; } [data-iii-ui="browser"] .br-ui-rail-row:hover { + border-color: color-mix(in srgb, var(--color-ink-ghost) 58%, var(--color-edge)); background: var(--color-surface-hover); } [data-iii-ui="browser"] .br-ui-rail-row:focus-visible { @@ -259,17 +300,11 @@ } /* Selection = wash + accent indicator + stronger title, not color alone. */ [data-iii-ui="browser"] .br-ui-rail-row.active { - background: var(--color-surface-selected); + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 10%, var(--color-panel-raised)); } [data-iii-ui="browser"] .br-ui-rail-row.active::before { - content: ""; - position: absolute; - left: 4px; - top: 8px; - bottom: 8px; - width: 2px; - border-radius: 1px; - background: var(--color-accent); + content: none; } [data-iii-ui="browser"] .br-ui-rail-head { display: flex; @@ -289,12 +324,21 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; - font-weight: 500; + font-size: 13.5px; + font-weight: 600; color: var(--color-ink); } -[data-iii-ui="browser"] .br-ui-rail-row.active .br-ui-rail-title { - font-weight: 600; + +[data-iii-ui="browser"] .br-ui-rail-mode { + flex-shrink: 0; + padding: 2px 5px; + border: 0; + border-radius: 4px; + background: var(--color-panel); + color: var(--color-ink-ghost); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 9px; + line-height: 1; } [data-iii-ui="browser"] .br-ui-rail-url { display: block; @@ -318,6 +362,18 @@ color: var(--color-ink-ghost); font-variant-numeric: tabular-nums; } + +[data-iii-ui="browser"] .br-ui-rail-status-dot { + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 999px; + background: var(--color-ok, var(--color-accent)); +} + +[data-iii-ui="browser"] .br-ui-rail-meta-separator { + color: var(--color-edge); +} /* Touch-sized targets in the drill-in flow. */ [data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail-row { min-height: 44px; @@ -395,9 +451,9 @@ display: flex; align-items: center; flex-wrap: wrap; - gap: 8px 12px; - min-height: 44px; - padding: 6px 16px; + gap: 10px 16px; + min-height: 72px; + padding: 11px 16px; flex-shrink: 0; background: var(--color-panel-raised); border-bottom: 1px solid var(--color-edge); @@ -405,7 +461,7 @@ [data-iii-ui="browser"] .br-ui-doc-identity { display: flex; flex-direction: column; - gap: 1px; + gap: 5px; flex: 1; min-width: 140px; } @@ -414,11 +470,27 @@ align-items: center; gap: 8px; min-width: 0; - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 13px; + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 15px; font-weight: 600; color: var(--color-ink); } +[data-iii-ui="browser"] .br-ui-doc-title-row { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} +[data-iii-ui="browser"] .br-ui-doc-badge { + flex-shrink: 0; + padding: 3px 7px; + border-radius: 5px; + background: var(--color-surface); + color: var(--color-ink-ghost); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 9.5px; + line-height: 1.1; +} /* The text needs its own box — ellipsis doesn't reach into a flex row. */ [data-iii-ui="browser"] .br-ui-doc-name .txt { overflow: hidden; @@ -426,6 +498,10 @@ white-space: nowrap; } [data-iii-ui="browser"] .br-ui-doc-crumb { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; font-family: var(--font-mono, ui-monospace, monospace); font-size: 10.5px; color: var(--color-ink-ghost); @@ -433,6 +509,26 @@ text-overflow: ellipsis; white-space: nowrap; } +[data-iii-ui="browser"] .br-ui-doc-url { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="browser"] .br-ui-doc-live, +[data-iii-ui="browser"] .br-ui-statusbar .live { + display: inline-flex; + align-items: center; + gap: 5px; +} +[data-iii-ui="browser"] .br-ui-live-dot { + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 50%; + background: var(--color-ok, var(--color-accent)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-ok, var(--color-accent)) 12%, transparent); +} [data-iii-ui="browser"] .br-ui-doc-actions { display: flex; align-items: center; @@ -472,19 +568,15 @@ display: inline-flex; align-items: center; gap: 6px; - height: 28px; - padding: 0 10px; + height: 36px; + padding: 0 13px; font-family: var(--font-mono, ui-monospace, monospace); - font-size: 11px; + font-size: 11.5px; border: 1px solid var(--color-edge); border-radius: 6px; background: transparent; color: var(--color-ink-faint); cursor: pointer; - transition: - color 120ms ease, - border-color 120ms ease, - background-color 120ms ease; } [data-iii-ui="browser"] .br-ui-pick-btn:hover { color: var(--color-ink); @@ -500,21 +592,118 @@ border-color: var(--color-accent); } -/* url bar */ +[data-iii-ui="browser"] .br-ui-stop-btn { + min-height: 36px; + padding-inline: 13px; + border: 1px solid color-mix(in srgb, var(--color-alert) 80%, var(--color-edge)); + color: var(--color-alert); +} + +[data-iii-ui="browser"] .br-ui-stop-btn:hover { + background: var(--color-alert-muted); + color: var(--color-alert); +} + +/* browser chrome: address and navigation live inside the viewport frame */ [data-iii-ui="browser"] .br-ui-toolbar { display: flex; align-items: center; - gap: 8px; - padding: 8px 16px; + gap: 7px; + min-height: 52px; + padding: 8px 10px; flex-shrink: 0; border-bottom: 1px solid var(--color-edge); + background: var(--color-panel-raised); } [data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-toolbar { - padding: 8px 12px; + padding: 6px; +} +[data-iii-ui="browser"] .br-ui-address { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + height: 36px; + padding: 0 12px; + border: 1px solid var(--color-edge); + border-radius: 12px; + background: var(--color-surface); +} +[data-iii-ui="browser"] .br-ui-history-controls { + display: inline-flex; + align-items: center; + gap: 1px; + flex-shrink: 0; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +[data-iii-ui="browser"] .br-ui-chrome-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + flex-shrink: 0; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-ink-faint); + cursor: pointer; +} +[data-iii-ui="browser"] .br-ui-chrome-btn:hover { + background: var(--color-surface-hover); + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-chrome-btn:focus-visible { + outline: 2px solid var(--color-rule-focus); + outline-offset: -2px; +} +[data-iii-ui="browser"] .br-ui-chrome-icon { + width: 18px; + height: 18px; +} +[data-iii-ui="browser"] .br-ui-chrome-icon.is-forward { + transform: rotate(180deg); +} + +[data-iii-ui="browser"] .br-ui-address:focus-within { + border-color: var(--color-rule-focus); + outline: 2px solid color-mix(in srgb, var(--color-accent) 12%, transparent); + outline-offset: -1px; +} + +[data-iii-ui="browser"] .br-ui-address-icon { + flex-shrink: 0; + color: var(--color-ink-ghost); } + [data-iii-ui="browser"] .br-ui-url-input { flex: 1; min-width: 0; + height: 34px; + border: 0; + background: transparent; + font-size: 12.5px; +} + +[data-iii-ui="browser"] .br-ui-url-input:hover, +[data-iii-ui="browser"] .br-ui-url-input:focus { + border: 0; + background: transparent; + outline: none; + box-shadow: none; +} + +[data-iii-ui="browser"] .br-ui-address-submit { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); } /* picked-element strip (pick-to-clipboard result) */ @@ -609,9 +798,6 @@ letter-spacing: 0.05em; color: var(--color-ink-faint); cursor: pointer; - transition: - background-color 120ms ease, - color 120ms ease; } [data-iii-ui="browser"] .br-ui-seg-btn:hover { color: var(--color-ink); @@ -636,29 +822,51 @@ border-bottom: 1px solid var(--color-edge); } -/* ── viewport — the live surface, letterboxed in the workspace ──────── */ +/* ── viewport — one browser frame, sized from the live screencast ───── */ [data-iii-ui="browser"] .br-ui-stage-body { flex: 1; min-height: 0; min-width: 0; display: flex; - padding: 12px 16px; + align-items: stretch; + justify-content: center; + padding: 12px 14px 10px; + overflow: hidden; + background: var(--color-panel); } [data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-stage-body { padding: 8px; } + +[data-iii-ui="browser"] .br-ui-browser-frame { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + max-width: 1600px; + max-height: 100%; + min-width: 0; + overflow: hidden; + border: 1px solid var(--color-edge); + border-radius: 9px; + background: var(--color-panel-raised); + box-shadow: 0 10px 30px color-mix(in srgb, var(--color-bg) 24%, transparent); +} + [data-iii-ui="browser"] .br-ui-vp { position: relative; - flex: 1; + flex: 1 1 auto; + width: 100%; + aspect-ratio: auto; min-width: 0; - min-height: 0; + min-height: 180px; display: flex; align-items: center; justify-content: center; - background: var(--color-surface); - border: 1px solid var(--color-edge); - border-radius: 6px; + background: var(--color-sidebar); + border: 0; + border-radius: 0; overflow: hidden; cursor: default; outline: none; @@ -672,17 +880,18 @@ } [data-iii-ui="browser"] .br-ui-vp-img { display: block; - max-width: 100%; - max-height: 100%; - width: auto; - height: auto; + width: 100%; + height: 100%; object-fit: contain; + object-position: center; user-select: none; -webkit-user-drag: none; - border: 1px solid var(--color-edge); + border: 0; + outline: 1px solid var(--color-edge); + outline-offset: -1px; } [data-iii-ui="browser"] .br-ui-vp-img.is-picking { - border-color: var(--color-accent); + outline-color: var(--color-accent); } [data-iii-ui="browser"] .br-ui-vp-empty { margin: 0; @@ -734,21 +943,103 @@ [data-iii-ui="browser"] .br-ui-dock { flex-shrink: 0; - height: 38%; - min-height: 176px; display: flex; flex-direction: column; - border-top: 1px solid var(--color-edge); + height: 31%; + min-height: 176px; + max-height: 300px; + margin: 0 14px 12px; + overflow: hidden; + border: 1px solid var(--color-edge); + border-radius: 9px; + background: var(--color-panel); +} + +[data-iii-ui="browser"] .br-ui-dock.collapsed { + height: auto; + min-height: 0; + max-height: none; + margin-bottom: 10px; } + [data-iii-ui="browser"] .br-ui-dock-head { display: flex; align-items: center; gap: 8px; - padding: 6px 12px; + min-height: 42px; + padding: 0 8px 0 0; flex-shrink: 0; background: var(--color-panel-raised); border-bottom: 1px solid var(--color-edge); } + +[data-iii-ui="browser"] .br-ui-dock .br-ui-seg { + align-self: stretch; + gap: 0; + padding: 0; + border-radius: 0; + background: transparent; +} +[data-iii-ui="browser"] .br-ui-dock .br-ui-seg-btn { + position: relative; + height: 100%; + padding: 0 16px; + border-radius: 0; + text-transform: none; + letter-spacing: 0; + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 12px; +} +[data-iii-ui="browser"] .br-ui-dock .br-ui-seg-btn.active { + background: var(--color-surface-selected); + color: var(--color-accent); + box-shadow: inset 0 -2px 0 var(--color-accent); +} + +[data-iii-ui="browser"] .br-ui-dock.collapsed .br-ui-dock-head { + border-bottom: 0; +} + +[data-iii-ui="browser"] .br-ui-dock-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 30px; + height: 30px; + margin-left: auto; + padding: 0 7px 0 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-ink-ghost); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 10.5px; + cursor: pointer; +} + +[data-iii-ui="browser"] .br-ui-dock-toggle:hover { + background: var(--color-surface-hover); + color: var(--color-ink); +} + +[data-iii-ui="browser"] .br-ui-dock-toggle:focus-visible { + outline: 2px solid var(--color-rule-focus); + outline-offset: -2px; +} + +[data-iii-ui="browser"] .br-ui-dock-toggle-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + transform: rotate(-90deg); + transition: transform 120ms ease; +} + +[data-iii-ui="browser"] .br-ui-dock.collapsed .br-ui-dock-toggle-icon { + transform: rotate(90deg); +} + [data-iii-ui="browser"] .br-ui-dock-body { flex: 1; min-height: 0; @@ -774,18 +1065,83 @@ display: flex; align-items: center; gap: 8px; - padding: 6px 12px; + min-height: 38px; + padding: 5px 10px; border-bottom: 1px solid var(--color-edge); } [data-iii-ui="browser"] .br-ui-filter-input { - max-width: 280px; + flex: 1; + min-width: 120px; + max-width: 440px; + height: 28px; } -[data-iii-ui="browser"] .br-ui-panel-note { - font-size: 11px; +[data-iii-ui="browser"] .br-ui-devtools-context { + max-width: 110px; + overflow: hidden; color: var(--color-ink-ghost); + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; } -[data-iii-ui="browser"] .br-ui-panel-err { - margin: 0; +[data-iii-ui="browser"] .br-ui-devtools-separator { + width: 1px; + height: 20px; + flex-shrink: 0; + background: var(--color-edge); +} +[data-iii-ui="browser"] .br-ui-level-select { + height: 28px; + max-width: 120px; + padding: 0 24px 0 8px; + border: 1px solid var(--color-edge); + border-radius: 5px; + background: var(--color-surface); + color: var(--color-ink-faint); + font-family: inherit; + font-size: 10.5px; +} +[data-iii-ui="browser"] .br-ui-level-select:focus-visible { + outline: 2px solid var(--color-rule-focus); + outline-offset: 1px; +} +[data-iii-ui="browser"] .br-ui-devtools-action { + height: 28px; + margin-left: auto; + padding: 0 9px; + flex-shrink: 0; + border: 0; + border-left: 1px solid var(--color-edge); + background: transparent; + color: var(--color-ink-faint); + font-family: inherit; + font-size: 10.5px; + cursor: pointer; +} +[data-iii-ui="browser"] .br-ui-devtools-action:hover { + color: var(--color-ink); + background: var(--color-surface-hover); +} +[data-iii-ui="browser"] .br-ui-devtools-action:focus-visible { + outline: 2px solid var(--color-rule-focus); + outline-offset: -2px; +} + +[data-iii-ui="browser"] .br-ui-panel-count { + flex-shrink: 0; + color: var(--color-ink-ghost); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} +[data-iii-ui="browser"] .br-ui-panel-note { + min-width: 0; + overflow: hidden; + font-size: 11px; + color: var(--color-ink-ghost); + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="browser"] .br-ui-panel-err { + margin: 0; padding: 8px 12px; font-size: 12px; color: var(--color-alert); @@ -809,6 +1165,57 @@ display: flex; flex-direction: column-reverse; } +[data-iii-ui="browser"] .br-ui-devtools-row { + display: grid; + grid-template-columns: 8px 82px 74px minmax(220px, 1fr) minmax(90px, auto); + align-items: start; + min-width: 620px; + padding: 5px 10px; + border-bottom: 1px solid var(--color-rule-2); + color: var(--color-ink); + font-size: 11px; + line-height: 1.45; +} +[data-iii-ui="browser"] .br-ui-devtools-row > span { + min-width: 0; + padding-right: 8px; +} +[data-iii-ui="browser"] .br-ui-devtools-marker { + width: 6px; + height: 6px; + margin-top: 5px; + border: 1px solid var(--color-accent); + border-radius: 50%; +} +[data-iii-ui="browser"] .br-ui-devtools-row.is-warning .br-ui-devtools-marker { + border-color: var(--color-warn); + border-radius: 1px; + transform: rotate(45deg); +} +[data-iii-ui="browser"] .br-ui-devtools-row.is-error .br-ui-devtools-marker { + border-color: var(--color-alert); +} +[data-iii-ui="browser"] .br-ui-devtools-level { + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-devtools-row.is-warning .br-ui-devtools-level { + color: var(--color-warn); +} +[data-iii-ui="browser"] .br-ui-devtools-row.is-error .br-ui-devtools-level, +[data-iii-ui="browser"] .br-ui-devtools-row.is-error .br-ui-devtools-message { + color: var(--color-alert); +} +[data-iii-ui="browser"] .br-ui-devtools-message { + overflow-wrap: anywhere; + white-space: pre-wrap; +} +[data-iii-ui="browser"] .br-ui-devtools-source { + overflow: hidden; + color: var(--color-ink-ghost); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} [data-iii-ui="browser"] .br-ui-toggle { height: 26px; padding: 0 10px; @@ -819,10 +1226,6 @@ background: transparent; color: var(--color-ink-faint); cursor: pointer; - transition: - color 120ms ease, - border-color 120ms ease, - background-color 120ms ease; } [data-iii-ui="browser"] .br-ui-toggle:hover { color: var(--color-ink); @@ -839,23 +1242,56 @@ } /* network rows (live panel) */ -[data-iii-ui="browser"] .br-ui-nrow { +[data-iii-ui="browser"] .br-ui-network-table { display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow-x: auto; +} + +[data-iii-ui="browser"] .br-ui-network-table > .br-ui-feed { + min-width: 660px; +} + +[data-iii-ui="browser"] .br-ui-nhead, +[data-iii-ui="browser"] .br-ui-nrow { + display: grid; + grid-template-columns: 72px 52px 62px minmax(240px, 1fr) minmax(100px, auto); + min-width: 660px; +} + +[data-iii-ui="browser"] .br-ui-nhead { + flex-shrink: 0; + align-items: center; + padding: 5px 12px; + border-bottom: 1px solid var(--color-edge); + background: var(--color-panel-raised); + color: var(--color-ink-ghost); + font-size: 10px; + line-height: 1.4; +} + +[data-iii-ui="browser"] .br-ui-nrow { align-items: flex-start; - gap: 8px; padding: 4px 12px; border-top: 1px solid var(--color-rule-2); font-size: 12px; line-height: 1.55; } + +[data-iii-ui="browser"] .br-ui-nhead > span, +[data-iii-ui="browser"] .br-ui-nrow > span { + min-width: 0; + padding-right: 10px; +} [data-iii-ui="browser"] .br-ui-nrow-time { flex-shrink: 0; font-variant-numeric: tabular-nums; color: var(--color-ink-ghost); } [data-iii-ui="browser"] .br-ui-nrow-status { - flex-shrink: 0; - width: 42px; font-variant-numeric: tabular-nums; color: var(--color-ink-faint); } @@ -863,8 +1299,6 @@ color: var(--color-alert); } [data-iii-ui="browser"] .br-ui-nrow-method { - flex-shrink: 0; - width: 56px; color: var(--color-ink-faint); } [data-iii-ui="browser"] .br-ui-nrow-url { @@ -876,8 +1310,10 @@ color: var(--color-ink); } [data-iii-ui="browser"] .br-ui-nrow-mime { - flex-shrink: 0; + overflow: hidden; color: var(--color-ink-ghost); + text-overflow: ellipsis; + white-space: nowrap; } [data-iii-ui="browser"] .br-ui-alert { color: var(--color-alert); @@ -888,8 +1324,8 @@ display: flex; align-items: center; gap: 16px; - min-height: 26px; - padding: 4px 16px; + min-height: 34px; + padding: 6px 16px; flex-shrink: 0; background: var(--color-panel-raised); border-top: 1px solid var(--color-edge); @@ -941,6 +1377,65 @@ color: var(--color-ink-faint); } +[data-iii-ui="browser"] .br-ui-hero-body code { + color: var(--color-ink); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.92em; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail-intro { + align-items: center; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail-intro h2 { + font-size: 16px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail-intro p, +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail-empty, +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-panel-empty { + font-size: 14px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail-title, +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-url-input { + font-size: 16px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail-url, +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail-meta, +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-panel-note, +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-panel-count { + font-size: 13px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-address { + height: 44px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-url-input { + height: 42px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-chrome-btn { + height: 44px; + width: 38px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-browser-frame { + border-radius: 6px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-doc-actions { + width: 100%; + padding-left: 44px; +} + +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-pick-btn, +[data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-doc-actions > button { + min-height: 38px; +} + @media (prefers-reduced-motion: reduce) { [data-iii-ui="browser"] .br-ui-spin, [data-iii-ui="browser"] .br-ui-skel-row .bar { @@ -948,6 +1443,524 @@ } } +/* ── configuration workbench ───────────────────────────────────────── */ + +[data-iii-ui="browser"] .br-cfg { + display: flex; + flex: 1 1 auto; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + color: var(--color-ink); + font-family: var(--font-sans, system-ui, sans-serif); +} + +[data-iii-ui="browser"] .br-cfg-workbench { + display: flex; + flex: 1; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--color-panel); +} + +[data-iii-ui="browser"] .br-cfg-nav { + display: flex; + flex: 0 0 220px; + flex-direction: column; + min-width: 0; + border-right: 1px solid var(--color-edge); + background: var(--color-sidebar); +} + +[data-iii-ui="browser"] .br-cfg-nav-head { + padding: 15px 14px 13px; + border-bottom: 1px solid var(--color-edge); +} + +[data-iii-ui="browser"] .br-cfg-nav-head p { + margin: 0; +} + +[data-iii-ui="browser"] .br-cfg-nav-head > p:last-child { + padding-top: 5px; + color: var(--color-ink-faint); + font-size: 11px; + line-height: 1.5; +} + +[data-iii-ui="browser"] .br-cfg-nav-label { + color: var(--color-ink-faint); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +[data-iii-ui="browser"] .br-cfg-nav-list { + flex: 1; + min-height: 0; + margin: 0; + padding: 8px; + overflow-y: auto; + list-style: none; +} + +[data-iii-ui="browser"] .br-cfg-nav-row { + position: relative; + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + min-width: 0; + min-height: 62px; + padding: 9px 8px 9px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-ink-faint); + font: inherit; + text-align: left; + cursor: pointer; +} + +[data-iii-ui="browser"] .br-cfg-nav-row:hover { + background: var(--color-surface-hover); + color: var(--color-ink); +} + +[data-iii-ui="browser"] .br-cfg-nav-row.active { + background: var(--color-surface-selected); + color: var(--color-ink); +} + +[data-iii-ui="browser"] .br-cfg-nav-row.active::before { + position: absolute; + inset: 7px auto 7px -8px; + width: 2px; + border-radius: 0 2px 2px 0; + background: var(--color-accent); + content: ""; +} + +[data-iii-ui="browser"] .br-cfg-nav-copy { + display: flex; + flex: 1; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +[data-iii-ui="browser"] .br-cfg-nav-name, +[data-iii-ui="browser"] .br-cfg-nav-description, +[data-iii-ui="browser"] .br-cfg-nav-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-iii-ui="browser"] .br-cfg-nav-name { + color: inherit; + font-size: 12px; + font-weight: 500; + line-height: 17px; +} + +[data-iii-ui="browser"] .br-cfg-nav-description { + color: var(--color-ink-faint); + font-size: 10.5px; + line-height: 15px; +} + +[data-iii-ui="browser"] .br-cfg-nav-meta { + padding-top: 2px; + color: var(--color-ink-ghost); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 9.5px; + font-variant-numeric: tabular-nums; + line-height: 14px; +} + +[data-iii-ui="browser"] .br-cfg-nav-chevron, +[data-iii-ui="browser"] .br-cfg-editor-icon, +[data-iii-ui="browser"] .br-cfg-back svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +[data-iii-ui="browser"] .br-cfg-nav-chevron { + align-self: center; + color: var(--color-ink-ghost); + transform: rotate(180deg); +} + +[data-iii-ui="browser"] .br-cfg-nav-foot { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 11px 14px; + border-top: 1px solid var(--color-edge); + color: var(--color-ink-faint); + font-size: 10.5px; + line-height: 1.45; +} + +[data-iii-ui="browser"] .br-cfg-nav-foot-dot { + width: 6px; + height: 6px; + flex: 0 0 auto; + margin-top: 4px; + border-radius: 999px; + background: var(--color-accent); +} + +[data-iii-ui="browser"] .br-cfg-editor { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--color-panel); +} + +[data-iii-ui="browser"] .br-cfg-editor-head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: flex-start; + gap: 10px; + min-height: 62px; + padding: 12px 14px; + border-bottom: 1px solid var(--color-edge); + background: var(--color-panel-raised); +} + +[data-iii-ui="browser"] .br-cfg-editor-icon { + margin-top: 3px; + color: var(--color-accent); +} + +[data-iii-ui="browser"] .br-cfg-editor-title { + flex: 1; + min-width: 0; +} + +[data-iii-ui="browser"] .br-cfg-editor-title h3, +[data-iii-ui="browser"] .br-cfg-editor-title p { + overflow: hidden; + margin: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-iii-ui="browser"] .br-cfg-editor-title h3 { + color: var(--color-ink); + font-size: 14px; + font-weight: 600; +} + +[data-iii-ui="browser"] .br-cfg-editor-title p { + padding-top: 3px; + color: var(--color-ink-faint); + font-size: 11px; +} + +[data-iii-ui="browser"] .br-cfg-back { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex: 0 0 auto; + margin: 1px 0 0 -5px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-ink-faint); + cursor: pointer; +} + +[data-iii-ui="browser"] .br-cfg-back:hover { + background: var(--color-surface-hover); + color: var(--color-ink); +} + +[data-iii-ui="browser"] .br-cfg-editor-scroll { + flex: 1; + min-width: 0; + min-height: 0; + overflow-y: auto; +} + +[data-iii-ui="browser"] .br-cfg-section { + display: flex; + flex-direction: column; + gap: 15px; + padding: 20px; + border-top: 1px solid var(--color-edge); +} + +[data-iii-ui="browser"] .br-cfg-section:first-child { + border-top: 0; +} + +[data-iii-ui="browser"] .br-cfg-section-head > div { + min-width: 0; +} + +[data-iii-ui="browser"] .br-cfg-section-head h4, +[data-iii-ui="browser"] .br-cfg-section-head p { + margin: 0; +} + +[data-iii-ui="browser"] .br-cfg-section-head h4 { + color: var(--color-ink); + font-size: 13px; + font-weight: 600; +} + +[data-iii-ui="browser"] .br-cfg-section-head p { + max-width: 66ch; + padding-top: 4px; + color: var(--color-ink-faint); + font-size: 11.5px; + line-height: 1.55; +} + +[data-iii-ui="browser"] .br-cfg-field-grid, +[data-iii-ui="browser"] .br-cfg-check-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +[data-iii-ui="browser"] .br-cfg-field, +[data-iii-ui="browser"] .br-cfg-check-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +[data-iii-ui="browser"] .br-cfg-field-label { + min-height: 17px; + color: var(--color-ink); + font-size: 11.5px; + font-weight: 500; +} + +[data-iii-ui="browser"] .br-cfg-input { + width: 100%; + min-width: 0; + height: 34px; + border: 1px solid var(--color-edge); + border-radius: 6px; + background: var(--color-panel); + color: var(--color-ink); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12px; +} + +[data-iii-ui="browser"] .br-cfg-input::placeholder { + color: var(--color-ink-ghost); +} + +[data-iii-ui="browser"] .br-cfg-input:focus, +[data-iii-ui="browser"] .br-cfg-editor:focus { + outline: 2px solid var(--color-rule-focus); + outline-offset: -1px; +} + +[data-iii-ui="browser"] .br-cfg-check-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + color: var(--color-ink); + font-size: 11.5px; + font-weight: 500; + cursor: pointer; +} + +[data-iii-ui="browser"] .br-cfg-check-row input { + width: 16px; + height: 16px; + flex: 0 0 auto; + margin: 0; + accent-color: var(--color-accent); +} + +[data-iii-ui="browser"] .br-cfg-hint, +[data-iii-ui="browser"] .br-cfg-error { + margin: 0; + font-size: 10.5px; + line-height: 1.5; +} + +[data-iii-ui="browser"] .br-cfg-hint { + color: var(--color-ink-faint); +} + +[data-iii-ui="browser"] .br-cfg-error { + color: var(--color-alert); +} + +[data-iii-ui="browser"] .br-cfg-warning { + padding: 10px 12px; + border-left: 2px solid var(--color-warn); + border-radius: 0 6px 6px 0; + background: var(--color-warn-muted); + color: var(--color-ink-faint); + font-size: 11.5px; + line-height: 1.55; +} + +[data-iii-ui="browser"] .br-cfg-preview { + display: flex; + align-items: flex-end; + gap: 14px; + padding: 12px; + border-radius: 6px; + background: var(--color-surface); +} + +[data-iii-ui="browser"] .br-cfg-preview-frame { + display: flex; + align-items: center; + justify-content: center; + width: min(220px, 42%); + min-width: 128px; + max-height: 136px; + border: 1px solid var(--color-edge); + border-radius: 5px; + background: var(--color-panel); + color: var(--color-ink-faint); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +[data-iii-ui="browser"] .br-cfg-preview p { + margin: 0; + color: var(--color-ink-faint); + font-size: 11px; + line-height: 1.5; +} + +[data-iii-ui="browser"] .br-cfg-status { + margin-top: 14px; +} + +[data-iii-ui="browser"] .br-cfg-nav-row:focus-visible, +[data-iii-ui="browser"] .br-cfg-back:focus-visible { + outline: 2px solid var(--color-rule-focus); + outline-offset: -2px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-workbench { + display: block; + min-height: 0; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-nav, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-editor { + width: 100%; + height: 100%; + min-height: 0; + border: 0; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-nav-head > p:last-child, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-section-head p, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-warning, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-preview p { + font-size: 14px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-nav-row { + min-height: 72px; + padding-top: 10px; + padding-bottom: 10px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-nav-name, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-field-label, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-check-row { + font-size: 16px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-nav-description, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-nav-meta, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-hint, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-error { + font-size: 13px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-editor-head { + min-height: 64px; + padding: 10px 12px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-back { + width: 44px; + height: 44px; + margin-top: 0; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-editor-title { + align-self: center; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-editor-title h3 { + font-size: 16px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-editor-title p { + font-size: 12px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-section { + gap: 17px; + padding: 17px 14px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-field-grid, +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-check-grid { + grid-template-columns: minmax(0, 1fr); + gap: 17px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-input { + height: 44px; + font-size: 16px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-check-row { + min-height: 44px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-check-row input { + width: 20px; + height: 20px; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-preview { + align-items: flex-start; + flex-direction: column; +} + +[data-iii-ui="browser"] .br-cfg.narrow .br-cfg-preview-frame { + width: min(260px, 100%); +} + /* --- function-trigger card ------------------------------------------- */ [data-iii-ui="browser"] .br-ui-call, [data-iii-ui="browser"] .br-ui-err { diff --git a/console/README.md b/console/README.md index b7dcdd364..e66ad5bbc 100644 --- a/console/README.md +++ b/console/README.md @@ -105,8 +105,8 @@ A purpose-built agentic chat UI on top of [Lexical](https://lexical.dev). Lives - **`@`-mentions** — fuzzy-search every function registered against the engine - **`/compact` slash command** — summarises conversation history via the `context-manager` worker's `context::compact`, then persists a `compaction` custom session entry; the durable transcript is untouched — the marker renders from that entry and the summary anchors future turns - **Attachments** — multi-file picker with text/image previews -- **Function calls** — running / pending / error cards, consecutive calls grouped, with **approve/deny** gating for pending approvals (`approval::resolve`) -- **Streaming** — abortable mid-flight; thinking shimmer; collapsible thought messages +- **Function calls** — running / pending / error cards; consecutive calls collapse to the latest and expand as one tight stack, while rich code/screenshot displays, approvals, and live calls stay visible; intermediate agent prose summarizes the completed batch; pending approvals use **approve/deny** gating (`approval::resolve`) +- **Streaming** — abortable mid-flight; live thought stream removed from the DOM on completion - **Markdown** — GFM, code blocks with [prism-react-renderer](https://github.com/FormidableLabs/prism-react-renderer), syntax-highlighted JSON inputs and outputs - **Conversation sidebar** — create, inline rename, delete, auto-title from the first message - **Context-usage meter** — token estimate with warn / danger thresholds and a `/compact` nudge @@ -235,8 +235,11 @@ disposes the old module and re-imports the new one. Injected scripts default- export `setup(host)` and register through `host.pages` (whole pages at `#/ext/`), `host.functionTriggers` (function-trigger message renderers — injected renderers dispatch before the built-in families, so matching a -built-in id overrides it), and `host.configForms` (replace the workers-tab -form region for one configuration id; dirty/save/reset stay host-owned). +built-in id overrides it; `metadata.display` promotes the winning renderer's +rich result into the collapsed chat flow), and `host.configForms` (replace the workers-tab +form region for one configuration id; dirty/save/reset stay host-owned). A +configuration form can opt into `{ layout: 'full' }` to receive the entire +available editor width and height; contained layout remains the default. Renders are fenced by an ErrorBoundary and scoped under `data-iii-ui=""`. `console::ui-manifest` (internal) lists the loadable assets. The `state` worker's `ui/` directory is the working diff --git a/console/SKILL.md b/console/SKILL.md index d7d061ec1..e8b3ff60f 100644 --- a/console/SKILL.md +++ b/console/SKILL.md @@ -1,70 +1,160 @@ --- name: console-injectable-ui -description: Build and ship worker UI (React pages, function-trigger renderers, configuration forms, stylesheets) into a running iii console at runtime — using the @iii-dev/console-ui npm package and the iii-console-ui Rust crate. Use when a worker needs its own console page, custom message rendering, or a custom config form, with hot reload and no console rebuild. +description: Build, structure, and validate polished responsive worker UI (React pages, function-trigger renderers, configuration forms, and stylesheets) injected into the running iii console. Use when adding or changing a worker's console UI, especially when it must match the visual quality of database, console functions/triggers, iii-directory, and state; retain the shared header and visual system; work in narrow/mobile-sized panes; preserve state safely; hot reload; and ship without rebuilding the console. --- # Injectable console UI -A worker can ship React pages, function-trigger renderers, configuration -forms, and stylesheets into every open console tab **at runtime** — no -console rebuild, no iframe, hot-reloaded on re-registration. This skill is -self-contained: everything needed to author, register, and debug injectable -UI from your own worker project is on this page. - -## How it works (one paragraph) - -The console owns three trigger types. A worker registers a `console:script` -or `console:style` trigger whose `config.path` (e.g. `mywork/page.js`) is -the asset's identity; the trigger's `function_id` names a *content function* -on the worker that the console invokes to fetch the source (`{path}` in, -`{content, content_type?}` out). The console hashes and caches the bytes, -serves them from its HTTP port (`GET /ui/?v=`), and pushes an -update to every open tab over the third type, `console:assets` (tabs -subscribe; you never register that one). Scripts are ES modules the tab -`import()`s and calls `setup(host)` on; styles are `` elements the tab -swaps in place. Re-registering the same path overrides it — that **is** the -hot-reload signal. Registration is deployment; disconnect is teardown. - -## Install - -Two packages, one per side of the wire: - -- **`@iii-dev/console-ui`** (npm) — the compile-time surface of the - console's runtime module: TypeScript types plus the component manifest. - It is types-only by design: at runtime the console's import map serves - the real module from the running SPA, so this package must stay - `external` in your build (its js entry throws to make a forgotten - external fail fast). - - ```bash - npm install --save-dev @iii-dev/console-ui - ``` - -- **`iii-console-ui`** (Rust crate) — the whole worker side for Rust - workers: registers the content function, one trigger per asset, and the - dev-loop file watcher. - - ```bash - cargo add iii-console-ui - ``` - - Match the crate and package versions to the console worker you deploy - against; the console is the runtime they both describe. (Node workers - need no worker-side package — they hand-write the two registration - pieces, shown below.) +A worker can ship pages, renderers, forms, and stylesheets into every console tab +**at runtime**—no rebuild, no iframe, and hot reload. Treat `database`, console functions/triggers, +`iii-directory`, and `state` as proven patterns, not visual templates: reuse +their visual grammar and interaction mechanics while choosing the information +architecture that best fits the worker. + +## How it works + +A worker registers `console:script` and `console:style` triggers whose +`config.path` identifies an asset and whose `function_id` serves `{content}` +for `{path}`. The console hashes and serves those bytes, then pushes changes +to open tabs. Tabs `import()` scripts and call their default `setup(host)`; +styles load as scoped `` assets. Re-registering a path hot-reloads it. +Registration is deployment; disconnect is teardown. + +## Add the internal dependencies + +This repository versions both sides of the contract together. Do not try to +install them from a public registry: + +1. Add `/ui` to the root `pnpm-workspace.yaml`. +2. Add the compile-time UI surface to `/ui/package.json`: + + ```json + { + "name": "@iii-workers/mywork-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { "build": "tsc --noEmit && node build.mjs", "watch": "node build.mjs --watch" }, + "dependencies": { "@iii-dev/console-ui": "workspace:*" }, + "devDependencies": { "@types/react": "^19.2.14", "esbuild": "^0.25.0", "typescript": "^5.9.2" } + } + ``` + +3. For a Rust worker, link the worker-side registration helper in + `/Cargo.toml`: + + ```toml + iii-console-ui = { path = "../crates/console-ui" } + ``` + +`@iii-dev/console-ui` is types-only at build time; the console serves its +runtime implementation from the active SPA. `iii-console-ui` registers the +content function, asset triggers, and development watcher. Node workers have +no worker-side helper and implement the wire contract directly. ## Project layout ```text mywork/ + build.rs # ensure dist assets exist before include_str! ui/ page.tsx # the script asset — default-exports setup(host) styles.css # the style asset — every rule scoped build.mjs # esbuild, five external specifiers - package.json # depends on @iii-dev/console-ui (dev) - src/ # the worker itself (Rust or Node) + package.json # workspace dependency on @iii-dev/console-ui + tsconfig.json + src/ # page, renderer, config form, hooks, widgets + src/ + ui.rs # embed and register dist/page.js + dist/styles.css ``` +Start from `state/ui/tsconfig.json`, `state/build.rs`, and `state/src/ui.rs` +for the mechanical files; rename worker/asset paths and keep their tests. +For UI structure, consult the references below before writing code. + +## Authoring workflow + +1. Read `packages/console-ui/index.d.ts`; never guess a component or prop. +2. Select only the needed slots, then model the primary object, navigation, + actions, async states, and state that must survive navigation or reload. +3. Design wide and narrow flows deliberately; do not squeeze desktop UI. +4. Build with shared primitives and minimal scoped CSS, then type-check, + register, inspect the manifest, and exercise the real console. + +### Living references + +| Need | Read | Reuse | +|---|---|---| +| Public API | `packages/console-ui/index.d.ts` | Exact exports and props | +| Shared page chrome | `console/web/src/components/ui/PageChrome.tsx` | `PageShell`, `PageHeader`, surface roles | +| Catalog/detail | `console/ui/src/catalog/widgets.tsx`, `console/ui/src/catalog/FunctionsPage.tsx`, `console/ui/src/catalog/TriggersPage.tsx`, `console/ui/styles.css` | Grouped rows, persistent hero, identity masthead, facts, tabs, contextual rail | +| Data workbench | `database/ui/src/page/index.tsx`, `database/ui/src/page/TableDataPanel.tsx`, `database/ui/styles.css` | Mode bar, schema tree, toolbars, data grid, inspector, nested container responses | +| List/detail editor | `iii-directory/ui/page.tsx`, `iii-directory/ui/src/page/browser.tsx`, `iii-directory/ui/styles.css` | `setup(host)`, container-width drill-in, dirty-draft guards, per-tab state | +| Multi-level browser | `state/ui/page.tsx`, `state/ui/src/page/browser.tsx`, `state/ui/styles.css` | One-pane-at-a-time narrow flow, live state updates, stale-request guards | +| Rust delivery | `state/src/ui.rs`, `state/build.rs` | Embedding, registration, asset tests, build freshness | + +Copy delivery plumbing when it matches. Do **not** copy a reference page's +sidebar count, breakpoints, controls, or visual hierarchy without deriving +them from the new worker's content. + +## Visual quality is part of correctness + +Choose one dominant archetype before writing JSX. Mixing all four produces a +generic dashboard with too many panels. + +| Archetype | Use for | Required shape | +|---|---|---| +| Console catalog | Many searchable objects with rich detail | Grouped list → persistent hero or breadcrumb + identity masthead + tabs; add a contextual rail only for genuinely related information | +| Database workbench | Several tools operating on one selected resource | Compact mode switcher, collapsible resource tree, one active work surface, local toolbar/status bar, optional inspector | +| Directory editor | Searchable documents with drafts or preview | List → document identity → edit/preview modes; keep draft state mounted and put save status beside the work | +| State explorer | Deep but compact hierarchy | Progressive columns on wide panes and one-level-at-a-time drill-in on narrow panes | + +### Apply the shared visual grammar + +- Build hierarchy with surfaces, not boxes: sidebar, panel, raised toolbar, + hover/selected wash. Reserve 1 px edges for structural or tabular + separation; avoid borders, shadows, or a card around every section. +- Use a restrained scale: 4/6/8 px for internal gaps, 12/14/20/24 px for + section spacing, and the system 6 px radius. Oversized padding makes these + dense operator tools look like marketing pages. +- Set document/hero titles around 17–18 px at weight 600; body copy around + 12.5–13 px with 1.55–1.65 line height and a 60–72ch measure; metadata around + 10–11.5 px. Use uppercase tracking only for short labels. +- Use sans for explanation and prose; use mono for ids, paths, schemas, + counts, code, data, and compact technical chrome. Do not make all text mono. +- Repeat one restrained identity glyph in the list row, empty hero, and + detail masthead, as console functions/triggers do. Use simple stroke SVGs, + not emoji or a new icon dependency. +- Make list rows full-width targets with one strong primary line and at most + one or two quieter supporting lines. Indicate selection with a surface wash, + stronger text, and a 2 px accent rail—never color alone. +- Keep page actions in `PageHeader`; put resource actions in the identity + masthead and work actions in the nearest toolbar. Show one clear primary + action at the point of work; move rare actions into a menu. +- Use `Tabs` for peer views of the same object and a segmented control for + mutually exclusive work modes. Do not use tabs as decoration or expose an + empty mode. +- Use compact fact sheets or stat tiles only for useful comparisons. Prefer a + quiet `--color-surface` group with label/value rows over a grid of large + KPI cards. +- Put loading, empty, error, and success states where content will appear so + the page silhouette stays stable. Use `Skeleton`, `EmptyState`, and + `StatusPanel`; never present raw error text as the main design. +- For tables, keep the header sticky, numbers aligned/tabular, rows hoverable, + selection distinct, and horizontal overflow inside the grid. For related + detail, use an inspector or context rail and stack it below before it crowds + the primary work. + +### Reject generic generated UI + +Do not ship card soup, gradients, glows, ornamental shadows, giant centered +headings, excessive badges, random accent colors, repeated descriptions, or +an empty canvas with controls floating in corners. Do not give navigation, +metadata, and the primary task equal visual weight. Compare the result beside +the closest reference at the same width in both themes; its structure may +differ, but density, typography, surface hierarchy, and control treatment +must feel native to the same console. + ## 1. The script asset (`ui/page.tsx`) Ordinary React. Import from `react` and `@iii-dev/console-ui` — both resolve @@ -74,135 +164,153 @@ registration through `host` (the loader attributes registrations to your script so it can dispose them on reload): ```tsx -import { Button, EmptyState, type Host } from '@iii-dev/console-ui' +import { + type Host, + PageHeader, + PageMain, + type PageRenderProps, + PageShell, +} from '@iii-dev/console-ui' + +function MyworkPage({ + host, + onRequestClose, +}: PageRenderProps & { host: Host }) { + return ( + + } + title="mywork" + description={host.path} + onClose={onRequestClose} + /> + + {/* Compose the chosen archetype here. */} + + + ) +} export default function setup(host: Host) { host.pages.register({ id: 'mywork-manager', // page URL: #/ext/mywork-manager title: 'mywork', // nav label - render: () => , + render: (props) => , }) - host.functionTriggers.register(createMyTriggerRenderer(host)) - host.configForms.register('mywork', MyConfigForm) - // optional: return a teardown fn; the loader runs it on dispose + + // Register other slots only when their implementations exist. + // host.functionTriggers.register(createMyTriggerRenderer(host)) + // host.configForms.register('mywork', MyConfigForm) + // host.providerConfigForms?.register('my-provider', MyProviderConfigForm) } ``` -`Button`, `EmptyState`, `Dialog`, `Markdown`, … are the console's own -components, re-exported by name with typed props — at runtime they come from -the running console's single React tree, so importing them adds **zero -bytes** to your bundle. Use them instead of copying base components into -your worker. +This is a delivery skeleton, not a finished design. Compose one archetype in +its body before evaluating the UI. Imports from the shared package add zero +bundle bytes because they resolve to the running console's React tree. ### The shared component library -`AnsiText`, `Badge`, `Button`, `CodeEditor`, `CodeHighlight`, `Dialog` -(+`DialogTrigger`, `DialogClose`, `DialogContent`, `DialogTitle`, -`DialogDescription`), `DropdownMenu` (+`Trigger/Content/Item/Label/Separator`), -`EmptyState`, `ErrorBoundary`, `FileDiff`, `Input`, `JsonHighlight`, -`Markdown`, `MarkdownPreview`, `PageShell`/`PageHeader`/`PageBody`/ -`PageSidebar`/`PageMain` (the page chrome — see below), `Select`, `Skeleton`, -`StatusDot`, `StatusPanel`, `Tabs` (+`TabsList/TabsTrigger/TabsContent`), -`TerminalCommandLine`, `TerminalStream`, `Tooltip` -(+`TooltipTrigger/TooltipContent`). +The package exports page chrome, buttons, inputs, select, tabs, dialogs, +menus, tooltips, badges, status/empty/loading components, Markdown and JSON +renderers, the terminal atoms (`AnsiText`, `TerminalStream`, +`TerminalCommandLine`), `CodeEditor`, `FileDiff`, and +`WorkerConfigurationDialog`. Read `packages/console-ui/index.d.ts` for the +authoritative names and props. -**The page chrome is the mandatory layout for pages.** Every registered -page composes the same five pieces, so your pane looks exactly like the -console's own screens (chat, traces) and every other worker's page: - -```tsx - - } // 16px glyph, faint ink - title="mywork" // mono lowercase — console chrome - description="what this page is" // truncates first - actions={ ``` -Rename tab labels if "terminal" is wrong for your UX (`custom` / `preview` / keep generic **preview** + **raw json**). +The console reuses an already-open page or places it beside chat without +replacing an existing pane. The target page receives `panelContext` in its +`PageRenderProps`; react to `panelContext.id`, not only object identity, so a +second click on the same item still opens it. Keep the payload JSON-sized and +send opaque ids for content that the page can fetch lazily. The shell +file-change renderer is the reference: filename → exact snapshot diff, and +“View file” → Monaco editor. ### 5. Storybook stories (required) @@ -401,37 +423,9 @@ Approve/deny handlers are props on `FunctionCallMessage`; custom modules do not ## Scale beyond one family -Duplicating `SandboxToolView` imports in FCM does not scale. Suggested refactor (not implemented yet): - -``` -src/components/chat/function-plugins/ - types.ts # FunctionCallRenderer interface - registry.ts # ordered list of plugins - index.ts # resolvePreview(message), resolveTerminal(message), resolveLabel(functionId) -``` - -```typescript -export interface FunctionCallRenderer { - id: string - isMatch: (functionId: string) => boolean - tryRender: (message: FunctionCallMessage) => React.ReactNode | null - tryRenderPreview?: (message: FunctionCallMessage) => React.ReactNode | null - FunctionIdLabel?: (props: { functionId: string }) => React.ReactNode - /** Tab label when this renderer wins; default "preview" */ - primaryTabLabel?: string -} -``` - -FCM becomes: - -```typescript -const terminal = !pending ? resolveTerminal(message) : null -const preview = resolvePreview(message) -``` - -Register `sandboxPlugin` and `myFeaturePlugin` in `registry.ts`. First non-null win, or explicit priority field. +The ordered registry already supports both runtime-injected and first-party families. Runtime registrations are fenced and prepended in registration order; first-party renderers follow; the raw JSON card is the final fallback. `firstRendered()` returns both the node and its owner so `metadata.display` cannot accidentally promote content produced by a later renderer. -Until that exists, follow the **minimal wiring** in step 4 above. +Keep worker logic in its worker UI whenever it can ship with the function. Add a first-party renderer only for console-owned functions or when the worker cannot ship assets. A focused renderer may claim one result shape (for example an image), return `null` for every other shape, and sit before its general family renderer. --- diff --git a/console/web/index.html b/console/web/index.html index 879d34efe..d1490cc25 100644 --- a/console/web/index.html +++ b/console/web/index.html @@ -2,7 +2,11 @@ - + + iii console \ No newline at end of file diff --git a/harness/harness-explained.html b/harness/harness-explained.html new file mode 100644 index 000000000..8a9908ccb --- /dev/null +++ b/harness/harness-explained.html @@ -0,0 +1,785 @@ + + + + + + + + Harness — Give models continuity + + + + + + + +
    +
    +
    +
    +
    Durable agent runtime for iii
    +

    Give models continuity.

    +

    Harness is the thin, durable turn loop that turns a language model and a mesh of workers into an agent that can remember, use tools, recover, and keep going.

    + +
    +
    01 / persist4ms
    Message saved
    +
    02 / context16ms
    Window assembled
    +
    03 / generate1.8s
    Model streaming
    +
    04 / dispatch82ms
    Worker called
    +
    05 / continue7ms
    Next step queued
    +
    +
    +
    + +
    +
    +
    +
    01 / IN ONE SENTENCE
    +

    The sequence is the product.

    +
    Not another monolith.

    Harness coordinates specialized workers. It owns what happens next—and records enough to do that safely after a restart.

    +
    +
    +
    A / Input

    A message arrives

    A console, bridge, webhook, cron job, or another trusted consumer starts a named session.

    +
    B / Process

    The loop coordinates

    Context is fitted. A model generates. Proposed calls pass policy and trusted hooks before workers run.

    +
    C / Outcome

    Progress survives

    Messages, call states, options, budgets, and steps become durable checkpoints—not process memory.

    +
    +
    +
    + +
    +
    +
    +
    02 / Anatomy of a turn
    +

    One loop. Six durable moves.

    +

    Scroll through a turn. The active operation, system component, diagram, and section accent change together.

    +
    +
    +
    +
    +
    + Step 01 · Session Manager

    Write first. Then work.

    The incoming message is persisted before generation begins. Model, provider, prompt, policy, budgets, and lineage are frozen into durable authority.

    +
    +
    + Step 02 · Context Manager

    Build only what fits.

    The transcript, system prompt, tool schemas, and trusted context are assembled to fit the model window. Compaction is a dependency, not an improvisation.

    +
    +
    + Step 03 · LLM Router

    Stream. Persist. Observe.

    The router selects a provider and streams the completion. Harness coalesces partial output, tracks usage, and keeps the transcript legible while generation is live.

    +
    +
    + Step 04 · Policy + Hooks

    A request is not permission.

    Every proposed function call must pass the turn’s frozen allow/deny policy. Trusted hooks can deny, rewrite, or hold the call before execution.

    +
    +
    + Step 05 · Worker Mesh

    Route by contract.

    The engine dispatches each allowed call to whoever registered its function ID. Language, process, and machine boundaries disappear behind the contract.

    +
    +
    + Step 06 · Durable Queue

    Finish—or take another step.

    Function results usually lead to another generation. Harness advances a monotonic step and enqueues it. Otherwise, the turn closes with a terminal outcome.

    +
    +
    + +
    +
    + + + +
    +
    +
    04 / Capability model

    Instructions do not create authority.

    The model is a planner. Actual reach comes from structural policy, trusted hooks, and the engine’s live function registry.

    +
    +
    iii.worker.yamldeny-by-default
    functions:
    +  allow:
    +    - "web::fetch"
    +    - "state::*"
    +  deny:
    +    - "state::delete"
    +
    +# no allow list = plain chat
    +# deny wins
    +# children can only narrow
    +
    +
    01

    Filtered discovery

    The model sees only contracts its turn may use.

    +
    02

    Trusted vetoes

    Hooks can deny, rewrite, or hold after policy passes.

    +
    03

    Narrow inheritance

    A child receives fewer capabilities, never more.

    +
    04

    Frozen authority

    Turn and binding policy does not drift mid-flight.

    +
    +
    +
    +
    + +
    +
    +
    05 / Beyond one turn

    Branch. React. Continue.

    Harness supports delegation and event-driven continuation without making a parent agent wait, poll, or hold open an expensive turn.

    +
    +

    Send focused work to leaf agents.

    harness::spawn starts a separate child session and returns immediately. The child gets one self-contained task and writes its result to an exact shared destination.

    parent → spawn → child session → shared result
    +

    Wake when the world changes.

    A trigger binding listens for state, a timer, HTTP, or another provider event. When it fires, Harness starts a fresh continuation turn in the owner session.

    bind first → start producer → event → wake
    +

    Route mechanical work without a model.

    A binding can call a plain allowed function directly. No generation, no token spend, and no ambiguity—just deterministic event routing.

    event + payload template → function call
    +
    +
    +
    + +
    +
    +
    06 / Live architecture

    A mesh, not a monolith.

    The engine is the switchboard. Harness coordinates by stable function contracts while each specialized worker owns its domain.

    +
    + +
    Session Managercanonical transcript + message persistence
    +
    Context Managertoken budget + compaction + fit
    +
    LLM Routerprovider selection + streamed generation
    +
    Worker Meshtools + triggers + domain capabilities
    +
    Queue + StateFIFO delivery + durable checkpoints
    +
    HARNESSsequence · policy · recovery
    lineage · hooks · outcomes
    +
    +
    +
    + +
    +
    +
    07 / Extension pipeline

    Five trusted points to shape a turn.

    Synchronous hooks let operator-controlled workers govern the loop without hiding authority in prompt text.

    +
    +
    01 / Gate

    Pre-turn

    Validate a request, enforce a global rule, or veto before model spend.

    harness::hook::pre-turn
    +
    02 / Enrich

    Pre-generate

    Add trusted context, extend the prompt, append messages, or stop generation.

    harness::hook::pre-generate
    +
    03 / Observe

    Post-generate

    Audit, evaluate, measure, or index the completed assistant output.

    harness::hook::post-generate
    +
    04 / Govern

    Pre-trigger

    Deny, rewrite, or hold a model-requested function after policy allows it.

    harness::hook::pre-trigger
    +
    05 / Sanitize

    Post-trigger

    Inspect or rewrite a worker result before it reaches the transcript.

    harness::hook::post-trigger
    +
    +
    +
    + +
    +
    +
    08 / Quickstart

    From project to agent.

    Start iii, add Harness and Console, open the local UI, then configure a model provider from the picker.

    Read the source  ↗
    +
    terminalquickstart.sh
    # install and start an iii project
    +$ curl -fsSL https://install.iii.dev/iii/main/install.sh | sh
    +$ iii project init iii-app && cd iii-app
    +$ iii
    +
    +# another terminal, same folder
    +$ iii worker add harness console
    +$ open http://localhost:3113
    +
    +
    +
    + + + + + + diff --git a/harness/prompts/default.txt b/harness/prompts/default.txt index 0ab3a7d0a..3e6e04ebe 100644 --- a/harness/prompts/default.txt +++ b/harness/prompts/default.txt @@ -1,9 +1,10 @@ You are an iii agent worker. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (the function id, like `engine::functions::list`) and -`payload` (a JSON OBJECT with the function's arguments). Everything you do happens through -`agent_trigger`. Never use a function id from memory. +three arguments: `function` (the function id, like `engine::functions::list`), `description` +(a short user-facing description of the action in the user's language), and `payload` (a JSON +OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +Never use a function id from memory. iii is a mesh of workers connected to one engine. Each worker registers functions. A function id looks like `worker::name`. Every call goes through the engine: worker → engine → worker. @@ -15,6 +16,28 @@ this reply ends, register a trigger; do not poll, and do not keep a turn alive t # System rules +## User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + Follow these steps for EVERY action. Do not skip a step. Step 1. Find the function id. Call `engine::functions::list` with an optional filter: @@ -36,7 +59,9 @@ field, or a registry-change notice appears. Need more than one contract at once? `{ function_ids: ["a::b", "c::d"] }` and it returns `{ functions: [...] }`, one per id — one call, never one per id. -Step 3. Call the function. The `payload` is a JSON OBJECT, never a string. Match the +Step 3. Call the function. Set `description` to a concise action label in the user's language +(for example, "Reading configuration files"), without the function id or implementation +jargon. The `payload` is a JSON OBJECT, never a string. Match the contract exactly: every required field, no extra fields, and the right value formats (single binary vs argv array, inline string vs base64, "K=V" entries). Guessing field names burns turns and can put workers into degraded states. If a value is long or multi-line @@ -48,9 +73,10 @@ Step 4. If you get an error, read it and change something. Never send the same ` user: List the files under /tmp. -assistant: [calls engine::functions::list { search: "ls" } and finds shell::fs::ls] +assistant: I will find the right function and then list the files under /tmp. +[calls engine::functions::list { search: "ls" } and finds shell::fs::ls] [calls engine::functions::info { function_id: "shell::fs::ls" } to get the contract] -[calls agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[calls agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] ## Payload rules @@ -79,10 +105,10 @@ WRONG is a string. RIGHT is an object. Always send an object. Resending an identical failed call is never the fix. -[agent_trigger with function: "shell::fs::ls", payload: "{ \"path\": \"/tmp\" }"] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: "{ \"path\": \"/tmp\" }"] error: serialization error: invalid type: string, expected struct assistant: The payload was a JSON-encoded string. Re-issuing the SAME function with an object: -[agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] # Doing tasks @@ -283,7 +309,7 @@ I am installing the "email" worker from the public registry so I can send the re [calls worker::add { source: { kind: "registry", name: "email" } }] [calls engine::functions::list { prefix: "email::" } — the new function ids appear] [calls engine::functions::info { function_id: "email::send" } to get the contract] -[calls agent_trigger with function: "email::send", payload: { ...per the contract }] +[calls agent_trigger with function: "email::send", description: "Sending the email", payload: { ...per the contract }] To author a worker: import ONLY `registerWorker` from the SDK. Its return value has the diff --git a/harness/prompts/subagent.txt b/harness/prompts/subagent.txt index 1a29da2ce..f25c86ccb 100644 --- a/harness/prompts/subagent.txt +++ b/harness/prompts/subagent.txt @@ -3,17 +3,42 @@ task. Nothing else exists for you: you know nothing about any larger goal, other whoever started you — by design. Do not try to find out. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (a namespaced id like `state::set`) and `payload` (a JSON OBJECT +three arguments: `function` (a namespaced id like `state::set`), `description` (a short +user-facing description of the action in the user's language), and `payload` (a JSON OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +# User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + # How to call a function Step 1. Find the id with `engine::functions::list` — filter with `{ search: "" }` or `{ prefix: "::" }`. Never use a function id from memory. Step 2. Fetch the contract with `engine::functions::info { function_id: "" }` — once per function; it stays valid all session. Batch several with `{ function_ids: [...] }`. -Step 3. Call it. `payload` is a JSON OBJECT, never a JSON-encoded string, and every argument -goes INSIDE `payload`. Nested objects stay literal objects; booleans stay unquoted. +Step 3. Call it. Set `description` to a concise action label in the user's language, without +the function id or implementation jargon. `payload` is a JSON OBJECT, never a JSON-encoded +string, and every argument goes INSIDE `payload`. Nested objects stay literal objects; +booleans stay unquoted. WRONG payload: "{\"scope\":\"run-a1\",\"key\":\"result\"}" diff --git a/harness/src/clients/router.rs b/harness/src/clients/router.rs index b770ba70c..93d79a186 100644 --- a/harness/src/clients/router.rs +++ b/harness/src/clients/router.rs @@ -22,7 +22,7 @@ use tokio::sync::mpsc; use tokio::sync::{watch, Notify}; use crate::error::HarnessError; -use crate::types::event::{AssistantMessageEvent, StopReason}; +use crate::types::event::{AssistantMessageEvent, FunctionCallArgumentsPreview, StopReason}; use crate::types::message::{empty_assistant, AssistantMessage}; use crate::types::model::{AgentFunction, Model, ThinkingLevel}; @@ -188,12 +188,11 @@ impl RouterClient { .unwrap_or_else(Instant::now); let mut final_message: Option = None; let mut terminal_error: Option = None; - // Raw in-flight tool-call arguments per call id: providers degrade - // incomplete args to a placeholder object (replay safety), so the - // delta text accumulated here is the only live view of a long - // arguments stream — injected into coalesced partials as - // `_streaming` so UIs can show the command being formed. - let mut args_acc: std::collections::HashMap = + // Incremental tool-call arguments per call id. Block snapshots keep a + // replay-safe placeholder until FunctioncallEnd; llm-router adds a + // bounded identity preview so even an open `function`/`description` + // string can be persisted into the coalesced assistant entry. + let mut args_acc: std::collections::HashMap = std::collections::HashMap::new(); // Cumulative message across slim delta frames (boundary snapshot + // open-block deltas); fat frames just refresh the snapshot. @@ -317,7 +316,12 @@ impl RouterClient { terminal_error.get_or_insert(msg); } other => { - if let AssistantMessageEvent::FunctioncallDelta { partial, delta, id } = &other + if let AssistantMessageEvent::FunctioncallDelta { + partial, + delta, + id, + arguments_preview, + } = &other { // Pre-id producers: guess from the frame's own fat // partial or, on slim frames, from the last boundary @@ -332,7 +336,10 @@ impl RouterClient { Some(id.clone()) }; if let Some(id) = id { - args_acc.entry(id).or_default().push_str(delta); + args_acc + .entry(id) + .or_default() + .push(delta, arguments_preview.as_ref()); } } tracker.apply(&other); @@ -727,14 +734,28 @@ fn open_call_id(partial: &AssistantMessage) -> Option<&str> { }) } -/// Inject the in-flight raw-arguments tail into a coalesced partial as a -/// `_streaming` field beside the provider's placeholder, so UIs can render -/// the command being formed. Injected only while the accumulated text does -/// not yet parse — once it parses, the provider partial already carries the -/// real arguments. Returns None when nothing was injected. +#[derive(Debug, Default)] +struct StreamingArguments { + raw: String, + preview: Option, +} + +impl StreamingArguments { + fn push(&mut self, delta: &str, preview: Option<&FunctionCallArgumentsPreview>) { + self.raw.push_str(delta); + if let Some(preview) = preview { + self.preview = Some(preview.clone()); + } + } +} + +/// Inject the router-produced in-flight identity into a coalesced partial. +/// `_streaming` keeps a bounded raw tail for diagnostics and the request-pane +/// live marker; `_partial` prevents a truncated snapshot from ever looking +/// executable. fn enrich_streaming_args( partial: &AssistantMessage, - acc: &std::collections::HashMap, + acc: &std::collections::HashMap, ) -> Option { use crate::types::content::ContentBlock; if acc.is_empty() { @@ -745,19 +766,40 @@ fn enrich_streaming_args( let ContentBlock::FunctionCall { id, arguments, .. } = block else { continue; }; - let Some(raw) = acc.get(id) else { continue }; - if raw.is_empty() || serde_json::from_str::(raw).is_ok() { + let Some(args) = acc.get(id) else { continue }; + if args.raw.is_empty() { continue; } - // Degraded placeholders are always objects; anything else is final. - let Value::Object(map) = arguments else { - continue; - }; - let mut map = map.clone(); - map.insert( - "_streaming".into(), - Value::String(utf8_tail(raw, 1500).to_string()), - ); + // A closing root brace makes a strict parse worth attempting. During + // the normal incomplete case, use the router's bounded identity + // preview without reparsing the cumulative document in the harness. + let complete_map = args + .raw + .trim_end() + .ends_with('}') + .then(|| serde_json::from_str::(&args.raw).ok()) + .flatten() + .and_then(|value| value.as_object().cloned()); + let complete = complete_map.is_some(); + let mut map = complete_map.unwrap_or_else(|| match arguments { + Value::Object(map) => map.clone(), + _ => serde_json::Map::new(), + }); + if !complete { + if let Some(preview) = &args.preview { + if let Some(function) = &preview.function { + map.insert("function".into(), Value::String(function.clone())); + } + if let Some(description) = &preview.description { + map.insert("description".into(), Value::String(description.clone())); + } + } + map.insert("_partial".into(), Value::Bool(true)); + map.insert( + "_streaming".into(), + Value::String(utf8_tail(&args.raw, 1500).to_string()), + ); + } let msg = out.get_or_insert_with(|| partial.clone()); if let ContentBlock::FunctionCall { arguments, .. } = &mut msg.content[i] { *arguments = Value::Object(map); @@ -784,7 +826,7 @@ mod streaming_args_tests { use std::collections::HashMap; #[test] - fn enrich_injects_tail_only_while_args_are_unparsed() { + fn enrich_replaces_the_placeholder_with_streamed_arguments() { let mut m = empty_assistant("p", "m"); m.content = vec![ContentBlock::FunctionCall { id: "c1".into(), @@ -798,23 +840,73 @@ mod streaming_args_tests { // Incomplete raw args → the live tail rides beside the salvaged // fields so the UI can show the command being formed. let mut acc = HashMap::new(); - acc.insert( - "c1".to_string(), - r#"{"function":"state::set","payload":{"value":"grow"#.to_string(), + let mut streamed = StreamingArguments::default(); + streamed.push( + r#"{"function":"state::set","payload":{"value":"grow"#, + Some(&FunctionCallArgumentsPreview { + function: Some("state::set".into()), + description: None, + }), ); + acc.insert("c1".to_string(), streamed); let enriched = enrich_streaming_args(&m, &acc).unwrap(); let ContentBlock::FunctionCall { arguments, .. } = &enriched.content[0] else { panic!("want function_call"); }; assert_eq!(arguments["function"], "state::set"); + assert_eq!(arguments["_partial"], true); assert!(arguments["_streaming"].as_str().unwrap().ends_with("grow")); - // Complete raw args → the provider partial already carries them. - acc.insert( - "c1".to_string(), - r#"{"function":"state::set","payload":{}}"#.to_string(), + // Complete raw args replace the provider's start-placeholder too. + let mut complete = StreamingArguments::default(); + complete.push(r#"{"function":"state::set","payload":{}}"#, None); + acc.insert("c1".to_string(), complete); + let enriched = enrich_streaming_args(&m, &acc).unwrap(); + let ContentBlock::FunctionCall { arguments, .. } = &enriched.content[0] else { + panic!("want function_call"); + }; + assert_eq!(arguments, &json!({"function": "state::set", "payload": {}})); + } + + #[test] + fn enrich_exposes_open_agent_trigger_identity_across_deltas() { + let mut m = empty_assistant("p", "m"); + m.content = vec![ContentBlock::FunctionCall { + id: "c1".into(), + function_id: "agent_trigger".into(), + arguments: json!({}), + }]; + let mut acc = HashMap::new(); + let mut streamed = StreamingArguments::default(); + streamed.push( + r#"{"function":"state::se"#, + Some(&FunctionCallArgumentsPreview { + function: Some("state::se".into()), + description: None, + }), ); - assert!(enrich_streaming_args(&m, &acc).is_none()); + acc.insert("c1".to_string(), streamed); + + let enriched = enrich_streaming_args(&m, &acc).unwrap(); + let ContentBlock::FunctionCall { arguments, .. } = &enriched.content[0] else { + panic!("want function_call"); + }; + assert_eq!(arguments["function"], "state::se"); + + acc.get_mut("c1").unwrap().push( + r#"t","description":"Updating arti"#, + Some(&FunctionCallArgumentsPreview { + function: Some("state::set".into()), + description: Some("Updating arti".into()), + }), + ); + let enriched = enrich_streaming_args(&m, &acc).unwrap(); + let ContentBlock::FunctionCall { arguments, .. } = &enriched.content[0] else { + panic!("want function_call"); + }; + assert_eq!(arguments["function"], "state::set"); + assert_eq!(arguments["description"], "Updating arti"); + assert_eq!(arguments["_partial"], true); } #[test] @@ -968,6 +1060,7 @@ mod streaming_args_tests { partial: None, delta: r#"{"x":1"#.into(), id: "c1".into(), + arguments_preview: None, }); let cum = tracker.current().unwrap(); assert_eq!(cum.content, with_call.content); diff --git a/harness/src/policy.rs b/harness/src/policy.rs index e8dba7b92..206f07f99 100644 --- a/harness/src/policy.rs +++ b/harness/src/policy.rs @@ -172,7 +172,7 @@ fn build_set(patterns: &[String]) -> GlobSet { } /// The single `agent_trigger` schema attached by default — the model triggers -/// any allowed function via `{ function, payload }`. +/// any allowed function via `{ function, description, payload }`. pub fn agent_trigger_schema() -> AgentFunction { AgentFunction { name: AGENT_TRIGGER_NAME.to_string(), @@ -184,9 +184,15 @@ pub fn agent_trigger_schema() -> AgentFunction { "type": "object", "properties": { "function": { "type": "string", "description": "Target iii function id." }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "description": "Short user-facing description of the action in the user's language. Describe the work, not the function id." + }, "payload": { "type": "object", "description": "Arguments for the target function." } }, - "required": ["function"] + "required": ["function", "description"] }), label: None, execution_mode: Some("sequential".to_string()), @@ -230,7 +236,8 @@ pub struct PlannedCall { } /// Extract the planned calls from an assistant message in content order. In -/// `agent_trigger` exposure the wrapper carries `{ function, payload }`; in +/// `agent_trigger` exposure the wrapper carries `{ function, description, +/// payload }`; in /// native exposure the block's `function_id` is the target directly. pub fn plan_calls(message: &AssistantMessage, expose: ExposeMode) -> Vec { let mut out = Vec::new(); @@ -281,6 +288,11 @@ pub fn plan_calls(message: &AssistantMessage, expose: ExposeMode) -> Vec Default::default(), }; map.remove("function"); + // Wrapper presentation belongs to the transcript, + // never to the target function's request. Older + // recovery logic hoists flattened target args, so + // explicitly strip the new display-only field. + map.remove("description"); Value::Object(map) } }; @@ -564,7 +576,12 @@ mod tests { let msg = assistant_with(vec![ContentBlock::FunctionCall { id: "fc_1".into(), function_id: AGENT_TRIGGER_NAME.into(), - arguments: json!({ "function": "engine::register_trigger", "trigger_type": "state", "config": { "key": "k" } }), + arguments: json!({ + "function": "engine::register_trigger", + "description": "Registering the state trigger", + "trigger_type": "state", + "config": { "key": "k" } + }), }]); let calls = plan_calls(&msg, ExposeMode::AgentTrigger); assert_eq!(calls[0].function_id, "engine::register_trigger"); @@ -574,6 +591,18 @@ mod tests { ); } + #[test] + fn agent_trigger_schema_requires_a_bounded_user_facing_description() { + let schema = agent_trigger_schema(); + let description = &schema.parameters["properties"]["description"]; + assert_eq!(description["type"], "string"); + assert_eq!(description["minLength"], 1); + assert_eq!(description["maxLength"], 120); + assert!(schema.parameters["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("description")))); + } + #[test] fn native_shaped_call_under_agent_trigger_exposure_keeps_arguments() { // The model hallucinated the target id as the tool name with real args. diff --git a/harness/src/prompt/tests.rs b/harness/src/prompt/tests.rs index f2cbd93e8..66f77c2be 100644 --- a/harness/src/prompt/tests.rs +++ b/harness/src/prompt/tests.rs @@ -326,6 +326,28 @@ fn default_variant_step_by_step() { assert!(out.contains("Step 1.")); } +#[test] +fn progress_updates_are_phase_scoped_and_keep_descriptions_and_final_text() { + for out in [variants::DEFAULT, variants::SUBAGENT] { + let normalized = out.replace('\n', " "); + assert!(normalized.contains("# User-visible progress")); + assert!(normalized.contains("materially new investigative or action phase")); + assert!(normalized.contains("One update may cover any number of related function calls")); + assert!(normalized.contains("Do not emit a new update for every call")); + assert!(normalized.contains("lead with its concrete result")); + assert!(normalized.contains("do not merely list calls")); + assert!(normalized.contains("summary of that whole batch")); + assert!( + normalized.contains("separate the result and next action into two short paragraphs") + ); + assert!(normalized.contains("still needs its concise `description`")); + assert!(normalized + .contains("return the final result through the turn's required output contract")); + assert!(normalized.contains("For the ordinary text contract, use normal assistant text")); + assert!(normalized.contains("a progress update never replaces the final answer")); + } +} + /// The reactive surface the prompt teaches must be the one the harness /// actually accepts: `harness::spawn` is the only subscription target, and /// Spawn targets, join barriers, and the fire-rate gate no longer exist, and @@ -416,9 +438,10 @@ fn default_variant_invariants() { #[test] fn subagent_variant_invariants() { let out = variants::SUBAGENT; + let normalized = out.replace('\n', " "); assert!(out.starts_with("You are an iii sub-agent.")); assert!(out.contains("agent_trigger")); - assert!(out.contains("JSON OBJECT, never a JSON-encoded string")); + assert!(normalized.contains("JSON OBJECT, never a JSON-encoded string")); assert!(out.contains("state::set")); assert!(out.contains("BEFORE your final reply")); assert!(out.contains("format the task specifies")); diff --git a/harness/src/types/event.rs b/harness/src/types/event.rs index f379ff37a..a5708e79d 100644 --- a/harness/src/types/event.rs +++ b/harness/src/types/event.rs @@ -49,6 +49,14 @@ pub struct Usage { pub cost_usd: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct FunctionCallArgumentsPreview { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + /// The frozen streaming vocabulary relayed by `llm-router`. The harness /// reads these frames off the `router::chat` channel to build the /// assistant message. @@ -92,6 +100,9 @@ pub enum AssistantMessageEvent { /// Call id receiving this delta; empty from pre-id producers. #[serde(default, skip_serializing_if = "String::is_empty")] id: String, + /// Incremental agent_trigger identity extracted by llm-router. + #[serde(default, skip_serializing_if = "Option::is_none")] + arguments_preview: Option, }, FunctioncallEnd { partial: AssistantMessage, diff --git a/harness/tests/prompts.rs b/harness/tests/prompts.rs index 14561260a..30a2efbdf 100644 --- a/harness/tests/prompts.rs +++ b/harness/tests/prompts.rs @@ -142,6 +142,7 @@ fn every_identity_prompt_teaches_the_live_surface() { { let name = label(&path); let body = std::fs::read_to_string(&path).expect("prompt is readable"); + let normalized = body.replace('\n', " "); assert!( body.contains("engine::register_trigger"), "{name} never mentions the callback primitive" @@ -154,6 +155,30 @@ fn every_identity_prompt_teaches_the_live_surface() { body.contains("orchestrator: true"), "{name} never mentions the leaf default's escape hatch" ); + assert!( + normalized.contains("materially new investigative or action phase"), + "{name} never asks for a user-visible update when a new phase starts" + ); + assert!( + normalized.contains("One update may cover any number of related function calls"), + "{name} asks for progress without allowing one update to cover a call batch" + ); + assert!( + normalized.contains("lead with its concrete result"), + "{name} never asks an update to report the preceding phase's result" + ); + assert!( + normalized.contains("summary of that whole batch"), + "{name} does not connect the progress result to the completed call batch" + ); + assert!( + normalized.contains("still needs its concise `description`"), + "{name} does not preserve the per-call activity description" + ); + assert!( + normalized.contains("For the ordinary text contract, use normal assistant text"), + "{name} does not preserve the final answer after progress updates" + ); assert!( !body.contains(r#"function_id: "harness::spawn""#), "{name} still teaches spawn as a binding target" diff --git a/harness/ui/src/context-chip/index.tsx b/harness/ui/src/context-chip/index.tsx index 074c2fedd..7b5681643 100644 --- a/harness/ui/src/context-chip/index.tsx +++ b/harness/ui/src/context-chip/index.tsx @@ -9,13 +9,13 @@ * by category with a stacked segment bar, legend, and last-turn actuals. */ -import { useEffect, useRef, useState } from 'react' import type { Host } from '@iii-dev/console-ui' +import { useEffect, useRef, useState } from 'react' import { formatCost, formatTokens } from '../lib/format' import { type ContextSnapshot, - type SnapshotUsage, isSnapshot, + type SnapshotUsage, } from '../lib/metrics' import { TONE_COLOR, toneFor } from '../lib/tone' @@ -233,7 +233,11 @@ function ContextPopover({ usage != null && (usage.input != null || usage.cache_read != null) const cache = cacheSummary(usage) return ( -
    +
    {snapshot.model || modelId || 'model'} @@ -282,7 +286,9 @@ function ContextPopover({ last step {formatTokens(usage?.input ?? 0)} in · output{' '} {formatTokens(usage?.output ?? 0)} - {usage?.cost_usd != null ? <> · {formatCost(usage.cost_usd)} : null} + {usage?.cost_usd != null ? ( + <> · {formatCost(usage.cost_usd)} + ) : null} ) : null} {cache ? ( @@ -292,8 +298,8 @@ function ContextPopover({ 'carries a premium. Hit rate is the cached share of the prompt.' } > - cache {formatTokens(cache.read)} read ·{' '} - {formatTokens(cache.write)} write ·{' '} + cache {formatTokens(cache.read)} read · {formatTokens(cache.write)}{' '} + write ·{' '} {cache.hitPct}% hit @@ -314,6 +320,66 @@ function ContextPopover({ ) } +function EmptyContextPopover({ + modelId, + contextWindow, +}: { + modelId?: string + contextWindow?: number +}) { + const hasWindow = contextWindow !== undefined && contextWindow > 0 + return ( +
    +
    + {modelId || 'model'} + waiting for usage +
    + {hasWindow ? ( + <> +
    +
    + +
    + + ) : null} +
    + + {hasWindow + ? 'the breakdown appears after the first generation step' + : 'the selected model did not report a context-window limit'} + +
    +
    + ) +} + +function ContextCaret() { + return ( + + Show context details + + + ) +} + export function createContextChip(host: Host) { return function ContextChip({ sessionId, @@ -363,7 +429,10 @@ export function createContextChip(host: Host) { const offHandler = host.iii.on(eventFn, (event) => { if (!event || event.key !== sessionId) return if (event.event_type === 'state:deleted') return - if (isSnapshot(event.new_value) && event.new_value.session_id === sessionId) + if ( + isSnapshot(event.new_value) && + event.new_value.session_id === sessionId + ) acceptNewer(event.new_value) }) const offTrigger = host.iii.registerTrigger({ @@ -397,28 +466,55 @@ export function createContextChip(host: Host) { if (!snapshot || snapshot.usable <= 0) { if (contextWindow && contextWindow > 0) { return ( -
    - ctx - + + {open ? ( + + ) : null}
    ) } return ( -
    - ctx - +
    + + {open ? : null}
    ) } @@ -433,6 +529,7 @@ export function createContextChip(host: Host) { type="button" className="harness-ui-chip-btn" onClick={() => setOpen((value) => !value)} + aria-haspopup="dialog" aria-expanded={open} aria-label={`context: ${snapshot.total.toLocaleString()} of ${snapshot.usable.toLocaleString()} tokens (${pct}%) — click for the breakdown`} > @@ -462,18 +559,7 @@ export function createContextChip(host: Host) { {/* Says "this opens something": drawn at lucide's `chevron-down` geometry, since an injected bundle has no icon dependency. */} - - - + {open ? : null}
    diff --git a/llm-router/src/chat/accumulate.rs b/llm-router/src/chat/accumulate.rs index d6a776b78..5a3913ff5 100644 --- a/llm-router/src/chat/accumulate.rs +++ b/llm-router/src/chat/accumulate.rs @@ -11,7 +11,7 @@ //! byte-identical to the old behavior, so old producers interop. use crate::types::content::ContentBlock; -use crate::types::events::AssistantMessageEvent; +use crate::types::events::{AssistantMessageEvent, FunctionCallArgumentsPreview}; use crate::types::messages::{degraded_arguments, AssistantMessage}; #[derive(Default)] @@ -23,9 +23,18 @@ pub struct PartialAccumulator { } enum OpenBlock { - Text { seed: String, acc: String }, - Thinking { seed: String, acc: String }, - Call { args: String }, + Text { + seed: String, + acc: String, + }, + Thinking { + seed: String, + acc: String, + }, + Call { + raw: String, + stream: crate::json_stream::JsonStream, + }, } impl PartialAccumulator { @@ -78,7 +87,8 @@ impl PartialAccumulator { AssistantMessageEvent::FunctioncallStart { partial } => { self.base = Some(partial.clone()); self.open = Some(OpenBlock::Call { - args: String::new(), + raw: String::new(), + stream: crate::json_stream::JsonStream::new(), }); } AssistantMessageEvent::TextDelta { partial, delta } => match partial { @@ -118,10 +128,16 @@ impl PartialAccumulator { self.open = None; } None => match &mut self.open { - Some(OpenBlock::Call { args }) => args.push_str(delta), + Some(OpenBlock::Call { raw, stream }) => { + raw.push_str(delta); + let _ = stream.write(delta); + } _ => { + let mut stream = crate::json_stream::JsonStream::new(); + let _ = stream.write(delta); self.open = Some(OpenBlock::Call { - args: delta.clone(), + raw: delta.clone(), + stream, }) } }, @@ -150,25 +166,80 @@ impl PartialAccumulator { signature: None, }); } - // Mid-call death: replace the open call block's placeholder args - // with the same replay-safe degraded object providers produce. - Some(OpenBlock::Call { args }) if !args.is_empty() => { + // Mid-call view: replace the open call block's placeholder args + // with every value observable so far. The incremental parser also + // exposes an open string, so agent_trigger's function and + // description no longer wait for their closing quote. + Some(OpenBlock::Call { raw, stream }) if !raw.is_empty() => { if let Some(ContentBlock::FunctionCall { arguments, .. }) = out .content .iter_mut() .rev() .find(|b| matches!(b, ContentBlock::FunctionCall { .. })) { - *arguments = serde_json::from_str(args) - .ok() - .filter(serde_json::Value::is_object) - .unwrap_or_else(|| degraded_arguments(args)); + *arguments = stream + .snapshot() + .and_then(|snapshot| match snapshot.value { + serde_json::Value::Object(mut map) => { + if !snapshot.complete { + map.insert("_partial".into(), serde_json::Value::Bool(true)); + } + Some(serde_json::Value::Object(map)) + } + _ => None, + }) + .unwrap_or_else(|| degraded_arguments(raw)); } } _ => {} } Some(out) } + + /// Only the small top-level identity needed by an in-flight call card. + /// Payload is deliberately excluded so relayed delta frames stay O(chunk) + /// instead of growing with the cumulative arguments document. + pub fn call_arguments_preview(&self) -> Option { + let arguments = match &self.open { + Some(OpenBlock::Call { stream, .. }) => stream.snapshot()?.value, + _ => self.base.as_ref()?.content.iter().rev().find_map(|block| { + let ContentBlock::FunctionCall { arguments, .. } = block else { + return None; + }; + Some(arguments.clone()) + })?, + }; + let object = arguments.as_object()?; + let function = object + .get("function") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|value| utf8_head(value, 512).to_string()); + let description = object + .get("description") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|value| utf8_head(value, 120).to_string()); + if function.is_none() && description.is_none() { + None + } else { + Some(FunctionCallArgumentsPreview { + function, + description, + }) + } + } +} + +fn utf8_head(value: &str, max: usize) -> &str { + if value.len() <= max { + return value; + } + let mut end = max; + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] } #[cfg(test)] @@ -270,7 +341,15 @@ mod tests { partial: None, delta: r#"{"function":"state::set","payload":{"x":"#.into(), id: "c1".into(), + arguments_preview: None, }); + assert_eq!( + acc.call_arguments_preview(), + Some(FunctionCallArgumentsPreview { + function: Some("state::set".into()), + description: None, + }) + ); let cum = acc.current().unwrap(); let ContentBlock::FunctionCall { arguments, .. } = &cum.content[0] else { panic!("want function_call"); @@ -294,6 +373,7 @@ mod tests { partial: None, delta: r#"{"function":"state::get"}"#.into(), id: "c1".into(), + arguments_preview: None, }); let cum = acc.current().unwrap(); let ContentBlock::FunctionCall { arguments, .. } = &cum.content[0] else { @@ -302,6 +382,30 @@ mod tests { assert_eq!(arguments, &json!({"function":"state::get"})); } + #[test] + fn open_call_strings_are_visible_before_their_closing_quote() { + let mut with_call = base(); + with_call.content = vec![ContentBlock::FunctionCall { + id: "c1".into(), + function_id: "agent_trigger".into(), + arguments: json!({}), + }]; + let mut acc = PartialAccumulator::default(); + acc.apply(&Ev::FunctioncallStart { partial: with_call }); + acc.apply(&Ev::FunctioncallDelta { + partial: None, + delta: r#"{"function":"state::se"#.into(), + id: "c1".into(), + arguments_preview: None, + }); + let cum = acc.current().unwrap(); + let ContentBlock::FunctionCall { arguments, .. } = &cum.content[0] else { + panic!("want function_call"); + }; + assert_eq!(arguments["function"], "state::se"); + assert_eq!(arguments["_partial"], true); + } + #[test] fn legacy_fat_delta_replaces_the_snapshot_wholesale() { let mut fat = base(); diff --git a/llm-router/src/chat/relay.rs b/llm-router/src/chat/relay.rs index adaeaa7a6..17c3be24d 100644 --- a/llm-router/src/chat/relay.rs +++ b/llm-router/src/chat/relay.rs @@ -155,7 +155,7 @@ pub async fn relay_frames( }; } ReadEvent::Msg(msg) => { - let Ok(ev) = serde_json::from_str::(&msg) else { + let Ok(mut ev) = serde_json::from_str::(&msg) else { continue; // malformed frame: skip, never fatal }; if !matches!(ev, AssistantMessageEvent::Ping) { @@ -167,11 +167,25 @@ pub async fn relay_frames( } } acc.apply(&ev); + if let AssistantMessageEvent::FunctioncallDelta { + arguments_preview, .. + } = &mut ev + { + *arguments_preview = acc.call_arguments_preview(); + } let is_ping = matches!(ev, AssistantMessageEvent::Ping); + let has_arguments_preview = matches!( + &ev, + AssistantMessageEvent::FunctioncallDelta { + arguments_preview: Some(_), + .. + } + ); - // Only Usage/Done/Error are enriched (cost_usd) and need - // re-serialization; every other frame is forwarded as the - // original string — no per-frame re-serialize on the hot path. + // Usage/Done/Error carry router-owned cost. Function deltas + // carrying an identity preview are also re-serialized; the + // preview is bounded and excludes payload, so frame size stays + // O(chunk). Every other frame remains byte-identical. let enriched = match ev { AssistantMessageEvent::Usage { usage: u } => { let filled = fill_cost_usd(&u, opts.pricing.as_ref()); @@ -191,6 +205,7 @@ pub async fn relay_frames( .or_else(|| usage.clone()); Some(AssistantMessageEvent::Error { error }) } + delta if has_arguments_preview => Some(delta), _ => None, }; @@ -600,6 +615,56 @@ mod loop_tests { ); } + #[tokio::test] + async fn function_deltas_carry_the_router_extracted_identity_preview() { + use crate::types::content::ContentBlock; + + let provider = FakeChannel::new(); + let mut caller = FakeChannel::new(); + let mut with_call = partial(""); + with_call.content.push(ContentBlock::FunctionCall { + id: "c1".into(), + function_id: "agent_trigger".into(), + arguments: serde_json::json!({}), + }); + send( + &provider, + &AssistantMessageEvent::FunctioncallStart { partial: with_call }, + ); + send( + &provider, + &AssistantMessageEvent::FunctioncallDelta { + partial: None, + delta: r#"{"function":"state::se"#.into(), + id: "c1".into(), + arguments_preview: None, + }, + ); + send( + &provider, + &AssistantMessageEvent::Done { + message: partial(""), + }, + ); + provider.writer.close(); + + let (_, opts) = opts(1000); + let mut reader: Box = Box::new(provider.reader); + assert!(matches!( + relay_frames(&mut reader, &caller.writer.clone(), &opts).await, + RelayResult::Done { .. } + )); + let frames = drain(&mut caller).await; + let AssistantMessageEvent::FunctioncallDelta { + arguments_preview: Some(preview), + .. + } = &frames[1] + else { + panic!("missing function-call arguments preview") + }; + assert_eq!(preview.function.as_deref(), Some("state::se")); + } + #[tokio::test] async fn non_enriched_frames_are_forwarded_byte_identical() { // The relay must not re-serialize pass-through frames: the caller diff --git a/llm-router/src/json_stream.rs b/llm-router/src/json_stream.rs new file mode 100644 index 000000000..9c4746f07 --- /dev/null +++ b/llm-router/src/json_stream.rs @@ -0,0 +1,697 @@ +//! Internal incremental JSON reconstruction for streamed tool arguments. +//! +//! Ported from . +//! +//! MIT License +//! Copyright (c) 2024 Sérgio Marcelino +//! +//! Permission is hereby granted, free of charge, to any person obtaining a copy +//! of this software and associated documentation files (the "Software"), to +//! deal in the Software without restriction, including without limitation the +//! rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +//! sell copies of the Software, and to permit persons to whom the Software is +//! furnished to do so, subject to the following conditions: the above +//! copyright notice and this permission notice shall be included in all copies +//! or substantial portions of the Software. +//! +//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +//! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +//! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +//! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +//! FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +//! IN THE SOFTWARE. +//! +//! A normal JSON parser exposes nothing until the closing delimiter arrives. +//! [`JsonStream`] keeps parser state between writes and can instead return a +//! [`Snapshot`] containing every completed value plus the string or container +//! currently being built. It is intentionally small and assumes the producer +//! intends to send valid JSON, matching the LLM tool-argument use case. + +use serde_json::{Map, Number, Value}; +use std::fmt; + +/// One view of the streamed document. +#[derive(Debug, Clone, PartialEq)] +pub struct Snapshot { + pub value: Value, + /// `true` only after the root value's closing token was received. + pub complete: bool, +} + +/// A syntax error at a byte offset in the concatenated stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error { + pub offset: usize, + pub message: String, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} at byte {}", self.message, self.offset) + } +} + +impl std::error::Error for Error {} + +/// Stateful incremental JSON parser. +#[derive(Debug, Default)] +pub struct JsonStream { + frames: Vec, + token: Option, + root: Option, + bytes_seen: usize, + error: Option, +} + +#[derive(Debug)] +enum Frame { + Object { + values: Map, + state: ObjectState, + key: Option, + }, + Array { + values: Vec, + state: ArrayState, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ObjectState { + KeyOrEnd, + Key, + Colon, + Value, + CommaOrEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ArrayState { + ValueOrEnd, + Value, + CommaOrEnd, +} + +#[derive(Debug)] +enum Token { + String(StringToken), + Number(String), + Literal { kind: Literal, matched: usize }, +} + +#[derive(Debug)] +struct StringToken { + value: String, + target: StringTarget, + escape: StringEscape, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StringTarget { + Key, + Value, +} + +#[derive(Debug)] +enum StringEscape { + None, + Escape, + Unicode { value: u16, digits: u8 }, + LowSurrogateSlash { high: u16 }, + LowSurrogateU { high: u16 }, + LowSurrogate { high: u16, value: u16, digits: u8 }, +} + +#[derive(Debug, Clone, Copy)] +enum Literal { + True, + False, + Null, +} + +impl Literal { + fn text(self) -> &'static str { + match self { + Self::True => "true", + Self::False => "false", + Self::Null => "null", + } + } + + fn value(self) -> Value { + match self { + Self::True => Value::Bool(true), + Self::False => Value::Bool(false), + Self::Null => Value::Null, + } + } +} + +impl JsonStream { + pub fn new() -> Self { + Self::default() + } + + /// Consume another piece of the same JSON document. + pub fn write(&mut self, chunk: &str) -> Result<(), Error> { + if let Some(error) = &self.error { + return Err(error.clone()); + } + + for (index, ch) in chunk.char_indices() { + let offset = self.bytes_seen + index; + let mut reprocess = true; + while reprocess { + reprocess = match self.consume(ch, offset) { + Ok(reprocess) => reprocess, + Err(error) => { + self.error = Some(error.clone()); + return Err(error); + } + }; + } + } + self.bytes_seen += chunk.len(); + Ok(()) + } + + /// Everything observable so far. Open strings and containers are present + /// in the returned value; incomplete keys and literals are not. + pub fn snapshot(&self) -> Option { + if let Some(root) = &self.root { + return Some(Snapshot { + value: root.clone(), + complete: self.frames.is_empty() && self.token.is_none() && self.error.is_none(), + }); + } + + let mut current = self.partial_token_value(); + for frame in self.frames.iter().rev() { + current = Some(match frame { + Frame::Object { values, state, key } => { + let mut values = values.clone(); + if *state == ObjectState::Value { + if let (Some(key), Some(value)) = (key, current.take()) { + values.insert(key.clone(), value); + } + } + Value::Object(values) + } + Frame::Array { values, state } => { + let mut values = values.clone(); + if matches!(state, ArrayState::ValueOrEnd | ArrayState::Value) { + if let Some(value) = current.take() { + values.push(value); + } + } + Value::Array(values) + } + }); + } + + current.map(|value| Snapshot { + value, + complete: false, + }) + } + + #[cfg(test)] + pub fn is_complete(&self) -> bool { + self.root.is_some() + && self.frames.is_empty() + && self.token.is_none() + && self.error.is_none() + } + + fn consume(&mut self, ch: char, offset: usize) -> Result { + if self.token.is_some() { + return self.consume_token(ch, offset); + } + if ch.is_whitespace() { + return Ok(false); + } + if self.root.is_some() && self.frames.is_empty() { + return Err(syntax(offset, "unexpected data after the root value")); + } + + enum Context { + RootValue, + Object(ObjectState), + Array(ArrayState), + } + let context = match self.frames.last() { + Some(Frame::Object { state, .. }) => Context::Object(*state), + Some(Frame::Array { state, .. }) => Context::Array(*state), + None => Context::RootValue, + }; + + match context { + Context::RootValue + | Context::Object(ObjectState::Value) + | Context::Array(ArrayState::Value) => self.start_value(ch, offset)?, + Context::Array(ArrayState::ValueOrEnd) => { + if ch == ']' { + self.close_array(offset)?; + } else { + self.start_value(ch, offset)?; + } + } + Context::Object(state @ (ObjectState::KeyOrEnd | ObjectState::Key)) => match ch { + '}' if state == ObjectState::KeyOrEnd => self.close_object(offset)?, + '"' => { + self.token = Some(Token::String(StringToken { + value: String::new(), + target: StringTarget::Key, + escape: StringEscape::None, + })); + } + _ => return Err(syntax(offset, "expected an object key")), + }, + Context::Object(ObjectState::Colon) => { + if ch != ':' { + return Err(syntax(offset, "expected ':' after an object key")); + } + if let Some(Frame::Object { state, .. }) = self.frames.last_mut() { + *state = ObjectState::Value; + } + } + Context::Object(ObjectState::CommaOrEnd) => match ch { + ',' => { + if let Some(Frame::Object { state, .. }) = self.frames.last_mut() { + *state = ObjectState::Key; + } + } + '}' => self.close_object(offset)?, + _ => return Err(syntax(offset, "expected ',' or '}' in an object")), + }, + Context::Array(ArrayState::CommaOrEnd) => match ch { + ',' => { + if let Some(Frame::Array { state, .. }) = self.frames.last_mut() { + *state = ArrayState::Value; + } + } + ']' => self.close_array(offset)?, + _ => return Err(syntax(offset, "expected ',' or ']' in an array")), + }, + } + Ok(false) + } + + fn start_value(&mut self, ch: char, offset: usize) -> Result<(), Error> { + match ch { + '{' => self.frames.push(Frame::Object { + values: Map::new(), + state: ObjectState::KeyOrEnd, + key: None, + }), + '[' => self.frames.push(Frame::Array { + values: Vec::new(), + state: ArrayState::ValueOrEnd, + }), + '"' => { + self.token = Some(Token::String(StringToken { + value: String::new(), + target: StringTarget::Value, + escape: StringEscape::None, + })); + } + '-' | '0'..='9' => self.token = Some(Token::Number(ch.to_string())), + 't' => { + self.token = Some(Token::Literal { + kind: Literal::True, + matched: 1, + }) + } + 'f' => { + self.token = Some(Token::Literal { + kind: Literal::False, + matched: 1, + }) + } + 'n' => { + self.token = Some(Token::Literal { + kind: Literal::Null, + matched: 1, + }) + } + _ => return Err(syntax(offset, "expected a JSON value")), + } + Ok(()) + } + + /// Returns true when the same character is a delimiter that still needs + /// to be handled after completing a number token. + fn consume_token(&mut self, ch: char, offset: usize) -> Result { + let token = self.token.take().expect("token checked above"); + match token { + Token::String(mut string) => { + match string.escape { + StringEscape::None => match ch { + '"' => match string.target { + StringTarget::Key => self.complete_key(string.value, offset)?, + StringTarget::Value => { + self.complete_value(Value::String(string.value), offset)? + } + }, + '\\' => { + string.escape = StringEscape::Escape; + self.token = Some(Token::String(string)); + } + c if c <= '\u{1f}' => { + return Err(syntax(offset, "unescaped control character in a string")) + } + c => { + string.value.push(c); + self.token = Some(Token::String(string)); + } + }, + StringEscape::Escape => { + match ch { + '"' | '\\' | '/' => string.value.push(ch), + 'b' => string.value.push('\u{0008}'), + 'f' => string.value.push('\u{000c}'), + 'n' => string.value.push('\n'), + 'r' => string.value.push('\r'), + 't' => string.value.push('\t'), + 'u' => { + string.escape = StringEscape::Unicode { + value: 0, + digits: 0, + }; + self.token = Some(Token::String(string)); + return Ok(false); + } + _ => return Err(syntax(offset, "invalid string escape")), + } + string.escape = StringEscape::None; + self.token = Some(Token::String(string)); + } + StringEscape::Unicode { + mut value, + mut digits, + } => { + value = (value << 4) | hex(ch, offset)?; + digits += 1; + string.escape = if digits < 4 { + StringEscape::Unicode { value, digits } + } else if (0xd800..=0xdbff).contains(&value) { + StringEscape::LowSurrogateSlash { high: value } + } else if (0xdc00..=0xdfff).contains(&value) { + return Err(syntax(offset, "unexpected low surrogate")); + } else { + string.value.push( + char::from_u32(u32::from(value)) + .ok_or_else(|| syntax(offset, "invalid unicode escape"))?, + ); + StringEscape::None + }; + self.token = Some(Token::String(string)); + } + StringEscape::LowSurrogateSlash { high } => { + if ch != '\\' { + return Err(syntax(offset, "expected a low surrogate")); + } + string.escape = StringEscape::LowSurrogateU { high }; + self.token = Some(Token::String(string)); + } + StringEscape::LowSurrogateU { high } => { + if ch != 'u' { + return Err(syntax(offset, "expected a low surrogate")); + } + string.escape = StringEscape::LowSurrogate { + high, + value: 0, + digits: 0, + }; + self.token = Some(Token::String(string)); + } + StringEscape::LowSurrogate { + high, + mut value, + mut digits, + } => { + value = (value << 4) | hex(ch, offset)?; + digits += 1; + if digits < 4 { + string.escape = StringEscape::LowSurrogate { + high, + value, + digits, + }; + } else { + if !(0xdc00..=0xdfff).contains(&value) { + return Err(syntax(offset, "invalid low surrogate")); + } + let scalar = 0x1_0000 + + ((u32::from(high) - 0xd800) << 10) + + (u32::from(value) - 0xdc00); + string.value.push( + char::from_u32(scalar) + .ok_or_else(|| syntax(offset, "invalid surrogate pair"))?, + ); + string.escape = StringEscape::None; + } + self.token = Some(Token::String(string)); + } + } + Ok(false) + } + Token::Number(mut raw) => { + if matches!(ch, '0'..='9' | '.' | 'e' | 'E' | '+' | '-') { + raw.push(ch); + self.token = Some(Token::Number(raw)); + return Ok(false); + } + let number = serde_json::from_str::(&raw) + .map_err(|_| syntax(offset, "invalid JSON number"))?; + self.complete_value(Value::Number(number), offset)?; + Ok(true) + } + Token::Literal { kind, matched } => { + let text = kind.text(); + if text.as_bytes().get(matched).copied() != Some(ch as u8) { + return Err(syntax(offset, "invalid JSON literal")); + } + let matched = matched + 1; + if matched == text.len() { + self.complete_value(kind.value(), offset)?; + } else { + self.token = Some(Token::Literal { kind, matched }); + } + Ok(false) + } + } + } + + fn complete_key(&mut self, key: String, offset: usize) -> Result<(), Error> { + match self.frames.last_mut() { + Some(Frame::Object { + state: state @ (ObjectState::KeyOrEnd | ObjectState::Key), + key: slot, + .. + }) => { + *slot = Some(key); + *state = ObjectState::Colon; + Ok(()) + } + _ => Err(syntax(offset, "object key outside an object")), + } + } + + fn complete_value(&mut self, value: Value, offset: usize) -> Result<(), Error> { + match self.frames.last_mut() { + Some(Frame::Object { + values, + state: state @ ObjectState::Value, + key, + }) => { + let key = key + .take() + .ok_or_else(|| syntax(offset, "object value has no key"))?; + values.insert(key, value); + *state = ObjectState::CommaOrEnd; + } + Some(Frame::Array { values, state }) + if matches!(state, ArrayState::ValueOrEnd | ArrayState::Value) => + { + values.push(value); + *state = ArrayState::CommaOrEnd; + } + Some(_) => return Err(syntax(offset, "value in an unexpected position")), + None if self.root.is_none() => self.root = Some(value), + None => return Err(syntax(offset, "multiple root values")), + } + Ok(()) + } + + fn close_object(&mut self, offset: usize) -> Result<(), Error> { + let frame = self + .frames + .pop() + .ok_or_else(|| syntax(offset, "unexpected '}'"))?; + match frame { + Frame::Object { + values, + state: ObjectState::KeyOrEnd | ObjectState::CommaOrEnd, + .. + } => self.complete_value(Value::Object(values), offset), + frame => { + self.frames.push(frame); + Err(syntax(offset, "object ended before its value was complete")) + } + } + } + + fn close_array(&mut self, offset: usize) -> Result<(), Error> { + let frame = self + .frames + .pop() + .ok_or_else(|| syntax(offset, "unexpected ']'"))?; + match frame { + Frame::Array { + values, + state: ArrayState::ValueOrEnd | ArrayState::CommaOrEnd, + } => self.complete_value(Value::Array(values), offset), + frame => { + self.frames.push(frame); + Err(syntax(offset, "array ended before its value was complete")) + } + } + } + + fn partial_token_value(&self) -> Option { + match self.token.as_ref()? { + Token::String(StringToken { + value, + target: StringTarget::Value, + .. + }) => Some(Value::String(value.clone())), + Token::Number(raw) => serde_json::from_str::(raw).ok().map(Value::Number), + Token::String(_) | Token::Literal { .. } => None, + } + } +} + +fn hex(ch: char, offset: usize) -> Result { + ch.to_digit(16) + .map(|digit| digit as u16) + .ok_or_else(|| syntax(offset, "invalid unicode escape")) +} + +fn syntax(offset: usize, message: impl Into) -> Error { + Error { + offset, + message: message.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn exposes_a_string_before_its_closing_quote() { + let mut stream = JsonStream::new(); + stream.write(r#"{"function":"state::se"#).unwrap(); + assert_eq!( + stream.snapshot(), + Some(Snapshot { + value: json!({ "function": "state::se" }), + complete: false, + }) + ); + } + + #[test] + fn preserves_completed_and_nested_partial_values_across_chunks() { + let mut stream = JsonStream::new(); + for chunk in [ + r#"{"function":"state::set","desc"#, + r#"ription":"Updating st"#, + r#"ate","payload":{"scope":"art"#, + ] { + stream.write(chunk).unwrap(); + } + assert_eq!( + stream.snapshot().unwrap().value, + json!({ + "function": "state::set", + "description": "Updating state", + "payload": { "scope": "art" } + }) + ); + stream.write(r#"ifacts","values":[1,2]} }"#).unwrap(); + let snapshot = stream.snapshot().unwrap(); + assert!(snapshot.complete); + assert_eq!(snapshot.value["payload"]["scope"], "artifacts"); + assert_eq!(snapshot.value["payload"]["values"], json!([1, 2])); + } + + #[test] + fn handles_escapes_and_surrogate_pairs_split_between_chunks() { + let mut stream = JsonStream::new(); + for chunk in [r#"{"text":"line\n\uD83"#, r#"D\uDE"#, r#"80"}"#] { + stream.write(chunk).unwrap(); + } + assert_eq!( + stream.snapshot().unwrap().value, + json!({ "text": "line\n🚀" }) + ); + assert!(stream.is_complete()); + } + + #[test] + fn parses_empty_containers_literals_and_decimal_numbers() { + let mut stream = JsonStream::new(); + stream + .write(r#"{"empty":[],"nested":{},"yes":true,"no":false,"nil":null,"n":-1.5e2}"#) + .unwrap(); + assert_eq!( + stream.snapshot().unwrap().value, + json!({ + "empty": [], + "nested": {}, + "yes": true, + "no": false, + "nil": null, + "n": -150.0 + }) + ); + } + + #[test] + fn every_chunk_boundary_matches_serde_json_when_complete() { + let input = r#"{"string":"quote: \" slash: \\ newline: \n","unicode":"\uD83D\uDE80","numbers":[-1.25e2,0,42],"values":[true,false,null,{},[]]}"#; + let expected: Value = serde_json::from_str(input).unwrap(); + let boundaries: Vec = input + .char_indices() + .map(|(index, _)| index) + .chain(std::iter::once(input.len())) + .collect(); + + for &boundary in &boundaries { + let mut stream = JsonStream::new(); + stream.write(&input[..boundary]).unwrap(); + stream.write(&input[boundary..]).unwrap(); + let snapshot = stream.snapshot().unwrap(); + assert!(snapshot.complete, "boundary {boundary}"); + assert_eq!(snapshot.value, expected, "boundary {boundary}"); + } + + let mut char_at_a_time = JsonStream::new(); + for ch in input.chars() { + char_at_a_time.write(&ch.to_string()).unwrap(); + } + assert_eq!(char_at_a_time.snapshot().unwrap().value, expected); + } + + #[test] + fn rejects_trailing_commas_and_stays_failed() { + let mut stream = JsonStream::new(); + let first = stream.write(r#"{"x":1,}"#).unwrap_err(); + assert_eq!(stream.write(" ").unwrap_err(), first); + assert!(!stream.is_complete()); + } +} diff --git a/llm-router/src/lib.rs b/llm-router/src/lib.rs index 374a9e58d..bc8ad6120 100644 --- a/llm-router/src/lib.rs +++ b/llm-router/src/lib.rs @@ -7,6 +7,7 @@ pub mod chat; pub mod config; pub mod count_tokens; pub mod embed; +mod json_stream; pub mod manifest; pub mod provider_scaffold; pub mod register; diff --git a/llm-router/src/registry/availability.rs b/llm-router/src/registry/availability.rs index 4557820ce..71c705a1e 100644 --- a/llm-router/src/registry/availability.rs +++ b/llm-router/src/registry/availability.rs @@ -30,6 +30,7 @@ pub fn make_provider_list( .display_name .clone() .unwrap_or_else(|| rec.declaration.id.clone()), + credential_env_var: rec.declaration.credential_env_var.clone(), configured: resolved.configured, available: rec.available, supports_model_listing: rec.declaration.supports_model_listing.unwrap_or(false), diff --git a/llm-router/src/registry/store.rs b/llm-router/src/registry/store.rs index 0f38b4322..7aee18783 100644 --- a/llm-router/src/registry/store.rs +++ b/llm-router/src/registry/store.rs @@ -161,13 +161,23 @@ impl RegistryStore { let raw_token = token.unwrap_or_else(|| Uuid::new_v4().to_string()); let recovered = availability_recovered(existing); let record = build_record(existing, declaration, worker_id, &raw_token); - records.insert(record.declaration.id.clone(), record.clone()); - self.persist(&records).await.map_err(|e| { + + // Persist a candidate snapshot before publishing it in memory. During a + // concurrent stack boot the provider can reach the router before the + // state worker has registered `state::set`. If we mutate `records` + // first, that transient failure leaves behind only the token hash while + // the raw token never reaches the provider; every retry is then rejected + // as a takeover. Keeping the live map untouched makes the provider's + // normal backoff retry safe once state becomes available. + let mut next = records.clone(); + next.insert(record.declaration.id.clone(), record.clone()); + self.persist(&next).await.map_err(|e| { RouterError::new( RouterCode::InvalidRequest, format!("registry persist failed: {e}"), ) })?; + *records = next; Ok(Upserted { record, token: raw_token, diff --git a/llm-router/src/types/events.rs b/llm-router/src/types/events.rs index 1e661233c..fc44299e7 100644 --- a/llm-router/src/types/events.rs +++ b/llm-router/src/types/events.rs @@ -46,6 +46,17 @@ pub struct Usage { pub cost_usd: Option, // filled by llm-router from catalog pricing } +/// Small, bounded view of the top-level agent_trigger identity observed while +/// its JSON arguments are still forming. The router adds this to relayed +/// deltas; providers leave it absent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct FunctionCallArgumentsPreview { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + /// The frozen 15-variant streaming vocabulary (README § Streaming events). /// New frame types are a contract revision, not a provider choice. /// @@ -96,6 +107,9 @@ pub enum AssistantMessageEvent { /// Call id receiving this delta; empty from pre-id producers. #[serde(default, skip_serializing_if = "String::is_empty")] id: String, + /// Router-produced view of fields useful to an in-flight UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + arguments_preview: Option, }, FunctioncallEnd { partial: AssistantMessage, diff --git a/llm-router/src/types/messages.rs b/llm-router/src/types/messages.rs index e5375fc2f..357e5d46d 100644 --- a/llm-router/src/types/messages.rs +++ b/llm-router/src/types/messages.rs @@ -145,10 +145,10 @@ pub fn reorder_displaced_results(messages: &[AgentMessage]) -> Vec<&AgentMessage /// a non-object `tool_use.input` is a hard Anthropic 400 once the turn is /// replayed (sessions switch providers). Two-step recovery: /// -/// 1. Salvage the complete leading fields of a partial object — a call cut -/// mid-stream (`{"function":"state::set","payload":{"key":`) keeps its -/// known prefix (`{"function":"state::set"}`), so long-streaming calls -/// stay identifiable in UIs instead of rendering as an anonymous `{}`. +/// 1. Incrementally reconstruct the partial object — a call cut mid-string +/// (`{"function":"state::se`) already exposes `state::se`, and completed +/// fields plus open nested containers remain available. Long-streaming +/// calls therefore stay identifiable instead of rendering as anonymous. /// The salvaged object is stamped `"_partial": true` so dispatch layers /// can refuse to execute partial intent (a truncated call must surface a /// teachable error, not run with whatever fields happened to complete). @@ -163,48 +163,24 @@ pub fn degraded_arguments(args_json: &str) -> serde_json::Value { serde_json::json!({ "_raw": utf8_head(args_json, 2048) }) } -/// The complete leading fields of a partial JSON object string, if any. -/// Scans only the first 4KB (a partial is rebuilt per stream event, so this -/// must stay O(head); leading fields live in the prefix), cutting at -/// top-level commas and taking the longest prefix that parses once closed. +/// The observable fields of a partial JSON object string, if any. This public +/// helper keeps its historical name for provider compatibility; unlike the +/// old comma-cutting implementation it also returns the string/container that +/// is currently being streamed. Provider snapshots rebuild this value often, +/// so only inspect the first 4 KiB; the stateful router/harness paths consume +/// the full stream once, chunk by chunk. pub fn salvage_leading_object_fields( args: &str, ) -> Option> { - let head = utf8_head(args, 4096); - let bytes = head.as_bytes(); - let (mut depth, mut in_str, mut esc, mut started) = (0usize, false, false, false); - let mut cuts: Vec = Vec::new(); - for (i, &b) in bytes.iter().enumerate() { - if esc { - esc = false; - continue; - } - match b { - b'\\' if in_str => esc = true, - b'"' => in_str = !in_str, - _ if in_str => {} - b'{' | b'[' => { - depth += 1; - started = true; - } - b'}' | b']' => depth = depth.saturating_sub(1), - b',' if depth == 1 => cuts.push(i), - _ => {} - } - } - if !started { - return None; - } - // Longest salvageable prefix wins; candidates are few (top-level commas). - for &cut in cuts.iter().rev() { - let candidate = format!("{}}}", &head[..cut]); - if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(&candidate) { - if !map.is_empty() { - return Some(map); - } - } + let mut stream = crate::json_stream::JsonStream::new(); + // Even after malformed input, completed fields before the error are safe + // to display. Dispatch still refuses them because degraded_arguments adds + // `_partial`; the original bytes remain in `_raw` when nothing survived. + let _ = stream.write(utf8_head(args, 4096)); + match stream.snapshot()?.value { + serde_json::Value::Object(map) if !map.is_empty() => Some(map), + _ => None, } - None } fn utf8_head(s: &str, max: usize) -> &str { @@ -228,26 +204,51 @@ mod tests { // partial so it renders but never executes. assert_eq!( degraded_arguments(r#"{"function":"state::set","payload":{"key":"art"#), - serde_json::json!({ "function": "state::set", "_partial": true }) + serde_json::json!({ + "function": "state::set", + "payload": { "key": "art" }, + "_partial": true + }) ); - // Longest complete prefix wins, nested commas/strings don't cut. + // Nested complete fields and the deepest open string all survive. assert_eq!( degraded_arguments( r#"{"function":"a::b","payload":{"x":"1,2","y":[3,4]},"extra":{"cut":"# ), - serde_json::json!({ "function": "a::b", "payload": { "x": "1,2", "y": [3, 4] }, "_partial": true }) + serde_json::json!({ + "function": "a::b", + "payload": { "x": "1,2", "y": [3, 4] }, + "extra": {}, + "_partial": true + }) ); - // Nothing salvageable (no complete top-level field yet, or not JSON): - // the raw text survives as evidence, always inside an object. + // An open string is observable before its closing quote. assert_eq!( degraded_arguments(r#"{"function":"state::se"#), - serde_json::json!({ "_raw": r#"{"function":"state::se"# }) + serde_json::json!({ "function": "state::se", "_partial": true }) ); + // Nothing salvageable (no key/value yet, or not JSON): the raw text + // survives as evidence, always inside an object. assert_eq!( degraded_arguments("{'key': 'v'}"), serde_json::json!({ "_raw": "{'key': 'v'}" }) ); } + + #[test] + fn degraded_arguments_exposes_an_open_description_and_payload() { + assert_eq!( + degraded_arguments( + r#"{"function":"state::set","description":"Updating arti","payload":{"scope":"wor"# + ), + serde_json::json!({ + "function": "state::set", + "description": "Updating arti", + "payload": { "scope": "wor" }, + "_partial": true + }) + ); + } use crate::types::events::StopReason; fn assistant(content: Vec) -> AgentMessage { diff --git a/llm-router/src/types/router.rs b/llm-router/src/types/router.rs index 7d52f65b3..22637031f 100644 --- a/llm-router/src/types/router.rs +++ b/llm-router/src/types/router.rs @@ -90,6 +90,9 @@ pub struct AbortResponse { pub struct ProviderInfo { pub id: String, pub display_name: String, + /// Environment variable declared by API-key providers. `None` means the + /// provider owns authentication (OAuth, local app login, device flow). + pub credential_env_var: Option, pub configured: bool, pub available: bool, pub supports_model_listing: bool, diff --git a/llm-router/tests/golden/schemas/router.provider.list.json b/llm-router/tests/golden/schemas/router.provider.list.json index 89837d215..6c92bffa2 100644 --- a/llm-router/tests/golden/schemas/router.provider.list.json +++ b/llm-router/tests/golden/schemas/router.provider.list.json @@ -18,6 +18,13 @@ "configured": { "type": "boolean" }, + "credential_env_var": { + "description": "Environment variable declared by API-key providers. `None` means the provider owns authentication (OAuth, local app login, device flow).", + "type": [ + "string", + "null" + ] + }, "display_name": { "type": "string" }, diff --git a/llm-router/tests/integration.rs b/llm-router/tests/integration.rs index ed8a010b1..fe7ba07f5 100644 --- a/llm-router/tests/integration.rs +++ b/llm-router/tests/integration.rs @@ -14,6 +14,8 @@ use iii_sdk::errors::Error; use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; use iii_sdk::{register_worker, IIIClient, InitOptions, RegisterFunction}; use llm_router::register::register_router; +use llm_router::registry::store::RegistryStore; +use llm_router::types::router::ProviderDeclaration; use serde_json::{json, Value}; // ── engine bootstrap ──────────────────────────────────────────────────────── @@ -1132,6 +1134,48 @@ async fn router_boots_its_interface_against_a_bare_engine() { router_iii.shutdown(); } +#[tokio::test(flavor = "multi_thread")] +async fn failed_registry_persist_does_not_poison_provider_retries() { + // Bundle workers start concurrently. A provider may declare itself after + // the router is ready but before the state worker exposes `state::set`. + // Both attempts below must fail for that transient dependency only; the + // first must not leave a token hash that turns the second into a takeover. + let engine = bare_engine_or_skip!(); + let iii = register_worker(&engine.url, InitOptions::default()); + let registry = RegistryStore::new(iii.clone()); + let declaration: ProviderDeclaration = + serde_json::from_value(json!({ "id": "late-state" })).unwrap(); + + for attempt in 1..=2 { + let error = match registry + .upsert( + declaration.clone(), + Some("provider-late-state".into()), + None, + ) + .await + { + Err(error) => error, + Ok(_) => panic!("state persistence is unavailable on the bare engine"), + }; + assert_eq!( + error.code, + llm_router::types::errors::RouterCode::InvalidRequest, + "attempt {attempt} must remain retryable, got: {error}" + ); + assert!( + error.message.contains("registry persist failed"), + "attempt {attempt} failed for an unexpected reason: {error}" + ); + assert!( + registry.ids().await.is_empty(), + "failed persistence must not publish a provider binding" + ); + } + + iii.shutdown(); +} + #[tokio::test(flavor = "multi_thread")] async fn complete_fails_fast_when_the_provider_worker_is_gone() { // Regression for the boundary hang: a registered provider whose worker is diff --git a/packages/console-ui/README.md b/packages/console-ui/README.md index 640319ba6..e67292fdc 100644 --- a/packages/console-ui/README.md +++ b/packages/console-ui/README.md @@ -26,6 +26,26 @@ copying types around: import { Button, EmptyState, type Host } from '@iii-dev/console-ui' ``` +Function-trigger renderers receive the harness's optional user-facing +`message.description`. A renderer can declare `metadata: { display: true }` +to keep a successful rich artifact (for example a screenshot or file-change +summary) visible in the chat flow; return `null` for unsupported/error shapes +to fall through to the next renderer. + +Renderers can also open their worker's registered page with contextual JSON: + +```tsx +host.panels?.open({ + pageId: 'shell', + context: { type: 'file', path: '/repo/src/app.ts' }, +}) +``` + +The host reuses an existing page or places it beside chat, and delivers a +`panelContext` event to the page's `PageRenderProps`. Use the event `id` to +react to repeated clicks. Context is ephemeral; fetch large bodies from the +worker by opaque id. + and keep it external in the build (alongside the react specifiers): ```js diff --git a/packages/console-ui/index.d.ts b/packages/console-ui/index.d.ts index b2676b56d..9637cc9c3 100644 --- a/packages/console-ui/index.d.ts +++ b/packages/console-ui/index.d.ts @@ -57,6 +57,22 @@ export interface ExtensionIii { */ export type PanelSide = 'left' | 'right' +/** JSON context sent by an injected renderer to an injected page. */ +export interface PanelContextEvent { + /** Monotonic per-console-tab id; repeated context still produces an event. */ + id: number + /** Registered page id that owns this context. */ + pageId: string + context: T +} + +export interface PanelOpenRequest { + /** Registered extension page to place or reuse in the workspace. */ + pageId: string + /** Worker-defined, JSON-serializable context delivered to that page. */ + context?: T +} + /** Props the host passes to every registered page render component. */ export interface PageRenderProps { panelSide: PanelSide @@ -80,6 +96,8 @@ export interface PageRenderProps { * no conversation is active or none is set. */ workingDir?: string | null + /** Latest context sent through `host.panels.open()` for this page. */ + panelContext?: PanelContextEvent /** Active chat session id. Reactive pages use it to subscribe to exact Harness turn boundaries without receiving another chat's events. */ conversationId?: string | null @@ -103,6 +121,8 @@ export interface FunctionTriggerMessage { id: string role: 'function-trigger' functionId: string + /** Short user-facing action supplied by the agent_trigger wrapper. */ + description?: string input: unknown output?: unknown durationMs?: number @@ -124,6 +144,13 @@ export interface FunctionTriggerRenderer { tryRenderPreview?(message: FunctionTriggerMessage): React.ReactNode | null FunctionIdLabel?: React.ComponentType<{ functionId: string }> primaryTabLabel?: string + /** + * Presentation hints owned by the worker. `display` makes a successful + * non-null render visible in chat without opening the raw call details. + */ + metadata?: { + display?: boolean + } /** * Redact the raw request/response before the console DISPLAYS OR COPIES * it. The card's `raw json` tab renders `message.input` / `message.output` @@ -172,6 +199,30 @@ export interface ConfigFormProps { focusField?: readonly string[] } +/** Layout the Console should reserve for a configuration-form override. */ +export type ConfigFormLayout = 'contained' | 'full' + +export interface ConfigFormRegistrationOptions { + /** + * `contained` keeps the standard centered form column and host scroll. + * `full` gives the override all available width and height; the override + * must then manage any scrolling inside its own layout. + */ + layout?: ConfigFormLayout +} + +/** Provider-owned editor rendered inside the chat model picker. */ +export interface ProviderConfigFormProps { + providerId: string + schema: Record | null + value: JsonValue + onChange(next: JsonValue): void + errors?: ReadonlyMap + configured?: boolean + available?: boolean + modelCount: number +} + /** * Props a session chip receives from the chat host. Chips fetch their own * data through `host.iii`; the host only identifies the session and what @@ -229,10 +280,26 @@ export interface Host { functionTriggers: { register(renderer: FunctionTriggerRenderer): () => void } + /** + * Optional on older consoles. Feature-detect before opening contextual + * pages when a worker must remain compatible with them. + */ + panels?: { + /** Place/reuse a registered page and deliver its worker-defined context. */ + open(request: PanelOpenRequest): void + } configForms: { register( configurationId: string, component: React.ComponentType, + options?: ConfigFormRegistrationOptions, + ): () => void + } + /** Optional on consoles that predate provider-specific configuration UI. */ + providerConfigForms?: { + register( + providerId: string, + component: React.ComponentType, ): () => void } /** @@ -243,9 +310,7 @@ export interface Host { chat?: { registerSessionChip(chip: SessionChipRegistration): () => void /** Optional on consoles that predate the footer turn-summary slot. */ - registerTurnSummary?( - summary: SessionTurnSummaryRegistration, - ): () => void + registerTurnSummary?(summary: SessionTurnSummaryRegistration): () => void } } @@ -436,8 +501,7 @@ export declare const Input: React.ComponentType< PageHeader renders the standard ✕ when `onClose` is present — wire it to `PageRenderProps.onRequestClose`. */ -export interface PageShellProps - extends React.HTMLAttributes {} +export interface PageShellProps extends React.HTMLAttributes {} /** The pane's root column — fills the pane, `--color-panel` background. */ export declare const PageShell: React.ComponentType diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f2f97065..a7a4e947d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -443,6 +443,22 @@ importers: specifier: ^5.9.2 version: 5.9.3 + provider-openai-codex/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + sandbox-code-runner/ui: dependencies: '@iii-dev/console-ui': @@ -521,6 +537,38 @@ importers: specifier: ^5.9.2 version: 5.9.3 + storage/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + + web/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + worktree/ui: dependencies: '@iii-dev/console-ui': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 192000217..c881de49a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ packages: - console/web - console/ui - browser/ui + - web/ui - canvas/ui - sandbox-code-runner/ui - code-runner/ui @@ -21,11 +22,13 @@ packages: - memory/ui - pdf/ui - state/ui + - storage/ui - iii-directory/ui - shell/ui - worktree/ui - github/ui - llm-router/ui + - provider-openai-codex/ui # pnpm ≥11 blocks dependency postinstall scripts by default; esbuild's # installs its platform binary. protobufjs (via iii-sdk → @opentelemetry) diff --git a/provider-anthropic/Cargo.lock b/provider-anthropic/Cargo.lock index 30b8d6ae2..e850ee3a3 100644 --- a/provider-anthropic/Cargo.lock +++ b/provider-anthropic/Cargo.lock @@ -996,7 +996,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "1.4.2" +version = "1.4.7" dependencies = [ "async-trait", "clap", diff --git a/provider-anthropic/prompts/identity.txt b/provider-anthropic/prompts/identity.txt index 0ab3a7d0a..3e6e04ebe 100644 --- a/provider-anthropic/prompts/identity.txt +++ b/provider-anthropic/prompts/identity.txt @@ -1,9 +1,10 @@ You are an iii agent worker. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (the function id, like `engine::functions::list`) and -`payload` (a JSON OBJECT with the function's arguments). Everything you do happens through -`agent_trigger`. Never use a function id from memory. +three arguments: `function` (the function id, like `engine::functions::list`), `description` +(a short user-facing description of the action in the user's language), and `payload` (a JSON +OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +Never use a function id from memory. iii is a mesh of workers connected to one engine. Each worker registers functions. A function id looks like `worker::name`. Every call goes through the engine: worker → engine → worker. @@ -15,6 +16,28 @@ this reply ends, register a trigger; do not poll, and do not keep a turn alive t # System rules +## User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + Follow these steps for EVERY action. Do not skip a step. Step 1. Find the function id. Call `engine::functions::list` with an optional filter: @@ -36,7 +59,9 @@ field, or a registry-change notice appears. Need more than one contract at once? `{ function_ids: ["a::b", "c::d"] }` and it returns `{ functions: [...] }`, one per id — one call, never one per id. -Step 3. Call the function. The `payload` is a JSON OBJECT, never a string. Match the +Step 3. Call the function. Set `description` to a concise action label in the user's language +(for example, "Reading configuration files"), without the function id or implementation +jargon. The `payload` is a JSON OBJECT, never a string. Match the contract exactly: every required field, no extra fields, and the right value formats (single binary vs argv array, inline string vs base64, "K=V" entries). Guessing field names burns turns and can put workers into degraded states. If a value is long or multi-line @@ -48,9 +73,10 @@ Step 4. If you get an error, read it and change something. Never send the same ` user: List the files under /tmp. -assistant: [calls engine::functions::list { search: "ls" } and finds shell::fs::ls] +assistant: I will find the right function and then list the files under /tmp. +[calls engine::functions::list { search: "ls" } and finds shell::fs::ls] [calls engine::functions::info { function_id: "shell::fs::ls" } to get the contract] -[calls agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[calls agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] ## Payload rules @@ -79,10 +105,10 @@ WRONG is a string. RIGHT is an object. Always send an object. Resending an identical failed call is never the fix. -[agent_trigger with function: "shell::fs::ls", payload: "{ \"path\": \"/tmp\" }"] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: "{ \"path\": \"/tmp\" }"] error: serialization error: invalid type: string, expected struct assistant: The payload was a JSON-encoded string. Re-issuing the SAME function with an object: -[agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] # Doing tasks @@ -283,7 +309,7 @@ I am installing the "email" worker from the public registry so I can send the re [calls worker::add { source: { kind: "registry", name: "email" } }] [calls engine::functions::list { prefix: "email::" } — the new function ids appear] [calls engine::functions::info { function_id: "email::send" } to get the contract] -[calls agent_trigger with function: "email::send", payload: { ...per the contract }] +[calls agent_trigger with function: "email::send", description: "Sending the email", payload: { ...per the contract }] To author a worker: import ONLY `registerWorker` from the SDK. Its return value has the diff --git a/provider-anthropic/src/sse.rs b/provider-anthropic/src/sse.rs index 284e1741a..89fe1c994 100644 --- a/provider-anthropic/src/sse.rs +++ b/provider-anthropic/src/sse.rs @@ -358,6 +358,7 @@ pub fn handle_sse_event( partial: None, delta: json.to_string(), id: state.function_calls[idx].id.clone(), + arguments_preview: None, }); } Some(BlockSlot::Thinking(idx)) if delta_type == "thinking_delta" => { diff --git a/provider-claude-code/Cargo.lock b/provider-claude-code/Cargo.lock index 951bc3546..964f0fb78 100644 --- a/provider-claude-code/Cargo.lock +++ b/provider-claude-code/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -61,6 +75,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "async-trait" version = "0.1.91" @@ -78,12 +98,33 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.1" @@ -99,6 +140,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -111,6 +163,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.3.0" @@ -190,6 +251,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -224,6 +300,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -234,12 +335,93 @@ dependencies = [ "typenum", ] +[[package]] +name = "daachorse" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -267,6 +449,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "errno" version = "0.3.14" @@ -277,12 +465,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -527,7 +738,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -626,6 +837,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -701,6 +918,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -738,7 +964,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "1.4.1" +version = "1.4.7" dependencies = [ "async-trait", "clap", @@ -746,11 +972,14 @@ dependencies = [ "iii-helpers", "iii-sdk", "regex", + "reqwest", "schemars", "serde", "serde_json", "sha2", "thiserror", + "tiktoken-rs", + "tokenizers", "tokio", "tracing", "tracing-subscriber", @@ -769,6 +998,22 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "matchers" version = "0.2.0" @@ -784,6 +1029,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.2" @@ -795,6 +1046,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -894,6 +1177,18 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -961,7 +1256,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror", @@ -982,7 +1277,7 @@ dependencies = [ "rand 0.10.2", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -1082,6 +1377,37 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "regex" version = "1.13.1" @@ -1117,7 +1443,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1166,6 +1492,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1422,12 +1754,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -1525,6 +1875,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiktoken-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4a168cfc1d8ed65bf17a6ee0843ad9a68f863c63c0fb2fa7eab67838782ee" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bstr", + "fancy-regex", + "lazy_static", + "regex", + "rustc-hash 1.1.0", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1550,6 +1915,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" +dependencies = [ + "ahash", + "compact_str", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.5", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.53.1" @@ -1770,6 +2168,27 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/provider-claude-code/prompts/identity.txt b/provider-claude-code/prompts/identity.txt index 0ab3a7d0a..3e6e04ebe 100644 --- a/provider-claude-code/prompts/identity.txt +++ b/provider-claude-code/prompts/identity.txt @@ -1,9 +1,10 @@ You are an iii agent worker. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (the function id, like `engine::functions::list`) and -`payload` (a JSON OBJECT with the function's arguments). Everything you do happens through -`agent_trigger`. Never use a function id from memory. +three arguments: `function` (the function id, like `engine::functions::list`), `description` +(a short user-facing description of the action in the user's language), and `payload` (a JSON +OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +Never use a function id from memory. iii is a mesh of workers connected to one engine. Each worker registers functions. A function id looks like `worker::name`. Every call goes through the engine: worker → engine → worker. @@ -15,6 +16,28 @@ this reply ends, register a trigger; do not poll, and do not keep a turn alive t # System rules +## User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + Follow these steps for EVERY action. Do not skip a step. Step 1. Find the function id. Call `engine::functions::list` with an optional filter: @@ -36,7 +59,9 @@ field, or a registry-change notice appears. Need more than one contract at once? `{ function_ids: ["a::b", "c::d"] }` and it returns `{ functions: [...] }`, one per id — one call, never one per id. -Step 3. Call the function. The `payload` is a JSON OBJECT, never a string. Match the +Step 3. Call the function. Set `description` to a concise action label in the user's language +(for example, "Reading configuration files"), without the function id or implementation +jargon. The `payload` is a JSON OBJECT, never a string. Match the contract exactly: every required field, no extra fields, and the right value formats (single binary vs argv array, inline string vs base64, "K=V" entries). Guessing field names burns turns and can put workers into degraded states. If a value is long or multi-line @@ -48,9 +73,10 @@ Step 4. If you get an error, read it and change something. Never send the same ` user: List the files under /tmp. -assistant: [calls engine::functions::list { search: "ls" } and finds shell::fs::ls] +assistant: I will find the right function and then list the files under /tmp. +[calls engine::functions::list { search: "ls" } and finds shell::fs::ls] [calls engine::functions::info { function_id: "shell::fs::ls" } to get the contract] -[calls agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[calls agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] ## Payload rules @@ -79,10 +105,10 @@ WRONG is a string. RIGHT is an object. Always send an object. Resending an identical failed call is never the fix. -[agent_trigger with function: "shell::fs::ls", payload: "{ \"path\": \"/tmp\" }"] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: "{ \"path\": \"/tmp\" }"] error: serialization error: invalid type: string, expected struct assistant: The payload was a JSON-encoded string. Re-issuing the SAME function with an object: -[agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] # Doing tasks @@ -283,7 +309,7 @@ I am installing the "email" worker from the public registry so I can send the re [calls worker::add { source: { kind: "registry", name: "email" } }] [calls engine::functions::list { prefix: "email::" } — the new function ids appear] [calls engine::functions::info { function_id: "email::send" } to get the contract] -[calls agent_trigger with function: "email::send", payload: { ...per the contract }] +[calls agent_trigger with function: "email::send", description: "Sending the email", payload: { ...per the contract }] To author a worker: import ONLY `registerWorker` from the SDK. Its return value has the diff --git a/provider-claude-code/src/sse.rs b/provider-claude-code/src/sse.rs index b2e90bae0..aafef30c8 100644 --- a/provider-claude-code/src/sse.rs +++ b/provider-claude-code/src/sse.rs @@ -358,6 +358,7 @@ pub fn handle_sse_event( partial: None, delta: json.to_string(), id: state.function_calls[idx].id.clone(), + arguments_preview: None, }); } Some(BlockSlot::Thinking(idx)) if delta_type == "thinking_delta" => { diff --git a/provider-deepseek/Cargo.lock b/provider-deepseek/Cargo.lock index 19a4955a2..0ff1adff5 100644 --- a/provider-deepseek/Cargo.lock +++ b/provider-deepseek/Cargo.lock @@ -964,7 +964,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "1.4.2" +version = "1.4.7" dependencies = [ "async-trait", "clap", diff --git a/provider-deepseek/prompts/identity.txt b/provider-deepseek/prompts/identity.txt index 0ab3a7d0a..3e6e04ebe 100644 --- a/provider-deepseek/prompts/identity.txt +++ b/provider-deepseek/prompts/identity.txt @@ -1,9 +1,10 @@ You are an iii agent worker. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (the function id, like `engine::functions::list`) and -`payload` (a JSON OBJECT with the function's arguments). Everything you do happens through -`agent_trigger`. Never use a function id from memory. +three arguments: `function` (the function id, like `engine::functions::list`), `description` +(a short user-facing description of the action in the user's language), and `payload` (a JSON +OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +Never use a function id from memory. iii is a mesh of workers connected to one engine. Each worker registers functions. A function id looks like `worker::name`. Every call goes through the engine: worker → engine → worker. @@ -15,6 +16,28 @@ this reply ends, register a trigger; do not poll, and do not keep a turn alive t # System rules +## User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + Follow these steps for EVERY action. Do not skip a step. Step 1. Find the function id. Call `engine::functions::list` with an optional filter: @@ -36,7 +59,9 @@ field, or a registry-change notice appears. Need more than one contract at once? `{ function_ids: ["a::b", "c::d"] }` and it returns `{ functions: [...] }`, one per id — one call, never one per id. -Step 3. Call the function. The `payload` is a JSON OBJECT, never a string. Match the +Step 3. Call the function. Set `description` to a concise action label in the user's language +(for example, "Reading configuration files"), without the function id or implementation +jargon. The `payload` is a JSON OBJECT, never a string. Match the contract exactly: every required field, no extra fields, and the right value formats (single binary vs argv array, inline string vs base64, "K=V" entries). Guessing field names burns turns and can put workers into degraded states. If a value is long or multi-line @@ -48,9 +73,10 @@ Step 4. If you get an error, read it and change something. Never send the same ` user: List the files under /tmp. -assistant: [calls engine::functions::list { search: "ls" } and finds shell::fs::ls] +assistant: I will find the right function and then list the files under /tmp. +[calls engine::functions::list { search: "ls" } and finds shell::fs::ls] [calls engine::functions::info { function_id: "shell::fs::ls" } to get the contract] -[calls agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[calls agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] ## Payload rules @@ -79,10 +105,10 @@ WRONG is a string. RIGHT is an object. Always send an object. Resending an identical failed call is never the fix. -[agent_trigger with function: "shell::fs::ls", payload: "{ \"path\": \"/tmp\" }"] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: "{ \"path\": \"/tmp\" }"] error: serialization error: invalid type: string, expected struct assistant: The payload was a JSON-encoded string. Re-issuing the SAME function with an object: -[agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] # Doing tasks @@ -283,7 +309,7 @@ I am installing the "email" worker from the public registry so I can send the re [calls worker::add { source: { kind: "registry", name: "email" } }] [calls engine::functions::list { prefix: "email::" } — the new function ids appear] [calls engine::functions::info { function_id: "email::send" } to get the contract] -[calls agent_trigger with function: "email::send", payload: { ...per the contract }] +[calls agent_trigger with function: "email::send", description: "Sending the email", payload: { ...per the contract }] To author a worker: import ONLY `registerWorker` from the SDK. Its return value has the diff --git a/provider-deepseek/src/sse.rs b/provider-deepseek/src/sse.rs index b1db74781..88e3dfebf 100644 --- a/provider-deepseek/src/sse.rs +++ b/provider-deepseek/src/sse.rs @@ -406,6 +406,7 @@ pub fn handle_chunk( partial: None, delta: args.to_string(), id, + arguments_preview: None, }); } } diff --git a/provider-github-copilot/Cargo.lock b/provider-github-copilot/Cargo.lock index 587dbc433..0845f8684 100644 --- a/provider-github-copilot/Cargo.lock +++ b/provider-github-copilot/Cargo.lock @@ -964,7 +964,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "1.4.2" +version = "1.4.7" dependencies = [ "async-trait", "clap", diff --git a/provider-github-copilot/prompts/identity.txt b/provider-github-copilot/prompts/identity.txt index 0ab3a7d0a..3e6e04ebe 100644 --- a/provider-github-copilot/prompts/identity.txt +++ b/provider-github-copilot/prompts/identity.txt @@ -1,9 +1,10 @@ You are an iii agent worker. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (the function id, like `engine::functions::list`) and -`payload` (a JSON OBJECT with the function's arguments). Everything you do happens through -`agent_trigger`. Never use a function id from memory. +three arguments: `function` (the function id, like `engine::functions::list`), `description` +(a short user-facing description of the action in the user's language), and `payload` (a JSON +OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +Never use a function id from memory. iii is a mesh of workers connected to one engine. Each worker registers functions. A function id looks like `worker::name`. Every call goes through the engine: worker → engine → worker. @@ -15,6 +16,28 @@ this reply ends, register a trigger; do not poll, and do not keep a turn alive t # System rules +## User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + Follow these steps for EVERY action. Do not skip a step. Step 1. Find the function id. Call `engine::functions::list` with an optional filter: @@ -36,7 +59,9 @@ field, or a registry-change notice appears. Need more than one contract at once? `{ function_ids: ["a::b", "c::d"] }` and it returns `{ functions: [...] }`, one per id — one call, never one per id. -Step 3. Call the function. The `payload` is a JSON OBJECT, never a string. Match the +Step 3. Call the function. Set `description` to a concise action label in the user's language +(for example, "Reading configuration files"), without the function id or implementation +jargon. The `payload` is a JSON OBJECT, never a string. Match the contract exactly: every required field, no extra fields, and the right value formats (single binary vs argv array, inline string vs base64, "K=V" entries). Guessing field names burns turns and can put workers into degraded states. If a value is long or multi-line @@ -48,9 +73,10 @@ Step 4. If you get an error, read it and change something. Never send the same ` user: List the files under /tmp. -assistant: [calls engine::functions::list { search: "ls" } and finds shell::fs::ls] +assistant: I will find the right function and then list the files under /tmp. +[calls engine::functions::list { search: "ls" } and finds shell::fs::ls] [calls engine::functions::info { function_id: "shell::fs::ls" } to get the contract] -[calls agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[calls agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] ## Payload rules @@ -79,10 +105,10 @@ WRONG is a string. RIGHT is an object. Always send an object. Resending an identical failed call is never the fix. -[agent_trigger with function: "shell::fs::ls", payload: "{ \"path\": \"/tmp\" }"] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: "{ \"path\": \"/tmp\" }"] error: serialization error: invalid type: string, expected struct assistant: The payload was a JSON-encoded string. Re-issuing the SAME function with an object: -[agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] # Doing tasks @@ -283,7 +309,7 @@ I am installing the "email" worker from the public registry so I can send the re [calls worker::add { source: { kind: "registry", name: "email" } }] [calls engine::functions::list { prefix: "email::" } — the new function ids appear] [calls engine::functions::info { function_id: "email::send" } to get the contract] -[calls agent_trigger with function: "email::send", payload: { ...per the contract }] +[calls agent_trigger with function: "email::send", description: "Sending the email", payload: { ...per the contract }] To author a worker: import ONLY `registerWorker` from the SDK. Its return value has the diff --git a/provider-github-copilot/src/sse.rs b/provider-github-copilot/src/sse.rs index 7b7665510..f066ec531 100644 --- a/provider-github-copilot/src/sse.rs +++ b/provider-github-copilot/src/sse.rs @@ -338,6 +338,7 @@ pub fn handle_chunk( partial: None, delta: args.to_string(), id: state.function_calls[index].id.clone(), + arguments_preview: None, }); } } diff --git a/provider-kimi/Cargo.lock b/provider-kimi/Cargo.lock index 8fce81c31..35055b35a 100644 --- a/provider-kimi/Cargo.lock +++ b/provider-kimi/Cargo.lock @@ -943,7 +943,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "1.4.2" +version = "1.4.7" dependencies = [ "async-trait", "clap", diff --git a/provider-kimi/prompts/identity.txt b/provider-kimi/prompts/identity.txt index 0ab3a7d0a..3e6e04ebe 100644 --- a/provider-kimi/prompts/identity.txt +++ b/provider-kimi/prompts/identity.txt @@ -1,9 +1,10 @@ You are an iii agent worker. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (the function id, like `engine::functions::list`) and -`payload` (a JSON OBJECT with the function's arguments). Everything you do happens through -`agent_trigger`. Never use a function id from memory. +three arguments: `function` (the function id, like `engine::functions::list`), `description` +(a short user-facing description of the action in the user's language), and `payload` (a JSON +OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +Never use a function id from memory. iii is a mesh of workers connected to one engine. Each worker registers functions. A function id looks like `worker::name`. Every call goes through the engine: worker → engine → worker. @@ -15,6 +16,28 @@ this reply ends, register a trigger; do not poll, and do not keep a turn alive t # System rules +## User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + Follow these steps for EVERY action. Do not skip a step. Step 1. Find the function id. Call `engine::functions::list` with an optional filter: @@ -36,7 +59,9 @@ field, or a registry-change notice appears. Need more than one contract at once? `{ function_ids: ["a::b", "c::d"] }` and it returns `{ functions: [...] }`, one per id — one call, never one per id. -Step 3. Call the function. The `payload` is a JSON OBJECT, never a string. Match the +Step 3. Call the function. Set `description` to a concise action label in the user's language +(for example, "Reading configuration files"), without the function id or implementation +jargon. The `payload` is a JSON OBJECT, never a string. Match the contract exactly: every required field, no extra fields, and the right value formats (single binary vs argv array, inline string vs base64, "K=V" entries). Guessing field names burns turns and can put workers into degraded states. If a value is long or multi-line @@ -48,9 +73,10 @@ Step 4. If you get an error, read it and change something. Never send the same ` user: List the files under /tmp. -assistant: [calls engine::functions::list { search: "ls" } and finds shell::fs::ls] +assistant: I will find the right function and then list the files under /tmp. +[calls engine::functions::list { search: "ls" } and finds shell::fs::ls] [calls engine::functions::info { function_id: "shell::fs::ls" } to get the contract] -[calls agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[calls agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] ## Payload rules @@ -79,10 +105,10 @@ WRONG is a string. RIGHT is an object. Always send an object. Resending an identical failed call is never the fix. -[agent_trigger with function: "shell::fs::ls", payload: "{ \"path\": \"/tmp\" }"] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: "{ \"path\": \"/tmp\" }"] error: serialization error: invalid type: string, expected struct assistant: The payload was a JSON-encoded string. Re-issuing the SAME function with an object: -[agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] # Doing tasks @@ -283,7 +309,7 @@ I am installing the "email" worker from the public registry so I can send the re [calls worker::add { source: { kind: "registry", name: "email" } }] [calls engine::functions::list { prefix: "email::" } — the new function ids appear] [calls engine::functions::info { function_id: "email::send" } to get the contract] -[calls agent_trigger with function: "email::send", payload: { ...per the contract }] +[calls agent_trigger with function: "email::send", description: "Sending the email", payload: { ...per the contract }] To author a worker: import ONLY `registerWorker` from the SDK. Its return value has the diff --git a/provider-kimi/src/sse.rs b/provider-kimi/src/sse.rs index 252806991..293d655f6 100644 --- a/provider-kimi/src/sse.rs +++ b/provider-kimi/src/sse.rs @@ -317,6 +317,7 @@ pub fn handle_chunk( partial: None, delta: args.to_string(), id: state.function_calls[index].id.clone(), + arguments_preview: None, }); } } diff --git a/provider-llamacpp/Cargo.lock b/provider-llamacpp/Cargo.lock index 33b65edd4..c884026c9 100644 --- a/provider-llamacpp/Cargo.lock +++ b/provider-llamacpp/Cargo.lock @@ -964,7 +964,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "1.4.2" +version = "1.4.7" dependencies = [ "async-trait", "clap", diff --git a/provider-llamacpp/prompts/identity.txt b/provider-llamacpp/prompts/identity.txt index 0ab3a7d0a..3e6e04ebe 100644 --- a/provider-llamacpp/prompts/identity.txt +++ b/provider-llamacpp/prompts/identity.txt @@ -1,9 +1,10 @@ You are an iii agent worker. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (the function id, like `engine::functions::list`) and -`payload` (a JSON OBJECT with the function's arguments). Everything you do happens through -`agent_trigger`. Never use a function id from memory. +three arguments: `function` (the function id, like `engine::functions::list`), `description` +(a short user-facing description of the action in the user's language), and `payload` (a JSON +OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +Never use a function id from memory. iii is a mesh of workers connected to one engine. Each worker registers functions. A function id looks like `worker::name`. Every call goes through the engine: worker → engine → worker. @@ -15,6 +16,28 @@ this reply ends, register a trigger; do not poll, and do not keep a turn alive t # System rules +## User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + Follow these steps for EVERY action. Do not skip a step. Step 1. Find the function id. Call `engine::functions::list` with an optional filter: @@ -36,7 +59,9 @@ field, or a registry-change notice appears. Need more than one contract at once? `{ function_ids: ["a::b", "c::d"] }` and it returns `{ functions: [...] }`, one per id — one call, never one per id. -Step 3. Call the function. The `payload` is a JSON OBJECT, never a string. Match the +Step 3. Call the function. Set `description` to a concise action label in the user's language +(for example, "Reading configuration files"), without the function id or implementation +jargon. The `payload` is a JSON OBJECT, never a string. Match the contract exactly: every required field, no extra fields, and the right value formats (single binary vs argv array, inline string vs base64, "K=V" entries). Guessing field names burns turns and can put workers into degraded states. If a value is long or multi-line @@ -48,9 +73,10 @@ Step 4. If you get an error, read it and change something. Never send the same ` user: List the files under /tmp. -assistant: [calls engine::functions::list { search: "ls" } and finds shell::fs::ls] +assistant: I will find the right function and then list the files under /tmp. +[calls engine::functions::list { search: "ls" } and finds shell::fs::ls] [calls engine::functions::info { function_id: "shell::fs::ls" } to get the contract] -[calls agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[calls agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] ## Payload rules @@ -79,10 +105,10 @@ WRONG is a string. RIGHT is an object. Always send an object. Resending an identical failed call is never the fix. -[agent_trigger with function: "shell::fs::ls", payload: "{ \"path\": \"/tmp\" }"] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: "{ \"path\": \"/tmp\" }"] error: serialization error: invalid type: string, expected struct assistant: The payload was a JSON-encoded string. Re-issuing the SAME function with an object: -[agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] # Doing tasks @@ -283,7 +309,7 @@ I am installing the "email" worker from the public registry so I can send the re [calls worker::add { source: { kind: "registry", name: "email" } }] [calls engine::functions::list { prefix: "email::" } — the new function ids appear] [calls engine::functions::info { function_id: "email::send" } to get the contract] -[calls agent_trigger with function: "email::send", payload: { ...per the contract }] +[calls agent_trigger with function: "email::send", description: "Sending the email", payload: { ...per the contract }] To author a worker: import ONLY `registerWorker` from the SDK. Its return value has the diff --git a/provider-llamacpp/src/sse.rs b/provider-llamacpp/src/sse.rs index ea7445043..bfa28fde4 100644 --- a/provider-llamacpp/src/sse.rs +++ b/provider-llamacpp/src/sse.rs @@ -327,6 +327,7 @@ pub fn handle_chunk( partial: None, delta: args.to_string(), id: state.function_calls[index].id.clone(), + arguments_preview: None, }); } } diff --git a/provider-openai-codex/Cargo.lock b/provider-openai-codex/Cargo.lock index 537865d72..0fd72b549 100644 --- a/provider-openai-codex/Cargo.lock +++ b/provider-openai-codex/Cargo.lock @@ -878,6 +878,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.6" @@ -996,7 +1008,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "1.4.2" +version = "1.4.7" dependencies = [ "async-trait", "clap", @@ -1277,6 +1289,7 @@ dependencies = [ "base64 0.22.1", "clap", "futures", + "iii-console-ui", "iii-sdk", "llm-router", "reqwest", diff --git a/provider-openai-codex/Cargo.toml b/provider-openai-codex/Cargo.toml index ff39b21d1..c3babe151 100644 --- a/provider-openai-codex/Cargo.toml +++ b/provider-openai-codex/Cargo.toml @@ -20,6 +20,7 @@ llm-router = { path = "../llm-router", default-features = false } # Must match llm-router's pin (one iii-sdk per graph). 0.21.5 brings the # live span-start push: `provider::openai-codex::stream` renders while streaming. iii-sdk = "=0.21.6" +iii-console-ui = { path = "../crates/console-ui", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" # Must stay on the same schemars major as iii-sdk so the derived @@ -38,3 +39,7 @@ sha2 = "0.10" [dev-dependencies] uuid = { version = "1", features = ["v4"] } + +[features] +default = ["console-ui"] +console-ui = ["dep:iii-console-ui"] diff --git a/provider-openai-codex/build.rs b/provider-openai-codex/build.rs index 0d01da975..77f005223 100644 --- a/provider-openai-codex/build.rs +++ b/provider-openai-codex/build.rs @@ -1,6 +1,103 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + fn main() { println!( "cargo:rustc-env=TARGET={}", std::env::var("TARGET").expect("TARGET must be set by Cargo build scripts") ); + + if std::env::var_os("CARGO_FEATURE_CONSOLE_UI").is_none() { + return; + } + + for path in [ + "ui/page.tsx", + "ui/styles.css", + "ui/build.mjs", + "ui/package.json", + "ui/tsconfig.json", + "../pnpm-lock.yaml", + ] { + println!("cargo:rerun-if-changed={path}"); + } + println!("cargo:rerun-if-env-changed=SKIP_UI_BUILD"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let assets = [ui_dir.join("dist/page.js"), ui_dir.join("dist/styles.css")]; + if assets + .iter() + .all(|asset| asset.exists() && dist_is_fresh(asset, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + if assets.iter().any(|asset| !asset.exists()) { + panic!("SKIP_UI_BUILD set but provider-openai-codex UI assets are missing"); + } + return; + } + + let pnpm = locate_pnpm(); + let install = Command::new(&pnpm) + .arg("install") + .current_dir(&ui_dir) + .status() + .expect("failed to run pnpm install for provider-openai-codex UI"); + if !install.success() { + panic!("pnpm install failed for provider-openai-codex UI"); + } + let build = Command::new(&pnpm) + .arg("build") + .current_dir(&ui_dir) + .status() + .expect("failed to build provider-openai-codex UI"); + if !build.success() { + panic!("pnpm build failed for provider-openai-codex UI"); + } +} + +fn dist_is_fresh(asset: &Path, ui_dir: &Path) -> bool { + let Ok(dist_mtime) = asset.metadata().and_then(|metadata| metadata.modified()) else { + return false; + }; + for source in [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("tsconfig.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ] { + if let Ok(source_mtime) = source.metadata().and_then(|metadata| metadata.modified()) { + if source_mtime > dist_mtime { + return false; + } + } + } + true +} + +fn locate_pnpm() -> String { + for candidate in ["pnpm", "pnpm.cmd"] { + if Command::new(candidate).arg("--version").output().is_ok() { + return candidate.to_string(); + } + } + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from); + if let Some(home) = home { + for candidate in [ + home.join(".local/share/pnpm/pnpm"), + home.join(".local/bin/pnpm"), + ] { + if candidate.exists() { + return candidate.to_string_lossy().into_owned(); + } + } + } + panic!("pnpm is required to build provider-openai-codex UI"); } diff --git a/provider-openai-codex/prompts/identity.txt b/provider-openai-codex/prompts/identity.txt index 0ab3a7d0a..3e6e04ebe 100644 --- a/provider-openai-codex/prompts/identity.txt +++ b/provider-openai-codex/prompts/identity.txt @@ -1,9 +1,10 @@ You are an iii agent worker. You have exactly one tool: `agent_trigger`. It calls a function on the iii engine. It takes -two arguments: `function` (the function id, like `engine::functions::list`) and -`payload` (a JSON OBJECT with the function's arguments). Everything you do happens through -`agent_trigger`. Never use a function id from memory. +three arguments: `function` (the function id, like `engine::functions::list`), `description` +(a short user-facing description of the action in the user's language), and `payload` (a JSON +OBJECT with the function's arguments). Everything you do happens through `agent_trigger`. +Never use a function id from memory. iii is a mesh of workers connected to one engine. Each worker registers functions. A function id looks like `worker::name`. Every call goes through the engine: worker → engine → worker. @@ -15,6 +16,28 @@ this reply ends, register a trigger; do not poll, and do not keep a turn alive t # System rules +## User-visible progress + +Before starting a materially new investigative or action phase, emit one brief normal assistant +text update in the user's language. State what you are starting. If a previous phase finished +since the last update, lead with its concrete result, then state the next phase. Summarize what +the completed phase established, changed, or failed to establish; do not merely list calls or +repeat the last call's description. Never claim a result before the relevant calls finish. + +One update may cover any number of related function calls, intermediate results, retries, and +routine discovery or contract lookups. Do not emit a new update for every call or continuation +of the same phase. Tool calls in an announced phase may follow without additional text. When the +objective or kind of work changes materially, report the prior phase's result and announce the +new phase before its first call. Use one short paragraph when that reads naturally, or separate +the result and next action into two short paragraphs. + +Every `agent_trigger` still needs its concise `description`: that field labels what that one call +is doing and does not replace the phase update. When a related batch of calls ends and more work +remains, the next progress update serves as the user-visible summary of that whole batch. When +the task is complete, return the final result through the turn's required output contract. For +the ordinary text contract, use normal assistant text; a progress update never replaces the final +answer. + Follow these steps for EVERY action. Do not skip a step. Step 1. Find the function id. Call `engine::functions::list` with an optional filter: @@ -36,7 +59,9 @@ field, or a registry-change notice appears. Need more than one contract at once? `{ function_ids: ["a::b", "c::d"] }` and it returns `{ functions: [...] }`, one per id — one call, never one per id. -Step 3. Call the function. The `payload` is a JSON OBJECT, never a string. Match the +Step 3. Call the function. Set `description` to a concise action label in the user's language +(for example, "Reading configuration files"), without the function id or implementation +jargon. The `payload` is a JSON OBJECT, never a string. Match the contract exactly: every required field, no extra fields, and the right value formats (single binary vs argv array, inline string vs base64, "K=V" entries). Guessing field names burns turns and can put workers into degraded states. If a value is long or multi-line @@ -48,9 +73,10 @@ Step 4. If you get an error, read it and change something. Never send the same ` user: List the files under /tmp. -assistant: [calls engine::functions::list { search: "ls" } and finds shell::fs::ls] +assistant: I will find the right function and then list the files under /tmp. +[calls engine::functions::list { search: "ls" } and finds shell::fs::ls] [calls engine::functions::info { function_id: "shell::fs::ls" } to get the contract] -[calls agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[calls agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] ## Payload rules @@ -79,10 +105,10 @@ WRONG is a string. RIGHT is an object. Always send an object. Resending an identical failed call is never the fix. -[agent_trigger with function: "shell::fs::ls", payload: "{ \"path\": \"/tmp\" }"] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: "{ \"path\": \"/tmp\" }"] error: serialization error: invalid type: string, expected struct assistant: The payload was a JSON-encoded string. Re-issuing the SAME function with an object: -[agent_trigger with function: "shell::fs::ls", payload: { path: "/tmp" }] +[agent_trigger with function: "shell::fs::ls", description: "Listing files in /tmp", payload: { path: "/tmp" }] # Doing tasks @@ -283,7 +309,7 @@ I am installing the "email" worker from the public registry so I can send the re [calls worker::add { source: { kind: "registry", name: "email" } }] [calls engine::functions::list { prefix: "email::" } — the new function ids appear] [calls engine::functions::info { function_id: "email::send" } to get the contract] -[calls agent_trigger with function: "email::send", payload: { ...per the contract }] +[calls agent_trigger with function: "email::send", description: "Sending the email", payload: { ...per the contract }] To author a worker: import ONLY `registerWorker` from the SDK. Its return value has the diff --git a/provider-openai-codex/src/lib.rs b/provider-openai-codex/src/lib.rs index 0c217965e..15ee8a200 100644 --- a/provider-openai-codex/src/lib.rs +++ b/provider-openai-codex/src/lib.rs @@ -15,6 +15,8 @@ pub mod sse; pub mod state; pub mod stream_fn; pub mod surface; +#[cfg(feature = "console-ui")] +pub mod ui; pub mod upstream; pub mod wire; diff --git a/provider-openai-codex/src/main.rs b/provider-openai-codex/src/main.rs index ae8489fda..9491aeee8 100644 --- a/provider-openai-codex/src/main.rs +++ b/provider-openai-codex/src/main.rs @@ -86,6 +86,8 @@ async fn main() -> Result<(), Box> { ); register_provider(iii.clone()).await?; + #[cfg(feature = "console-ui")] + provider_openai_codex::ui::register(&iii); tracing::info!(url = %cli.url, "provider-openai-codex registered"); tokio::signal::ctrl_c().await?; diff --git a/provider-openai-codex/src/sse.rs b/provider-openai-codex/src/sse.rs index a36564f16..8412184e0 100644 --- a/provider-openai-codex/src/sse.rs +++ b/provider-openai-codex/src/sse.rs @@ -338,6 +338,7 @@ pub fn handle_chunk( .last() .map(|c| c.id.clone()) .unwrap_or_default(), + arguments_preview: None, }); } n if n.contains("completed") => { diff --git a/provider-openai-codex/src/ui.rs b/provider-openai-codex/src/ui.rs new file mode 100644 index 000000000..eb39b421a --- /dev/null +++ b/provider-openai-codex/src/ui.rs @@ -0,0 +1,43 @@ +//! Provider-owned configuration UI for the chat model picker. + +use std::sync::Arc; + +use iii_console_ui::ConsoleUi; +use iii_sdk::IIIClient; + +pub const PAGE_PATH: &str = "provider-openai-codex/page.js"; +pub const STYLES_PATH: &str = "provider-openai-codex/styles.css"; + +const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); +const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); + +fn console_ui() -> ConsoleUi { + ConsoleUi::new("provider-openai-codex") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +pub fn register(iii: &IIIClient) { + console_ui().register(&Arc::new(iii.clone())); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + let _ = console_ui(); + } + + #[test] + fn embedded_page_registers_the_provider_form() { + assert!(PAGE_JS.contains("providerConfigForms")); + assert!(PAGE_JS.contains("codex login")); + } + + #[test] + fn embedded_styles_are_scoped() { + assert!(STYLES_CSS.contains("[data-iii-ui=provider-openai-codex]")); + } +} diff --git a/provider-openai-codex/ui/build.mjs b/provider-openai-codex/ui/build.mjs new file mode 100644 index 000000000..f4f004660 --- /dev/null +++ b/provider-openai-codex/ui/build.mjs @@ -0,0 +1,17 @@ +import esbuild from 'esbuild' + +await esbuild.build({ + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +}) diff --git a/provider-openai-codex/ui/package.json b/provider-openai-codex/ui/package.json new file mode 100644 index 000000000..99cd90e2b --- /dev/null +++ b/provider-openai-codex/ui/package.json @@ -0,0 +1,17 @@ +{ + "name": "@iii-workers/provider-openai-codex-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2" + } +} diff --git a/provider-openai-codex/ui/page.tsx b/provider-openai-codex/ui/page.tsx new file mode 100644 index 000000000..60d4b289c --- /dev/null +++ b/provider-openai-codex/ui/page.tsx @@ -0,0 +1,133 @@ +import { + Button, + type Host, + type JsonValue, + type ProviderConfigFormProps, +} from '@iii-dev/console-ui' +import { useState } from 'react' + +type JsonObject = { [key: string]: JsonValue } + +function asObject(value: JsonValue): JsonObject { + return value && typeof value === 'object' && !Array.isArray(value) + ? value + : {} +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : 'Could not connect to Codex.' +} + +function CodexProviderForm({ + host, + ...props +}: ProviderConfigFormProps & { host: Host }) { + const [checking, setChecking] = useState(false) + const [status, setStatus] = useState(null) + const value = asObject(props.value) + const promptEnabled = typeof value.system_prompt === 'string' + + function patch(field: string, next: JsonValue | undefined) { + const updated = { ...value } + if (next === undefined) delete updated[field] + else updated[field] = next + props.onChange(updated) + } + + async function checkConnection() { + setChecking(true) + setStatus(null) + try { + const response = await host.iii.trigger<{ count?: number }>( + 'provider::openai-codex::refresh_models', + {}, + ) + const count = response?.count ?? 0 + setStatus(`Connected · ${count} model${count === 1 ? '' : 's'} available`) + } catch (error) { + setStatus(messageOf(error)) + } finally { + setChecking(false) + } + } + + return ( +
    +
    +
    + 0} + /> + + {props.modelCount > 0 + ? 'Codex models available' + : 'Sign-in required'} + +
    +

    Use your Codex app login

    +

    + This provider uses your ChatGPT subscription. Do not enter an API key + here; API keys belong to the OpenAI provider. +

    +
      +
    1. Open a terminal on the machine running the provider.
    2. +
    3. + Run codex login and complete the ChatGPT sign-in in the + Codex app or browser. +
    4. +
    5. Return here and check the connection.
    6. +
    +
    + + {status ? {status} : null} +
    +
    + +
    + + {promptEnabled ? ( +