singularity-forge/packages/daemon/src/orchestrator.ts
2026-05-05 14:46:18 +02:00

509 lines
16 KiB
TypeScript

/**
* Orchestrator — LLM-powered agent for the #sf-control Discord channel.
*
* Receives Discord messages, maintains conversation history, calls the
* Anthropic messages API with 5 tool definitions (list_projects, start_session,
* get_status, stop_session, get_session_detail), and sends the LLM's response
* back to Discord.
*
* Uses the standard messages.create() tool-use loop (not betaZodTool helpers,
* which don't exist in SDK v0.52). Zod schemas are used for input validation
* at the tool execution layer.
*/
import type Anthropic from "@anthropic-ai/sdk";
import type {
ContentBlockParam,
MessageParam,
TextBlock,
Tool,
ToolResultBlockParam,
ToolUseBlock,
} from "@anthropic-ai/sdk/resources/messages/messages";
import { z } from "zod";
import type { ChannelManager } from "./channel-manager.js";
import type { Logger } from "./logger.js";
import type { SessionManager } from "./session-manager.js";
import type { ManagedSession, ProjectInfo } from "./types.js";
// ---------------------------------------------------------------------------
// API key resolution — requires ANTHROPIC_API_KEY env var
// Anthropic OAuth removed per TOS compliance (see docs/user-docs/claude-code-auth-compliance.md)
// ---------------------------------------------------------------------------
function resolveAnthropicApiKey(): string {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error(
"ANTHROPIC_API_KEY is required. Set it in your environment or run `sf config`.",
);
}
return apiKey;
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
export interface OrchestratorConfig {
model: string;
max_tokens: number;
control_channel_id: string;
}
export interface OrchestratorDeps {
sessionManager: SessionManager;
channelManager: ChannelManager;
scanProjects: () => Promise<ProjectInfo[]>;
config: OrchestratorConfig;
logger: Logger;
ownerId: string;
}
// ---------------------------------------------------------------------------
// System Prompt
// ---------------------------------------------------------------------------
const SYSTEM_PROMPT = `You are SF Control — a concise, capable orchestrator for managing SF (Singularity Forge) coding agent sessions via Discord.
You have tools to list projects, start sessions, get status, stop sessions, and inspect session details. Use them to fulfill the user's requests.
Response guidelines:
- Be terse and direct. No filler, no performed enthusiasm.
- When reporting status, use bullet points with project name, status, duration, and cost.
- When starting a session, confirm with the project name and session ID.
- When stopping a session, confirm which session was stopped.
- If something fails, say what went wrong plainly.
- Use Discord markdown formatting (bold, code blocks) for readability.
- Never expose internal error stack traces to the user — summarize the issue.`;
// ---------------------------------------------------------------------------
// Tool Definitions (Anthropic API format)
// ---------------------------------------------------------------------------
const TOOLS: Tool[] = [
{
name: "list_projects",
description:
"List all detected projects across configured scan roots. Returns project names, paths, and detected markers (git, node, sf, etc.).",
input_schema: {
type: "object" as const,
properties: {},
required: [],
},
},
{
name: "start_session",
description:
'Start a new SF autonomous-mode session for a project. Provide the absolute project path. Optionally provide a command to run instead of the default "/sf autonomous".',
input_schema: {
type: "object" as const,
properties: {
projectPath: {
type: "string",
description: "Absolute path to the project directory",
},
command: {
type: "string",
description: 'Optional command to send instead of "/sf autonomous"',
},
},
required: ["projectPath"],
},
},
{
name: "get_status",
description:
"Get the current status of all active SF sessions. Shows project name, status, duration, and cost for each.",
input_schema: {
type: "object" as const,
properties: {},
required: [],
},
},
{
name: "stop_session",
description:
"Stop a running SF session. Provide a session ID or project name — fuzzy matching is used to find the session.",
input_schema: {
type: "object" as const,
properties: {
identifier: {
type: "string",
description: "Session ID or project name to match",
},
},
required: ["identifier"],
},
},
{
name: "get_session_detail",
description:
"Get detailed information about a specific session including cost breakdown, recent events, pending blockers, and error state.",
input_schema: {
type: "object" as const,
properties: {
sessionId: { type: "string", description: "The session ID to inspect" },
},
required: ["sessionId"],
},
},
];
// ---------------------------------------------------------------------------
// Zod schemas for tool input validation
// ---------------------------------------------------------------------------
const StartSessionInput = z.object({
projectPath: z.string(),
command: z.string().optional(),
});
const StopSessionInput = z.object({
identifier: z.string(),
});
const GetSessionDetailInput = z.object({
sessionId: z.string(),
});
// ---------------------------------------------------------------------------
// Conversation History Cap
// ---------------------------------------------------------------------------
const MAX_HISTORY = 30;
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
export class Orchestrator {
private readonly deps: OrchestratorDeps;
private client: Anthropic | null;
private history: MessageParam[] = [];
/**
* @param deps - orchestrator dependencies (session manager, channel manager, etc.)
* @param client - optional Anthropic client for testability; if omitted, created from env
*/
constructor(deps: OrchestratorDeps, client?: Anthropic) {
this.deps = deps;
this.client = client ?? null;
}
/**
* Lazily initialise the Anthropic client. Dynamic import handles K007 module resolution.
* Requires ANTHROPIC_API_KEY environment variable.
*/
private async getClient(): Promise<Anthropic> {
if (this.client) return this.client;
const apiKey = resolveAnthropicApiKey();
const { default: AnthropicSDK } = await import("@anthropic-ai/sdk");
this.client = new AnthropicSDK({ apiKey });
return this.client;
}
/**
* Handle an incoming Discord message. Entry point called by the bot's
* message handler for every message in every channel.
*
* Guards: ignores bot messages, non-owner messages, and non-control-channel messages.
*/
async handleMessage(message: DiscordMessageLike): Promise<void> {
// Ignore bot messages
if (message.author.bot) return;
// Ignore non-control-channel messages
if (message.channelId !== this.deps.config.control_channel_id) return;
// Auth guard — only the owner can use the orchestrator
if (message.author.id !== this.deps.ownerId) {
this.deps.logger.debug("orchestrator auth rejected", {
userId: message.author.id,
});
return;
}
const content = message.content?.trim();
if (!content) return;
this.deps.logger.info("orchestrator message received", {
userId: message.author.id,
channelId: message.channelId,
contentLength: content.length,
});
// Append user message to history
this.history.push({ role: "user", content });
try {
// Show typing indicator while processing
await message.channel.sendTyping().catch(() => {});
const responseText = await this.runAgentLoop();
// Send response to Discord
await message.channel.send(responseText);
this.deps.logger.info("orchestrator response sent", {
channelId: message.channelId,
responseLength: responseText.length,
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
// Invalidate cached client on auth errors so next call re-resolves OAuth token
if (
errorMsg.includes("authentication") ||
errorMsg.includes("apiKey") ||
errorMsg.includes("authToken") ||
errorMsg.includes("401")
) {
this.client = null;
}
this.deps.logger.error("orchestrator error", {
error: errorMsg,
userId: message.author.id,
channelId: message.channelId,
});
// Send error feedback to Discord
try {
await message.channel.send(
"⚠️ Something went wrong processing your request.",
);
} catch (sendErr) {
this.deps.logger.warn("orchestrator error reply failed", {
error: sendErr instanceof Error ? sendErr.message : String(sendErr),
});
}
// Still append a synthetic assistant message so history stays paired
this.history.push({ role: "assistant", content: "[error — see logs]" });
}
this.trimHistory();
}
/**
* Run the tool-use loop: call messages.create(), execute any tool calls,
* feed results back, repeat until the model produces a final text response.
*/
private async runAgentLoop(): Promise<string> {
const client = await this.getClient();
const { model, max_tokens } = this.deps.config;
let loopMessages: MessageParam[] = [...this.history];
const maxIterations = 10; // safety valve
for (let i = 0; i < maxIterations; i++) {
const response = await client.messages.create({
model,
max_tokens,
system: SYSTEM_PROMPT,
tools: TOOLS,
messages: loopMessages,
});
// If the model stopped for end_turn (no tool calls), extract text and return
if (
response.stop_reason === "end_turn" ||
response.stop_reason !== "tool_use"
) {
const textBlocks = response.content.filter(
(b): b is TextBlock => b.type === "text",
);
const finalText =
textBlocks.map((b) => b.text).join("\n") || "(No response)";
// Append assistant message to conversation history
this.history.push({ role: "assistant", content: finalText });
return finalText;
}
// Model wants to use tools — execute them all
const toolUseBlocks = response.content.filter(
(b): b is ToolUseBlock => b.type === "tool_use",
);
// Build tool results
const toolResults: ToolResultBlockParam[] = [];
for (const toolUse of toolUseBlocks) {
const result = await this.executeTool(
toolUse.name,
toolUse.input as Record<string, unknown>,
);
toolResults.push({
type: "tool_result",
tool_use_id: toolUse.id,
content: result,
});
}
// Append the assistant message (with tool_use blocks) and user tool_result message
loopMessages = [
...loopMessages,
{ role: "assistant", content: response.content as ContentBlockParam[] },
{ role: "user", content: toolResults },
];
}
// If we hit max iterations, return a fallback
return "I hit the maximum number of tool iterations. Please try a simpler request.";
}
/**
* Execute a single tool by name. Returns a string result for the LLM.
* All errors are caught and returned as error strings (the LLM can reason about them).
*/
private async executeTool(
name: string,
input: Record<string, unknown>,
): Promise<string> {
try {
switch (name) {
case "list_projects":
return await this.toolListProjects();
case "start_session":
return await this.toolStartSession(input);
case "get_status":
return this.toolGetStatus();
case "get_session_detail":
return this.toolGetSessionDetail(input);
case "stop_session":
return await this.toolStopSession(input);
default:
return `Unknown tool: ${name}`;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.deps.logger.error("tool execution error", {
tool: name,
error: msg,
});
return `Error: ${msg}`;
}
}
// ---------------------------------------------------------------------------
// Tool implementations
// ---------------------------------------------------------------------------
private async toolListProjects(): Promise<string> {
const projects = await this.deps.scanProjects();
if (projects.length === 0) return "No projects found.";
return JSON.stringify(
projects.map((p) => ({ name: p.name, path: p.path, markers: p.markers })),
null,
2,
);
}
private async toolStartSession(
input: Record<string, unknown>,
): Promise<string> {
const parsed = StartSessionInput.parse(input);
const sessionId = await this.deps.sessionManager.startSession({
projectDir: parsed.projectPath,
command: parsed.command,
});
return `Session started: ${sessionId} for ${parsed.projectPath}`;
}
private toolGetStatus(): string {
const sessions = this.deps.sessionManager.getAllSessions();
if (sessions.length === 0) return "No active sessions.";
return sessions
.map((s: ManagedSession) => {
const durationMin = Math.floor((Date.now() - s.startTime) / 60_000);
const cost = s.cost.totalCost.toFixed(4);
return `${s.projectName}${s.status} (${durationMin}m, $${cost})`;
})
.join("\n");
}
private async toolStopSession(
input: Record<string, unknown>,
): Promise<string> {
const parsed = StopSessionInput.parse(input);
const { identifier } = parsed;
// Try exact sessionId match first
const byId = this.deps.sessionManager.getSession(identifier);
if (byId) {
await this.deps.sessionManager.cancelSession(identifier);
return `Stopped session ${identifier} (${byId.projectName})`;
}
// Fuzzy match by project name
const all = this.deps.sessionManager.getAllSessions();
const match = all.find(
(s: ManagedSession) =>
s.projectName.toLowerCase().includes(identifier.toLowerCase()) ||
s.projectDir.toLowerCase().includes(identifier.toLowerCase()),
);
if (match) {
await this.deps.sessionManager.cancelSession(match.sessionId);
return `Stopped session ${match.sessionId} (${match.projectName})`;
}
return `No session found matching "${identifier}"`;
}
private toolGetSessionDetail(input: Record<string, unknown>): string {
const parsed = GetSessionDetailInput.parse(input);
const result = this.deps.sessionManager.getResult(parsed.sessionId);
return JSON.stringify(result, null, 2);
}
// ---------------------------------------------------------------------------
// History management
// ---------------------------------------------------------------------------
/**
* Trim conversation history to MAX_HISTORY entries.
* Removes the oldest user+assistant pair from the front to keep pairs aligned.
*/
private trimHistory(): void {
while (this.history.length > MAX_HISTORY) {
// Remove from front — two messages at a time to keep user/assistant pairs
this.history.splice(0, 2);
}
}
/**
* Return a copy of the conversation history (for debugging / observability).
*/
getHistory(): MessageParam[] {
return [...this.history];
}
/**
* Stop the orchestrator — clears history and nulls client reference.
*/
stop(): void {
this.history = [];
this.client = null;
}
}
// ---------------------------------------------------------------------------
// Discord message type (minimal interface for testability)
// ---------------------------------------------------------------------------
/**
* Minimal Discord message interface — avoids importing discord.js directly,
* making the orchestrator testable without full discord.js mocking.
*/
export interface DiscordMessageLike {
author: { id: string; bot: boolean };
channelId: string;
content: string;
channel: {
send: (content: string) => Promise<unknown>;
sendTyping: () => Promise<unknown>;
};
}