Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,13 @@
# LINEAR_WEBHOOK_SECRET=
# LINEAR_TEAM_ID=

# Chat providers (optional; an agent picks one under Runtime > Provider).
# These run as plain chat: no shell, files or MCP connectors.
# OPENAI_API_KEY=
# DEEPSEEK_API_KEY=
# MIMO_API_KEY=
# OLLAMA_URL=http://localhost:11434
# Optional endpoint overrides (proxies, tests): OPENAI_BASE_URL, DEEPSEEK_BASE_URL, MIMO_BASE_URL

# Server
# PORT=4173
56 changes: 56 additions & 0 deletions agent-providers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Chat-completion providers for agents that are not Claude Code.
// agent-runtime.mjs sends a task here when the agent's provider is not 'claude-code'.
//
// These are plain chat calls: no shell, no files, no MCP connectors. The agent's tool
// settings (mode, allowedTools) do not apply to them.

// Where to send the request, which env var holds the key, and the default model.
// Set <PROVIDER>_BASE_URL (e.g. DEEPSEEK_BASE_URL) to point at a proxy or a test server.
export const PROVIDERS = {
openai: { baseUrl: 'https://api.openai.com/v1', keyEnv: 'OPENAI_API_KEY', model: 'gpt-4o' },
deepseek: { baseUrl: 'https://api.deepseek.com', keyEnv: 'DEEPSEEK_API_KEY', model: 'deepseek-v4-pro' },
mimo: { baseUrl: 'https://api.xiaomimimo.com/v1', keyEnv: 'MIMO_API_KEY', model: 'mimo-v2.5-pro' },
}

export const CHAT_PROVIDERS = [...Object.keys(PROVIDERS), 'ollama']

export const isChatProvider = name => CHAT_PROVIDERS.includes(name)

/**
* Send one system message and one user message; return the reply text.
* Errors never include an API key.
*/
export async function complete({ provider, model, system, user, timeoutSec = 300, signal }) {
const timeout = AbortSignal.timeout(timeoutSec * 1000)
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
const messages = [{ role: 'system', content: system }, { role: 'user', content: user }]

if (provider === 'ollama') {
const base = process.env.OLLAMA_URL || 'http://localhost:11434'
const res = await fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: model || 'llama3.1', messages, stream: false }),
signal: combined,
})
if (!res.ok) throw new Error(`ollama error ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`)
const data = await res.json()
return data.message?.content ?? ''
}

const p = PROVIDERS[provider]
if (!p) throw new Error(`unknown provider: ${provider}`)
const apiKey = process.env[p.keyEnv]
if (!apiKey) throw new Error(`${p.keyEnv} is not set`)
const baseUrl = process.env[`${provider.toUpperCase()}_BASE_URL`] || p.baseUrl

const res = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model: model || p.model, messages, max_tokens: 4096 }),
signal: combined,
})
if (!res.ok) throw new Error(`${provider} error ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`)
const data = await res.json()
return data.choices?.[0]?.message?.content ?? ''
}
51 changes: 50 additions & 1 deletion agent-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Shared by both vite.config.ts (dev) and server.mjs (prod).
*/
import { spawn } from 'node:child_process'
import { isChatProvider, complete } from './agent-providers.mjs'

const CLAUDE_CMD = process.env.CLAUDE_CMD || 'claude'
const MAX_RESULT_LEN = 2000
Expand Down Expand Up @@ -44,6 +45,16 @@ export function registerAgent(agentId, name, role, systemPrompt = '', runtimeCon
console.log(`[agent-runtime] Registered agent: ${agentId} (${name})`)
}

/**
* Merge changed runtime settings into a registered agent (called after a PATCH).
* @param {string} agentId
* @param {Record<string, unknown>} patch
*/
export function updateAgentRuntime(agentId, patch) {
const entry = registry.get(agentId)
if (entry) entry.runtimeConfig = { ...entry.runtimeConfig, ...patch }
}

