Skip to content

Commit 9681c94

Browse files
committed
feat(mothership): sim_cli and run_code execute sim-side; agent CLI augmentations
Live dev incident (elder's chats): run_code faked success against a route that does not exist, sim's pickup ghost-claimed worker tools off the stale catalog, and a mid-turn deploy downgraded the toolset (CLI "Not logged in", AI_NoSuchToolError). Root fixes, all on the right layer: - sim/embed: the CLI's own command tree runs in-process (AsyncLocalStorage identity + output capture, no profiles/config/env, process.exit shimmed). The worker stops spawning a vendored CLI binary; no credential crosses the wire — sim mints the delegation identity inside the handler per call. - sim_cli handler routes each invocation: agent-only augmentations first (agent-cli/ registry — workflow blocks/edges views, workflow-scoped and cross-workflow grep, typed over the v2 surface via the CLI's SimClient), else the embedded real CLI; root --help merges both surfaces. - run_code + function-execute handlers restored from the pre-revamp tree (same sandbox as workflow Function blocks), registered again. - Pickup guard: a frame whose executor is 'go' is never dispatched here, whatever the stale catalog says; registry handlers are authoritative for worker-declared tools outside the catalog. - Lanes: the task dispatch row is absorbed by its titled lane (id-precise), 'task' agentId gets a sane fallback label. - Resume delegation-token threading removed — obsolete by design. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent 2ba3487 commit 9681c94

22 files changed

Lines changed: 1686 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
129129
*/
130130
const SUBAGENT_DISPATCH_TOOLS: Record<string, string> = {
131131
[FILE_SUBAGENT_ID]: PrepareFileEdit.id,
132+
// The worker's general subagent: the `task` tool row is the dispatch; the lane
133+
// (titled by the model) replaces it.
134+
task: 'task',
132135
}
133136

