Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions desktop/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ export async function cancelRun(sessionId: string): Promise<void> {
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<void> {
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<SessionRecord[]> {
const { sessions } = await getJson<{ sessions: SessionRecord[] }>("/api/sessions");
return sessions;
Expand Down
56 changes: 52 additions & 4 deletions desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isTauri,
listSecretKeys,
listSessions,
nudgeRun,
openStream,
pickDirectory,
restartEngine,
Expand Down Expand Up @@ -372,15 +373,15 @@ async function renderModels(): Promise<void> {
}
/** 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 {
return commandMissing(p) || keyMissing(p);
}
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}`;
}

Expand Down Expand Up @@ -442,7 +443,7 @@ async function renderModels(): Promise<void> {
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"));
Expand All @@ -460,7 +461,7 @@ async function renderModels(): Promise<void> {
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;
}
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
]);

Expand All @@ -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<void> => {
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();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const usage = h("div", { class: "card" }, [
h("h3", {}, ["Usage"]),
h("div", { class: "usage-body", id: "usage-body" }, ["No runner calls yet."]),
Expand Down Expand Up @@ -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;
Expand Down
63 changes: 61 additions & 2 deletions desktop/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" */
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -296,14 +340,29 @@ 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<string, unknown>;
}

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";
Expand Down
4 changes: 4 additions & 0 deletions src/adapters/roleBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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, {
Expand All @@ -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 };
Expand Down
18 changes: 18 additions & 0 deletions src/adapters/runnerRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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<PlanDraftResult> {
Expand Down Expand Up @@ -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 };
}
Expand All @@ -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.`, {
Expand All @@ -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.`, {
Expand All @@ -220,13 +234,16 @@ 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;
this.prompts = opts.prompts ?? DEFAULT_CRITIC_PROMPTS;
this.cwd = opts.cwd ?? ".";
this.signal = opts.signal;
this.onEvent = activityForwarder(runner, "critic", opts.onActivity);
this.steerRegister = opts.onSteer;
}

/**
Expand All @@ -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 };
}
Expand Down
Loading
Loading