2026-03-27 15:39:53 -06:00
/ * *
2026-04-15 15:37:12 +02:00
* Orchestrator — LLM - powered agent for the # sf - control Discord channel .
2026-03-27 15:39:53 -06:00
*
* 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 .
* /
2026-05-05 14:31:16 +02:00
import type Anthropic from "@anthropic-ai/sdk" ;
2026-03-27 15:39:53 -06:00
import type {
2026-05-05 14:31:16 +02:00
ContentBlockParam ,
2026-05-05 14:46:18 +02:00
MessageParam ,
TextBlock ,
2026-05-05 14:31:16 +02:00
Tool ,
ToolResultBlockParam ,
ToolUseBlock ,
} from "@anthropic-ai/sdk/resources/messages/messages" ;
2026-05-05 14:46:18 +02:00
import { z } from "zod" ;
2026-05-05 14:31:16 +02:00
import type { ChannelManager } from "./channel-manager.js" ;
import type { Logger } from "./logger.js" ;
2026-05-05 14:46:18 +02:00
import type { SessionManager } from "./session-manager.js" ;
import type { ManagedSession , ProjectInfo } from "./types.js" ;
2026-03-27 15:39:53 -06:00
2026-03-27 20:22:30 -06:00
// ---------------------------------------------------------------------------
2026-04-10 17:33:34 -05:00
// API key resolution — requires ANTHROPIC_API_KEY env var
// Anthropic OAuth removed per TOS compliance (see docs/user-docs/claude-code-auth-compliance.md)
2026-03-27 20:22:30 -06:00
// ---------------------------------------------------------------------------
2026-04-10 17:33:34 -05:00
function resolveAnthropicApiKey ( ) : string {
2026-05-05 14:31:16 +02:00
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 ;
2026-03-27 20:22:30 -06:00
}
2026-03-27 15:39:53 -06:00
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
export interface OrchestratorConfig {
2026-05-05 14:31:16 +02:00
model : string ;
max_tokens : number ;
control_channel_id : string ;
2026-03-27 15:39:53 -06:00
}
export interface OrchestratorDeps {
2026-05-05 14:31:16 +02:00
sessionManager : SessionManager ;
channelManager : ChannelManager ;
scanProjects : ( ) = > Promise < ProjectInfo [ ] > ;
config : OrchestratorConfig ;
logger : Logger ;
ownerId : string ;
2026-03-27 15:39:53 -06:00
}
// ---------------------------------------------------------------------------
// System Prompt
// ---------------------------------------------------------------------------
2026-04-15 14:54:20 +02:00
const SYSTEM_PROMPT = ` You are SF Control — a concise, capable orchestrator for managing SF (Singularity Forge) coding agent sessions via Discord.
2026-03-27 15:39:53 -06:00
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 [ ] = [
2026-05-05 14:31:16 +02:00
{
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" ] ,
} ,
} ,
2026-03-27 15:39:53 -06:00
] ;
// ---------------------------------------------------------------------------
// Zod schemas for tool input validation
// ---------------------------------------------------------------------------
const StartSessionInput = z . object ( {
2026-05-05 14:31:16 +02:00
projectPath : z.string ( ) ,
command : z.string ( ) . optional ( ) ,
2026-03-27 15:39:53 -06:00
} ) ;
const StopSessionInput = z . object ( {
2026-05-05 14:31:16 +02:00
identifier : z.string ( ) ,
2026-03-27 15:39:53 -06:00
} ) ;
const GetSessionDetailInput = z . object ( {
2026-05-05 14:31:16 +02:00
sessionId : z.string ( ) ,
2026-03-27 15:39:53 -06:00
} ) ;
// ---------------------------------------------------------------------------
// Conversation History Cap
// ---------------------------------------------------------------------------
const MAX_HISTORY = 30 ;
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
export class Orchestrator {
2026-05-05 14:31:16 +02:00
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 ;
}
2026-03-27 15:39:53 -06:00
}
// ---------------------------------------------------------------------------
// 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 {
2026-05-05 14:31:16 +02:00
author : { id : string ; bot : boolean } ;
channelId : string ;
content : string ;
channel : {
send : ( content : string ) = > Promise < unknown > ;
sendTyping : ( ) = > Promise < unknown > ;
} ;
2026-03-27 15:39:53 -06:00
}