/**
* Unregister an agent and kill any in-flight subprocess.
* @param {string} agentId
Expand All @@ -62,7 +73,43 @@ export function unregisterAgent(agentId) {
}

/**
* Dispatch a task to an agent via `claude -p`.
* Run a task through a chat-completion provider (DeepSeek, MiMo, OpenAI, Ollama).
* No subprocess: the reply is the result. Cancel aborts the request.
*/
function dispatchChat(entry, assignment, callbacks) {
const controller = new AbortController()
// cancelTask and shutdownAll call childProcess.kill(), so give them an object with kill()
entry.currentTask = { assignmentId: assignment.id, childProcess: { kill: () => controller.abort() } }

Promise.resolve(callbacks.onStart(assignment.id)).catch(() => {})

const system = entry.systemPrompt || `You are ${entry.name}, a ${entry.role}.`
const user = `Task: ${assignment.taskTitle}\n\n${assignment.taskBrief || ''}\n\nProvide your response directly.`

complete({
provider: entry.runtimeConfig.provider,
model: entry.runtimeConfig.model,
system,
user,
timeoutSec: entry.runtimeConfig.timeoutSec || 300,
signal: controller.signal,
}).then(
text => {
if (entry.currentTask?.assignmentId !== assignment.id) return // cancelled meanwhile
entry.currentTask = null
return callbacks.onComplete(assignment.id, text)
},
err => {
if (entry.currentTask?.assignmentId !== assignment.id) return
entry.currentTask = null
return callbacks.onError(assignment.id, err.message)
},
)
return true
}

