Skip to content

Commit b8506f4

Browse files
committed
Add copilot sleep tool
1 parent 12c07bd commit b8506f4

File tree

5 files changed

+218
-0
lines changed

5 files changed

+218
-0
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ const ACTION_VERBS = [
101101
'Generated',
102102
'Rendering',
103103
'Rendered',
104+
'Sleeping',
105+
'Slept',
106+
'Resumed',
104107
] as const
105108

106109
/**
@@ -580,6 +583,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
580583
(toolCall.state === (ClientToolCallState.executing as any) ||
581584
toolCall.state === ('executing' as any))
582585

586+
const showWake =
587+
toolCall.name === 'sleep' &&
588+
(toolCall.state === (ClientToolCallState.executing as any) ||
589+
toolCall.state === ('executing' as any))
590+
583591
const handleStateChange = (state: any) => {
584592
forceUpdate({})
585593
onStateChange?.(state)
@@ -1102,6 +1110,37 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
11021110
Move to Background
11031111
</Button>
11041112
</div>
1113+
) : showWake ? (
1114+
<div className='mt-[8px]'>
1115+
<Button
1116+
onClick={async () => {
1117+
try {
1118+
const instance = getClientTool(toolCall.id)
1119+
// Get elapsed seconds before waking
1120+
const elapsedSeconds = instance?.getElapsedSeconds?.() || 0
1121+
// Transition to background state locally so UI updates immediately
1122+
// Pass elapsed seconds in the result so dynamic text can use it
1123+
instance?.setState?.((ClientToolCallState as any).background, {
1124+
result: { _elapsedSeconds: elapsedSeconds },
1125+
})
1126+
// Update the tool call params in the store to include elapsed time for display
1127+
const { updateToolCallParams } = useCopilotStore.getState()
1128+
updateToolCallParams?.(toolCall.id, { _elapsedSeconds: Math.round(elapsedSeconds) })
1129+
await instance?.markToolComplete?.(
1130+
200,
1131+
`User woke you up after ${Math.round(elapsedSeconds)} seconds`
1132+
)
1133+
// Optionally force a re-render; store should sync state from server
1134+
forceUpdate({})
1135+
onStateChange?.('background')
1136+
} catch {}
1137+
}}
1138+
variant='primary'
1139+
title='Wake'
1140+
>
1141+
Wake
1142+
</Button>
1143+
</div>
11051144
) : null}
11061145
</div>
11071146
)

apps/sim/lib/copilot/registry.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const ToolIds = z.enum([
3333
'knowledge_base',
3434
'manage_custom_tool',
3535
'manage_mcp_tool',
36+
'sleep',
3637
])
3738
export type ToolId = z.infer<typeof ToolIds>
3839

@@ -252,6 +253,14 @@ export const ToolArgSchemas = {
252253
.optional()
253254
.describe('Required for add and edit operations. The MCP server configuration.'),
254255
}),
256+
257+
sleep: z.object({
258+
seconds: z
259+
.number()
260+
.min(0)
261+
.max(180)
262+
.describe('The number of seconds to sleep (0-180, max 3 minutes)'),
263+
}),
255264
} as const
256265
export type ToolArgSchemaMap = typeof ToolArgSchemas
257266

@@ -318,6 +327,7 @@ export const ToolSSESchemas = {
318327
knowledge_base: toolCallSSEFor('knowledge_base', ToolArgSchemas.knowledge_base),
319328
manage_custom_tool: toolCallSSEFor('manage_custom_tool', ToolArgSchemas.manage_custom_tool),
320329
manage_mcp_tool: toolCallSSEFor('manage_mcp_tool', ToolArgSchemas.manage_mcp_tool),
330+
sleep: toolCallSSEFor('sleep', ToolArgSchemas.sleep),
321331
} as const
322332
export type ToolSSESchemaMap = typeof ToolSSESchemas
323333

@@ -552,6 +562,11 @@ export const ToolResultSchemas = {
552562
serverName: z.string().optional(),
553563
message: z.string().optional(),
554564
}),
565+
sleep: z.object({
566+
success: z.boolean(),
567+
seconds: z.number(),
568+
message: z.string().optional(),
569+
}),
555570
} as const
556571
export type ToolResultSchemaMap = typeof ToolResultSchemas
557572

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { Loader2, MinusCircle, Moon, XCircle } from 'lucide-react'
2+
import {
3+
BaseClientTool,
4+
type BaseClientToolMetadata,
5+
ClientToolCallState,
6+
} from '@/lib/copilot/tools/client/base-tool'
7+
import { createLogger } from '@/lib/logs/console/logger'
8+
9+
/** Maximum sleep duration in seconds (3 minutes) */
10+
const MAX_SLEEP_SECONDS = 180
11+
12+
/** Track sleep start times for calculating elapsed time on wake */
13+
const sleepStartTimes: Record<string, number> = {}
14+
15+
interface SleepArgs {
16+
seconds?: number
17+
}
18+
19+
/**
20+
* Format seconds into a human-readable duration string
21+
*/
22+
function formatDuration(seconds: number): string {
23+
if (seconds >= 60) {
24+
return `${Math.round(seconds / 60)} minute${seconds >= 120 ? 's' : ''}`
25+
}
26+
return `${seconds} second${seconds !== 1 ? 's' : ''}`
27+
}
28+
29+
export class SleepClientTool extends BaseClientTool {
30+
static readonly id = 'sleep'
31+
32+
constructor(toolCallId: string) {
33+
super(toolCallId, SleepClientTool.id, SleepClientTool.metadata)
34+
}
35+
36+
static readonly metadata: BaseClientToolMetadata = {
37+
displayNames: {
38+
[ClientToolCallState.generating]: { text: 'Preparing to sleep', icon: Loader2 },
39+
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
40+
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
41+
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
42+
[ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle },
43+
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
44+
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
45+
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
46+
},
47+
// No interrupt - auto-execute immediately
48+
getDynamicText: (params, state) => {
49+
const seconds = params?.seconds
50+
if (typeof seconds === 'number' && seconds > 0) {
51+
const displayTime = formatDuration(seconds)
52+
switch (state) {
53+
case ClientToolCallState.success:
54+
return `Slept for ${displayTime}`
55+
case ClientToolCallState.executing:
56+
case ClientToolCallState.pending:
57+
return `Sleeping for ${displayTime}`
58+
case ClientToolCallState.generating:
59+
return `Preparing to sleep for ${displayTime}`
60+
case ClientToolCallState.error:
61+
return `Failed to sleep for ${displayTime}`
62+
case ClientToolCallState.rejected:
63+
return `Skipped sleeping for ${displayTime}`
64+
case ClientToolCallState.aborted:
65+
return `Aborted sleeping for ${displayTime}`
66+
case ClientToolCallState.background: {
67+
// Calculate elapsed time from when sleep started
68+
const elapsedSeconds = params?._elapsedSeconds
69+
if (typeof elapsedSeconds === 'number' && elapsedSeconds > 0) {
70+
return `Resumed after ${formatDuration(Math.round(elapsedSeconds))}`
71+
}
72+
return 'Resumed early'
73+
}
74+
}
75+
}
76+
return undefined
77+
},
78+
}
79+
80+
/**
81+
* Get elapsed seconds since sleep started
82+
*/
83+
getElapsedSeconds(): number {
84+
const startTime = sleepStartTimes[this.toolCallId]
85+
if (!startTime) return 0
86+
return (Date.now() - startTime) / 1000
87+
}
88+
89+
async handleReject(): Promise<void> {
90+
await super.handleReject()
91+
this.setState(ClientToolCallState.rejected)
92+
}
93+
94+
async handleAccept(args?: SleepArgs): Promise<void> {
95+
const logger = createLogger('SleepClientTool')
96+
97+
// Use a timeout slightly longer than max sleep (3 minutes + buffer)
98+
const timeoutMs = (MAX_SLEEP_SECONDS + 30) * 1000
99+
100+
await this.executeWithTimeout(async () => {
101+
const params = args || {}
102+
logger.debug('handleAccept() called', {
103+
toolCallId: this.toolCallId,
104+
state: this.getState(),
105+
hasArgs: !!args,
106+
seconds: params.seconds,
107+
})
108+
109+
// Validate and clamp seconds
110+
let seconds = typeof params.seconds === 'number' ? params.seconds : 0
111+
if (seconds < 0) seconds = 0
112+
if (seconds > MAX_SLEEP_SECONDS) seconds = MAX_SLEEP_SECONDS
113+
114+
logger.debug('Starting sleep', { seconds })
115+
116+
// Track start time for elapsed calculation
117+
sleepStartTimes[this.toolCallId] = Date.now()
118+
119+
this.setState(ClientToolCallState.executing)
120+
121+
try {
122+
// Sleep for the specified duration
123+
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
124+
125+
logger.debug('Sleep completed successfully')
126+
this.setState(ClientToolCallState.success)
127+
await this.markToolComplete(200, `Slept for ${seconds} seconds`)
128+
} catch (error) {
129+
const message = error instanceof Error ? error.message : String(error)
130+
logger.error('Sleep failed', { error: message })
131+
this.setState(ClientToolCallState.error)
132+
await this.markToolComplete(500, message)
133+
} finally {
134+
// Clean up start time tracking
135+
delete sleepStartTimes[this.toolCallId]
136+
}
137+
}, timeoutMs)
138+
}
139+
140+
async execute(args?: SleepArgs): Promise<void> {
141+
// Auto-execute without confirmation - go straight to executing
142+
await this.handleAccept(args)
143+
}
144+
}