134137
function isToolResultRead(params?: Record<string, unknown>): boolean {
@@ -287,12 +290,27 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
287290
// When a subagent spawns, drop the dispatch tool that triggered it (e.g.
288291
// workspace_file -> file) from whichever container it landed in so it does not
289292
// render as a separate entry beside the agent group.
290-
const absorbDispatchTool = (toolName: string, parentSpanId: string | undefined): void => {
293+
const absorbDispatchTool = (
294+
toolName: string,
295+
parentSpanId: string | undefined,
296+
dispatchToolCallId?: string
297+
): void => {
291298
const container =
292299
parentSpanId && parentSpanId !== SPAN_ROOT
293300
? groupsBySpanId.get(parentSpanId)
294301
: tailMothershipGroup()
295302
if (!container) return
303+
// Prefer the precise id match anywhere in the container — parallel sibling
304+
// tools can push the dispatch row off the tail position.
305+
if (dispatchToolCallId) {
306+
const idx = container.items.findIndex(
307+
(it) => it.type === 'tool' && it.data.id === dispatchToolCallId
308+
)
309+
if (idx >= 0) {
310+
container.items.splice(idx, 1)
311+
return
312+
}
313+
}
296314
const last = container.items[container.items.length - 1]
297315
if (last?.type === 'tool' && last.data.toolName === toolName) {
298316
container.items.pop()
@@ -379,7 +397,9 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
379397
// Absorb a trailing dispatch tool (e.g. workspace_file -> file) so it does
380398
// not render as a separate entry alongside the agent group.
381399
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content]
382-
if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId)
400+
if (dispatchToolName) {
401+
absorbDispatchTool(dispatchToolName, block.parentSpanId, block.parentToolCallId)
402+
}
383403
const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId)
384404
if (block.subagentName) g.agentLabel = block.subagentName
385405
if (block.endedAt !== undefined) {

apps/sim/app/workspace/[workspaceId]/home/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ export const SUBAGENT_LABELS: Record<string, string> = {
198198
// `extensions` is its current model-facing trigger tool name.
199199
agent: 'Extensions Agent',
200200
extensions: 'Extensions Agent',
201+
// The worker's general subagent; the lane header normally carries the
202+
// model-chosen title, this label is only the pre-start race fallback.
203+
task: 'Subagent',
201204
// `job` retained as a backward-compat alias so historical transcripts still render a label.
202205
job: 'Job Agent',
203206
file: 'File Agent',

apps/sim/lib/mothership/generated/protocol.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export interface ChatRequest {
3232
integrationTools?: unknown[] | undefined;
3333
/** User-configured MCP tool schemas — same shape as integrationTools. */
3434
mothershipTools?: unknown[] | undefined;
35-
/** D23: sim-minted run-scoped credential. In-memory only on the worker (S44). */
35+
/** Deprecated: unused since the CLI moved to sim-side in-process execution (no
36+
* credential crosses the wire); accepted so current senders keep validating. */
3637
delegationToken?: string | undefined;
3738
/** Enterprise BYOK: customer's own key; per-run instance, zero retention (S27). */
3839
byokApiKey?: string | undefined;
@@ -42,7 +43,7 @@ export interface ChatRequest {
4243
/**
4344
* What the CALLER can execute client-side. PRESENT = an explicit declaration — an
4445
* empty array means "I pick up nothing", so sim-side dispatch must skip client-pickup
45-
* grace windows and run tools server-side immediately. ABSENT = legacy/unknown caller —
46+
* grace windows and run tools server-side immediately. ABSENT = older/unknown caller —
4647
* dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates
4748
* this field still gets waited on). Known capability: "workflow-tool-pickup".
4849
*/

apps/sim/lib/mothership/request/handlers/tool.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,11 @@ async function handleCallPhase(
516516
const { clientExecutable, simExecutable, internal, inbandOwned } = ui
517517
const catalogEntry = getToolEntry(toolName)
518518
const isInternal = internal || catalogEntry?.internal === true
519-
const staticSimExecuted = isSimExecuted(toolName)
519+
// The frame's executor is authoritative over the static catalog: a backend-executed
520+
// ('go') frame must never be dispatched here even when the catalog lists the name as
521+
// sim-routed — the worker runs some legacy-named tools in-process, and a stale-catalog
522+
// dispatch raced them with a second, handlerless execution ("No handler for tool").
523+
const staticSimExecuted = isSimExecuted(toolName) && data.executor !== 'go'
520524
// Go executes inband-owned calls itself via /api/copilot/tools/execute
521525
// (background lanes, and the main lane while background agents run); the
522526
// event exists only to draw the row. Dispatching it here would run the

apps/sim/lib/mothership/tool-executor/executor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,13 @@ export async function executeTool(
7373

7474
const normalizedParams = normalizeToolParams(toolId, params, context)
7575

76+
// A registered handler is authoritative for worker-declared tools OUTSIDE the catalog
77+
// (sim_cli, run_code's successors): the catalog gate below would otherwise misroute
78+
// them into the app-tool registry and fail with "Tool not found". Catalog-known tools
79+
// keep the routing the catalog declares.
7680
const canUseRegisteredHandler =
77-
isKnownTool(toolId) && (isSimExecuted(toolId) || usesHeadlessClientFallback)
81+
hasHandler(toolId) &&
82+
(!isKnownTool(toolId) || isSimExecuted(toolId) || usesHeadlessClientFallback)
7883
if (!canUseRegisteredHandler) {
7984
const appParams = buildAppToolParams(normalizedParams, context)
8085
const options = {

apps/sim/lib/mothership/tool-executor/register-handlers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
} from '@/lib/mothership/generated/tool-catalog-v1'
88
import { createServerToolHandler } from '@/lib/mothership/tools/registry/server-tool-adapter'
99
import { getRegisteredServerToolNames } from '@/lib/mothership/tools/server/router'
10+
import { executeRunCode } from '../tools/handlers/run-code'
11+
import { executeSimCli } from '../tools/handlers/sim-cli'
1012
import {
1113
executeRunBlock,
1214
executeRunFromBlock,
@@ -48,6 +50,13 @@ function buildHandlerMap(): Record<string, ToolHandler> {
4850
[RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock),
4951
[RunFromBlock.id]: h(executeRunFromBlock),
5052
[RunBlock.id]: h(executeRunBlock),
53+
// The worker's sandboxed code execution — deferred here because the sandbox
54+
// (E2B/VM, mounts, secret materialization) lives on this side, same as the
55+
// workflow Function block. Compute-only: the handler rejects write vectors.
56+
run_code: h(executeRunCode),
57+
// The worker's CLI surface, executed in-process via the CLI's own command
58+
// tree (sim/embed) against this deployment's internal API base.
59+
sim_cli: h(executeSimCli),
5160
...buildServerToolHandlers(),
5261
}
5362
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
agentCliHelpSection,
7+
executeAgentCliCommand,
8+
isRootHelpInvocation,
9+
matchAgentCliCommand,
10+
} from '@/lib/mothership/tools/handlers/agent-cli'
11+
import type { AgentCliRuntime } from '@/lib/mothership/tools/handlers/agent-cli/types'
12+
13+
const WORKFLOW_STATE = {
14+
blocks: {
15+
'block-1': { type: 'starter', name: 'Start', enabled: true },
16+
'block-2': { type: 'agent', name: 'Summarize emails', enabled: true },
17+
},
18+
edges: [{ source: 'block-1', target: 'block-2', sourceHandle: 'source', id: 'edge-1' }],
19+
variables: { apiBase: 'https://api.example.com' },
20+
}
21+
22+
function runtimeWith(responses: Record<string, unknown>): AgentCliRuntime {
23+
return {
24+
workspaceId: 'ws-1',
25+
client: {
26+
request: async <T>(path: string): Promise<T> => {
27+
const hit = responses[path]
28+
if (hit === undefined) throw new Error(`Unexpected request: ${path}`)
29+
return hit as T
30+
},
31+
},
32+
}
33+
}
34+
35+
const EXPORT_PATH = '/api/v2/workflows/wf-1/export'
36+
const exportResponse = { data: { state: WORKFLOW_STATE } }
37+
38+
describe('agent-cli routing', () => {
39+
it('matches agent commands through leading global flags', () => {
40+
const match = matchAgentCliCommand(['--output', 'json', 'workflow', 'edges', 'wf-1'])
41+
expect(match?.command.path).toEqual(['workflow', 'edges'])
42+
expect(match?.rest).toEqual(['wf-1'])
43+
})
44+
45+
it('leaves real CLI commands unmatched', () => {
46+
expect(matchAgentCliCommand(['workflows', 'list'])).toBeNull()
47+
expect(matchAgentCliCommand(['tables', 'get', 'tbl_1'])).toBeNull()
48+
})
49+
50+
it('detects root help invocations only', () => {
51+
expect(isRootHelpInvocation(['--help'])).toBe(true)
52+
expect(isRootHelpInvocation(['--output', 'json', 'help'])).toBe(true)
53+
expect(isRootHelpInvocation(['workflows', '--help'])).toBe(false)
54+
})
55+
56+
it('lists every registered command in the help section', () => {
57+
const section = agentCliHelpSection()
58+
for (const usage of ['workflow blocks', 'workflow edges', 'workflow grep', 'workflows grep']) {
59+
expect(section).toContain(usage)
60+
}
61+
})
62+
})
63+
64+
describe('workflow views', () => {
65+
it('projects just the blocks', async () => {
66+
const match = matchAgentCliCommand(['workflow', 'blocks', 'wf-1'])
67+
const result = await executeAgentCliCommand(
68+
match!,
69+
runtimeWith({ [EXPORT_PATH]: exportResponse })
70+
)
71+
expect(result.exitCode).toBe(0)
72+
const blocks = JSON.parse(result.stdout)
73+
expect(blocks).toEqual([
74+
{ id: 'block-1', type: 'starter', name: 'Start', enabled: true },
75+
{ id: 'block-2', type: 'agent', name: 'Summarize emails', enabled: true },
76+
])
77+
})
78+
79+
it('projects just the edges', async () => {
80+
const match = matchAgentCliCommand(['workflow', 'edges', 'wf-1'])
81+
const result = await executeAgentCliCommand(
82+
match!,
83+
runtimeWith({ [EXPORT_PATH]: exportResponse })
84+
)
85+
expect(result.exitCode).toBe(0)
86+
expect(JSON.parse(result.stdout)).toEqual([
87+
{ source: 'block-1', target: 'block-2', sourceHandle: 'source' },
88+
])
89+
})
90+
91+
it('fails usefully without a workflow id', async () => {
92+
const match = matchAgentCliCommand(['workflow', 'blocks'])
93+
const result = await executeAgentCliCommand(match!, runtimeWith({}))
94+
expect(result.exitCode).toBe(1)
95+
expect(result.stderr).toContain('Usage:')
96+
})
97+
})
98+
99+
describe('workflow grep', () => {
100+
it('reports matches as path: value lines', async () => {
101+
const match = matchAgentCliCommand(['workflow', 'grep', 'wf-1', 'Summarize'])
102+
const result = await executeAgentCliCommand(
103+
match!,
104+
runtimeWith({ [EXPORT_PATH]: exportResponse })
105+
)
106+
expect(result.exitCode).toBe(0)
107+
expect(result.stdout).toContain('.blocks.block-2.name: Summarize emails')
108+
})
109+
110+
it('falls back to literal search on an invalid regex', async () => {
111+
const match = matchAgentCliCommand(['workflow', 'grep', 'wf-1', 'api.example.com['])
112+
const result = await executeAgentCliCommand(
113+
match!,
114+
runtimeWith({ [EXPORT_PATH]: exportResponse })
115+
)
116+
expect(result.exitCode).toBe(0)
117+
expect(result.stdout).toBe('No matches.')
118+
})
119+
120+
it('searches across all workspace workflows', async () => {
121+
const match = matchAgentCliCommand(['workflows', 'grep', 'Summarize'])
122+
const result = await executeAgentCliCommand(
123+
match!,
124+
runtimeWith({
125+
'/api/v2/workflows': {
126+
data: [{ id: 'wf-1', name: 'Email digest' }],
127+
nextCursor: null,
128+
},
129+
[EXPORT_PATH]: exportResponse,
130+
})
131+
)
132+
expect(result.exitCode).toBe(0)
133+
expect(result.stdout).toContain('Email digest (wf-1).blocks.block-2.name: Summarize emails')
134+
})
135+
136+
it('surfaces execution errors as a failed result, never a throw', async () => {
137+
const match = matchAgentCliCommand(['workflow', 'grep', 'wf-missing', 'x'])
138+
const result = await executeAgentCliCommand(match!, runtimeWith({}))
139+
expect(result.exitCode).toBe(1)
140+
expect(result.stderr).toContain('Unexpected request')
141+
})
142+
})
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { ListWorkflowsResponse } from 'sim/embed'
2+
import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views'
3+
import {
4+
type AgentCliCommand,
5+
type AgentCliRuntime,
6+
agentCliFail,
7+
agentCliOk,
8+
} from '@/lib/mothership/tools/handlers/agent-cli/types'
9+
10+
/**
11+
* Structural grep over workflow state. Matches walk the exported JSON tree and
12+
* report `path: value` lines, so a hit names exactly where in the workflow it
13+
* lives (block param, edge handle, variable) instead of a rendered blob.
14+
*/
15+
16+
const MAX_MATCHES = 200
17+
const SNIPPET_CHARS = 200
18+
const EXPORT_CONCURRENCY = 5
19+
20+
function compilePattern(raw: string): (value: string) => boolean {
21+
try {
22+
const regex = new RegExp(raw, 'i')
23+
return (value) => regex.test(value)
24+
} catch {
25+
const needle = raw.toLowerCase()
26+
return (value) => value.toLowerCase().includes(needle)
27+
}
28+
}
29+
30+
function grepTree(
31+
node: unknown,
32+
matches: (value: string) => boolean,
33+
path: string,
34+
out: string[]
35+
): void {
36+
if (out.length >= MAX_MATCHES) return
37+
if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') {
38+
const text = String(node)
39+
if (matches(text)) {
40+
const snippet = text.length > SNIPPET_CHARS ? `${text.slice(0, SNIPPET_CHARS)}…` : text
41+
out.push(`${path}: ${snippet.replaceAll('\n', '\\n')}`)
42+
}
43+
return
44+
}
45+
if (Array.isArray(node)) {
46+
node.forEach((child, index) => grepTree(child, matches, `${path}[${index}]`, out))
47+
return
48+
}
49+
if (typeof node === 'object' && node !== null) {
50+
for (const [key, child] of Object.entries(node)) {
51+
// Keys are searchable too: a block id or param name is often the target.
52+
if (matches(key) && out.length < MAX_MATCHES) out.push(`${path}.${key}`)
53+
grepTree(child, matches, `${path}.${key}`, out)
54+
}
55+
}
56+
}
57+
58+
function renderMatches(lines: string[]): string {
59+
if (lines.length === 0) return 'No matches.'
60+
const capped =
61+
lines.length >= MAX_MATCHES ? [...lines, `[capped at ${MAX_MATCHES} matches]`] : lines
62+
return capped.join('\n')
63+
}
64+
65+
export const workflowGrepCommand: AgentCliCommand = {
66+
path: ['workflow', 'grep'],
67+
summary: 'Search one workflow state (blocks, params, edges) for a pattern',
68+
usage: 'workflow grep <workflowId> <pattern>',
69+
async execute(rest, runtime) {
70+
const [workflowId, ...patternParts] = rest
71+
const pattern = patternParts.join(' ')
72+
if (!workflowId || !pattern)
73+
return agentCliFail('Usage: sim workflow grep <workflowId> <pattern>')
74+
const state = await fetchWorkflowState(runtime, workflowId)
75+
const out: string[] = []
76+
grepTree(state, compilePattern(pattern), '', out)
77+
return agentCliOk(renderMatches(out))
78+
},
79+
}
80+
81+
async function listAllWorkflows(runtime: AgentCliRuntime): Promise<ListWorkflowsResponse['data']> {
82+
const rows: ListWorkflowsResponse['data'] = []
83+
let cursor: string | null = null
84+
do {
85+
const page: ListWorkflowsResponse = await runtime.client.request<ListWorkflowsResponse>(
86+
'/api/v2/workflows',
87+
{ query: { workspaceId: runtime.workspaceId, ...(cursor ? { cursor } : {}) } }
88+
)
89+
rows.push(...page.data)
90+
cursor = page.nextCursor
91+
} while (cursor)
92+
return rows
93+
}
94+
95+
export const workflowsGrepCommand: AgentCliCommand = {
96+
path: ['workflows', 'grep'],
97+
summary: 'Search every workflow in the workspace for a pattern',
98+
usage: 'workflows grep <pattern>',
99+
async execute(rest, runtime) {
100+
const pattern = rest.join(' ')
101+
if (!pattern) return agentCliFail('Usage: sim workflows grep <pattern>')
102+
const matches = compilePattern(pattern)
103+
const workflows = await listAllWorkflows(runtime)
104+
const out: string[] = []
105+
for (let i = 0; i < workflows.length && out.length < MAX_MATCHES; i += EXPORT_CONCURRENCY) {
106+
const batch = workflows.slice(i, i + EXPORT_CONCURRENCY)
107+
const states = await Promise.all(
108+
batch.map(async (workflow) => {
109+
try {
110+
return { workflow, state: await fetchWorkflowState(runtime, workflow.id) }
111+
} catch {
112+
// One unexportable workflow must not sink the whole search.
113+
return { workflow, state: null }
114+
}
115+
})
116+
)
117+
for (const { workflow, state } of states) {
118+
const label = `${workflow.name} (${workflow.id})`
119+
if (matches(workflow.name) && out.length < MAX_MATCHES) out.push(`${label}: name matches`)
120+
if (state) grepTree(state, matches, label, out)
121+
}
122+
}
123+
return agentCliOk(renderMatches(out))
124+
},
125+
}

0 commit comments

Comments
 (0)