/**
* Dispatch a task to an agent via `claude -p`, or via a chat provider if the agent has one.
* @param {string} agentId
* @param {{ id: string, taskTitle: string, taskBrief: string }} assignment
* @param {{ onStart: (id: string) => void|Promise<void>, onComplete: (id: string, result: string) => void|Promise<void>, onError: (id: string, error: string) => void|Promise<void> }} callbacks
Expand All @@ -79,6 +126,8 @@ export function dispatchTask(agentId, assignment, callbacks) {
return false
}

if (isChatProvider(entry.runtimeConfig.provider)) return dispatchChat(entry, assignment, callbacks)

const prompt = entry.systemPrompt
? `${entry.systemPrompt}\n\nTask: ${assignment.taskTitle}\n\n${assignment.taskBrief || ''}\n\nProvide your response directly.`
: `You are ${entry.name}, a ${entry.role}.\n\nTask: ${assignment.taskTitle}\n\n${assignment.taskBrief || ''}\n\nProvide your response directly.`
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ services:
- SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL:-}
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
- LINEAR_API_KEY=${LINEAR_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
- MIMO_API_KEY=${MIMO_API_KEY:-}
- OLLAMA_URL=${OLLAMA_URL:-}
restart: unless-stopped

# postgres:
Expand Down
5 changes: 4 additions & 1 deletion server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { registerAgent, unregisterAgent, dispatchTask, cancelTask, getAllAgentStatuses, startTaskQueue, shutdownAll, setProjectRoot } from './agent-runtime.mjs'
import { registerAgent, unregisterAgent, updateAgentRuntime, dispatchTask, cancelTask, getAllAgentStatuses, startTaskQueue, shutdownAll, setProjectRoot } from './agent-runtime.mjs'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DIST = path.join(__dirname, 'dist')
Expand Down Expand Up @@ -57,6 +57,7 @@ try {
// Wire agent runtime hooks
apiCtx.registerAgentRuntime = (id, name, role, systemPrompt, runtimeConfig) => registerAgent(id, name, role, systemPrompt, runtimeConfig)
apiCtx.unregisterAgentRuntime = (id) => unregisterAgent(id)
apiCtx.updateAgentRuntime = (id, patch) => updateAgentRuntime(id, patch)
apiCtx.getAgentRuntimeStatuses = () => getAllAgentStatuses()
apiCtx.cancelAgentTask = (agentId) => cancelTask(agentId)

Expand Down Expand Up @@ -330,6 +331,8 @@ server.listen(PORT, '0.0.0.0', async () => {
workingDir: agent.runtimeWorkingDir ?? undefined,
allowedTools: agent.runtimeAllowedTools ?? undefined,
mode: agent.runtimeMode ?? 'full',
provider: agent.runtimeProvider ?? 'claude-code',
model: agent.runtimeModel || undefined,
})
}
// Re-queue stuck active assignments
Expand Down
6 changes: 4 additions & 2 deletions src/components/forms/AgentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,10 @@ export function AgentForm({ agent, onClose }: { agent?: OfficeAgent; onClose: ()
<label htmlFor="agent-provider" className="settings-label">Provider</label>
<select id="agent-provider" name="runtimeProvider" className="assign-select" defaultValue={agent?.runtimeProvider ?? 'claude-code'}>
<option value="claude-code">Claude Code (CLI)</option>
<option value="openai">OpenAI</option>
<option value="ollama">Ollama (local)</option>
<option value="deepseek">DeepSeek (API, chat only)</option>
<option value="mimo">Xiaomi MiMo (API, chat only)</option>
<option value="openai">OpenAI (API, chat only)</option>
<option value="ollama">Ollama (local, chat only)</option>
</select>

<label htmlFor="agent-model" className="settings-label">Model</label>
Expand Down
23 changes: 23 additions & 0 deletions src/server/api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export interface ApiContext {
// Runtime hooks
registerAgentRuntime?(id: string, name: string, role: string, systemPrompt: string, runtimeConfig?: Record<string, unknown>): void
unregisterAgentRuntime?(id: string): void
updateAgentRuntime?(id: string, patch: Record<string, unknown>): void
dispatchToRuntime?(agentId: string, assignment: Record<string, unknown>): void
getAgentRuntimeStatuses?(): unknown[]
cancelAgentTask?(agentId: string): string | null
Expand Down Expand Up @@ -115,15 +116,32 @@ export async function getSnapshot(ctx: ApiContext): Promise<ApiResponse> {
}
}

const VALID_PROVIDERS = ['claude-code', 'openai', 'deepseek', 'mimo', 'ollama']

export async function patchAgent(ctx: ApiContext, agentId: string, raw: unknown): Promise<ApiResponse> {
if (!validateObject(raw)) return err(400, 'Body must be a JSON object')
const patch = sanitizePatch(raw as Record<string, unknown>)
if (Object.keys(patch).length === 0) return err(400, 'No valid fields to update')
if ('presence' in patch && !VALID_PRESENCE.includes(patch.presence as typeof VALID_PRESENCE[number])) {
return err(400, `Invalid presence value. Must be one of: ${VALID_PRESENCE.join(', ')}`)
}
if ('runtimeProvider' in patch && !VALID_PROVIDERS.includes(String(patch.runtimeProvider))) {
return err(400, `Invalid provider. Must be one of: ${VALID_PROVIDERS.join(', ')}`)
}
const found = await ctx.patchAgent(agentId, patch)
if (!found) return err(404, 'Not found')
// Keep the live runtime in step with the stored agent, so a provider or model change applies to the next task
if (ctx.updateAgentRuntime) {
const rt: Record<string, unknown> = {}
if ('runtimeProvider' in patch) rt.provider = String(patch.runtimeProvider)
if ('runtimeModel' in patch) rt.model = String(patch.runtimeModel || '') || undefined
if ('runtimeMaxTurns' in patch) rt.maxTurns = patch.runtimeMaxTurns
if ('runtimeTimeoutSec' in patch) rt.timeoutSec = patch.runtimeTimeoutSec
if ('runtimeWorkingDir' in patch) rt.workingDir = patch.runtimeWorkingDir
if ('runtimeAllowedTools' in patch) rt.allowedTools = patch.runtimeAllowedTools
if ('runtimeMode' in patch) rt.mode = patch.runtimeMode
if (Object.keys(rt).length) ctx.updateAgentRuntime(agentId, rt)
}
return ok(200, { ok: true })
}

Expand Down Expand Up @@ -153,6 +171,9 @@ export async function createAgent(ctx: ApiContext, input: unknown): Promise<ApiR
if (o.presence && !VALID_PRESENCE.includes(o.presence as typeof VALID_PRESENCE[number])) {
return err(400, `Invalid presence. Must be one of: ${VALID_PRESENCE.join(', ')}`)
}
if (o.runtimeProvider !== undefined && !VALID_PROVIDERS.includes(String(o.runtimeProvider))) {
return err(400, `Invalid provider. Must be one of: ${VALID_PROVIDERS.join(', ')}`)
}
try {
const result = await ctx.createAgent(o)
if (ctx.registerAgentRuntime) {
Expand All @@ -162,6 +183,8 @@ export async function createAgent(ctx: ApiContext, input: unknown): Promise<ApiR
workingDir: o.runtimeWorkingDir ?? undefined,
allowedTools: o.runtimeAllowedTools ?? undefined,
mode: o.runtimeMode ?? 'full',
provider: o.runtimeProvider ?? 'claude-code',
model: o.runtimeModel ? String(o.runtimeModel) : undefined,
})
}
emit({ type: 'agent.created', agentId: String(o.id), name: String(o.name) })
Expand Down
Loading