diff --git a/desktop/src/api.ts b/desktop/src/api.ts index decb8e9..d6770ad 100644 --- a/desktop/src/api.ts +++ b/desktop/src/api.ts @@ -101,6 +101,20 @@ export async function cancelRun(sessionId: string): Promise { if (!res.ok && res.status !== 404) throw new Error(`${res.status} ${await res.text()}`); } +/** + * Injects a steering "nudge" into an in-flight run. Only runners with an inner + * loop (the native agent runner) are steerable; otherwise the server replies + * 409, surfaced here as an Error the caller can show. + */ +export async function nudgeRun(sessionId: string, text: string): Promise { + const res = await fetch((await apiBase()) + `/api/runs/${encodeURIComponent(sessionId)}/nudge`, { + method: "POST", + headers: await authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ text }), + }); + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); +} + export async function listSessions(): Promise { const { sessions } = await getJson<{ sessions: SessionRecord[] }>("/api/sessions"); return sessions; diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 26cc12c..0b716f2 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -11,6 +11,7 @@ import { isTauri, listSecretKeys, listSessions, + nudgeRun, openStream, pickDirectory, restartEngine, @@ -372,7 +373,7 @@ async function renderModels(): Promise { } /** Whether a preset's required API key is known-missing. */ function keyMissing(p: RunnerPreset): boolean { - const env = p.kind === "http-responses" ? p.apiKeyEnv : p.requiresEnv; + const env = p.kind === "cli" ? p.requiresEnv : p.apiKeyEnv; return env !== undefined && knowKeys && !storedKeys.has(env); } function presetMissing(p: RunnerPreset): boolean { @@ -380,7 +381,7 @@ async function renderModels(): Promise { } function presetMissingLabel(p: RunnerPreset): string { if (commandMissing(p)) return `install ${p.command}`; - const env = p.kind === "http-responses" ? p.apiKeyEnv : p.requiresEnv; + const env = p.kind === "cli" ? p.requiresEnv : p.apiKeyEnv; return `add ${env}`; } @@ -442,7 +443,7 @@ async function renderModels(): Promise { if (!preset) return note; if (keyMissing(preset)) { - const env = preset.kind === "http-responses" ? preset.apiKeyEnv : preset.requiresEnv; + const env = preset.kind === "cli" ? preset.requiresEnv : preset.apiKeyEnv; note.className = "model-note warn"; const link = h("button", { type: "button", class: "linklike" }, [`Add ${env}`]); link.addEventListener("click", () => navigate("secrets")); @@ -460,7 +461,7 @@ async function renderModels(): Promise { note.append(h("span", {}, [`${preset.authHint} Edits files directly — runs can produce real, committable changes.`])); } else { note.className = "model-note warn"; - note.append(h("span", {}, ["Returns a diff but doesn't edit files. Pick a CLI writer (Codex CLI / Kiro CLI) to change a repo."])); + note.append(h("span", {}, ["Returns a diff but doesn't edit files. Pick a CLI or agent writer (Codex CLI / Kiro CLI / a native agent) to change a repo."])); } return note; } @@ -897,10 +898,20 @@ function renderMonitor(sessionId: string, goal: string): void { const phase = h("div", { class: "phase running", id: "phase" }, ["running…"]); const plan = h("div", { class: "plan", id: "plan" }); const stopBtn = h("button", { class: "danger small" }, ["Stop run"]); + // Steering: nudge an in-flight agent run with extra guidance. Only effective + // for steerable backends (the native agent runner); other backends reply 409. + const nudgeInput = h("input", { + type: "text", + class: "nudge-input", + placeholder: "Nudge the agent…", + "aria-label": "Nudge the in-flight run", + }) as HTMLInputElement; + const nudgeBtn = h("button", { class: "small" }, ["Nudge"]); const header = h("div", { class: "card run-head" }, [ h("div", { class: "goal" }, [goal]), h("div", { class: "meta-row" }, [phase, stopBtn, h("span", { class: "session-id" }, [`session ${sessionId}`])]), + h("div", { class: "meta-row nudge-row" }, [nudgeInput, nudgeBtn]), plan, ]); @@ -917,6 +928,35 @@ function renderMonitor(sessionId: string, goal: string): void { } }); + // Nudge control: inject steering guidance into the running agent. + let nudging = false; + const sendNudge = async (): Promise => { + if (nudging) return; + const text = nudgeInput.value.trim(); + if (!text) return; + nudging = true; + nudgeBtn.setAttribute("disabled", "true"); + nudgeInput.setAttribute("disabled", "true"); + try { + await nudgeRun(sessionId, text); + nudgeInput.value = ""; + appendLog(` · you → nudge: ${text}`); + } catch (err) { + plan.textContent = `Nudge failed: ${(err as Error).message}`; + } finally { + nudging = false; + nudgeBtn.removeAttribute("disabled"); + nudgeInput.removeAttribute("disabled"); + } + }; + nudgeBtn.addEventListener("click", () => void sendNudge()); + nudgeInput.addEventListener("keydown", (ev) => { + if ((ev as KeyboardEvent).key === "Enter") { + ev.preventDefault(); + void sendNudge(); + } + }); + const usage = h("div", { class: "card" }, [ h("h3", {}, ["Usage"]), h("div", { class: "usage-body", id: "usage-body" }, ["No runner calls yet."]), @@ -983,6 +1023,14 @@ function renderMonitor(sessionId: string, goal: string): void { } else if (ev.type === "plan_reviewed") { (document.getElementById("plan") as HTMLElement).textContent = `Plan: approved=${ev.data.approved} · revisions=${ev.data.revisions} · open items=${ev.data.openItems}`; + } else if (ev.type === "runner_activity") { + // Live sub-step feed from an agent runner's inner loop (tool calls). + const d = ev.data ?? {}; + if (d.phase === "tool_start") { + appendLog(` · ${d.role} → ${d.toolName}`); + } else if (d.phase === "tool_end") { + appendLog(` · ${d.role} ✓ ${d.toolName}${d.isError ? " (error)" : ""}`); + } } } else if (msg.type === "status") { const phaseEl = document.getElementById("phase") as HTMLElement; diff --git a/desktop/src/settings.ts b/desktop/src/settings.ts index 39bb2f6..f71cefb 100644 --- a/desktop/src/settings.ts +++ b/desktop/src/settings.ts @@ -24,7 +24,7 @@ */ /** Wire format each preset maps to (matches the engine's RunnerProfile.kind). */ -export type PresetKind = "cli" | "http-responses"; +export type PresetKind = "cli" | "http-responses" | "agent"; export interface RunnerPreset { /** stable preset id, e.g. "codex-cli" */ @@ -54,6 +54,11 @@ export interface RunnerPreset { command?: string; /** an env var the preset needs to authenticate (used for auth status) */ requiresEnv?: string; + /** + * agent: the pi-ai provider id used to resolve the model (e.g. "anthropic"). + * Agent presets run a real in-process tool-calling loop and edit files. + */ + agentProvider?: string; } /** @@ -97,6 +102,45 @@ export const PRESETS: RunnerPreset[] = [ command: "kiro-cli", requiresEnv: "KIRO_API_KEY", }, + // Native agent runners: a real in-process tool-calling loop (pi agent-core + + // ai) that edits files directly. Grouped by the same API key as the matching + // HTTP provider; model ids must be ones pi-ai knows for that provider. + { + id: "anthropic-agent", + label: "Anthropic (agent)", + kind: "agent", + description: "Runs a native in-process agent (pi agent-core + ai) on Anthropic models and edits files directly.", + defaultModel: "claude-3-7-sonnet-20250219", + configurableModel: true, + editsFiles: true, + authHint: "Needs ANTHROPIC_API_KEY stored under Secrets.", + apiKeyEnv: "ANTHROPIC_API_KEY", + agentProvider: "anthropic", + }, + { + id: "openai-agent", + label: "OpenAI (agent)", + kind: "agent", + description: "Runs a native in-process agent (pi agent-core + ai) on OpenAI models and edits files directly.", + defaultModel: "gpt-4.1", + configurableModel: true, + editsFiles: true, + authHint: "Needs OPENAI_API_KEY stored under Secrets.", + apiKeyEnv: "OPENAI_API_KEY", + agentProvider: "openai", + }, + { + id: "google-agent", + label: "Google (agent)", + kind: "agent", + description: "Runs a native in-process agent (pi agent-core + ai) on Google Gemini models and edits files directly.", + defaultModel: "gemini-2.5-pro", + configurableModel: true, + editsFiles: true, + authHint: "Needs GEMINI_API_KEY stored under Secrets.", + apiKeyEnv: "GEMINI_API_KEY", + agentProvider: "google", + }, ]; /** A concrete preset choice: which preset + (for configurable presets) model id. */ @@ -296,7 +340,7 @@ export function saveSettings(settings: RunSettings): void { /** A runner profile as accepted by LOOPWRIGHT_RUNNERS. */ interface RunnerProfile { id: string; - kind: "cli" | "http-responses"; + kind: "cli" | "http-responses" | "agent"; model: string; options: Record; } @@ -304,6 +348,21 @@ interface RunnerProfile { function profileFor(settings: RunSettings, id: string, choice: ModelChoice): RunnerProfile { const preset = getPreset(choice.preset) ?? getPreset(DEFAULT_SETTINGS.writer.preset)!; + // Native in-process agent: a real tool-calling loop that edits files. The + // engine resolves the model via the pi-ai provider id; the API key env is + // forwarded so the runner can authenticate. + if (preset.kind === "agent") { + return { + id, + kind: "agent", + model: effectiveModel(choice), + options: { + provider: preset.agentProvider ?? preset.id, + ...(preset.apiKeyEnv ? { apiKeyEnv: preset.apiKeyEnv } : {}), + }, + }; + } + if (preset.id === "kiro-cli") { const trust = settings.kiroTrustTools.trim(); const trustArg = trust ? `--trust-tools=${trust}` : "--trust-all-tools"; diff --git a/src/adapters/roleBindings.ts b/src/adapters/roleBindings.ts index 08740a4..3b4bc03 100644 --- a/src/adapters/roleBindings.ts +++ b/src/adapters/roleBindings.ts @@ -41,6 +41,8 @@ export interface CreateRolesOptions { onRunnerCall?: RunnerCallSink; /** when set, mid-call runner activity (tool calls) streams to this sink */ onActivity?: (e: RunnerActivityEvent) => void; + /** when set, each runner call registers its live steer handle here */ + onSteer?: (steer: (text: string) => void) => void; /** cooperative cancellation, threaded into the actor/critic runner calls */ signal?: AbortSignal; } @@ -103,6 +105,7 @@ export function createRoles( ...(opts.log ? { log: opts.log } : {}), ...(opts.signal ? { signal: opts.signal } : {}), ...(opts.onActivity ? { onActivity: opts.onActivity } : {}), + ...(opts.onSteer ? { onSteer: opts.onSteer } : {}), }); const critic = new RunnerCritic(instrumentedCritic, { @@ -111,6 +114,7 @@ export function createRoles( ...(opts.log ? { log: opts.log } : {}), ...(opts.signal ? { signal: opts.signal } : {}), ...(opts.onActivity ? { onActivity: opts.onActivity } : {}), + ...(opts.onSteer ? { onSteer: opts.onSteer } : {}), }); return { actor, critic }; diff --git a/src/adapters/runnerRoles.ts b/src/adapters/runnerRoles.ts index 8389a06..dc9fcc5 100644 --- a/src/adapters/runnerRoles.ts +++ b/src/adapters/runnerRoles.ts @@ -69,6 +69,12 @@ export interface RunnerActorOptions { signal?: AbortSignal; /** sink for mid-call runner activity (sub-step streaming), if any */ onActivity?: (e: RunnerActivityEvent) => void; + /** + * Steering registration. Threaded into each runner call as `req.steering`; + * a runner with an inner loop invokes it with a `steer(text)` bound to that + * live call, so a supervisor can nudge the in-flight work. + */ + onSteer?: (steer: (text: string) => void) => void; } export interface RunnerCriticOptions { @@ -79,6 +85,8 @@ export interface RunnerCriticOptions { signal?: AbortSignal; /** sink for mid-call runner activity (sub-step streaming), if any */ onActivity?: (e: RunnerActivityEvent) => void; + /** steering registration, threaded into each runner call as req.steering */ + onSteer?: (steer: (text: string) => void) => void; } /** @@ -117,6 +125,8 @@ export class RunnerActor implements Actor { private readonly signal: AbortSignal | undefined; /** per-call activity sink, enriched with role identity (undefined = off) */ private readonly onEvent: ((a: RunnerActivity) => void) | undefined; + /** steering registration passed as req.steering on every run (undefined = off) */ + private readonly steerRegister: ((steer: (text: string) => void) => void) | undefined; constructor(runner: AgentRunner, opts: RunnerActorOptions = {}) { this.runner = runner; @@ -126,6 +136,7 @@ export class RunnerActor implements Actor { this.log = opts.log; this.signal = opts.signal; this.onEvent = activityForwarder(runner, "actor", opts.onActivity); + this.steerRegister = opts.onSteer; } async draftPlan(goal: string, feedback?: Finding[]): Promise { @@ -163,6 +174,7 @@ export class RunnerActor implements Actor { system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), ...(this.onEvent ? { onEvent: this.onEvent } : {}), + ...(this.steerRegister ? { steering: this.steerRegister } : {}), }); return { text: res.text, quotaExhausted: res.quotaExhausted }; } @@ -180,6 +192,7 @@ export class RunnerActor implements Actor { system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), ...(this.onEvent ? { onEvent: this.onEvent } : {}), + ...(this.steerRegister ? { steering: this.steerRegister } : {}), }); if (res.quotaExhausted) { throw new RunnerRoleError(`Actor ${what} failed: runner quota exhausted.`, { @@ -196,6 +209,7 @@ export class RunnerActor implements Actor { system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), ...(this.onEvent ? { onEvent: this.onEvent } : {}), + ...(this.steerRegister ? { steering: this.steerRegister } : {}), }); if (res.quotaExhausted) { throw new RunnerRoleError(`Actor ${what} failed: runner quota exhausted.`, { @@ -220,6 +234,8 @@ export class RunnerCritic implements Critic { private readonly signal: AbortSignal | undefined; /** per-call activity sink, enriched with role identity (undefined = off) */ private readonly onEvent: ((a: RunnerActivity) => void) | undefined; + /** steering registration passed as req.steering on every run (undefined = off) */ + private readonly steerRegister: ((steer: (text: string) => void) => void) | undefined; constructor(runner: AgentRunner, opts: RunnerCriticOptions = {}) { this.runner = runner; @@ -227,6 +243,7 @@ export class RunnerCritic implements Critic { this.cwd = opts.cwd ?? "."; this.signal = opts.signal; this.onEvent = activityForwarder(runner, "critic", opts.onActivity); + this.steerRegister = opts.onSteer; } /** @@ -246,6 +263,7 @@ export class RunnerCritic implements Critic { system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), ...(this.onEvent ? { onEvent: this.onEvent } : {}), + ...(this.steerRegister ? { steering: this.steerRegister } : {}), }); return { text: res.text, quotaExhausted: res.quotaExhausted }; } diff --git a/src/server/server.ts b/src/server/server.ts index 32860c6..ef41656 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -254,6 +254,13 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { let lastRun: { env: Record; repoDir?: string } | undefined; /** abort controllers for in-flight runs, keyed by session id (for cancel) */ const controllers = new Map(); + /** + * Live steer handle for each in-flight run, keyed by session id. Updated each + * time a runner call (with an inner loop) starts; POST /api/runs/:id/nudge + * invokes it to inject guidance into the running agent. Latest-wins, so a + * nudge targets whatever runner call is currently active. + */ + const steerers = new Map void>(); /** open SSE responses, so a graceful shutdown can end them deterministically */ const sseClients = new Set(); /** @@ -337,6 +344,34 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { return send(res, 202, { cancelling: true }, cors); } + // Steer (nudge) an in-flight run: inject a user message into the currently + // active agent runner call, taking effect after its current turn. Only + // runners with an inner loop (the native agent runner) register a steer + // handle; for others this reports that there's nothing steerable. + const nudgeMatch = pathname.match(/^\/api\/runs\/([^/]+)\/nudge$/); + if (nudgeMatch && method === "POST") { + const id = decodeURIComponent(nudgeMatch[1] as string); + let nudgeBody: { text?: unknown }; + try { + nudgeBody = ((await readJsonBody(req)) ?? {}) as { text?: unknown }; + } catch (err) { + return send(res, 400, { error: `invalid JSON body: ${String((err as Error).message)}` }, cors); + } + const text = typeof nudgeBody.text === "string" ? nudgeBody.text.trim() : ""; + if (!text) return send(res, 400, { error: "text is required" }, cors); + const steer = steerers.get(id); + if (!steer) { + return send( + res, + 409, + { error: `no steerable runner active for session "${id}" (the active backend may not support steering)` }, + cors, + ); + } + steer(text); + return send(res, 202, { nudged: true }, cors); + } + // Graceful shutdown: the desktop shell calls this before killing the // sidecar so in-flight runs are cancelled (which kills their detached // subprocess trees) and SSE clients are closed cleanly, rather than being @@ -485,6 +520,7 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { const settle = (): void => { activeRuns = Math.max(0, activeRuns - 1); controllers.delete(sessionId); + steerers.delete(sessionId); const timer = setTimeout(() => hub.forget(sessionId, generation), retainMs); if (typeof timer.unref === "function") timer.unref(); }; @@ -501,6 +537,7 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { observer, log: (line) => void hub.publish(sessionId, "log", { line }), signal: controller.signal, + onSteer: (steer) => steerers.set(sessionId, steer), ...(repoDir ? { repoDir } : {}), }) .then((result) => { diff --git a/test/runnerRoles.test.ts b/test/runnerRoles.test.ts index 788f220..de947a0 100644 --- a/test/runnerRoles.test.ts +++ b/test/runnerRoles.test.ts @@ -280,4 +280,23 @@ describe("runner activity streaming (sub-step events)", () => { await actor.build(sampleTask, undefined, "."); expect(called).toBe(false); }); + + it("registers the runner's live steer handle via onSteer", async () => { + const steered: string[] = []; + const runner: AgentRunner = { + profile, + async run(req: RunRequest): Promise { + req.steering?.((text) => steered.push(text)); // runner exposes its bound steer + return { text: buildJson("t1") }; + }, + }; + let captured: ((t: string) => void) | undefined; + const actor = new RunnerActor(runner, { onSteer: (s) => (captured = s) }); + + await actor.build(sampleTask, undefined, "."); + + expect(typeof captured).toBe("function"); + captured?.("focus on edge cases"); + expect(steered).toEqual(["focus on edge cases"]); + }); }); diff --git a/test/server.test.ts b/test/server.test.ts index 99e2dbe..6ae868e 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -241,6 +241,96 @@ describe("server: run cancellation", () => { }); }); +describe("server: steering (nudge)", () => { + /** Builds a run that registers a steer handle (optionally) and stays active + * until its AbortSignal fires, so the test can nudge it mid-flight. */ + function pendingRun(opts: { registersSteer: boolean }): RunGoalImpl { + return (_g, _c, o = {}) => + new Promise((_resolve, reject) => { + if (opts.registersSteer) o.onSteer?.((text) => nudges.push(text)); + o.signal?.addEventListener("abort", () => { + const e = new Error("run cancelled"); + e.name = "AbortError"; + reject(e); + }); + }); + } + let nudges: string[]; + beforeEach(() => { + nudges = []; + }); + + it("injects a nudge into a steerable in-flight run", async () => { + await server?.stop(); + server = createServer({ + store: new MemoryStore(), + config: baseConfig, + baseEnv: {}, + token: TOKEN, + runGoalImpl: pendingRun({ registersSteer: true }), + }); + base = `http://127.0.0.1:${await server.start(0)}`; + + const id = await startRun("long task"); + const res = await fetch(`${base}/api/runs/${id}/nudge`, { + method: "POST", + headers: auth({ "content-type": "application/json" }), + body: JSON.stringify({ text: "focus on the failing test" }), + }); + expect(res.status).toBe(202); + expect(nudges).toEqual(["focus on the failing test"]); + }); + + it("rejects a nudge with no text (400) and an unknown/non-steerable run (409/404)", async () => { + await server?.stop(); + server = createServer({ + store: new MemoryStore(), + config: baseConfig, + baseEnv: {}, + token: TOKEN, + runGoalImpl: pendingRun({ registersSteer: false }), + }); + base = `http://127.0.0.1:${await server.start(0)}`; + + const id = await startRun("long task"); + + // No text -> 400. + const noText = await fetch(`${base}/api/runs/${id}/nudge`, { + method: "POST", + headers: auth({ "content-type": "application/json" }), + body: JSON.stringify({ text: " " }), + }); + expect(noText.status).toBe(400); + + // Active run that didn't register a steer handle -> 409. + const notSteerable = await fetch(`${base}/api/runs/${id}/nudge`, { + method: "POST", + headers: auth({ "content-type": "application/json" }), + body: JSON.stringify({ text: "hi" }), + }); + expect(notSteerable.status).toBe(409); + + // Unknown session -> 409 (no steerer registered for it). + const ghost = await fetch(`${base}/api/runs/ghost/nudge`, { + method: "POST", + headers: auth({ "content-type": "application/json" }), + body: JSON.stringify({ text: "hi" }), + }); + expect(ghost.status).toBe(409); + }); + + it("requires the auth token", async () => { + await startServer(pendingRun({ registersSteer: true })); + const id = await startRun("t"); + const res = await fetch(`${base}/api/runs/${id}/nudge`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(res.status).toBe(401); + }); +}); + describe("server: admission control", () => { it("rejects new runs past the active cap with 429", async () => { let release: () => void = () => {};