Rewrite RPC mode with typed protocol and client

- Move RPC files to modes/rpc/ directory
- Add properly typed RpcCommand and RpcResponse types
- Expose full AgentSession API via RPC commands:
  - State: get_state
  - Model: set_model, cycle_model, get_available_models
  - Thinking: set_thinking_level, cycle_thinking_level
  - Queue: set_queue_mode
  - Compaction: compact, set_auto_compaction
  - Bash: bash, abort_bash
  - Session: get_session_stats, export_html, switch_session, branch, etc.
- Add RpcClient class for programmatic access
- Rewrite tests to use RpcClient instead of raw process spawning
- All commands support optional correlation ID for request/response matching
This commit is contained in:
Mario Zechner 2025-12-09 14:13:28 +01:00
parent b2e1054e5e
commit 3559a43ba0
7 changed files with 1039 additions and 401 deletions

View file

@ -0,0 +1,256 @@
/**
* RPC mode: Headless operation with JSON stdin/stdout protocol.
*
* Used for embedding the agent in other applications.
* Receives commands as JSON on stdin, outputs events and responses as JSON on stdout.
*
* Protocol:
* - Commands: JSON objects with `type` field, optional `id` for correlation
* - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error`
* - Events: AgentSessionEvent objects streamed as they occur
*/
import * as readline from "readline";
import type { AgentSession } from "../../core/agent-session.js";
import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js";
// Re-export types for consumers
export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js";
/**
* Run in RPC mode.
* Listens for JSON commands on stdin, outputs events and responses on stdout.
*/
export async function runRpcMode(session: AgentSession): Promise<never> {
const output = (obj: RpcResponse | object) => {
console.log(JSON.stringify(obj));
};
const success = <T extends RpcCommand["type"]>(
id: string | undefined,
command: T,
data?: object | null,
): RpcResponse => {
if (data === undefined) {
return { id, type: "response", command, success: true } as RpcResponse;
}
return { id, type: "response", command, success: true, data } as RpcResponse;
};
const error = (id: string | undefined, command: string, message: string): RpcResponse => {
return { id, type: "response", command, success: false, error: message };
};
// Output all agent events as JSON
session.subscribe((event) => {
output(event);
});
// Handle a single command
const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
const id = command.id;
switch (command.type) {
// =================================================================
// Prompting
// =================================================================
case "prompt": {
// Don't await - events will stream
session
.prompt(command.message, {
attachments: command.attachments,
expandSlashCommands: false,
})
.catch((e) => output(error(id, "prompt", e.message)));
return success(id, "prompt");
}
case "queue_message": {
await session.queueMessage(command.message);
return success(id, "queue_message");
}
case "abort": {
await session.abort();
return success(id, "abort");
}
case "reset": {
await session.reset();
return success(id, "reset");
}
// =================================================================
// State
// =================================================================
case "get_state": {
const model = session.model;
const state: RpcSessionState = {
model: model ? { provider: model.provider, id: model.id, contextWindow: model.contextWindow } : null,
thinkingLevel: session.thinkingLevel,
isStreaming: session.isStreaming,
queueMode: session.queueMode,
sessionFile: session.sessionFile,
sessionId: session.sessionId,
autoCompactionEnabled: session.autoCompactionEnabled,
messageCount: session.messages.length,
queuedMessageCount: session.queuedMessageCount,
};
return success(id, "get_state", state);
}
// =================================================================
// Model
// =================================================================
case "set_model": {
const models = await session.getAvailableModels();
const model = models.find((m) => m.provider === command.provider && m.id === command.modelId);
if (!model) {
return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`);
}
await session.setModel(model);
return success(id, "set_model", { provider: model.provider, id: model.id });
}
case "cycle_model": {
const result = await session.cycleModel();
if (!result) {
return success(id, "cycle_model", null);
}
return success(id, "cycle_model", {
model: { provider: result.model.provider, id: result.model.id },
thinkingLevel: result.thinkingLevel,
isScoped: result.isScoped,
});
}
case "get_available_models": {
const models = await session.getAvailableModels();
return success(id, "get_available_models", {
models: models.map((m) => ({
provider: m.provider,
id: m.id,
contextWindow: m.contextWindow,
reasoning: !!m.reasoning,
})),
});
}
// =================================================================
// Thinking
// =================================================================
case "set_thinking_level": {
session.setThinkingLevel(command.level);
return success(id, "set_thinking_level");
}
case "cycle_thinking_level": {
const level = session.cycleThinkingLevel();
if (!level) {
return success(id, "cycle_thinking_level", null);
}
return success(id, "cycle_thinking_level", { level });
}
// =================================================================
// Queue Mode
// =================================================================
case "set_queue_mode": {
session.setQueueMode(command.mode);
return success(id, "set_queue_mode");
}
// =================================================================
// Compaction
// =================================================================
case "compact": {
const result = await session.compact(command.customInstructions);
return success(id, "compact", result);
}
case "set_auto_compaction": {
session.setAutoCompactionEnabled(command.enabled);
return success(id, "set_auto_compaction");
}
// =================================================================
// Bash
// =================================================================
case "bash": {
const result = await session.executeBash(command.command);
return success(id, "bash", result);
}
case "abort_bash": {
session.abortBash();
return success(id, "abort_bash");
}
// =================================================================
// Session
// =================================================================
case "get_session_stats": {
const stats = session.getSessionStats();
return success(id, "get_session_stats", stats);
}
case "export_html": {
const path = session.exportToHtml(command.outputPath);
return success(id, "export_html", { path });
}
case "switch_session": {
await session.switchSession(command.sessionPath);
return success(id, "switch_session");
}
case "branch": {
const text = session.branch(command.entryIndex);
return success(id, "branch", { text });
}
case "get_branch_messages": {
const messages = session.getUserMessagesForBranching();
return success(id, "get_branch_messages", { messages });
}
case "get_last_assistant_text": {
const text = session.getLastAssistantText();
return success(id, "get_last_assistant_text", { text });
}
default: {
const unknownCommand = command as { type: string };
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
}
}
};
// Listen for JSON input
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
});
rl.on("line", async (line: string) => {
try {
const command = JSON.parse(line) as RpcCommand;
const response = await handleCommand(command);
output(response);
} catch (e: any) {
output(error(undefined, "parse", `Failed to parse command: ${e.message}`));
}
});
// Keep process alive forever
return new Promise(() => {});
}