From f4bec6041e7e17d3a4c783c5b34a5d4ec6b37430 Mon Sep 17 00:00:00 2001 From: Shellishack <40737228+Shellishack@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:42:52 +0800 Subject: [PATCH] Support Claude Managed Agents --- packages/app/api/lib/ai-backend.ts | 181 ++++++++++++++++++++++++++ packages/app/api/lib/route-message.ts | 8 ++ packages/app/shared/index.ts | 29 ++++- 3 files changed, 217 insertions(+), 1 deletion(-) diff --git a/packages/app/api/lib/ai-backend.ts b/packages/app/api/lib/ai-backend.ts index 7356642..c8916cf 100644 --- a/packages/app/api/lib/ai-backend.ts +++ b/packages/app/api/lib/ai-backend.ts @@ -53,6 +53,8 @@ export interface GenerateOptions { sender?: string; /** Chat platform the message came from (e.g. "telegram", "discord") */ platform?: string; + /** Callback to persist config changes (e.g. auto-created agentId/environmentId) */ + onConfigUpdate?: (patch: Partial) => void; } // ── Lazy singletons per config hash ────────────────────────────────────────── @@ -424,6 +426,181 @@ async function handlePtyWebSocket( }); } +// ── Claude Managed Agents handler ─────────────────────────────────────────── + +const ANTHROPIC_API = 'https://api.anthropic.com'; +const ANTHROPIC_BETA = 'managed-agents-2026-04-01'; +const ANTHROPIC_VERSION = '2023-06-01'; + +function anthropicHeaders(apiKey: string): Record { + return { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': ANTHROPIC_VERSION, + 'anthropic-beta': ANTHROPIC_BETA, + }; +} + +/** Ensure the managed agent and environment exist, creating them if needed. Returns { agentId, environmentId }. */ +async function ensureAgentAndEnvironment( + cfg: AiBackendProviderConfig, + onUpdate?: (patch: Partial) => void, +): Promise<{ agentId: string; environmentId: string }> { + const apiKey = cfg.apiKey!; + const headers = anthropicHeaders(apiKey); + + let agentId = cfg.agentId; + let environmentId = cfg.environmentId; + let updated = false; + + if (!agentId) { + const res = await fetch(`${ANTHROPIC_API}/v1/agents`, { + method: 'POST', + headers, + body: JSON.stringify({ + name: 'ClawScale Agent', + model: cfg.model || 'claude-sonnet-4-6', + ...(cfg.systemPrompt ? { system: cfg.systemPrompt } : {}), + tools: [{ type: 'agent_toolset_20260401' }], + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to create Claude agent: ${res.status} ${body.slice(0, 300)}`); + } + const data = (await res.json()) as { id: string }; + agentId = data.id; + updated = true; + } + + if (!environmentId) { + const res = await fetch(`${ANTHROPIC_API}/v1/environments`, { + method: 'POST', + headers, + body: JSON.stringify({ + name: 'ClawScale Environment', + config: { + type: 'cloud', + networking: { type: 'unrestricted' }, + }, + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to create Claude environment: ${res.status} ${body.slice(0, 300)}`); + } + const data = (await res.json()) as { id: string }; + environmentId = data.id; + updated = true; + } + + if (updated) { + cfg.agentId = agentId; + cfg.environmentId = environmentId; + onUpdate?.({ agentId, environmentId }); + } + + return { agentId, environmentId }; +} + +/** + * Handle Claude Managed Agents backend. + * Creates a session per request, sends the last user message, streams events until idle. + */ +async function handleClaudeAgent( + cfg: AiBackendProviderConfig, + history: HistoryMessage[], + onConfigUpdate?: (patch: Partial) => void, +): Promise { + if (!cfg.apiKey) throw new Error('Claude Agent backend: apiKey is required'); + + const { agentId, environmentId } = await ensureAgentAndEnvironment(cfg, onConfigUpdate); + const headers = anthropicHeaders(cfg.apiKey); + + // Create a session + const sessionRes = await fetch(`${ANTHROPIC_API}/v1/sessions`, { + method: 'POST', + headers, + body: JSON.stringify({ + agent: agentId, + environment_id: environmentId, + }), + }); + if (!sessionRes.ok) { + const body = await sessionRes.text().catch(() => ''); + throw new Error(`Failed to create Claude session: ${sessionRes.status} ${body.slice(0, 300)}`); + } + const session = (await sessionRes.json()) as { id: string }; + + // Build user message content from the last user message in history + const lastUserMsg = [...history].reverse().find((m) => m.role === 'user'); + if (!lastUserMsg) throw new Error('No user message in history'); + + const content: { type: string; text?: string }[] = [{ type: 'text', text: lastUserMsg.content }]; + + // Send user event + const sendRes = await fetch(`${ANTHROPIC_API}/v1/sessions/${session.id}/events`, { + method: 'POST', + headers, + body: JSON.stringify({ + events: [{ type: 'user.message', content }], + }), + }); + if (!sendRes.ok) { + const body = await sendRes.text().catch(() => ''); + throw new Error(`Failed to send event: ${sendRes.status} ${body.slice(0, 300)}`); + } + + // Stream SSE response until idle + const streamRes = await fetch(`${ANTHROPIC_API}/v1/sessions/${session.id}/stream`, { + method: 'GET', + headers: { + ...anthropicHeaders(cfg.apiKey), + 'Accept': 'text/event-stream', + }, + signal: AbortSignal.timeout(300_000), // 5 min timeout for agent tasks + }); + if (!streamRes.ok || !streamRes.body) { + const body = await streamRes.text().catch(() => ''); + throw new Error(`Failed to stream session: ${streamRes.status} ${body.slice(0, 300)}`); + } + + // Read SSE events and accumulate agent message text + const reader = streamRes.body.getReader(); + const decoder = new TextDecoder(); + let accumulated = ''; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data) continue; + + let event: any; + try { event = JSON.parse(data); } catch { continue; } + + if (event.type === 'agent.message' && Array.isArray(event.content)) { + for (const block of event.content) { + if (block.type === 'text' && block.text) accumulated += block.text; + } + } else if (event.type === 'session.status_idle' || event.type === 'session.status_terminated') { + reader.cancel(); + break; + } + } + } + + return accumulated.trim(); +} + // ── Public API ──────────────────────────────────────────────────────────────── const CONNECTION_ERROR_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET']); @@ -459,6 +636,10 @@ export async function generateReply(options: GenerateOptions): Promise { switch (transport) { case 'http': case 'sse': { + // Claude Managed Agents has its own multi-step handler + if (type === 'claude-agent') { + return await handleClaudeAgent(cfg, history, options.onConfigUpdate); + } // llm and openclaw use the OpenAI SDK client if (type === 'llm' || type === 'openclaw') { return await handleOpenAiSdk(type, cfg, history); diff --git a/packages/app/api/lib/route-message.ts b/packages/app/api/lib/route-message.ts index 6ca475b..bb3080c 100644 --- a/packages/app/api/lib/route-message.ts +++ b/packages/app/api/lib/route-message.ts @@ -703,5 +703,13 @@ async function runBackend( history, sender: meta?.sender, platform: meta?.platform, + onConfigUpdate: (patch) => { + // Persist auto-created config (e.g. Claude Agent agentId/environmentId) + const existing = (backend.config ?? {}) as Record; + db.aiBackend.update({ + where: { id: backend.id }, + data: { config: { ...existing, ...patch } }, + }).catch((err: unknown) => console.error('[config-update] Failed to persist config:', err)); + }, }); } diff --git a/packages/app/shared/index.ts b/packages/app/shared/index.ts index 131a371..516644a 100644 --- a/packages/app/shared/index.ts +++ b/packages/app/shared/index.ts @@ -86,7 +86,7 @@ export interface TenantSettings { backendLabels?: 'show' | 'hide' | 'force-hide'; } -export type AiBackendType = 'llm' | 'openclaw' | 'palmos' | 'claude-code' | 'custom' | 'cli-bridge'; +export type AiBackendType = 'llm' | 'openclaw' | 'palmos' | 'claude-code' | 'claude-agent' | 'custom' | 'cli-bridge'; /** Transport method — how ClawScale connects to the backend. */ export type Transport = 'http' | 'sse' | 'websocket' | 'pty-websocket'; @@ -113,6 +113,10 @@ export interface AiBackendProviderConfig { responseFormat?: ResponseFormat; /** Auto-generated token for CLI bridge authentication */ bridgeToken?: string; + /** Claude Managed Agents — persisted agent ID (auto-created on first use) */ + agentId?: string; + /** Claude Managed Agents — persisted environment ID (auto-created on first use) */ + environmentId?: string; } // ── Backend type descriptors ───────────────────────────────────────────────── @@ -202,6 +206,29 @@ export const BACKEND_TYPE_DESCRIPTORS: Record