diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae6de0..ba4e0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ Every PR that bumps `package.json` moves its entries from **Unreleased** into a new version heading in the same commit. ## [Unreleased] + +## [0.282.0] — 2026-07-31 +### Added +- **Cockpit `action` tier now *executes* — a governed operator carries it out (auto-router Phase 3).** + A detected action ("create a task to migrate the acme site", "run the churn report every morning") no + longer just deep-links — clicking **Set it up** spawns the **operator**: an ephemeral System agent + (`src/edge/concierge.ts`, sibling to the read-only concierge), run-as the member, that carries out the + request via the **governed** tools and nothing else — `task_create` (filed immediately, audited) for a + task, `automation_propose` (a DRAFT an owner must approve — it never fires unattended) for a scheduled + job. Cockpit polls its transcript and shows the one-line confirmation inline ("✓ Created task: …" / + "✓ Proposed automation … — pending an owner's approval"), with a link to Tasks / the Inbox. Explicit + consent by design: nothing executes until **Set it up** is clicked; the operator can't bypass the gate + hook, and an automation still needs a human approval — so this adds no ungoverned power. The card keeps + "Open Automations/Tasks" (do it yourself) and "Route to an agent" as alternatives. New endpoint + `POST /api/router/act`; the operator is `category:'System'` so it's never a route target. + `src/edge/concierge.ts` (`ensureOperator`/`OPERATOR_ID` + shared provisioner), `src/server.ts`, + `web/src/App.tsx`, `web/src/lib/api.ts`. ### Fixed - **Governance conformance passes off the author's Mac again.** Three `file-guard` fixtures hardcoded `/Users/vmini/.ssh/…` as "the service user's home", but `sensitiveWriteRoots` resolves the home with diff --git a/package-lock.json b/package-lock.json index 5754f06..fe4c5f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.281.0", + "version": "0.282.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.281.0", + "version": "0.282.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 70c6f71..f99553a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.281.0", + "version": "0.282.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/edge/concierge.ts b/src/edge/concierge.ts index c8c1094..91df70c 100644 --- a/src/edge/concierge.ts +++ b/src/edge/concierge.ts @@ -1,15 +1,15 @@ /** - * The **concierge** — a code-provisioned System agent that answers freeform questions ABOUT this - * workspace for Cockpit's `ask` tier. It exists so `ask` can answer questions that need reasoning/tools - * WITHOUT depending on a separately-configured LLM API key: the native "LLM" in Agent OS is a claude - * session (authenticated via the agents' subscription), and every session already gets the OS MCP tools - * (recall / kb_search / session_history / task_list / list_capabilities / directory_lookup). So the - * concierge answers by querying real state, not a hand-assembled context snapshot. + * Cockpit's two code-provisioned System agents — the native-LLM backends for the `ask` and `action` + * tiers, so neither needs a separately-configured LLM API key (the native "LLM" in Agent OS is a claude + * session). Both run as ordinary one-shot chat runs (governed, run-as the member); Cockpit polls the + * transcript and renders the result inline. Category `System` keeps them out of the agent ROUTER (you're + * never "routed to the concierge" for work) and the fleet picker. Provisioning mirrors the consolidator: + * idempotent, writes a manifest + persona under the home's agents dir, registers a live claude runtime. * - * It's spawned as an ordinary one-shot chat run (governed, run-as the asker), and Cockpit renders its - * reply inline. Category `System` keeps it out of the agent ROUTER (you never get "routed to the - * concierge" for work) and out of the fleet picker. Provisioning mirrors the consolidator: idempotent, - * writes a manifest + persona under the home's agents dir, registers a live claude-code runtime. + * - **concierge** (read-only) — answers questions ABOUT the workspace using its tools. + * - **operator** (acts) — carries out a requested action via the GOVERNED tools: `task_create` + * (auto-applied) and `automation_propose` (a draft an owner approves). It cannot bypass governance — + * every tool call still passes the gate hook, and an automation never fires until a human approves. */ import * as fs from 'fs'; import * as path from 'path'; @@ -17,8 +17,21 @@ import { AgentManifest } from '../types'; import { AgentOS } from '../kernel'; export const CONCIERGE_ID = 'concierge'; +export const OPERATOR_ID = 'operator'; -const MANIFEST: AgentManifest = { +/** Provision + register a System agent from its manifest + persona. Idempotent; safe to call on demand. */ +function ensureSystemAgent(os: AgentOS, manifest: AgentManifest, claudeMd: string): void { + if (os.agents.get(manifest.id)?.dir) return; + const base = os.paths?.userAgents ?? path.join(process.cwd(), 'data', 'agents'); + const dir = path.join(base, manifest.id); + fs.mkdirSync(dir, { recursive: true }); + const manifestPath = path.join(dir, 'agent.json'); + if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), claudeMd); + os.registerAgent({ ...manifest, dir }); +} + +const CONCIERGE_MANIFEST: AgentManifest = { id: CONCIERGE_ID, version: '1.0.0', description: 'Workspace concierge — answers questions about this Agent OS workspace using its own tools.', @@ -29,7 +42,7 @@ const MANIFEST: AgentManifest = { budget: { usdCap: 0.5, tokenCap: 150_000, wallClockMs: 300_000 }, }; -const CLAUDE_MD = `# Workspace concierge +const CONCIERGE_MD = `# Workspace concierge You are the **concierge** for this Agent OS workspace. A member asked a question about the workspace itself — the agents, sessions, tasks, automations, memory, knowledge base, policy, or how any of it @@ -49,15 +62,49 @@ works. Answer it, concisely, grounded in **real state**. You act on the member's behalf and only read workspace state — nothing you do needs their connectors.`; -/** Ensure the concierge agent exists on disk + is registered as a live runtime. Idempotent; safe to call - * on every `ask` that needs it. Mirrors the consolidator's self-provisioning. */ +const OPERATOR_MANIFEST: AgentManifest = { + id: OPERATOR_ID, + version: '1.0.0', + description: 'Workspace operator — carries out a requested action (create a task / propose an automation) via governed tools.', + category: 'System', + principal: 'svc-operator', + policyContext: 'default@v3', + runtime: 'claude-code', + budget: { usdCap: 0.5, tokenCap: 150_000, wallClockMs: 300_000 }, +}; + +const OPERATOR_MD = `# Workspace operator + +A member asked you to **set something up** in this Agent OS workspace. Do exactly that — nothing more — +using the governed tools, then confirm in one line what happened. You act on the member's behalf. + +## What to do +- **A task / to-do** ("create a task to migrate the acme site", "add a todo to call the vendor") → + call \`task_create\` with a short imperative \`title\` (and \`body\`/\`assignee\` only if the member + gave them). It's filed immediately. Confirm: "✓ Created task: ". +- **A recurring / scheduled / triggered job** ("run the churn report every morning", "audit the fleet + weekly") → call \`automation_propose\`: a short \`name\`, the \`task\` (the prompt the run executes each + time), a 5-field cron \`schedule\` (e.g. "0 9 * * 1-5" = 9am weekdays), and \`agentId\` = the best-fit + agent for the job (use \`directory_lookup\` / \`list_capabilities\` to pick; don't guess a name — verify + it exists). Include a one-line \`rationale\`. This is a **DRAFT an owner must approve** — it will NOT + fire until then. Confirm: "✓ Proposed automation "<name>" (<when>, <agent>) — pending an owner's approval + in the Inbox". + +## Rules +- **Do ONLY what was asked.** One task, or one automation proposal. Never invent extra work, and never + take a destructive or privilege-bearing action beyond these two tools. +- **Make sensible defaults and state them** (e.g. "every morning" → "0 9 * * *"). If something essential + is genuinely ambiguous (which agent should run it, and you can't tell), pick the closest fit and say so + in your confirmation rather than stalling. +- Your reply text IS the confirmation shown inline — no preamble, no sign-off, lead with the "✓" line. + Do not call \`report\`/\`ask\`.`; + +/** Ensure the read-only concierge exists. */ export function ensureConcierge(os: AgentOS): void { - if (os.agents.get(CONCIERGE_ID)?.dir) return; - const base = os.paths?.userAgents ?? path.join(process.cwd(), 'data', 'agents'); - const dir = path.join(base, CONCIERGE_ID); - fs.mkdirSync(dir, { recursive: true }); - const manifestPath = path.join(dir, 'agent.json'); - if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(MANIFEST, null, 2)); - if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), CLAUDE_MD); - os.registerAgent({ ...MANIFEST, dir }); + ensureSystemAgent(os, CONCIERGE_MANIFEST, CONCIERGE_MD); +} + +/** Ensure the action-taking operator exists. */ +export function ensureOperator(os: AgentOS): void { + ensureSystemAgent(os, OPERATOR_MANIFEST, OPERATOR_MD); } diff --git a/src/server.ts b/src/server.ts index 805b2a5..d8b2c48 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,7 +23,7 @@ import { Automation, Automations, nextCronRun, derivedConcurrencyCap, chatTitle import { chooseAgent } from './edge/router'; import { classifyIntent } from './edge/intent'; import { resolveLlm, chatComplete } from './edge/llm'; -import { ensureConcierge, CONCIERGE_ID } from './edge/concierge'; +import { ensureConcierge, CONCIERGE_ID, ensureOperator, OPERATOR_ID } from './edge/concierge'; import { answerFromState } from './edge/ask'; import { SlackSocket } from './edge/slack-socket'; import { ClickupIngress } from './edge/clickup-ingress'; @@ -2610,6 +2610,22 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: return respondWork(); } + + // Cockpit `action` execution (Phase 3): the member explicitly clicked "Set it up" on a detected action + // ("create a task to…", "schedule … every morning"). Spawn the governed **operator** — an ephemeral + // System agent, run-as the member — which carries it out via `task_create` (filed) or + // `automation_propose` (a draft an owner approves; nothing fires unattended). Every tool call still + // passes the gate hook; this endpoint only starts the run — the client polls its transcript for the + // one-line confirmation. Explicit-consent by design: nothing executes until this is called. + if (method === 'POST' && p === '/api/router/act') { + const b = await readBody(req); + const text = String(b.text || '').trim(); + if (!text) return sendJson(res, 400, { error: 'text is required' }); + ensureOperator(os); + if (!os.agents.get(OPERATOR_ID)?.dir) return sendJson(res, 500, { error: 'could not start the operator' }); + const s = tm.createSession(OPERATOR_ID, chatTitle(text, OPERATOR_ID), text, `chat:${me.id}`, true, undefined, undefined, me.id, undefined, false); + return sendJson(res, 200, { sessionId: s.id }); + } // Read the friendly conversation timeline for a session (poll this like the rest of the console). const convoMatch = p.match(/^\/api\/sessions\/([\w-]+)\/conversation$/); if (method === 'GET' && convoMatch) { diff --git a/web/src/App.tsx b/web/src/App.tsx index c6fb11d..f7612e7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3911,11 +3911,12 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: { const runPreview = async (force?: 'work') => { const text = draft.trim() if (!text || busy) return - setBusy(true); setErr(''); setPreview(null) + setBusy(true); setErr(''); setPreview(null); setRunId('') const r = await api.routerPreview(text, force) setBusy(false) if (r.error) { setErr(r.error); return } setPreview(r) + if (r.intent === 'ask' && r.run?.sessionId) setRunId(r.run.sessionId) // concierge answer streams in } // Dispatch the chosen agent in the selected launch mode, using the ORIGINAL message as its task. @@ -3940,34 +3941,46 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runPreview() } } - // `ask` band 2b: the server started an ephemeral concierge run — poll its transcript and surface the - // reply inline (so a claude-backed answer still feels session-free). Stops once the reply stabilises. - const runSession = preview?.intent === 'ask' ? preview.run?.sessionId : undefined - const [askAnswer, setAskAnswer] = useState('') - const [askThinking, setAskThinking] = useState(false) + // An ephemeral System-agent run whose reply we surface inline — the `ask` concierge (band 2b) OR the + // `action` operator (Phase 3, started by "Set it up"). Either way it feels session-free: we poll the + // transcript and render the one-line result, stopping once it stabilises. + const [runId, setRunId] = useState('') + const [runAnswer, setRunAnswer] = useState('') + const [runThinking, setRunThinking] = useState(false) const prevAnswer = useRef('') useEffect(() => { - setAskAnswer(''); prevAnswer.current = '' - if (!runSession) { setAskThinking(false); return } - setAskThinking(true) + setRunAnswer(''); prevAnswer.current = '' + if (!runId) { setRunThinking(false); return } + setRunThinking(true) let stop = false const poll = async () => { - const r = await api.conversation(runSession) + const r = await api.conversation(runId) if (stop) return const assistant = (r.turns || []).filter((t) => t.kind !== 'user') const last = assistant[assistant.length - 1] const text = (last && 'text' in last ? last.text : '') || '' if (text) { - setAskAnswer(text) - if (text === prevAnswer.current) { setAskThinking(false); stop = true; clearInterval(timer); clearTimeout(to) } // stable → done + setRunAnswer(text) + if (text === prevAnswer.current) { setRunThinking(false); stop = true; clearInterval(timer); clearTimeout(to) } // stable → done prevAnswer.current = text } } poll() const timer = setInterval(poll, 1500) - const to = setTimeout(() => { stop = true; clearInterval(timer); setAskThinking(false) }, 60000) + const to = setTimeout(() => { stop = true; clearInterval(timer); setRunThinking(false) }, 90000) return () => { stop = true; clearInterval(timer); clearTimeout(to) } - }, [runSession]) + }, [runId]) + + // Phase 3: carry out a detected action via the governed operator run. + const setUp = async () => { + const text = draft.trim() + if (!text || busy) return + setBusy(true); setErr('') + const r = await api.routerAct(text) + setBusy(false) + if (r.error || !r.sessionId) { setErr(r.error || 'could not start the operator'); return } + setRunId(r.sessionId) + } const isWork = !preview?.intent || preview.intent === 'work' @@ -4031,31 +4044,43 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: { <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{preview.answer}</ReactMarkdown></div> )} {preview.run && ( - askAnswer - ? <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{askAnswer}</ReactMarkdown></div> + runAnswer + ? <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{runAnswer}</ReactMarkdown></div> : <div className="flex items-center gap-2 py-2 text-sm text-muted-foreground"><RefreshCw className="h-3.5 w-3.5 animate-spin" />Looking into it…</div> )} <div className="mt-3 flex items-center gap-3 text-xs"> <button onClick={() => runPreview('work')} className="text-muted-foreground underline-offset-2 hover:underline">Not what you meant? Route to an agent instead →</button> - {preview.run && !askThinking && askAnswer && <button onClick={() => onOpenChat(preview.run!.sessionId)} className="text-muted-foreground underline-offset-2 hover:underline">Open full session →</button>} + {preview.run && !runThinking && runAnswer && <button onClick={() => onOpenChat(preview.run!.sessionId)} className="text-muted-foreground underline-offset-2 hover:underline">Open full session →</button>} </div> </div> )} - {/* ACTION — deep-link into the primitive's surface; execution stays human-driven. */} + {/* ACTION — let the governed operator carry it out ("Set it up"), or open the surface / route. */} {preview.intent === 'action' && preview.surface && ( <div className="rounded-xl border bg-card p-4"> <div className="flex items-start gap-3"> <span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary/10">{preview.surface === 'automations' ? <Zap className="h-4.5 w-4.5 text-primary" /> : <ListChecks className="h-4.5 w-4.5 text-primary" />}</span> <div className="min-w-0 flex-1"> <div className="font-medium">This looks like {preview.surface === 'automations' ? 'scheduling an automation' : 'creating a task'}.</div> - <div className="mt-0.5 text-xs text-muted-foreground">Open the {preview.surface === 'automations' ? 'Automations' : 'Tasks'} page to set it up — or have an agent do it for you.</div> + <div className="mt-0.5 text-xs text-muted-foreground">{preview.surface === 'automations' ? 'I can draft the automation for an owner to approve' : "I'll create the task for you"} — or open the {preview.surface === 'automations' ? 'Automations' : 'Tasks'} page yourself.</div> </div> </div> - <div className="mt-3 flex gap-2"> - <Button size="sm" onClick={() => nav(preview.surface === 'automations' ? 'automations' : 'tasks')}>Open {preview.surface === 'automations' ? 'Automations' : 'Tasks'}</Button> - <Button size="sm" variant="outline" disabled={busy} onClick={() => runPreview('work')}>Have an agent do it</Button> - </div> + {runId ? ( + runAnswer + ? <div className="mt-3 rounded-lg border bg-muted/40 p-3"><div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1"><ReactMarkdown remarkPlugins={[remarkGfm]}>{runAnswer}</ReactMarkdown></div> + <div className="mt-2 flex gap-3 text-xs"> + <button onClick={() => nav(preview.surface === 'automations' ? 'inbox' : 'tasks')} className="text-muted-foreground underline-offset-2 hover:underline">{preview.surface === 'automations' ? 'Review in Inbox →' : 'Open Tasks →'}</button> + <button onClick={() => onOpenChat(runId)} className="text-muted-foreground underline-offset-2 hover:underline">Open full session →</button> + </div> + </div> + : <div className="mt-3 flex items-center gap-2 py-1 text-sm text-muted-foreground"><RefreshCw className="h-3.5 w-3.5 animate-spin" />Setting it up…</div> + ) : ( + <div className="mt-3 flex gap-2"> + <Button size="sm" disabled={busy} onClick={setUp}>Set it up</Button> + <Button size="sm" variant="outline" onClick={() => nav(preview.surface === 'automations' ? 'automations' : 'tasks')}>Open {preview.surface === 'automations' ? 'Automations' : 'Tasks'}</Button> + <Button size="sm" variant="ghost" disabled={busy} onClick={() => runPreview('work')}>Route to an agent</Button> + </div> + )} </div> )} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6d5e6e0..5f3fb45 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1343,6 +1343,10 @@ export const api = { * `force:'work'` skips classification (the "route to an agent anyway" escape hatch). */ routerPreview: (text: string, force?: 'work') => call<RouterPreviewResp>('POST', '/api/router/preview', force ? { text, force } : { text }), + /** Cockpit `action` execution: carry out a detected action (create a task / propose an automation) by + * starting the governed operator run. Returns its session id; poll `conversation` for the result. */ + routerAct: (text: string) => + call<{ sessionId?: string; error?: string }>('POST', '/api/router/act', { text }), /** Send the human's next turn into a chat session — a clean headless resume run. `busy` = a prior * turn is still generating (keep the draft, resend shortly). */ reply: (id: string, message: string) =>