mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 09:00:32 +00:00
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:
parent
b2e1054e5e
commit
3559a43ba0
7 changed files with 1039 additions and 401 deletions
256
packages/coding-agent/src/modes/rpc/rpc-mode.ts
Normal file
256
packages/coding-agent/src/modes/rpc/rpc-mode.ts
Normal 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(() => {});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue