Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ const ACTION_VERBS = [
'Generated',
'Rendering',
'Rendered',
'Sleeping',
'Slept',
'Resumed',
] as const

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

const showWake =
toolCall.name === 'sleep' &&
(toolCall.state === (ClientToolCallState.executing as any) ||
toolCall.state === ('executing' as any))

const handleStateChange = (state: any) => {
forceUpdate({})
onStateChange?.(state)
Expand Down Expand Up @@ -1102,6 +1110,37 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
Move to Background
</Button>
</div>
) : showWake ? (
<div className='mt-[8px]'>
<Button
onClick={async () => {
try {
const instance = getClientTool(toolCall.id)
// Get elapsed seconds before waking
const elapsedSeconds = instance?.getElapsedSeconds?.() || 0
// Transition to background state locally so UI updates immediately
// Pass elapsed seconds in the result so dynamic text can use it
instance?.setState?.((ClientToolCallState as any).background, {
result: { _elapsedSeconds: elapsedSeconds },
})
// Update the tool call params in the store to include elapsed time for display
const { updateToolCallParams } = useCopilotStore.getState()
updateToolCallParams?.(toolCall.id, { _elapsedSeconds: Math.round(elapsedSeconds) })
await instance?.markToolComplete?.(
200,
`User woke you up after ${Math.round(elapsedSeconds)} seconds`
)
// Optionally force a re-render; store should sync state from server
forceUpdate({})
onStateChange?.('background')
} catch {}
}}
variant='primary'
title='Wake'
>
Wake
</Button>
</div>
) : null}
</div>
)
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/copilot/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const ToolIds = z.enum([
'knowledge_base',
'manage_custom_tool',
'manage_mcp_tool',
'sleep',
])
export type ToolId = z.infer<typeof ToolIds>

Expand Down Expand Up @@ -252,6 +253,14 @@ export const ToolArgSchemas = {
.optional()
.describe('Required for add and edit operations. The MCP server configuration.'),
}),

sleep: z.object({
seconds: z
.number()
.min(0)
.max(180)
.describe('The number of seconds to sleep (0-180, max 3 minutes)'),
}),
} as const
export type ToolArgSchemaMap = typeof ToolArgSchemas

Expand Down Expand Up @@ -318,6 +327,7 @@ export const ToolSSESchemas = {
knowledge_base: toolCallSSEFor('knowledge_base', ToolArgSchemas.knowledge_base),
manage_custom_tool: toolCallSSEFor('manage_custom_tool', ToolArgSchemas.manage_custom_tool),
manage_mcp_tool: toolCallSSEFor('manage_mcp_tool', ToolArgSchemas.manage_mcp_tool),
sleep: toolCallSSEFor('sleep', ToolArgSchemas.sleep),
} as const
export type ToolSSESchemaMap = typeof ToolSSESchemas

Expand Down Expand Up @@ -552,6 +562,11 @@ export const ToolResultSchemas = {
serverName: z.string().optional(),
message: z.string().optional(),
}),
sleep: z.object({
success: z.boolean(),
seconds: z.number(),
message: z.string().optional(),
}),
} as const
export type ToolResultSchemaMap = typeof ToolResultSchemas

Expand Down
144 changes: 144 additions & 0 deletions apps/sim/lib/copilot/tools/client/other/sleep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Loader2, MinusCircle, Moon, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { createLogger } from '@/lib/logs/console/logger'

/** Maximum sleep duration in seconds (3 minutes) */
const MAX_SLEEP_SECONDS = 180

/** Track sleep start times for calculating elapsed time on wake */
const sleepStartTimes: Record<string, number> = {}

interface SleepArgs {
seconds?: number
}

/**
* Format seconds into a human-readable duration string
*/
function formatDuration(seconds: number): string {
if (seconds >= 60) {
return `${Math.round(seconds / 60)} minute${seconds >= 120 ? 's' : ''}`
}
return `${seconds} second${seconds !== 1 ? 's' : ''}`
}

export class SleepClientTool extends BaseClientTool {
static readonly id = 'sleep'

constructor(toolCallId: string) {
super(toolCallId, SleepClientTool.id, SleepClientTool.metadata)
}

static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Preparing to sleep', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
[ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
},
// No interrupt - auto-execute immediately
getDynamicText: (params, state) => {
const seconds = params?.seconds
if (typeof seconds === 'number' && seconds > 0) {
const displayTime = formatDuration(seconds)
switch (state) {
case ClientToolCallState.success:
return `Slept for ${displayTime}`
case ClientToolCallState.executing:
case ClientToolCallState.pending:
return `Sleeping for ${displayTime}`
case ClientToolCallState.generating:
return `Preparing to sleep for ${displayTime}`
case ClientToolCallState.error:
return `Failed to sleep for ${displayTime}`
case ClientToolCallState.rejected:
return `Skipped sleeping for ${displayTime}`
case ClientToolCallState.aborted:
return `Aborted sleeping for ${displayTime}`
case ClientToolCallState.background: {
// Calculate elapsed time from when sleep started
const elapsedSeconds = params?._elapsedSeconds
if (typeof elapsedSeconds === 'number' && elapsedSeconds > 0) {
return `Resumed after ${formatDuration(Math.round(elapsedSeconds))}`
}
return 'Resumed early'
}
}
}
return undefined
},
}

/**
* Get elapsed seconds since sleep started
*/
getElapsedSeconds(): number {
const startTime = sleepStartTimes[this.toolCallId]
if (!startTime) return 0
return (Date.now() - startTime) / 1000
}

async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}

async handleAccept(args?: SleepArgs): Promise<void> {
const logger = createLogger('SleepClientTool')

// Use a timeout slightly longer than max sleep (3 minutes + buffer)
const timeoutMs = (MAX_SLEEP_SECONDS + 30) * 1000

await this.executeWithTimeout(async () => {
const params = args || {}
logger.debug('handleAccept() called', {
toolCallId: this.toolCallId,
state: this.getState(),
hasArgs: !!args,
seconds: params.seconds,
})

// Validate and clamp seconds
let seconds = typeof params.seconds === 'number' ? params.seconds : 0
if (seconds < 0) seconds = 0
if (seconds > MAX_SLEEP_SECONDS) seconds = MAX_SLEEP_SECONDS

logger.debug('Starting sleep', { seconds })

// Track start time for elapsed calculation
sleepStartTimes[this.toolCallId] = Date.now()

this.setState(ClientToolCallState.executing)

try {
// Sleep for the specified duration
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))

logger.debug('Sleep completed successfully')
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Slept for ${seconds} seconds`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Sleep failed', { error: message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, message)
} finally {
// Clean up start time tracking
delete sleepStartTimes[this.toolCallId]
}
}, timeoutMs)
}

async execute(args?: SleepArgs): Promise<void> {
// Auto-execute without confirmation - go straight to executing
await this.handleAccept(args)
}
}
Loading
Loading