Skip to content
Merged
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
181 changes: 181 additions & 0 deletions packages/app/api/lib/ai-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AiBackendProviderConfig>) => void;
}

// ── Lazy singletons per config hash ──────────────────────────────────────────
Expand Down Expand Up @@ -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<string, string> {
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<AiBackendProviderConfig>) => 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<AiBackendProviderConfig>) => void,
): Promise<string> {
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']);
Expand Down Expand Up @@ -459,6 +636,10 @@ export async function generateReply(options: GenerateOptions): Promise<string> {
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);
Expand Down
8 changes: 8 additions & 0 deletions packages/app/api/lib/route-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
db.aiBackend.update({
where: { id: backend.id },
data: { config: { ...existing, ...patch } },
}).catch((err: unknown) => console.error('[config-update] Failed to persist config:', err));
},
});
}
29 changes: 28 additions & 1 deletion packages/app/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 ─────────────────────────────────────────────────
Expand Down Expand Up @@ -202,6 +206,29 @@ export const BACKEND_TYPE_DESCRIPTORS: Record<AiBackendType, BackendTypeDescript
{ key: 'systemPrompt', label: 'System Prompt', inputType: 'textarea' },
],
},
'claude-agent': {
type: 'claude-agent',
label: 'Claude Agent',
transport: 'http',
responseFormat: 'json-auto',
fields: [
{ key: 'apiKey', label: 'Anthropic API Key', inputType: 'password', required: true },
{
key: 'model', label: 'Model', inputType: 'select',
selectOptions: [
{ label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
{ label: 'Claude Opus 4.6', value: 'claude-opus-4-6' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5' },
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5' },
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5' },
],
defaultValue: 'claude-sonnet-4-6',
},
{ key: 'systemPrompt', label: 'System Prompt', inputType: 'textarea' },
{ key: 'agentId', label: 'Agent ID', hint: 'Auto-created on first use. Leave blank for auto-setup.' },
{ key: 'environmentId', label: 'Environment ID', hint: 'Auto-created on first use. Leave blank for auto-setup.' },
],
},
custom: {
type: 'custom',
label: 'Custom Backend',
Expand Down
Loading