Skip to content

Commit 6496608

Browse files
committed
feat(managed-agents): add Claude Managed Agents workflow block
1 parent 4d76385 commit 6496608

26 files changed

Lines changed: 1933 additions & 9 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import {
5+
listManagedAgentOptionsContract,
6+
MANAGED_AGENT_BYOK_PROVIDER,
7+
type ManagedAgentOption,
8+
type ManagedAgentResource,
9+
} from '@/lib/api/contracts/managed-agents'
10+
import { parseRequest } from '@/lib/api/server'
11+
import { getBYOKKey } from '@/lib/api-key/byok'
12+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
13+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { AGENT_MEMORY_BETA, managedAgentsList } from '@/lib/managed-agents/session-client'
15+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
16+
17+
export const dynamic = 'force-dynamic'
18+
19+
const logger = createLogger('ManagedAgentListAPI')
20+
21+
interface AnthropicListRow {
22+
id?: string
23+
name?: string | null
24+
config?: { type?: 'cloud' | 'self_hosted' }
25+
}
26+
27+
/**
28+
* Anthropic list path + the beta header it requires. Memory-store endpoints
29+
* use `agent-memory-2026-07-22`; combining it with the managed-agents beta is
30+
* a documented 400, so each resource declares exactly one.
31+
*/
32+
const RESOURCE_ENDPOINTS: Record<ManagedAgentResource, { path: string; beta?: string }> = {
33+
agents: { path: '/v1/agents' },
34+
environments: { path: '/v1/environments' },
35+
vaults: { path: '/v1/vaults' },
36+
'memory-stores': { path: '/v1/memory_stores', beta: AGENT_MEMORY_BETA },
37+
}
38+
39+
function toOption(
40+
resource: ManagedAgentResource,
41+
row: AnthropicListRow
42+
): ManagedAgentOption | null {
43+
if (!row.id) return null
44+
const name = row.name?.trim()
45+
if (resource === 'environments') {
46+
const type = row.config?.type
47+
const suffix = type ? ` (${type})` : ''
48+
return { id: row.id, label: `${name || row.id}${suffix}` }
49+
}
50+
if (resource === 'vaults') {
51+
return { id: row.id, label: name || row.id }
52+
}
53+
return { id: row.id, label: name ? `${name} (${row.id})` : row.id }
54+
}
55+
56+
/**
57+
* Resolves Managed Agent dropdown options (agents / environments / vaults /
58+
* memory stores) for the block editor. The workspace's Claude Platform BYOK
59+
* key is decrypted server-side and never crosses the client boundary — the
60+
* browser only ever receives `{ id, label }` options.
61+
*/
62+
export const GET = withRouteHandler(async (request: NextRequest) => {
63+
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
64+
if (!auth.success || !auth.userId) {
65+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
66+
}
67+
68+
const parsed = await parseRequest(listManagedAgentOptionsContract, request, {})
69+
if (!parsed.success) return parsed.response
70+
const { workspaceId, resource } = parsed.data.query
71+
72+
const permission = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId)
73+
if (!permission) {
74+
return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 })
75+
}
76+
77+
const byok = await getBYOKKey(workspaceId, MANAGED_AGENT_BYOK_PROVIDER)
78+
if (!byok) {
79+
// No Claude Platform key linked yet — return an empty list so the
80+
// dropdown renders cleanly rather than erroring.
81+
return NextResponse.json({ options: [] })
82+
}
83+
84+
try {
85+
const endpoint = RESOURCE_ENDPOINTS[resource]
86+
const rows = await managedAgentsList<AnthropicListRow>({
87+
apiKey: byok.apiKey,
88+
path: endpoint.path,
89+
beta: endpoint.beta,
90+
signal: request.signal,
91+
})
92+
const options = rows
93+
.map((row) => toOption(resource, row))
94+
.filter((option): option is ManagedAgentOption => option !== null)
95+
return NextResponse.json({ options })
96+
} catch (error) {
97+
// Some beta workspaces may not expose every resource (e.g. vaults). Log
98+
// and degrade to an empty list rather than breaking the editor.
99+
logger.warn('Managed agent list proxy failed', {
100+
workspaceId,
101+
resource,
102+
error: getErrorMessage(error),
103+
})
104+
return NextResponse.json({ options: [] })
105+
}
106+
})
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { db } from '@sim/db'
2+
import { workflow } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import {
7+
MANAGED_AGENT_BYOK_PROVIDER,
8+
runManagedAgentContract,
9+
} from '@/lib/api/contracts/managed-agents'
10+
import { parseRequest } from '@/lib/api/server'
11+
import { getBYOKKey } from '@/lib/api-key/byok'
12+
import { checkInternalAuth } from '@/lib/auth/hybrid'
13+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { runManagedAgentSession } from '@/lib/managed-agents/run-session'
15+
16+
export const dynamic = 'force-dynamic'
17+
/** Sessions can run for several minutes; bound generously above the run loop's own caps. */
18+
export const maxDuration = 800
19+
20+
const logger = createLogger('ManagedAgentRunAPI')
21+
22+
/**
23+
* Internal route that runs one Managed Agent session. Executor-only
24+
* (server-to-server internal auth) — the browser never invokes it. The
25+
* workspace Claude Platform key is resolved server-side from the workflow's
26+
* workspace and never leaves the server.
27+
*/
28+
export const POST = withRouteHandler(async (request: NextRequest) => {
29+
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
30+
if (!auth.success) {
31+
return NextResponse.json(
32+
{ success: false, error: auth.error || 'Unauthorized' },
33+
{ status: 401 }
34+
)
35+
}
36+
37+
const parsed = await parseRequest(runManagedAgentContract, request, {})
38+
if (!parsed.success) return parsed.response
39+
const { body, query } = parsed.data
40+
41+
const workflowId = query.workflowId
42+
if (!workflowId) {
43+
return NextResponse.json(
44+
{ success: false, error: 'Missing workflowId — is this tool running inside a workflow?' },
45+
{ status: 400 }
46+
)
47+
}
48+
49+
const [row] = await db
50+
.select({ workspaceId: workflow.workspaceId, name: workflow.name })
51+
.from(workflow)
52+
.where(eq(workflow.id, workflowId))
53+
.limit(1)
54+
const workspaceId = row?.workspaceId
55+
if (!workspaceId) {
56+
return NextResponse.json(
57+
{ success: false, error: 'Workflow is not associated with a workspace.' },
58+
{ status: 400 }
59+
)
60+
}
61+
62+
// Vault authorization ack — enforced here because the block's condition
63+
// engine cannot test array-non-empty. Fails closed: attaching a vault
64+
// requires explicit confirmation, since the session assumes its identity.
65+
if (body.vaults && body.vaults.length > 0 && !body.vaultsAck) {
66+
return NextResponse.json(
67+
{
68+
success: false,
69+
error:
70+
'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).',
71+
},
72+
{ status: 400 }
73+
)
74+
}
75+
76+
const byok = await getBYOKKey(workspaceId, MANAGED_AGENT_BYOK_PROVIDER)
77+
if (!byok) {
78+
return NextResponse.json(
79+
{
80+
success: false,
81+
error:
82+
'No Claude Platform API key is configured for this workspace. Add one under Settings → API Keys (Claude Platform).',
83+
},
84+
{ status: 400 }
85+
)
86+
}
87+
88+
const result = await runManagedAgentSession({
89+
apiKey: byok.apiKey,
90+
agentId: body.agent,
91+
environmentId: body.environment,
92+
userMessage: body.userMessage,
93+
title: row?.name ? `Sim - ${row.name}` : undefined,
94+
vaultIds: body.vaults,
95+
memoryStoreId: body.memoryStoreId,
96+
memoryAccess: body.memoryAccess,
97+
fileIds: body.fileIds,
98+
sessionParameters: body.sessionParameters,
99+
signal: request.signal,
100+
})
101+
102+
if (!result.ok) {
103+
logger.warn('Managed agent session failed', {
104+
workspaceId,
105+
workflowId,
106+
sessionId: result.sessionId,
107+
error: result.error,
108+
})
109+
return NextResponse.json(
110+
{ success: false, error: result.error ?? 'Managed Agent session failed' },
111+
{ status: 502 }
112+
)
113+
}
114+
115+
return NextResponse.json({
116+
success: true,
117+
output: { content: result.content, sessionId: result.sessionId ?? '' },
118+
})
119+
})

apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
AnthropicIcon,
77
BasetenIcon,
88
BrandfetchIcon,
9+
ClaudeIcon,
910
ContextDevIcon,
1011
DatagmaIcon,
1112
DropcontactIcon,
@@ -66,6 +67,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [
6667
description: 'LLM calls',
6768
placeholder: 'sk-ant-...',
6869
},
70+
{
71+
id: 'claude-platform',
72+
name: 'Claude Platform',
73+
icon: ClaudeIcon,
74+
description: 'Managed Agents block',
75+
placeholder: 'sk-ant-...',
76+
},
6977
{
7078
id: 'google',
7179
name: 'Google',
@@ -303,6 +311,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [
303311
ids: [
304312
'openai',
305313
'anthropic',
314+
'claude-platform',
306315
'google',
307316
'mistral',
308317
'xai',
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('next/navigation', () => ({
9+
useParams: () => ({ workspaceId: 'ws-1' }),
10+
}))
11+
12+
vi.mock('@/hooks/queries/folders', () => ({
13+
useReorderFolders: () => ({ mutateAsync: vi.fn() }),
14+
}))
15+
16+
vi.mock('@/hooks/queries/workflows', () => ({
17+
useReorderWorkflows: () => ({ mutateAsync: vi.fn() }),
18+
}))
19+
20+
vi.mock('@/hooks/queries/utils/folder-cache', () => ({
21+
getFolderMap: () => ({}),
22+
}))
23+
24+
vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
25+
getWorkflows: () => [],
26+
}))
27+
28+
vi.mock('@/lib/folders/tree', () => ({
29+
getFolderPath: () => [],
30+
}))
31+
32+
const { mockUseFolderStore } = vi.hoisted(() => {
33+
const folderState = { setExpanded: () => {}, expandedFolders: new Set<string>() }
34+
const store = Object.assign(
35+
(selector: (state: typeof folderState) => unknown) => selector(folderState),
36+
{ getState: () => folderState }
37+
)
38+
return { mockUseFolderStore: store }
39+
})
40+
vi.mock('@/stores/folders/store', () => ({ useFolderStore: mockUseFolderStore }))
41+
42+
import { useDragDrop } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop'
43+
44+
type DragDropApi = ReturnType<typeof useDragDrop>
45+
46+
let latest: DragDropApi
47+
48+
function Harness() {
49+
latest = useDragDrop()
50+
return null
51+
}
52+
53+
/** Minimal stand-in for the dragOver event `initDragOver` consumes. */
54+
function fakeDragOverEvent(): unknown {
55+
const node = {}
56+
return {
57+
preventDefault: () => {},
58+
stopPropagation: () => {},
59+
clientY: 0,
60+
// target !== currentTarget so the root drop zone skips indicator math (getBoundingClientRect)
61+
target: node,
62+
currentTarget: {},
63+
}
64+
}
65+
66+
let container: HTMLDivElement
67+
let root: Root
68+
69+
describe('useDragDrop stranded-drag reset', () => {
70+
beforeEach(() => {
71+
// Prevent the auto-scroll rAF loop from spinning in jsdom.
72+
vi.stubGlobal(
73+
'requestAnimationFrame',
74+
() => 0 as unknown as ReturnType<typeof requestAnimationFrame>
75+
)
76+
vi.stubGlobal('cancelAnimationFrame', () => {})
77+
container = document.createElement('div')
78+
document.body.appendChild(container)
79+
root = createRoot(container)
80+
act(() => {
81+
root.render(<Harness />)
82+
})
83+
// The reset listeners only attach once a scroll container is registered.
84+
act(() => {
85+
latest.setScrollContainer(document.createElement('div'))
86+
})
87+
})
88+
89+
afterEach(() => {
90+
act(() => {
91+
root.unmount()
92+
})
93+
container.remove()
94+
vi.unstubAllGlobals()
95+
vi.clearAllMocks()
96+
})
97+
98+
it('clears isDragging on a window dragend when no drop fired', () => {
99+
// A drag entering the list flips isDragging on via initDragOver.
100+
act(() => {
101+
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
102+
})
103+
expect(latest.isDragging).toBe(true)
104+
105+
// The drag is cancelled/dropped outside the list: only `dragend` fires, no `drop`.
106+
act(() => {
107+
window.dispatchEvent(new Event('dragend'))
108+
})
109+
expect(latest.isDragging).toBe(false)
110+
})
111+
112+
it('keeps isDragging active across dragOver updates until the drag ends', () => {
113+
act(() => {
114+
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
115+
})
116+
expect(latest.isDragging).toBe(true)
117+
118+
// A subsequent dragOver must not tear down the active drag.
119+
act(() => {
120+
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
121+
})
122+
expect(latest.isDragging).toBe(true)
123+
})
124+
})

0 commit comments

Comments
 (0)