Skip to content

Commit 7fdab14

Browse files
authored
improvement(mothership): new agent loop (#3920)
* feat(transport): replace shared chat transport with mothership-stream module * improvement(contracts): regenerate contracts from go * feat(tools): add tool catalog codegen from go tool contracts * feat(tools): add tool-executor dispatch framework for sim side tool routing * feat(orchestrator): rewrite tool dispatch with catalog-driven executor and simplified resume loop * feat(orchestrator): checkpoint resume flow * refactor(copilot): consolidate orchestrator into request/ layer * refactor(mothership): reorganize lib/copilot into structured subdirectories * refactor(mothership): canonical transcript layer, dead code cleanup, type consolidation * refactor(mothership): rebase onto latest staging * refactor(mothership): rename request continue to lifecycle * feat(trace): add initial version of request traces * improvement(stream): batch stream from redis * fix(resume): fix the resume checkpoint * fix(resume): fix resume client tool * fix(subagents): subagent resume should join on existing subagent text block * improvement(reconnect): harden reconnect logic * fix(superagent): fix superagent integration tools * improvement(stream): improve stream perf * Rebase with origin dev * fix(tests): fix failing test * fix(build): fix type errors * fix(build): fix build errors * fix(build): fix type errors * feat(mothership): add cli execution * fix(mothership): fix function execute tests
1 parent 3b9e663 commit 7fdab14

File tree

200 files changed

+12205
-9691
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

200 files changed

+12205
-9691
lines changed

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { z } from 'zod'
55
import { recordUsage } from '@/lib/billing/core/usage-log'
66
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
7-
import { checkInternalApiKey } from '@/lib/copilot/utils'
7+
import { checkInternalApiKey } from '@/lib/copilot/request/http'
88
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010

apps/sim/app/api/copilot/api-keys/validate/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { z } from 'zod'
44
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
5-
import { checkInternalApiKey } from '@/lib/copilot/utils'
5+
import { checkInternalApiKey } from '@/lib/copilot/request/http'
66

77
const logger = createLogger('CopilotApiKeysValidate')
88

apps/sim/app/api/copilot/chat/abort/route.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { createLogger } from '@sim/logger'
12
import { NextResponse } from 'next/server'
23
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
3-
import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/chat-streaming'
44
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
5-
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
5+
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
6+
import { abortActiveStream } from '@/lib/copilot/request/session/abort'
67
import { env } from '@/lib/core/config/env'
78

9+
const logger = createLogger('CopilotChatAbortAPI')
810
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
911

1012
export async function POST(request: Request) {
@@ -15,7 +17,12 @@ export async function POST(request: Request) {
1517
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
1618
}
1719

18-
const body = await request.json().catch(() => ({}))
20+
const body = await request.json().catch((err) => {
21+
logger.warn('Abort request body parse failed; continuing with empty object', {
22+
error: err instanceof Error ? err.message : String(err),
23+
})
24+
return {}
25+
})
1926
const streamId = typeof body.streamId === 'string' ? body.streamId : ''
2027
let chatId = typeof body.chatId === 'string' ? body.chatId : ''
2128

@@ -24,7 +31,13 @@ export async function POST(request: Request) {
2431
}
2532

2633
if (!chatId) {
27-
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch(() => null)
34+
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => {
35+
logger.warn('getLatestRunForStream failed while resolving chatId for abort', {
36+
streamId,
37+
error: err instanceof Error ? err.message : String(err),
38+
})
39+
return null
40+
})
2841
if (run?.chatId) {
2942
chatId = run.chatId
3043
}
@@ -50,15 +63,13 @@ export async function POST(request: Request) {
5063
if (!response.ok) {
5164
throw new Error(`Explicit abort marker request failed: ${response.status}`)
5265
}
53-
} catch {
54-
// best effort: local abort should still proceed even if Go marker fails
66+
} catch (err) {
67+
logger.warn('Explicit abort marker request failed; proceeding with local abort', {
68+
streamId,
69+
error: err instanceof Error ? err.message : String(err),
70+
})
5571
}
5672

5773
const aborted = await abortActiveStream(streamId)
58-
if (chatId) {
59-
await waitForPendingChatStream(chatId, GO_EXPLICIT_ABORT_TIMEOUT_MS + 1000, streamId).catch(
60-
() => false
61-
)
62-
}
6374
return NextResponse.json({ aborted })
6475
}

apps/sim/app/api/copilot/chat/delete/route.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ vi.mock('drizzle-orm', () => ({
3636
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
3737
}))
3838

39-
vi.mock('@/lib/copilot/chat-lifecycle', () => ({
39+
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
4040
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
4141
}))
4242

43-
vi.mock('@/lib/copilot/task-events', () => ({
43+
vi.mock('@/lib/copilot/tasks', () => ({
4444
taskPubSub: { publishStatusChanged: vi.fn() },
4545
}))
4646

apps/sim/app/api/copilot/chat/delete/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
8-
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
9-
import { taskPubSub } from '@/lib/copilot/task-events'
8+
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
9+
import { taskPubSub } from '@/lib/copilot/tasks'
1010

1111
const logger = createLogger('DeleteChatAPI')
1212

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 { copilotChats } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, desc, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
7+
import {
8+
authenticateCopilotRequestSessionOnly,
9+
createBadRequestResponse,
10+
createInternalServerErrorResponse,
11+
createUnauthorizedResponse,
12+
} from '@/lib/copilot/request/http'
13+
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
14+
import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
15+
16+
const logger = createLogger('CopilotChatAPI')
17+
18+
function transformChat(chat: {
19+
id: string
20+
title: string | null
21+
model: string | null
22+
messages: unknown
23+
planArtifact?: unknown
24+
config?: unknown
25+
conversationId?: string | null
26+
resources?: unknown
27+
createdAt: Date | null
28+
updatedAt: Date | null
29+
}) {
30+
return {
31+
id: chat.id,
32+
title: chat.title,
33+
model: chat.model,
34+
messages: Array.isArray(chat.messages) ? chat.messages : [],
35+
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
36+
planArtifact: chat.planArtifact || null,
37+
config: chat.config || null,
38+
...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}),
39+
...('resources' in chat
40+
? { resources: Array.isArray(chat.resources) ? chat.resources : [] }
41+
: {}),
42+
createdAt: chat.createdAt,
43+
updatedAt: chat.updatedAt,
44+
}
45+
}
46+
47+
export async function GET(req: NextRequest) {
48+
try {
49+
const { searchParams } = new URL(req.url)
50+
const workflowId = searchParams.get('workflowId')
51+
const workspaceId = searchParams.get('workspaceId')
52+
const chatId = searchParams.get('chatId')
53+
54+
const { userId: authenticatedUserId, isAuthenticated } =
55+
await authenticateCopilotRequestSessionOnly()
56+
if (!isAuthenticated || !authenticatedUserId) {
57+
return createUnauthorizedResponse()
58+
}
59+
60+
if (chatId) {
61+
const chat = await getAccessibleCopilotChat(chatId, authenticatedUserId)
62+
if (!chat) {
63+
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
64+
}
65+
66+
logger.info(`Retrieved chat ${chatId}`)
67+
return NextResponse.json({ success: true, chat: transformChat(chat) })
68+
}
69+
70+
if (!workflowId && !workspaceId) {
71+
return createBadRequestResponse('workflowId, workspaceId, or chatId is required')
72+
}
73+
74+
if (workspaceId) {
75+
await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId)
76+
}
77+
78+
if (workflowId) {
79+
const authorization = await authorizeWorkflowByWorkspacePermission({
80+
workflowId,
81+
userId: authenticatedUserId,
82+
action: 'read',
83+
})
84+
if (!authorization.allowed) {
85+
return createUnauthorizedResponse()
86+
}
87+
}
88+
89+
const scopeFilter = workflowId
90+
? eq(copilotChats.workflowId, workflowId)
91+
: eq(copilotChats.workspaceId, workspaceId!)
92+
93+
const chats = await db
94+
.select({
95+
id: copilotChats.id,
96+
title: copilotChats.title,
97+
model: copilotChats.model,
98+
messages: copilotChats.messages,
99+
planArtifact: copilotChats.planArtifact,
100+
config: copilotChats.config,
101+
createdAt: copilotChats.createdAt,
102+
updatedAt: copilotChats.updatedAt,
103+
})
104+
.from(copilotChats)
105+
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
106+
.orderBy(desc(copilotChats.updatedAt))
107+
108+
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
109+
logger.info(`Retrieved ${chats.length} chats for ${scope}`)
110+
111+
return NextResponse.json({
112+
success: true,
113+
chats: chats.map(transformChat),
114+
})
115+
} catch (error) {
116+
logger.error('Error fetching copilot chats:', error)
117+
return createInternalServerErrorResponse('Failed to fetch chats')
118+
}
119+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { db } from '@sim/db'
2+
import { copilotChats } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { getSession } from '@/lib/auth'
8+
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
9+
import { taskPubSub } from '@/lib/copilot/tasks'
10+
11+
const logger = createLogger('RenameChatAPI')
12+
13+
const RenameChatSchema = z.object({
14+
chatId: z.string().min(1),
15+
title: z.string().min(1).max(200),
16+
})
17+
18+
export async function PATCH(request: NextRequest) {
19+
try {
20+
const session = await getSession()
21+
if (!session?.user?.id) {
22+
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const body = await request.json()
26+
const { chatId, title } = RenameChatSchema.parse(body)
27+
28+
const chat = await getAccessibleCopilotChat(chatId, session.user.id)
29+
if (!chat) {
30+
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
31+
}
32+
33+
const now = new Date()
34+
const [updated] = await db
35+
.update(copilotChats)
36+
.set({ title, updatedAt: now, lastSeenAt: now })
37+
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
38+
.returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
39+
40+
if (!updated) {
41+
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
42+
}
43+
44+
logger.info('Chat renamed', { chatId, title })
45+
46+
if (updated.workspaceId) {
47+
taskPubSub?.publishStatusChanged({
48+
workspaceId: updated.workspaceId,
49+
chatId,
50+
type: 'renamed',
51+
})
52+
}
53+
54+
return NextResponse.json({ success: true })
55+
} catch (error) {
56+
if (error instanceof z.ZodError) {
57+
return NextResponse.json(
58+
{ success: false, error: 'Invalid request data', details: error.errors },
59+
{ status: 400 }
60+
)
61+
}
62+
logger.error('Error renaming chat:', error)
63+
return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
64+
}
65+
}

apps/sim/app/api/copilot/chat/resources/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import {
1010
createInternalServerErrorResponse,
1111
createNotFoundResponse,
1212
createUnauthorizedResponse,
13-
} from '@/lib/copilot/request-helpers'
14-
import type { ChatResource, ResourceType } from '@/lib/copilot/resources'
13+
} from '@/lib/copilot/request/http'
14+
import type { ChatResource, ResourceType } from '@/lib/copilot/resources/persistence'
1515

1616
const logger = createLogger('CopilotChatResourcesAPI')
1717

0 commit comments

Comments
 (0)