apps/sim/stores/panel/copilot/store.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/
3232
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
3333
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
3434
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
35+
import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
3536
import { createExecutionContext, getTool } from '@/lib/copilot/tools/client/registry'
3637
import { GetCredentialsClientTool } from '@/lib/copilot/tools/client/user/get-credentials'
3738
import { SetEnvironmentVariablesClientTool } from '@/lib/copilot/tools/client/user/set-environment-variables'
@@ -104,6 +105,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
104105
navigate_ui: (id) => new NavigateUIClientTool(id),
105106
manage_custom_tool: (id) => new ManageCustomToolClientTool(id),
106107
manage_mcp_tool: (id) => new ManageMcpToolClientTool(id),
108+
sleep: (id) => new SleepClientTool(id),
107109
}
108110

109111
// Read-only static metadata for class-based tools (no instances)
@@ -141,6 +143,7 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
141143
navigate_ui: (NavigateUIClientTool as any)?.metadata,
142144
manage_custom_tool: (ManageCustomToolClientTool as any)?.metadata,
143145
manage_mcp_tool: (ManageMcpToolClientTool as any)?.metadata,
146+
sleep: (SleepClientTool as any)?.metadata,
144147
}
145148

146149
function ensureClientToolInstance(toolName: string | undefined, toolCallId: string | undefined) {
@@ -2260,6 +2263,22 @@ export const useCopilotStore = create<CopilotStore>()(
22602263
set({ toolCallsById: map })
22612264
} catch {}
22622265
},
2266+
2267+
updateToolCallParams: (toolCallId: string, params: Record<string, any>) => {
2268+
try {
2269+
if (!toolCallId) return
2270+
const map = { ...get().toolCallsById }
2271+
const current = map[toolCallId]
2272+
if (!current) return
2273+
const updatedParams = { ...current.params, ...params }
2274+
map[toolCallId] = {
2275+
...current,
2276+
params: updatedParams,
2277+
display: resolveToolDisplay(current.name, current.state, toolCallId, updatedParams),
2278+
}
2279+
set({ toolCallsById: map })
2280+
} catch {}
2281+
},
22632282
updatePreviewToolCallState: (
22642283
toolCallState: 'accepted' | 'rejected' | 'error',
22652284
toolCallId?: string

apps/sim/stores/panel/copilot/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export interface CopilotActions {
178178
toolCallId?: string
179179
) => void
180180
setToolCallState: (toolCall: any, newState: ClientToolCallState, options?: any) => void
181+
updateToolCallParams: (toolCallId: string, params: Record<string, any>) => void
181182
sendDocsMessage: (query: string, options?: { stream?: boolean; topK?: number }) => Promise<void>
182183
saveChatMessages: (chatId: string) => Promise<void>
183184

0 commit comments

Comments
 (0)