disclaw/src/agent/runner.ts
Nick Tabeling e396e6d163
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
feat(DIS-106): RunResult discriminated union replaces Promise<string>
Add RunResult type in src/agent/types.ts (success|timeout|cli-error|parse-error).
Runner resolves instead of rejects for all error cases.
Router handles all four status variants explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:18:19 +02:00

319 lines
9 KiB
TypeScript

import spawn from "cross-spawn";
import * as path from "path";
import type { Conversation } from "../db/database";
import { loadAgentIdentity } from "./identity";
import { loadDisclawConfig } from "../config/loader";
import { resolveClaude } from "../runtime/resolve-claude";
import { sanitizedEnv } from "../runtime/env";
import { Semaphore } from "../runtime/concurrency";
import type { RunResult } from "./types";
// ---------------------------------------------------------------------------
// Module-level semaphore — lazy-initialised on first runAgent() call so the
// config is guaranteed to be loaded before we fix the concurrency cap.
// ---------------------------------------------------------------------------
let _semaphore: Semaphore | null = null;
function getSemaphore(): Semaphore {
if (!_semaphore) {
let max = 4;
try {
const rootDir = path.resolve(__dirname, "../..");
const config = loadDisclawConfig(rootDir);
max = config.max_concurrent_agents;
} catch {
// Config not available — fall back to default
}
_semaphore = new Semaphore(max);
}
return _semaphore;
}
/**
* Exposed only for testing — resets the module-level semaphore so unit tests
* can inject a fresh instance with a known concurrency cap.
* @internal
*/
export function _resetSemaphore(max?: number): void {
_semaphore = max !== undefined ? new Semaphore(max) : null;
}
export interface RunAgentOptions {
workspacePath: string;
channelName: string;
userMessage: string;
conversationHistory: Conversation[];
abortController?: AbortController;
}
/**
* Resolves the Claude CLI timeout from configuration.
*/
function resolveTimeoutMs(): number {
try {
const rootDir = path.resolve(__dirname, "../..");
const config = loadDisclawConfig(rootDir);
if (config.claude_timeout_seconds) {
return config.claude_timeout_seconds * 1000;
}
} catch {
// Config not available, use default
}
return 120_000;
}
/**
* Builds the full prompt string including conversation history context.
* The agent identity is handled by CLAUDE.md in the workspace directory,
* which Claude Code reads automatically.
*/
function buildPrompt(
userMessage: string,
conversationHistory: Conversation[],
channelName: string
): string {
const parts: string[] = [];
// Add conversation history if present (oldest first)
const history = [...conversationHistory].reverse();
if (history.length > 0) {
parts.push("Previous conversation context:");
parts.push("");
for (const msg of history) {
const author = msg.author_name ?? msg.role;
parts.push(`[${msg.role}] ${author}: ${msg.content}`);
parts.push("");
}
parts.push("---");
parts.push("");
}
// Add runtime context
parts.push(`Discord channel: #${channelName}`);
parts.push(`Date: ${new Date().toISOString().split("T")[0]}`);
parts.push("");
// Add the actual user message
parts.push(`New message:`);
parts.push(userMessage);
return parts.join("\n");
}
/**
* Parses the JSON output from `claude -p --output-format json`.
* Extracts the text content from the response.
*
* The JSON output format contains a "result" field with the response text,
* or an array of content blocks with type "text".
*/
function parseClaudeJsonOutput(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "(no response from agent)";
}
try {
const parsed = JSON.parse(trimmed);
// Format 1: { result: "text" }
if (typeof parsed.result === "string") {
return parsed.result;
}
// Format 2: { content: [{ type: "text", text: "..." }] } or similar
if (Array.isArray(parsed.content)) {
const texts: string[] = [];
for (const block of parsed.content) {
if (block && block.type === "text" && typeof block.text === "string") {
texts.push(block.text);
}
}
if (texts.length > 0) {
return texts.join("\n");
}
}
// Format 3: Direct array of content blocks
if (Array.isArray(parsed)) {
const texts: string[] = [];
for (const block of parsed) {
if (block && block.type === "text" && typeof block.text === "string") {
texts.push(block.text);
}
}
if (texts.length > 0) {
return texts.join("\n");
}
}
// Fallback: stringify the whole response
return typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2);
} catch {
// Not valid JSON -- return the raw output as plain text
return trimmed;
}
}
/**
* Runs the Claude Code CLI as a child process and returns the text response.
*
* The CLI is invoked with:
* claude -p "prompt" --output-format json
*
* The working directory is set to the workspace path so Claude Code
* automatically reads the CLAUDE.md file for agent identity and context.
*
* Timeout defaults to 120 seconds. The process is killed if it exceeds
* the timeout.
*/
export async function runAgent(options: RunAgentOptions): Promise<RunResult> {
const {
workspacePath,
channelName,
userMessage,
conversationHistory,
abortController,
} = options;
// Verify the workspace has an agent.yaml (validates it exists) and get agent name
const identity = loadAgentIdentity(workspacePath);
const claudeCommand = await resolveClaude();
const prompt = buildPrompt(userMessage, conversationHistory, channelName);
const timeoutMs = resolveTimeoutMs();
// Load config for env_blocklist
let envBlocklist: string[] = [];
try {
const rootDir = path.resolve(__dirname, "../..");
const config = loadDisclawConfig(rootDir);
envBlocklist = config.env_blocklist;
} catch {
// Config not available, use empty blocklist
}
// Acquire a concurrency slot before spawning; always release in finally.
const semaphore = getSemaphore();
await semaphore.acquire();
try {
return await new Promise<RunResult>((resolve) => {
// NOTE: --bare is intentionally NOT used here. --bare disables OAuth/keychain
// auth and requires ANTHROPIC_API_KEY, which breaks Claude Pro/Max subscriptions.
// CLAUDE.md isolation is handled structurally: workspaces live under
// ~/.disclaw/workspaces/ (outside the DisClaw repo), so walk-up will never
// reach the DisClaw project CLAUDE.md. The workspace CLAUDE.md is picked up
// automatically because cwd is set to workspacePath.
const args = [
"-p",
prompt,
"--output-format",
"json",
];
const child = spawn(claudeCommand, args, {
cwd: workspacePath,
env: sanitizedEnv(
{ DISCLAW_AGENT_NAME: identity.name },
envBlocklist
),
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
let killed = false;
// Collect stdout
if (child.stdout) {
child.stdout.setEncoding("utf8");
child.stdout.on("data", (data: string) => {
stdout += data;
});
}
// Collect stderr
if (child.stderr) {
child.stderr.setEncoding("utf8");
child.stderr.on("data", (data: string) => {
stderr += data;
});
}
// Timeout handling — kill the process; the "close" handler will resolve
// with { status: "timeout" } once the process exits (killed flag is set).
const timer = setTimeout(() => {
killed = true;
child.kill("SIGTERM");
// Give it a moment to die gracefully, then force kill
setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// Already dead
}
}, 5000);
}, timeoutMs);
// Abort controller support
if (abortController) {
abortController.signal.addEventListener("abort", () => {
killed = true;
child.kill("SIGTERM");
});
}
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT") {
resolve({
status: "cli-error",
message:
`Claude Code CLI not found at "${claudeCommand}". ` +
`Install it with: npm install -g @anthropic-ai/claude-code\n` +
`Or set CLAUDE_PATH in your .env file.`,
stderr: undefined,
});
} else {
resolve({
status: "cli-error",
message: `Failed to start Claude Code CLI: ${err.message}`,
stderr: undefined,
});
}
});
child.on("close", (code) => {
clearTimeout(timer);
if (killed) {
resolve({ status: "timeout", timeoutMs });
return;
}
if (code !== 0) {
const errorDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
resolve({
status: "cli-error",
message: `Claude Code CLI error: ${errorDetail}`,
stderr: stderr.trim() || undefined,
});
return;
}
const text = parseClaudeJsonOutput(stdout);
resolve({ status: "success", text });
});
// Close stdin immediately since we pass everything via args
if (child.stdin) {
child.stdin.end();
}
});
} finally {
semaphore.release();
}
}