Skip to content

Commit e9a9e48

Browse files
committed
feat(home): make Ask turns answer from the searched sources with the knowledge tool alone
1 parent d2432b8 commit e9a9e48

10 files changed

Lines changed: 161 additions & 12 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,12 @@ export function Home({ chatId, userName, userId }: HomeProps) {
511511
searchedKnowledgeBases(knowledgeBasesRef.current, workspaceId)
512512
)
513513
: contexts
514-
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, turnContexts)
514+
sendMessage(
515+
trimmed || 'Analyze the attached file(s).',
516+
fileAttachments,
517+
turnContexts,
518+
mode === 'ask' ? { requestMode: 'ask' } : undefined
519+
)
515520
},
516521
[workspaceId, chatId, prepareResourceViewForAgentTurn, sendMessage, setSearchQuery]
517522
)
@@ -561,6 +566,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
561566
prepareResourceViewForAgentTurn()
562567
sendMessage(detail.message, detail.fileAttachments, detail.contexts, {
563568
...(detail.resumeUserMessageId ? { resumeUserMessageId: detail.resumeUserMessageId } : {}),
569+
...(detail.requestMode ? { requestMode: detail.requestMode } : {}),
564570
})
565571
}
566572
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
@@ -595,6 +601,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
595601
...(handoff.resumeUserMessageId
596602
? { resumeUserMessageId: handoff.resumeUserMessageId }
597603
: {}),
604+
...(handoff.requestMode ? { requestMode: handoff.requestMode } : {}),
598605
})
599606
return
600607
}

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
142142
import type {
143143
ChatMessage,
144144
ChatMessageContext,
145+
ChatRequestMode,
145146
ContentBlock,
146147
FileAttachmentForApi,
147148
GenericResourceData,
@@ -158,6 +159,8 @@ export interface SendMessageOptions {
158159
* attempts instead of opening a second chat.
159160
*/
160161
resumeUserMessageId?: string
162+
/** Asked for beyond the default agent turn; `ask` answers from the attached knowledge alone. */
163+
requestMode?: ChatRequestMode
161164
}
162165

163166
/**
@@ -181,6 +184,7 @@ interface StartSendMessageOptions {
181184
* opening a second chat and billing a second turn.
182185
*/
183186
resumeUserMessageId?: string
187+
requestMode?: ChatRequestMode
184188
}
185189

186190
/** A send an unmount cleanup withdrew, as handed to the next chat surface. */
@@ -189,6 +193,7 @@ interface WithdrawnSend {
189193
fileAttachments?: FileAttachmentForApi[]
190194
contexts?: ChatContext[]
191195
userMessageId: string
196+
requestMode?: ChatRequestMode
192197
}
193198

194199
export interface UseChatReturn {
@@ -299,6 +304,7 @@ interface QueuedSendHandoffState {
299304
message: string
300305
fileAttachments?: FileAttachmentForApi[]
301306
contexts?: ChatContext[]
307+
requestMode?: ChatRequestMode
302308
requestedAt: number
303309
resolveAttempts?: number
304310
}
@@ -3698,7 +3704,8 @@ export function useChat(
36983704
message: string,
36993705
fileAttachments?: FileAttachmentForApi[],
37003706
contexts?: ChatContext[],
3701-
resumeUserMessageId?: string
3707+
resumeUserMessageId?: string,
3708+
requestMode?: ChatRequestMode
37023709
): QueuedMothershipMessage => {
37033710
const id = generateId()
37043711
const handoffChatId = selectedChatIdRef.current ?? chatIdRef.current
@@ -3719,6 +3726,7 @@ export function useChat(
37193726
fileAttachments,
37203727
contexts,
37213728
...(resumeUserMessageId ? { resumeUserMessageId } : {}),
3729+
...(requestMode ? { requestMode } : {}),
37223730
...(supersededStreamId || handoffChatId
37233731
? {
37243732
queuedSendHandoff: {
@@ -3860,6 +3868,7 @@ export function useChat(
38603868
message,
38613869
...(fileAttachments ? { fileAttachments } : {}),
38623870
...(contexts ? { contexts } : {}),
3871+
...(options?.requestMode ? { requestMode: options.requestMode } : {}),
38633872
requestedAt: Date.now(),
38643873
})
38653874
}
@@ -4086,6 +4095,7 @@ export function useChat(
40864095
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
40874096
...(resourceAttachments ? { resourceAttachments } : {}),
40884097
...(contexts && contexts.length > 0 ? { contexts } : {}),
4098+
...(options?.requestMode ? { mode: options.requestMode } : {}),
40894099
...(workflowIdRef.current ? { workflowId: workflowIdRef.current } : {}),
40904100
// Desktop-only capabilities (local filesystem tools, browser
40914101
// subagent) — the server gates the features on these flags.
@@ -4286,7 +4296,13 @@ export function useChat(
42864296
const handOffWithdrawnSend = useCallback(
42874297
(send: WithdrawnSend) => {
42884298
if (
4289-
sendMothershipMessage(send.content, send.contexts, send.fileAttachments, send.userMessageId)
4299+
sendMothershipMessage(
4300+
send.content,
4301+
send.contexts,
4302+
send.fileAttachments,
4303+
send.userMessageId,
4304+
send.requestMode
4305+
)
42904306
) {
42914307
return
42924308
}
@@ -4296,6 +4312,7 @@ export function useChat(
42964312
...(send.contexts?.length ? { contexts: send.contexts } : {}),
42974313
...(send.fileAttachments?.length ? { fileAttachments: send.fileAttachments } : {}),
42984314
resumeUserMessageId: send.userMessageId,
4315+
...(send.requestMode ? { requestMode: send.requestMode } : {}),
42994316
},
43004317
workspaceId
43014318
)
@@ -4352,7 +4369,13 @@ export function useChat(
43524369
) {
43534370
queueStore.enqueue(
43544371
activeChatKey,
4355-
createQueuedMessage(message, fileAttachments, contexts, options?.resumeUserMessageId)
4372+
createQueuedMessage(
4373+
message,
4374+
fileAttachments,
4375+
contexts,
4376+
options?.resumeUserMessageId,
4377+
options?.requestMode
4378+
)
43564379
)
43574380
if (pendingStopPromiseRef.current || (queuedAheadCount > 0 && !sendingRef.current)) {
43584381
void enqueueQueueDispatchRef.current({ type: 'send_head' })
@@ -4373,6 +4396,7 @@ export function useChat(
43734396
fileAttachments,
43744397
contexts,
43754398
userMessageId: result.userMessageId,
4399+
...(options?.requestMode ? { requestMode: options.requestMode } : {}),
43764400
}
43774401
if (activeChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) {
43784402
handOffWithdrawnSend(withdrawn)
@@ -4382,7 +4406,13 @@ export function useChat(
43824406
.getState()
43834407
.enqueue(
43844408
activeChatKey,
4385-
createQueuedMessage(message, fileAttachments, contexts, result.userMessageId)
4409+
createQueuedMessage(
4410+
message,
4411+
fileAttachments,
4412+
contexts,
4413+
result.userMessageId,
4414+
options?.requestMode
4415+
)
43864416
)
43874417
},
43884418
[workspaceId, createQueuedMessage, startSendMessage, handOffWithdrawnSend]
@@ -4556,6 +4586,7 @@ export function useChat(
45564586
recoveringQueuedSendHandoffRef.current = { id: handoff.id, ownerId: claimOwnerId }
45574587
void startSendMessage(handoff.message, handoff.fileAttachments, handoff.contexts, {
45584588
pendingStop: null,
4589+
...(handoff.requestMode ? { requestMode: handoff.requestMode } : {}),
45594590
queuedSendHandoff: {
45604591
id: handoff.id,
45614592
chatId: handoff.chatId,
@@ -5016,6 +5047,7 @@ export function useChat(
50165047
...(liveMsg.resumeUserMessageId
50175048
? { resumeUserMessageId: liveMsg.resumeUserMessageId }
50185049
: {}),
5050+
...(liveMsg.requestMode ? { requestMode: liveMsg.requestMode } : {}),
50195051
}
50205052
)
50215053

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,19 @@ export interface FileAttachmentForApi {
2323
path?: string
2424
}
2525

26+
/**
27+
* A request mode a send asks the agent for beyond the default. `ask` is an
28+
* answer drawn from the attached knowledge bases with the knowledge tool
29+
* alone: the server attaches no integration tools to the turn.
30+
*/
31+
export type ChatRequestMode = 'ask'
32+
2633
export interface QueuedMessage {
2734
id: string
2835
content: string
2936
fileAttachments?: FileAttachmentForApi[]
3037
contexts?: ChatContext[]
38+
requestMode?: ChatRequestMode
3139
}
3240

3341
export const ToolCallStatus = {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { ASK_MODE_AGENT_CONTEXT, withAskModeContext } from '@/lib/copilot/chat/ask-mode'
3+
4+
const knowledge = { type: 'knowledge', content: '', tag: '@Sim Search', path: 'knowledge/x.json' }
5+
6+
describe('withAskModeContext', () => {
7+
it('appends the Ask skill after the attached contexts on an Ask turn', () => {
8+
expect(withAskModeContext([knowledge], 'ask')).toEqual([knowledge, ASK_MODE_AGENT_CONTEXT])
9+
})
10+
11+
it('leaves every other mode untouched', () => {
12+
for (const mode of ['agent', 'build', 'plan', undefined]) {
13+
expect(withAskModeContext([knowledge], mode)).toEqual([knowledge])
14+
}
15+
})
16+
17+
it('renders as a skill the agent injects for the turn', () => {
18+
expect(ASK_MODE_AGENT_CONTEXT.type).toBe('skill')
19+
expect(ASK_MODE_AGENT_CONTEXT.content).toContain('<source>')
20+
expect(ASK_MODE_AGENT_CONTEXT.content).toContain('query')
21+
})
22+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/** A context item as the agent receives it; skills carry their instructions in `content`. */
2+
export interface AskModeAgentContext {
3+
type: 'skill'
4+
tag: string
5+
content: string
6+
}
7+
8+
/** The request mode of an Ask turn, as the composer sends it. */
9+
export const ASK_REQUEST_MODE = 'ask'
10+
11+
/**
12+
* The instructions an Ask turn carries. Rendered by the agent as an active
13+
* skill for the turn, alongside the knowledge bases the composer attached, so
14+
* the model searches them and answers with citations instead of reaching for
15+
* a connected service. The executor refuses integration tools on the turn as
16+
* well; this is what tells the model up front.
17+
*/
18+
export const ASK_MODE_AGENT_CONTEXT: AskModeAgentContext = {
19+
type: 'skill',
20+
tag: '@Ask',
21+
content: [
22+
'The person chose Ask: they want an answer drawn from their connected sources, not an action.',
23+
'',
24+
'- Answer only from the knowledge bases attached to this message. Search them with the knowledge tool `query` operation, and search again with other phrasings when the first pass returns little. Do not read a base or its metadata first; search.',
25+
"- Do not use integrations, workflows, tables, files, or the browser. Integration tools are unavailable on this turn. When the question needs live data that is not indexed (today's inbox, a calendar), say that Ask answers from indexed content and suggest Build.",
26+
'- Cite every claim with a `<source>` tag exactly as the knowledge tool describes. When nothing relevant is found, say so plainly instead of guessing.',
27+
'- Keep the answer short: lead with the answer, then the supporting points.',
28+
].join('\n'),
29+
}
30+
31+
/** The turn's contexts with the Ask instructions appended when the request asked for Ask mode. */
32+
export function withAskModeContext<T extends { type: string; content: string }>(
33+
contexts: T[],
34+
mode: string | undefined
35+
): Array<T | AskModeAgentContext> {
36+
return mode === ASK_REQUEST_MODE ? [...contexts, ASK_MODE_AGENT_CONTEXT] : contexts
37+
}

apps/sim/lib/copilot/chat/post.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
1212
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
1313
import { chatOperations } from '@/lib/copilot/application/operations'
14+
import { withAskModeContext } from '@/lib/copilot/chat/ask-mode'
1415
import {
1516
DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH,
1617
DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH,
@@ -934,7 +935,7 @@ async function resolveBranch(params: {
934935
workspaceId: requestedWorkspaceId,
935936
userId: payloadParams.userId,
936937
userMessageId: payloadParams.userMessageId,
937-
mode: 'agent',
938+
mode: mode ?? 'agent',
938939
model: '',
939940
contexts: payloadParams.contexts,
940941
mcpServerIds: payloadParams.mcpServerIds,
@@ -961,7 +962,7 @@ async function resolveBranch(params: {
961962
chatId,
962963
messageId,
963964
userTimezone,
964-
requestMode: 'agent',
965+
requestMode: mode ?? 'agent',
965966
}),
966967
}
967968
}
@@ -1374,6 +1375,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
13741375
// typed snapshot Go diffs into baseline+delta messages.
13751376
const workspaceContext = workspaceSnapshot?.markdown
13761377
const vfs = workspaceSnapshot?.snapshot
1378+
const turnContexts = withAskModeContext(agentContexts, body.mode)
13771379

13781380
executionContext.userPermission = userPermission ?? undefined
13791381

@@ -1397,7 +1399,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
13971399
userId: authenticatedUserId,
13981400
userMessageId,
13991401
chatId: actualChatId,
1400-
contexts: agentContexts,
1402+
contexts: turnContexts,
14011403
mcpServerIds,
14021404
fileAttachments: body.fileAttachments,
14031405
userPermission: userPermission ?? undefined,
@@ -1425,7 +1427,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
14251427
userId: authenticatedUserId,
14261428
userMessageId,
14271429
chatId: actualChatId,
1428-
contexts: agentContexts,
1430+
contexts: turnContexts,
14291431
mcpServerIds,
14301432
fileAttachments: body.fileAttachments,
14311433
userPermission: userPermission ?? undefined,

apps/sim/lib/copilot/tool-executor/executor.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,27 @@ describe('copilot tool executor fallback', () => {
151151
expect(result).toEqual({ success: true, output: { emails: [] } })
152152
})
153153

154+
it('refuses integration tools on an Ask turn without dispatching them', async () => {
155+
isKnownTool.mockReturnValue(false)
156+
isSimExecuted.mockReturnValue(false)
157+
158+
const result = await executeTool(
159+
'gmail_read',
160+
{ maxResults: 10 },
161+
{
162+
userId: 'user-1',
163+
workflowId: '',
164+
workspaceId: 'ws-1',
165+
chatId: 'chat-1',
166+
requestMode: 'ask',
167+
}
168+
)
169+
170+
expect(executeAppTool).not.toHaveBeenCalled()
171+
expect(result.success).toBe(false)
172+
expect(result.error).toContain('Ask mode')
173+
})
174+
154175
it('forwards trusted authority and cancellation to dynamic custom tools', async () => {
155176
isKnownTool.mockReturnValue(false)
156177
isSimExecuted.mockReturnValue(false)

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
33
import { toError } from '@sim/utils/errors'
4+
import { ASK_REQUEST_MODE } from '@/lib/copilot/chat/ask-mode'
45
import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
56
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
67
import { recordSecretUsage } from '@/lib/secrets/usage/record'
@@ -33,6 +34,10 @@ export function clearHandlers(): void {
3334
handlerRegistry.clear()
3435
}
3536

37+
/** An Ask turn answers from the attached knowledge bases; the agent reaches no connected service. */
38+
const ASK_MODE_INTEGRATION_REFUSAL =
39+
'Integration tools are not available in Ask mode. Answer from the attached knowledge bases with the knowledge tool (query operation), cite each source, and say so when nothing relevant is found.'
40+
3641
export async function executeTool(
3742
toolId: string,
3843
params: Record<string, unknown>,
@@ -76,6 +81,9 @@ export async function executeTool(
7681
const canUseRegisteredHandler =
7782
isKnownTool(toolId) && (isSimExecuted(toolId) || usesHeadlessClientFallback)
7883
if (!canUseRegisteredHandler) {
84+
if (context.requestMode === ASK_REQUEST_MODE) {
85+
return { success: false, error: ASK_MODE_INTEGRATION_REFUSAL }
86+
}
7987
const appParams = buildAppToolParams(normalizedParams, context)
8088
const options = {
8189
...(context.resolvedSecretTraceRegistry

apps/sim/lib/core/utils/browser-storage.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
*/
55

66
import { createLogger } from '@sim/logger'
7-
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
7+
import type {
8+
ChatRequestMode,
9+
FileAttachmentForApi,
10+
} from '@/app/workspace/[workspaceId]/home/types'
811
import type { ChatContext } from '@/stores/panel'
912

1013
const logger = createLogger('BrowserStorage')
@@ -317,6 +320,8 @@ export interface MothershipHandoff {
317320
* chat and billing a second turn.
318321
*/
319322
resumeUserMessageId?: string
323+
/** The request mode the withdrawn send asked for, so a retry stays the same kind of turn. */
324+
requestMode?: ChatRequestMode
320325
}
321326

322327
interface StoredHandoff extends MothershipHandoff {

0 commit comments

Comments
 (0)