mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-16 16:02:33 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
143f95f2ad | ||
|
|
b513998d0e | ||
|
|
e5f5611676 | ||
|
|
1ddee9637c | ||
|
|
a566107d9f |
@@ -174,6 +174,9 @@ pub struct SignDebugRequest {
|
||||
pub code: String,
|
||||
/// The programming language (python3, bun, typescript, etc.)
|
||||
pub language: String,
|
||||
/// Caller-asserted audit hint only; never verified, so never trust it for authz.
|
||||
#[serde(default)]
|
||||
pub agent_originated: bool,
|
||||
}
|
||||
|
||||
/// JWT claims for debug tokens
|
||||
@@ -205,6 +208,14 @@ pub struct SignedDebugPayload {
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
fn bool_str(b: bool) -> &'static str {
|
||||
if b {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a debug request and create audit log + job entries for full traceability.
|
||||
///
|
||||
/// This endpoint must be called before starting a debug session.
|
||||
@@ -320,6 +331,8 @@ async fn sign_debug_request(
|
||||
.await?;
|
||||
|
||||
// Create audit log entry (identical to jobs.run.preview)
|
||||
let job_id_str = job_id.to_string();
|
||||
let agent_flag = bool_str(request.agent_originated);
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -327,7 +340,13 @@ async fn sign_debug_request(
|
||||
ActionKind::Execute,
|
||||
&w_id,
|
||||
None,
|
||||
Some([("job_id", job_id.to_string().as_str())].into()),
|
||||
Some(
|
||||
[
|
||||
("job_id", job_id_str.as_str()),
|
||||
("agent_originated", agent_flag),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -346,6 +365,9 @@ pub struct SignExpressionRequest {
|
||||
pub expression: String,
|
||||
/// The job ID of the parent debug session
|
||||
pub job_id: String,
|
||||
/// Caller-asserted audit hint only; never verified, so never trust it for authz.
|
||||
#[serde(default)]
|
||||
pub agent_originated: bool,
|
||||
}
|
||||
|
||||
/// JWT claims for expression evaluation tokens
|
||||
@@ -428,6 +450,7 @@ async fn sign_expression(
|
||||
request.expression.clone()
|
||||
};
|
||||
|
||||
let agent_flag = bool_str(request.agent_originated);
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -439,6 +462,7 @@ async fn sign_expression(
|
||||
[
|
||||
("job_id", request.job_id.as_str()),
|
||||
("expression", request.expression.as_str()),
|
||||
("agent_originated", agent_flag),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
|
||||
@@ -73,8 +73,11 @@
|
||||
fetchContextualVariables,
|
||||
signDebugRequest,
|
||||
signMultiplayerRequest,
|
||||
getDebugErrorMessage
|
||||
getDebugErrorMessage,
|
||||
preemptToHuman,
|
||||
type DebugController
|
||||
} from '$lib/components/debug'
|
||||
import type { DAPClient } from '$lib/components/debug'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
import type { ScriptEditorWhitelabelCustomUi } from './custom_ui'
|
||||
@@ -1205,51 +1208,96 @@
|
||||
monacoEditor.revealLineInCenter(line)
|
||||
}
|
||||
|
||||
// `onClientReady` fires after connect+initialize but before launch, so the agent can
|
||||
// attach its transition listener in time to catch the first breakpoint stop. Rethrows
|
||||
// so the agent path can report a signing/connection failure to the model.
|
||||
async function runDebugSession(opts?: {
|
||||
agentArgs?: Record<string, unknown>
|
||||
agentOriginated?: boolean
|
||||
onClientReady?: (client: DAPClient) => void
|
||||
}): Promise<void> {
|
||||
showDebugConsole = true
|
||||
selectedDebugFrameId = null
|
||||
|
||||
if (!debugMode) {
|
||||
debugMode = true
|
||||
if (testPanelSize === 0) {
|
||||
expandTestPanel()
|
||||
}
|
||||
}
|
||||
|
||||
resetDAPClient()
|
||||
dapClient = getDAPClient(dapServerUrl)
|
||||
|
||||
const env = await fetchContextualVariables(opWs ?? '')
|
||||
|
||||
const signedPayload = await signDebugRequest(
|
||||
opWs ?? '',
|
||||
code ?? '',
|
||||
lang ?? 'python3',
|
||||
opts?.agentOriginated
|
||||
)
|
||||
debugSessionJobId = signedPayload.job_id
|
||||
|
||||
await dapClient.connect()
|
||||
await dapClient.initialize()
|
||||
opts?.onClientReady?.(dapClient)
|
||||
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
|
||||
await dapClient.configurationDone()
|
||||
await dapClient.launch({
|
||||
code,
|
||||
cwd: '/tmp',
|
||||
args: opts?.agentArgs ?? args ?? {},
|
||||
callMain: true,
|
||||
env,
|
||||
token: signedPayload.token
|
||||
})
|
||||
}
|
||||
|
||||
async function startDebugging(): Promise<void> {
|
||||
try {
|
||||
// Show console when starting a debug session
|
||||
showDebugConsole = true
|
||||
// Reset selected frame when starting new session
|
||||
selectedDebugFrameId = null
|
||||
|
||||
// Always reset and create a fresh DAP client with the correct URL for the current language
|
||||
// This ensures we connect to the correct endpoint even if language changed
|
||||
resetDAPClient()
|
||||
dapClient = getDAPClient(dapServerUrl)
|
||||
|
||||
// Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) from backend
|
||||
const env = await fetchContextualVariables(opWs ?? '')
|
||||
|
||||
// Sign the debug request (creates audit log entry)
|
||||
let signedPayload
|
||||
try {
|
||||
signedPayload = await signDebugRequest(opWs ?? '', code ?? '', lang ?? 'python3')
|
||||
debugSessionJobId = signedPayload.job_id
|
||||
} catch (signError) {
|
||||
sendUserToast(getDebugErrorMessage(signError), true)
|
||||
return
|
||||
}
|
||||
|
||||
await dapClient.connect()
|
||||
await dapClient.initialize()
|
||||
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
|
||||
await dapClient.configurationDone()
|
||||
// Pass the signed token along with other launch parameters
|
||||
await dapClient.launch({
|
||||
code,
|
||||
cwd: '/tmp',
|
||||
args: args ?? {},
|
||||
callMain: true,
|
||||
env,
|
||||
// JWT token for verification by the debugger
|
||||
token: signedPayload.token
|
||||
})
|
||||
// Claim after the reset (runDebugSession's resetDAPClient clears the owner);
|
||||
// claiming before it would be wiped, leaving the session seizable by the agent.
|
||||
await runDebugSession({ onClientReady: () => preemptToHuman() })
|
||||
} catch (error) {
|
||||
console.error('Failed to start debugging:', error)
|
||||
sendUserToast(getDebugErrorMessage(error), true)
|
||||
}
|
||||
}
|
||||
|
||||
const agentDebugController: DebugController = {
|
||||
language: () => lang ?? 'python3',
|
||||
start: async (agentArgs, onClientReady) => {
|
||||
await runDebugSession({ agentArgs, agentOriginated: true, onClientReady })
|
||||
},
|
||||
stop: () => stopDebugging(),
|
||||
signExpression: (expression) => signAgentExpression(expression),
|
||||
setBreakpoints: async (lines) => {
|
||||
debugBreakpoints.clear()
|
||||
for (const line of lines) {
|
||||
debugBreakpoints.add(line)
|
||||
}
|
||||
updateBreakpointDecorations()
|
||||
await syncBreakpointsWithServer()
|
||||
}
|
||||
}
|
||||
|
||||
async function signAgentExpression(expression: string): Promise<string | undefined> {
|
||||
if (!opWs || !debugSessionJobId) return undefined
|
||||
try {
|
||||
const response = await fetch(`/api/w/${opWs}/debug/sign_expression`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expression, job_id: debugSessionJobId, agent_originated: true })
|
||||
})
|
||||
if (!response.ok) return undefined
|
||||
const result = await response.json()
|
||||
return result.token
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDebugging(): Promise<void> {
|
||||
if (!dapClient) return
|
||||
try {
|
||||
@@ -1264,21 +1312,25 @@
|
||||
}
|
||||
|
||||
async function continueExecution(): Promise<void> {
|
||||
preemptToHuman()
|
||||
if (!dapClient) return
|
||||
await dapClient.continue_()
|
||||
}
|
||||
|
||||
async function stepOver(): Promise<void> {
|
||||
preemptToHuman()
|
||||
if (!dapClient) return
|
||||
await dapClient.stepOver()
|
||||
}
|
||||
|
||||
async function stepIn(): Promise<void> {
|
||||
preemptToHuman()
|
||||
if (!dapClient) return
|
||||
await dapClient.stepIn()
|
||||
}
|
||||
|
||||
async function stepOut(): Promise<void> {
|
||||
preemptToHuman()
|
||||
if (!dapClient) return
|
||||
await dapClient.stepOut()
|
||||
}
|
||||
@@ -1437,6 +1489,10 @@
|
||||
await inferSchema(code, { applyInitialArgs: true })
|
||||
}
|
||||
aiChatManager.saveAndClear()
|
||||
// Register before changeMode so the debug tools are gated in on first SCRIPT setup.
|
||||
aiChatManager.scriptEditorDebugController = isDebuggableScript
|
||||
? agentDebugController
|
||||
: undefined
|
||||
aiChatManager.changeMode(AIMode.SCRIPT)
|
||||
if (customUi?.previewPanel?.loadLastRunOnMount) {
|
||||
void loadLastRunIntoTestPanel()
|
||||
@@ -1551,6 +1607,7 @@
|
||||
aiChatManager.scriptEditorApplyCode = undefined
|
||||
aiChatManager.scriptEditorShowDiffMode = undefined
|
||||
aiChatManager.scriptEditorGetLintErrors = undefined
|
||||
aiChatManager.scriptEditorDebugController = undefined
|
||||
aiChatManager.scriptEditorOptions = undefined
|
||||
aiChatManager.saveAndClear()
|
||||
aiChatManager.changeMode(AIMode.NAVIGATOR)
|
||||
@@ -1742,6 +1799,9 @@
|
||||
editor?.getLintErrors() ?? { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
|
||||
)
|
||||
}
|
||||
aiChatManager.scriptEditorDebugController = isDebuggableScript
|
||||
? agentDebugController
|
||||
: undefined
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import type { ScriptLintResult } from './shared'
|
||||
import { navigatorTools, prepareNavigatorSystemMessage } from './navigator/core'
|
||||
import { loadApiTools } from './api/apiTools'
|
||||
import { prepareScriptUserMessage } from './script/core'
|
||||
import { prepareScriptUserMessage, type ScriptDebugHelpers } from './script/core'
|
||||
import { prepareNavigatorUserMessage } from './navigator/core'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceAIClients, getNonStreamingCompletion } from '../lib'
|
||||
@@ -78,6 +78,19 @@ import {
|
||||
import type { Selection } from 'monaco-editor'
|
||||
import type AIChatInput from './AIChatInput.svelte'
|
||||
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
|
||||
import {
|
||||
type DebugController,
|
||||
resetDebugBudget,
|
||||
onAgentTurnEnd,
|
||||
agentDebugGetState,
|
||||
agentDebugStart,
|
||||
agentDebugContinue,
|
||||
agentDebugStep,
|
||||
agentDebugWait,
|
||||
agentDebugEvaluate,
|
||||
agentDebugSetBreakpoints,
|
||||
agentDebugStop
|
||||
} from '$lib/components/debug'
|
||||
import { runChatLoop, truncateToToolPairedPrefix } from './chatLoop'
|
||||
import { sanitizeToolCallArguments } from './toolCallArguments'
|
||||
import { normalizeContextUsage } from './tokenUsage'
|
||||
@@ -380,6 +393,7 @@ export class AIChatManager {
|
||||
>(undefined)
|
||||
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
|
||||
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
|
||||
scriptEditorDebugController = $state<DebugController | undefined>(undefined)
|
||||
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
|
||||
appAiChatHelpers = $state<AppAIChatHelpers | undefined>(undefined)
|
||||
/** Datatable creation policy: enabled flag, datatable name, and optional schema */
|
||||
@@ -1357,6 +1371,30 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Late-bound so the debug tools work whether the editor registered its controller
|
||||
// before or after this mode switch.
|
||||
private buildDebugHelpers(): ScriptDebugHelpers {
|
||||
const controllerOr = <T>(onMissing: T, use: (c: DebugController) => T): T => {
|
||||
const controller = this.scriptEditorDebugController
|
||||
return controller ? use(controller) : onMissing
|
||||
}
|
||||
const unavailableMsg = 'The step debugger is not available here (no editor debug session).'
|
||||
const unavailable = Promise.resolve(unavailableMsg)
|
||||
return {
|
||||
getState: () => controllerOr(unavailableMsg, () => agentDebugGetState()),
|
||||
start: (args, timeoutMs) =>
|
||||
controllerOr(unavailable, (c) => agentDebugStart(c, args, timeoutMs)),
|
||||
continue: (timeoutMs) => controllerOr(unavailable, () => agentDebugContinue(timeoutMs)),
|
||||
step: (kind, timeoutMs) => controllerOr(unavailable, () => agentDebugStep(kind, timeoutMs)),
|
||||
wait: (timeoutMs) => controllerOr(unavailable, () => agentDebugWait(timeoutMs)),
|
||||
evaluate: (expression, frameId) =>
|
||||
controllerOr(unavailable, (c) => agentDebugEvaluate(c, expression, frameId)),
|
||||
setBreakpoints: (lines) =>
|
||||
controllerOr(unavailable, (c) => agentDebugSetBreakpoints(c, lines)),
|
||||
stop: () => controllerOr(unavailable, (c) => agentDebugStop(c))
|
||||
}
|
||||
}
|
||||
|
||||
changeMode(
|
||||
mode: AIMode,
|
||||
pendingPrompt?: string,
|
||||
@@ -1379,14 +1417,15 @@ export class AIChatManager {
|
||||
options?.workflowAsCode ??
|
||||
(options?.lang ? false : (this.scriptEditorOptions?.workflowAsCode ?? false))
|
||||
const context = this.contextManager.getSelectedContext()
|
||||
const debugAvailable = !!this.scriptEditorDebugController
|
||||
this.systemMessage = prepareScriptSystemMessage(
|
||||
currentModel,
|
||||
lang,
|
||||
{ isPreprocessor: options?.isPreprocessor, workflowAsCode },
|
||||
{ isPreprocessor: options?.isPreprocessor, workflowAsCode, debugAvailable },
|
||||
customPrompt
|
||||
)
|
||||
this.systemMessage.content = this.systemMessage.content
|
||||
this.tools = [...prepareScriptTools(currentModel, lang, context)]
|
||||
this.tools = [...prepareScriptTools(currentModel, lang, context, { debugAvailable })]
|
||||
this.helpers = {
|
||||
getScriptOptions: () => {
|
||||
return {
|
||||
@@ -1405,7 +1444,8 @@ export class AIChatManager {
|
||||
return this.scriptEditorGetLintErrors()
|
||||
}
|
||||
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
|
||||
}
|
||||
},
|
||||
debug: this.buildDebugHelpers()
|
||||
}
|
||||
if (options?.closeScriptSettings) {
|
||||
const closeComponent = triggerablesByAi['close-script-builder-settings']
|
||||
@@ -1981,6 +2021,7 @@ export class AIChatManager {
|
||||
const pastes = options.pastes ?? []
|
||||
const optimisticIndex = this.displayMessages.length
|
||||
this.loading = true
|
||||
resetDebugBudget()
|
||||
// Create the abort controller before the (possibly slow) beforeSend pre-flight,
|
||||
// not after: the loading indicator below exposes Stop/Escape during "Creating
|
||||
// workspace fork...", and those call cancel() → abortController.abort(). Without a
|
||||
@@ -2454,6 +2495,8 @@ export class AIChatManager {
|
||||
// releases the loop; it never discards uncommitted text.
|
||||
this.replyReveal.reset()
|
||||
this.reasoningReveal.reset()
|
||||
// Settle any pending debug wait and hand debugger control back to the human.
|
||||
onAgentTurnEnd()
|
||||
}
|
||||
// Flush the queued message. Send it after a cleanly committed turn OR a
|
||||
// deliberate user cancel (Esc / Stop) — in both cases the user is ready
|
||||
@@ -2503,6 +2546,7 @@ export class AIChatManager {
|
||||
})
|
||||
this.abortController?.abort(cancelReason)
|
||||
this.cancelLoadingTools()
|
||||
onAgentTurnEnd()
|
||||
}
|
||||
|
||||
cancelInlineRequest = (reason?: string) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ContextElement } from '../context'
|
||||
import {
|
||||
createSearchHubScriptsTool,
|
||||
type Tool,
|
||||
type ToolCallbacks,
|
||||
executeTestRun,
|
||||
buildTestRunArgs,
|
||||
buildContextString,
|
||||
@@ -28,6 +29,7 @@ import type { ReviewChangesOpts } from '../monaco-adapter'
|
||||
import { getCurrentModel } from '$lib/aiStore'
|
||||
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
|
||||
import { getScriptPrompt, getWorkflowAsCodePrompt } from '$system_prompts'
|
||||
import { isDebuggableLanguage } from '$lib/components/debug'
|
||||
|
||||
// Score threshold for npm packages search filtering
|
||||
const SCORE_THRESHOLD = 1000
|
||||
@@ -306,6 +308,14 @@ INSTRUCTIONS:
|
||||
|
||||
`
|
||||
|
||||
const DEBUGGER_SYSTEM_PROMPT = `INTERACTIVE DEBUGGER:
|
||||
You can drive a step-through debugger for this script to inspect runtime state, rather than reasoning about it statically or scattering print statements. The loop is: read the code → debug_set_breakpoints on the interesting lines → debug_start → at each stop, debug_evaluate targeted expressions to test a hypothesis → debug_continue/debug_step to advance → form a fix → edit_code → re-run to confirm.
|
||||
- Prefer debug_evaluate with a specific expression (e.g. \`len(rows)\`, \`obj.field\`) over dumping whole scopes — it is far cheaper on context and is rate-limited per turn.
|
||||
- debug_get_state is free (no code runs); use it to re-orient after a stop.
|
||||
- If debug_continue/debug_start reports the code is "still running", call debug_wait to keep waiting; do not busy-loop.
|
||||
- Breakpoints in a hot loop will pause you thousands of times and exhaust the per-turn budget — break before the loop and step, or remove the breakpoint.
|
||||
- If the user takes manual control, stop issuing debug commands until they ask you to resume. Call debug_stop when you are done.`
|
||||
|
||||
export function prepareScriptSystemMessage(
|
||||
currentModel: AIProviderModel,
|
||||
language: ScriptLang | 'bunnative',
|
||||
@@ -313,6 +323,7 @@ export function prepareScriptSystemMessage(
|
||||
isPreprocessor?: boolean
|
||||
allowResourcesFetch?: boolean
|
||||
workflowAsCode?: boolean
|
||||
debugAvailable?: boolean
|
||||
} = {},
|
||||
customPrompt?: string
|
||||
): ChatCompletionSystemMessageParam {
|
||||
@@ -322,6 +333,10 @@ export function prepareScriptSystemMessage(
|
||||
const langContext = getLangContext(language, { allowResourcesFetch: true, ...options })
|
||||
content += `\n\nWINDMILL LANGUAGE CONTEXT:\n${langContext}`
|
||||
|
||||
if (options.debugAvailable && isDebuggableLanguage(language)) {
|
||||
content += `\n\n${DEBUGGER_SYSTEM_PROMPT}`
|
||||
}
|
||||
|
||||
// If there's a custom prompt, append it to the system prompt
|
||||
if (customPrompt?.trim()) {
|
||||
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
|
||||
@@ -336,7 +351,8 @@ export function prepareScriptSystemMessage(
|
||||
export function prepareScriptTools(
|
||||
currentModel: AIProviderModel,
|
||||
language: ScriptLang | 'bunnative',
|
||||
context: ContextElement[]
|
||||
context: ContextElement[],
|
||||
options?: { debugAvailable?: boolean }
|
||||
): Tool<ScriptChatHelpers>[] {
|
||||
const tools: Tool<ScriptChatHelpers>[] = []
|
||||
if (['python3', 'php', 'bun', 'deno', 'nativets', 'bunnative'].includes(language)) {
|
||||
@@ -357,6 +373,19 @@ export function prepareScriptTools(
|
||||
}
|
||||
tools.push(testRunScriptTool)
|
||||
tools.push(getLintErrorsTool)
|
||||
// Gated on a live editor debug controller so the schemas aren't dead weight elsewhere.
|
||||
if (options?.debugAvailable && isDebuggableLanguage(language)) {
|
||||
tools.push(
|
||||
debugStartTool,
|
||||
debugGetStateTool,
|
||||
debugSetBreakpointsTool,
|
||||
debugContinueTool,
|
||||
debugStepTool,
|
||||
debugWaitTool,
|
||||
debugEvaluateTool,
|
||||
debugStopTool
|
||||
)
|
||||
}
|
||||
tools.push(createSearchWorkspaceTool())
|
||||
tools.push(createGetRunnableDetailsTool())
|
||||
tools.push(...createWorkspaceMutationTools<ScriptChatHelpers>())
|
||||
@@ -433,6 +462,17 @@ async function formatDBSchema(dbSchema: DBSchema) {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScriptDebugHelpers {
|
||||
getState: () => string
|
||||
start: (args: Record<string, unknown> | undefined, timeoutMs: number) => Promise<string>
|
||||
continue: (timeoutMs: number) => Promise<string>
|
||||
step: (kind: 'over' | 'in' | 'out', timeoutMs: number) => Promise<string>
|
||||
wait: (timeoutMs: number) => Promise<string>
|
||||
evaluate: (expression: string, frameId?: number) => Promise<string>
|
||||
setBreakpoints: (lines: number[]) => Promise<string>
|
||||
stop: () => Promise<string>
|
||||
}
|
||||
|
||||
export interface ScriptChatHelpers {
|
||||
getScriptOptions: () => {
|
||||
code: string
|
||||
@@ -443,6 +483,8 @@ export interface ScriptChatHelpers {
|
||||
applyCode: (code: string, opts?: ReviewChangesOpts) => Promise<void>
|
||||
/** Get lint errors from the Monaco editor */
|
||||
getLintErrors?: () => ScriptLintResult
|
||||
/** Interactive step-debugger surface; undefined when unavailable. */
|
||||
debug?: ScriptDebugHelpers
|
||||
}
|
||||
|
||||
export const resourceTypeTool: Tool<ScriptChatHelpers> = {
|
||||
@@ -751,6 +793,162 @@ const GET_LINT_ERRORS_TOOL: ChatCompletionFunctionTool = {
|
||||
}
|
||||
}
|
||||
|
||||
// Wait window a control tool blocks before returning "still running" as an outcome.
|
||||
const DEBUG_CONTROL_WAIT_MS = 8000
|
||||
const DEBUG_DEFAULT_WAIT_S = 15
|
||||
const DEBUG_MAX_WAIT_MS = 60000
|
||||
|
||||
const DEBUG_START_TOOL: ChatCompletionFunctionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'debug_start',
|
||||
description:
|
||||
'Start an interactive step-through debug session for the current script, then run to the first breakpoint or to completion. Set breakpoints first with debug_set_breakpoints. Returns the stop location and stack; use debug_evaluate to read runtime values, debug_continue/debug_step to advance.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
args: { type: 'string', description: 'JSON string containing the arguments for the tool' }
|
||||
},
|
||||
additionalProperties: false,
|
||||
strict: false,
|
||||
required: ['args']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_GET_STATE_TOOL: ChatCompletionFunctionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'debug_get_state',
|
||||
description:
|
||||
'Read the current debug session state (status, current line, stack, scope names, output, result) without running any code. Cheap; use it to re-orient after a stop.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
strict: true,
|
||||
required: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_EVALUATE_TOOL: ChatCompletionFunctionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'debug_evaluate',
|
||||
description:
|
||||
'Evaluate an expression in the paused frame and return its value. This is the preferred way to inspect runtime state — ask a targeted question (e.g. `len(rows)`, `user.id`) instead of dumping whole scopes. Runs debuggee code, so it is rate-limited per turn.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
expression: {
|
||||
type: 'string',
|
||||
description: 'The expression to evaluate in the current frame'
|
||||
},
|
||||
frameId: {
|
||||
type: 'number',
|
||||
description: 'Optional stack frame id to evaluate in; defaults to the top frame'
|
||||
}
|
||||
},
|
||||
additionalProperties: false,
|
||||
strict: false,
|
||||
required: ['expression']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_CONTINUE_TOOL: ChatCompletionFunctionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'debug_continue',
|
||||
description:
|
||||
'Resume execution until the next breakpoint, the end of the script, or an error. Returns the new stop location, or reports that the code is still running (then call debug_wait).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
strict: true,
|
||||
required: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_STEP_TOOL: ChatCompletionFunctionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'debug_step',
|
||||
description:
|
||||
'Step one source line while paused. `over` runs a call without descending, `in` descends into a call, `out` runs until the current function returns.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['over', 'in', 'out'], description: 'The kind of step' }
|
||||
},
|
||||
additionalProperties: false,
|
||||
strict: false,
|
||||
required: ['kind']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_WAIT_TOOL: ChatCompletionFunctionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'debug_wait',
|
||||
description:
|
||||
'Keep waiting for long-running code to stop or finish after debug_continue/debug_start reported it was still running. Each call spends budget, so prefer it over polling.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
timeout_seconds: {
|
||||
type: 'number',
|
||||
description: `How long to wait, in seconds (default ${DEBUG_DEFAULT_WAIT_S}, min 1, max ${DEBUG_MAX_WAIT_MS / 1000})`
|
||||
}
|
||||
},
|
||||
additionalProperties: false,
|
||||
strict: false,
|
||||
required: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_SET_BREAKPOINTS_TOOL: ChatCompletionFunctionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'debug_set_breakpoints',
|
||||
description:
|
||||
'Replace the set of breakpoints with the given 1-based line numbers (pass an empty array to clear all). Read the code first to pick lines. Conditions are not supported yet — for a hot loop, break before it and step in.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lines: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
description: '1-based line numbers to break at; empty to clear all'
|
||||
}
|
||||
},
|
||||
additionalProperties: false,
|
||||
strict: false,
|
||||
required: ['lines']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_STOP_TOOL: ChatCompletionFunctionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'debug_stop',
|
||||
description: 'Terminate the debug session and release its resources.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
strict: true,
|
||||
required: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const editCodeToolWithDiff: Tool<ScriptChatHelpers> = {
|
||||
def: EDIT_CODE_TOOL_WITH_DIFF,
|
||||
streamArguments: true,
|
||||
@@ -941,3 +1139,122 @@ export const getLintErrorsTool: Tool<ScriptChatHelpers> = {
|
||||
return formatScriptLintResult(lintResult)
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_UNAVAILABLE_MESSAGE =
|
||||
'The step debugger is not available here. It requires a debuggable language (Python or TypeScript/Bun) with the debug service and signing configured.'
|
||||
|
||||
function requireDebug(
|
||||
helpers: ScriptChatHelpers,
|
||||
toolCallbacks: ToolCallbacks,
|
||||
toolId: string
|
||||
): ScriptDebugHelpers | undefined {
|
||||
if (!helpers.debug) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Debugger unavailable',
|
||||
error: 'No debug controller in this context'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
return helpers.debug
|
||||
}
|
||||
|
||||
export const debugStartTool: Tool<ScriptChatHelpers> = {
|
||||
def: DEBUG_START_TOOL,
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: 'Start a debug session for the current script',
|
||||
showDetails: true,
|
||||
fn: async function ({ args, helpers, toolCallbacks, toolId }) {
|
||||
const debug = requireDebug(helpers, toolCallbacks, toolId)
|
||||
if (!debug) return DEBUG_UNAVAILABLE_MESSAGE
|
||||
const parsedArgs = await buildTestRunArgs(args, this.def)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Starting debug session...' })
|
||||
const result = await debug.start(parsedArgs, DEBUG_CONTROL_WAIT_MS)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Debug session started' })
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const debugGetStateTool: Tool<ScriptChatHelpers> = {
|
||||
def: DEBUG_GET_STATE_TOOL,
|
||||
fn: async function ({ helpers, toolCallbacks, toolId }) {
|
||||
const debug = requireDebug(helpers, toolCallbacks, toolId)
|
||||
if (!debug) return DEBUG_UNAVAILABLE_MESSAGE
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Reading debug state' })
|
||||
return debug.getState()
|
||||
}
|
||||
}
|
||||
|
||||
export const debugEvaluateTool: Tool<ScriptChatHelpers> = {
|
||||
def: DEBUG_EVALUATE_TOOL,
|
||||
fn: async function ({ args, helpers, toolCallbacks, toolId }) {
|
||||
const debug = requireDebug(helpers, toolCallbacks, toolId)
|
||||
if (!debug) return DEBUG_UNAVAILABLE_MESSAGE
|
||||
if (!args.expression || typeof args.expression !== 'string') {
|
||||
return 'debug_evaluate requires a non-empty "expression" string.'
|
||||
}
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Evaluating ${args.expression}` })
|
||||
return debug.evaluate(
|
||||
args.expression,
|
||||
typeof args.frameId === 'number' ? args.frameId : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const debugContinueTool: Tool<ScriptChatHelpers> = {
|
||||
def: DEBUG_CONTINUE_TOOL,
|
||||
fn: async function ({ helpers, toolCallbacks, toolId }) {
|
||||
const debug = requireDebug(helpers, toolCallbacks, toolId)
|
||||
if (!debug) return DEBUG_UNAVAILABLE_MESSAGE
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Continuing execution...' })
|
||||
return debug.continue(DEBUG_CONTROL_WAIT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
export const debugStepTool: Tool<ScriptChatHelpers> = {
|
||||
def: DEBUG_STEP_TOOL,
|
||||
fn: async function ({ args, helpers, toolCallbacks, toolId }) {
|
||||
const debug = requireDebug(helpers, toolCallbacks, toolId)
|
||||
if (!debug) return DEBUG_UNAVAILABLE_MESSAGE
|
||||
const kind = args.kind === 'in' || args.kind === 'out' ? args.kind : 'over'
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Stepping ${kind}...` })
|
||||
return debug.step(kind, DEBUG_CONTROL_WAIT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
export const debugWaitTool: Tool<ScriptChatHelpers> = {
|
||||
def: DEBUG_WAIT_TOOL,
|
||||
fn: async function ({ args, helpers, toolCallbacks, toolId }) {
|
||||
const debug = requireDebug(helpers, toolCallbacks, toolId)
|
||||
if (!debug) return DEBUG_UNAVAILABLE_MESSAGE
|
||||
const requested =
|
||||
typeof args.timeout_seconds === 'number'
|
||||
? args.timeout_seconds * 1000
|
||||
: DEBUG_DEFAULT_WAIT_S * 1000
|
||||
const timeoutMs = Math.min(Math.max(requested, 1000), DEBUG_MAX_WAIT_MS)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Waiting for execution to settle...' })
|
||||
return debug.wait(timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
export const debugSetBreakpointsTool: Tool<ScriptChatHelpers> = {
|
||||
def: DEBUG_SET_BREAKPOINTS_TOOL,
|
||||
fn: async function ({ args, helpers, toolCallbacks, toolId }) {
|
||||
const debug = requireDebug(helpers, toolCallbacks, toolId)
|
||||
if (!debug) return DEBUG_UNAVAILABLE_MESSAGE
|
||||
const lines = Array.isArray(args.lines)
|
||||
? args.lines.filter((l: unknown): l is number => typeof l === 'number' && l > 0)
|
||||
: []
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Setting breakpoints...' })
|
||||
return debug.setBreakpoints(lines)
|
||||
}
|
||||
}
|
||||
|
||||
export const debugStopTool: Tool<ScriptChatHelpers> = {
|
||||
def: DEBUG_STOP_TOOL,
|
||||
fn: async function ({ helpers, toolCallbacks, toolId }) {
|
||||
const debug = requireDebug(helpers, toolCallbacks, toolId)
|
||||
if (!debug) return DEBUG_UNAVAILABLE_MESSAGE
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Stopping debug session...' })
|
||||
return debug.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { X, AlertCircle, Trash2 } from 'lucide-svelte'
|
||||
import type { DAPClient } from './dapClient'
|
||||
import { preemptToHuman } from './agentDebugSession'
|
||||
|
||||
interface Props {
|
||||
client: DAPClient | null
|
||||
@@ -94,6 +95,9 @@
|
||||
const expression = inputValue.trim()
|
||||
if (!expression || !client || isEvaluating) return
|
||||
|
||||
// A human typing in the console takes control back from the agent.
|
||||
preemptToHuman()
|
||||
|
||||
// Add to command history
|
||||
if (commandHistory[commandHistory.length - 1] !== expression) {
|
||||
commandHistory = [...commandHistory, expression]
|
||||
@@ -226,7 +230,10 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-0.5 hover:bg-[#3c3c3c] rounded text-[#969696] hover:text-[#d4d4d4]"
|
||||
onclick={(e) => { e.stopPropagation(); clearConsole(); }}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
clearConsole()
|
||||
}}
|
||||
title="Clear console (Ctrl+L)"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
@@ -234,7 +241,10 @@
|
||||
{#if onClose}
|
||||
<button
|
||||
class="p-0.5 hover:bg-[#3c3c3c] rounded text-[#969696] hover:text-[#d4d4d4]"
|
||||
onclick={(e) => { e.stopPropagation(); onClose?.(); }}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose?.()
|
||||
}}
|
||||
title="Close console (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
@@ -244,10 +254,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Console output -->
|
||||
<div
|
||||
bind:this={consoleRef}
|
||||
class="flex-1 overflow-auto min-h-0"
|
||||
>
|
||||
<div bind:this={consoleRef} class="flex-1 overflow-auto min-h-0">
|
||||
{#if history.length === 0}
|
||||
<div class="px-3 py-2 text-[#969696] text-[11px]">
|
||||
Evaluate expressions in the current scope. Use ↑↓ for history.
|
||||
@@ -263,7 +270,9 @@
|
||||
<span class="text-[#ce9178] break-all whitespace-pre-wrap">{entry.content}</span>
|
||||
{:else if entry.type === 'output'}
|
||||
<span class="text-[#569cd6] mr-2 select-none opacity-0">></span>
|
||||
<span class="{getValueClass(entry.content, 'output')} break-all whitespace-pre-wrap">{formatValue(entry.content, 'output')}</span>
|
||||
<span class="{getValueClass(entry.content, 'output')} break-all whitespace-pre-wrap"
|
||||
>{formatValue(entry.content, 'output')}</span
|
||||
>
|
||||
{:else}
|
||||
<AlertCircle size={12} class="text-red-500 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span class="text-red-400 break-all whitespace-pre-wrap">{entry.content}</span>
|
||||
@@ -289,7 +298,9 @@
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if isEvaluating}
|
||||
<div class="w-3 h-3 border border-[#569cd6] border-t-transparent rounded-full animate-spin ml-2"></div>
|
||||
<div
|
||||
class="w-3 h-3 border border-[#569cd6] border-t-transparent rounded-full animate-spin ml-2"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Shared holders the mocked `./dapClient` writes into, so tests can control the
|
||||
// singleton client and the store, and fire the reset hook the module registers.
|
||||
const holders = vi.hoisted(() => ({
|
||||
state: null as any,
|
||||
client: null as any,
|
||||
resetHooks: [] as Array<() => void>,
|
||||
initial: () => ({
|
||||
connected: false,
|
||||
initialized: false,
|
||||
running: false,
|
||||
stopped: false,
|
||||
stoppedReason: undefined,
|
||||
currentLine: undefined,
|
||||
currentFile: undefined,
|
||||
stackFrames: [],
|
||||
scopes: [],
|
||||
variables: new Map(),
|
||||
breakpoints: new Map(),
|
||||
output: [],
|
||||
logs: '',
|
||||
result: undefined,
|
||||
error: undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./dapClient', async () => {
|
||||
const { writable } = await import('svelte/store')
|
||||
holders.state = writable(holders.initial())
|
||||
return {
|
||||
debugState: holders.state,
|
||||
peekDAPClient: () => holders.client,
|
||||
onDAPReset: (fn: () => void) => {
|
||||
holders.resetHooks.push(fn)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./debugUtils', () => ({
|
||||
getDebugErrorMessage: (e: unknown) => String((e as any)?.message ?? e)
|
||||
}))
|
||||
|
||||
import {
|
||||
agentDebugContinue,
|
||||
agentDebugEvaluate,
|
||||
agentDebugGetState,
|
||||
agentDebugStart,
|
||||
agentDebugStep,
|
||||
agentDebugStop,
|
||||
agentDebugWait,
|
||||
debugOwner,
|
||||
getDebugOwner,
|
||||
onAgentTurnEnd,
|
||||
preemptToHuman,
|
||||
resetDebugBudget,
|
||||
type DebugController
|
||||
} from './agentDebugSession'
|
||||
|
||||
type FakeClient = ReturnType<typeof makeClient>
|
||||
|
||||
// Faithful fake: mirrors dapClient.handleEvent's store mutations on each event so the
|
||||
// tier-1 shaping paths and the isStopped/isRunning guards see production-like state.
|
||||
function makeClient() {
|
||||
const listeners = new Set<(e: any) => void>()
|
||||
const patch = (p: any) => holders.state.update((s: any) => ({ ...s, ...p }))
|
||||
return {
|
||||
stopped: false,
|
||||
connected: true,
|
||||
running: false,
|
||||
evalCalls: [] as any[],
|
||||
emit(event: string, body?: any) {
|
||||
if (event === 'stopped') {
|
||||
this.stopped = true
|
||||
this.running = false
|
||||
patch({
|
||||
stopped: true,
|
||||
running: false,
|
||||
stoppedReason: body?.reason,
|
||||
currentLine: body?.line
|
||||
})
|
||||
} else if (event === 'continued') {
|
||||
this.stopped = false
|
||||
this.running = true
|
||||
patch({ stopped: false, running: true, stoppedReason: undefined })
|
||||
} else if (event === 'terminated') {
|
||||
this.stopped = false
|
||||
this.running = false
|
||||
patch({ running: false, stopped: false, result: body?.result, error: body?.error })
|
||||
} else if (event === 'closed') {
|
||||
this.connected = false
|
||||
this.running = false
|
||||
patch({ connected: false })
|
||||
}
|
||||
for (const l of listeners) l({ seq: 0, type: 'event', event, body })
|
||||
},
|
||||
onEvent(l: (e: any) => void) {
|
||||
listeners.add(l)
|
||||
return () => listeners.delete(l)
|
||||
},
|
||||
isConnected() {
|
||||
return this.connected
|
||||
},
|
||||
isStopped() {
|
||||
return this.stopped
|
||||
},
|
||||
isRunning() {
|
||||
return this.running
|
||||
},
|
||||
async continue_() {
|
||||
this.stopped = false
|
||||
this.running = true
|
||||
patch({ stopped: false, running: true })
|
||||
},
|
||||
async stepOver() {},
|
||||
async stepIn() {},
|
||||
async stepOut() {},
|
||||
async terminate() {
|
||||
this.connected = false
|
||||
patch({ connected: false })
|
||||
},
|
||||
async getStackTrace() {
|
||||
const frames = [
|
||||
{ id: 1, name: 'main', source: { path: '/tmp/script.ts' }, line: 6, column: 0 }
|
||||
]
|
||||
patch({ stackFrames: frames, currentLine: 6, currentFile: '/tmp/script.ts' })
|
||||
return frames
|
||||
},
|
||||
async getScopes() {
|
||||
const scopes = [{ name: 'Locals', variablesReference: 10, expensive: false }]
|
||||
patch({ scopes })
|
||||
return scopes
|
||||
},
|
||||
async evaluate(expression: string, frameId?: number, context?: string, token?: string) {
|
||||
this.evalCalls.push({ expression, frameId, context, token })
|
||||
return { result: '42', variablesReference: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeController(client: FakeClient): DebugController {
|
||||
return {
|
||||
language: () => 'bun',
|
||||
start: async (_args, onClientReady) => {
|
||||
holders.client = client
|
||||
client.connected = true
|
||||
holders.state.update((s: any) => ({ ...s, connected: true }))
|
||||
onClientReady(client as any)
|
||||
},
|
||||
stop: async () => {
|
||||
await client.terminate()
|
||||
},
|
||||
signExpression: async () => 'signed-token',
|
||||
setBreakpoints: async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
/** Drive continue → immediate stop so the op resolves fast (for budget loops). */
|
||||
async function continueThenStop(client: FakeClient, timeoutMs = 5000): Promise<string> {
|
||||
client.stopped = true // paused so continue's guard passes
|
||||
const p = agentDebugContinue(timeoutMs)
|
||||
await tick()
|
||||
client.emit('stopped', { reason: 'breakpoint' })
|
||||
return p
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of holders.resetHooks) fn() // simulate resetDAPClient(): settle waiters, drop tracker, clear owner
|
||||
holders.client = null
|
||||
holders.state.set(holders.initial())
|
||||
debugOwner.set(null)
|
||||
resetDebugBudget()
|
||||
})
|
||||
|
||||
describe('ownership gate', () => {
|
||||
it('refuses agent driving/tier-2/stop tools while the human owns the session', async () => {
|
||||
holders.client = makeClient()
|
||||
preemptToHuman()
|
||||
expect(getDebugOwner()).toBe('human')
|
||||
|
||||
const controller = makeController(holders.client)
|
||||
expect(await agentDebugContinue(50)).toMatch(/control was taken by the user/i)
|
||||
expect(await agentDebugStep('over', 50)).toMatch(/control was taken by the user/i)
|
||||
expect(await agentDebugEvaluate(controller, 'x')).toMatch(/control was taken by the user/i)
|
||||
expect(await agentDebugStop(controller)).toMatch(/control was taken by the user/i)
|
||||
})
|
||||
|
||||
it('keeps tier-1 getState live regardless of owner and reports the owner', () => {
|
||||
holders.client = makeClient()
|
||||
holders.state.update((s: any) => ({ ...s, connected: true }))
|
||||
preemptToHuman()
|
||||
expect(agentDebugGetState()).toMatch(/controlled by: human/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('no-session / not-paused guards', () => {
|
||||
it('getState reports no session when there is no client', () => {
|
||||
expect(agentDebugGetState()).toMatch(/no active debug session/i)
|
||||
})
|
||||
|
||||
it('continue fast-fails when the session is not paused', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = false
|
||||
holders.client = client
|
||||
expect(await agentDebugContinue(50)).toMatch(/not paused/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transition-wait state machine', () => {
|
||||
it('resolves a pending continue when a stop fires, and shapes the stop state', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = true
|
||||
holders.client = client
|
||||
|
||||
const pending = agentDebugContinue(5000)
|
||||
await tick()
|
||||
client.emit('stopped', { reason: 'breakpoint', line: 6 })
|
||||
|
||||
const res = await pending
|
||||
expect(res).toMatch(/stopped \(breakpoint\)/i)
|
||||
// tier-1 shaping actually ran against populated store state
|
||||
expect(res).toMatch(/#0 main @ line 6/)
|
||||
expect(res).toMatch(/Scopes at top frame: Locals/)
|
||||
})
|
||||
|
||||
it('returns "still running" on timeout, then resolves on the next wait', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = true
|
||||
holders.client = client
|
||||
|
||||
const timedOut = await agentDebugContinue(10)
|
||||
expect(timedOut).toMatch(/still running/i)
|
||||
expect(client.isRunning()).toBe(true)
|
||||
|
||||
const waiting = agentDebugWait(5000)
|
||||
await tick()
|
||||
client.emit('stopped', { reason: 'breakpoint' })
|
||||
expect(await waiting).toMatch(/stopped \(breakpoint\)/i)
|
||||
})
|
||||
|
||||
it('reports the paused state if a stop fired before the next wait was issued', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = true
|
||||
holders.client = client
|
||||
|
||||
const timedOut = await agentDebugContinue(10)
|
||||
expect(timedOut).toMatch(/still running/i)
|
||||
client.emit('stopped', { reason: 'breakpoint' }) // fires in the gap -> isStopped true
|
||||
expect(await agentDebugWait(10)).toMatch(/already paused/i)
|
||||
})
|
||||
|
||||
it('reports completion on terminated, and a follow-up wait says finished (not failed)', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = true
|
||||
holders.client = client
|
||||
|
||||
const pending = agentDebugContinue(5000)
|
||||
await tick()
|
||||
client.emit('terminated', { result: { ok: true } })
|
||||
expect(await pending).toMatch(/ran to completion/i)
|
||||
|
||||
expect(await agentDebugWait(10)).toMatch(/finished or has not started/i)
|
||||
})
|
||||
|
||||
it('preemptToHuman settles a pending wait as preempted', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = true
|
||||
holders.client = client
|
||||
|
||||
const pending = agentDebugContinue(5000)
|
||||
await tick()
|
||||
preemptToHuman()
|
||||
|
||||
expect(await pending).toMatch(/control was taken by the user/i)
|
||||
expect(getDebugOwner()).toBe('human')
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluate', () => {
|
||||
it('threads the signed token, frame, and repl context through to the client', async () => {
|
||||
const client = makeClient()
|
||||
const controller = makeController(client)
|
||||
holders.client = client
|
||||
|
||||
const res = await agentDebugEvaluate(controller, 'a + b', 3)
|
||||
expect(res).toBe('42')
|
||||
expect(client.evalCalls).toHaveLength(1)
|
||||
expect(client.evalCalls[0]).toEqual({
|
||||
expression: 'a + b',
|
||||
frameId: 3,
|
||||
context: 'repl',
|
||||
token: 'signed-token'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-turn budget', () => {
|
||||
it('caps tier-2 evaluations and resets on a new turn', async () => {
|
||||
const client = makeClient()
|
||||
holders.client = client
|
||||
const controller = makeController(client)
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
expect(await agentDebugEvaluate(controller, 'x')).toBe('42')
|
||||
}
|
||||
expect(await agentDebugEvaluate(controller, 'x')).toMatch(/evaluation budget reached/i)
|
||||
|
||||
resetDebugBudget()
|
||||
expect(await agentDebugEvaluate(controller, 'x')).toBe('42')
|
||||
})
|
||||
|
||||
it('caps transitions (continue/wait/step) at the per-turn limit', async () => {
|
||||
const client = makeClient()
|
||||
holders.client = client
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
expect(await continueThenStop(client)).toMatch(/stopped/i)
|
||||
}
|
||||
client.stopped = true
|
||||
expect(await agentDebugContinue(5000)).toMatch(/run\/step actions|budget reached/i)
|
||||
})
|
||||
|
||||
it('does not spend transition budget on a failed launch', async () => {
|
||||
const failing: DebugController = {
|
||||
language: () => 'bun',
|
||||
start: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
stop: async () => {},
|
||||
signExpression: async () => undefined,
|
||||
setBreakpoints: async () => {}
|
||||
}
|
||||
expect(await agentDebugStart(failing, undefined, 50)).toMatch(/could not start the debugger/i)
|
||||
|
||||
// budget intact: 40 transitions still succeed after the failed start
|
||||
const client = makeClient()
|
||||
holders.client = client
|
||||
for (let i = 0; i < 40; i++) {
|
||||
expect(await continueThenStop(client)).toMatch(/stopped/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('start', () => {
|
||||
it('claims agent ownership and resolves at the first stop', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = false
|
||||
const controller = makeController(client)
|
||||
|
||||
const startPromise = agentDebugStart(controller, { a: 1 }, 5000)
|
||||
await tick()
|
||||
client.emit('stopped', { reason: 'breakpoint', line: 2 })
|
||||
|
||||
expect(await startPromise).toMatch(/stopped \(breakpoint\)/i)
|
||||
expect(getDebugOwner()).toBe('agent')
|
||||
})
|
||||
|
||||
it('takes over a human-owned session (debug_start is confirmation-gated in the UI)', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = false
|
||||
const controller = makeController(client)
|
||||
preemptToHuman()
|
||||
expect(getDebugOwner()).toBe('human')
|
||||
|
||||
const startPromise = agentDebugStart(controller, undefined, 5000)
|
||||
await tick()
|
||||
client.emit('stopped', { reason: 'breakpoint', line: 2 })
|
||||
|
||||
expect(await startPromise).toMatch(/stopped \(breakpoint\)/i)
|
||||
expect(getDebugOwner()).toBe('agent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stop', () => {
|
||||
it('tears down and releases ownership on success', async () => {
|
||||
const client = makeClient()
|
||||
holders.client = client
|
||||
const controller = makeController(client)
|
||||
debugOwner.set('agent')
|
||||
|
||||
expect(await agentDebugStop(controller)).toMatch(/stopped/i)
|
||||
expect(getDebugOwner()).toBe(null)
|
||||
expect(client.connected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('turn / teardown lifecycle', () => {
|
||||
it('onAgentTurnEnd settles a pending wait as turn-ended and releases ownership', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = true
|
||||
holders.client = client
|
||||
|
||||
const pending = agentDebugContinue(5000)
|
||||
await tick()
|
||||
onAgentTurnEnd()
|
||||
|
||||
expect(await pending).toMatch(/turn ended/i)
|
||||
expect(getDebugOwner()).toBe(null)
|
||||
})
|
||||
|
||||
it('the reset hook settles a pending wait and clears ownership', async () => {
|
||||
const client = makeClient()
|
||||
client.stopped = true
|
||||
holders.client = client
|
||||
|
||||
const pending = agentDebugContinue(5000)
|
||||
await tick()
|
||||
for (const fn of holders.resetHooks) fn()
|
||||
|
||||
expect(await pending).toMatch(/torn down/i)
|
||||
expect(getDebugOwner()).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Agent-facing control layer over the singleton debug session (`dapClient` +
|
||||
* `debugState`), shared by the copilot and the human. It adds the three things the
|
||||
* raw DAP client lacks: an ownership token (DAP has no actor attribution), a
|
||||
* transition-wait state machine (a control tool must not return until execution
|
||||
* settles), and a per-turn budget (one `continue` in a hot loop is thousands of
|
||||
* stops). Operations return short model-facing strings so the tools stay thin.
|
||||
*/
|
||||
|
||||
import { get, writable } from 'svelte/store'
|
||||
import {
|
||||
debugState,
|
||||
peekDAPClient,
|
||||
onDAPReset,
|
||||
type DAPClient,
|
||||
type DAPMessage,
|
||||
type StackFrame,
|
||||
type Variable
|
||||
} from './dapClient'
|
||||
import { getDebugErrorMessage } from './debugUtils'
|
||||
|
||||
export interface DebugController {
|
||||
language: () => string
|
||||
// `onClientReady` MUST run once the client is connected but BEFORE launch, so the
|
||||
// transition listener is attached in time to catch the first breakpoint stop.
|
||||
start: (
|
||||
agentArgs: Record<string, unknown> | undefined,
|
||||
onClientReady: (client: DAPClient) => void
|
||||
) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
signExpression: (expression: string) => Promise<string | undefined>
|
||||
setBreakpoints: (lines: number[]) => Promise<void>
|
||||
}
|
||||
|
||||
// --- Ownership ---
|
||||
|
||||
export type DebugOwner = 'human' | 'agent' | null
|
||||
|
||||
export const debugOwner = writable<DebugOwner>(null)
|
||||
|
||||
export function getDebugOwner(): DebugOwner {
|
||||
return get(debugOwner)
|
||||
}
|
||||
|
||||
// Called from every human debug entry point. Claiming for the human and settling the
|
||||
// agent's pending wait is what makes ownership a hand-off rather than a deadlock.
|
||||
export function preemptToHuman(): void {
|
||||
if (get(debugOwner) !== 'human') debugOwner.set('human')
|
||||
if (tracker) tracker.latched = null
|
||||
settleWaiter({ kind: 'preempted' })
|
||||
}
|
||||
|
||||
function acquireAgentOwner(force: boolean): boolean {
|
||||
if (get(debugOwner) === 'human' && !force) return false
|
||||
debugOwner.set('agent')
|
||||
return true
|
||||
}
|
||||
|
||||
function releaseAgentOwner(): void {
|
||||
if (get(debugOwner) === 'agent') debugOwner.set(null)
|
||||
}
|
||||
|
||||
// --- Transition-wait state machine (one slot per session) ---
|
||||
|
||||
export type DebugLanding =
|
||||
| { kind: 'stopped'; reason?: string }
|
||||
| { kind: 'terminated'; result?: unknown; error?: string }
|
||||
| { kind: 'disconnected' }
|
||||
| { kind: 'timeout' }
|
||||
| { kind: 'preempted' }
|
||||
| { kind: 'torn_down' }
|
||||
| { kind: 'turn_ended' }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
type Tracker = {
|
||||
client: DAPClient
|
||||
unsub: () => void
|
||||
latched: DebugLanding | null
|
||||
waiter: { promise: Promise<DebugLanding>; resolve: (l: DebugLanding) => void; timer: any } | null
|
||||
}
|
||||
|
||||
let tracker: Tracker | null = null
|
||||
|
||||
function eventToLanding(event: DAPMessage): DebugLanding | null {
|
||||
const body = event.body as Record<string, unknown> | undefined
|
||||
switch (event.event) {
|
||||
case 'stopped':
|
||||
return { kind: 'stopped', reason: body?.reason as string }
|
||||
case 'terminated':
|
||||
return { kind: 'terminated', result: body?.result, error: body?.error as string | undefined }
|
||||
case 'closed':
|
||||
return { kind: 'disconnected' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function onTrackerEvent(event: DAPMessage): void {
|
||||
if (!tracker) return
|
||||
const landing = eventToLanding(event)
|
||||
if (!landing) return
|
||||
if (tracker.waiter) {
|
||||
const waiter = tracker.waiter
|
||||
tracker.waiter = null
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.resolve(landing)
|
||||
} else {
|
||||
tracker.latched = landing
|
||||
}
|
||||
// After a terminal landing the listener is useless; keep the tracker only while a
|
||||
// landing is still latched for a pending await to read.
|
||||
if (landing.kind === 'terminated' || landing.kind === 'disconnected') {
|
||||
tracker.unsub()
|
||||
if (!tracker.latched) tracker = null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTracker(client: DAPClient): Tracker {
|
||||
if (tracker && tracker.client === client) return tracker
|
||||
tracker?.unsub()
|
||||
tracker = { client, unsub: client.onEvent(onTrackerEvent), latched: null, waiter: null }
|
||||
return tracker
|
||||
}
|
||||
|
||||
function settleWaiter(landing: DebugLanding): void {
|
||||
if (!tracker?.waiter) return
|
||||
const waiter = tracker.waiter
|
||||
tracker.waiter = null
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.resolve(landing)
|
||||
}
|
||||
|
||||
// On timeout the listener stays attached so a later `debug_wait` reads the latch
|
||||
// instead of racing a stop that fired in the gap.
|
||||
function awaitTransition(timeoutMs: number): Promise<DebugLanding> {
|
||||
if (!tracker) return Promise.resolve({ kind: 'error', message: 'no active debug session' })
|
||||
if (tracker.latched) {
|
||||
const landing = tracker.latched
|
||||
tracker.latched = null
|
||||
if (landing.kind === 'terminated' || landing.kind === 'disconnected') tracker = null
|
||||
return Promise.resolve(landing)
|
||||
}
|
||||
if (tracker.waiter) return tracker.waiter.promise
|
||||
let resolveFn!: (l: DebugLanding) => void
|
||||
const promise = new Promise<DebugLanding>((resolve) => {
|
||||
resolveFn = resolve
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
if (tracker?.waiter) {
|
||||
const resolve = tracker.waiter.resolve
|
||||
tracker.waiter = null
|
||||
resolve({ kind: 'timeout' })
|
||||
}
|
||||
}, timeoutMs)
|
||||
tracker.waiter = { promise, resolve: resolveFn, timer }
|
||||
return promise
|
||||
}
|
||||
|
||||
// --- Per-turn budget ---
|
||||
|
||||
const MAX_TIER2_PER_TURN = 25
|
||||
const MAX_TRANSITIONS_PER_TURN = 40
|
||||
|
||||
let tier2Count = 0
|
||||
let transitionCount = 0
|
||||
|
||||
export function resetDebugBudget(): void {
|
||||
tier2Count = 0
|
||||
transitionCount = 0
|
||||
}
|
||||
|
||||
function chargeTransition(): boolean {
|
||||
transitionCount += 1
|
||||
return transitionCount <= MAX_TRANSITIONS_PER_TURN
|
||||
}
|
||||
|
||||
// Check without charging: a start that fails to launch is not a transition.
|
||||
function hasTransitionBudget(): boolean {
|
||||
return transitionCount < MAX_TRANSITIONS_PER_TURN
|
||||
}
|
||||
|
||||
function chargeTier2(): boolean {
|
||||
tier2Count += 1
|
||||
return tier2Count <= MAX_TIER2_PER_TURN
|
||||
}
|
||||
|
||||
const BUDGET_TRANSITION_MESSAGE =
|
||||
`Per-turn debug budget reached (${MAX_TRANSITIONS_PER_TURN} run/step actions). A breakpoint may be inside a hot loop — ` +
|
||||
'remove or narrow it, or ask the user to continue in a new turn.'
|
||||
|
||||
const BUDGET_TIER2_MESSAGE =
|
||||
`Per-turn debug evaluation budget reached (${MAX_TIER2_PER_TURN} evaluations). ` +
|
||||
'Read the state you already have, or ask the user to continue in a new turn.'
|
||||
|
||||
// --- Result shaping (token-frugal, frame-scoped, depth-limited) ---
|
||||
|
||||
const MAX_VALUE_LEN = 200
|
||||
const MAX_LOG_TAIL = 2000
|
||||
const MAX_FRAMES = 8
|
||||
|
||||
function truncate(value: string, max = MAX_VALUE_LEN): string {
|
||||
if (value.length <= max) return value
|
||||
return value.slice(0, max) + `… (${value.length} chars)`
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function formatFrames(frames: StackFrame[]): string {
|
||||
if (frames.length === 0) return ' (no stack frames)'
|
||||
return frames
|
||||
.slice(0, MAX_FRAMES)
|
||||
.map((f, i) => ` #${i} ${f.name} @ line ${f.line}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function formatVariables(vars: Variable[]): string {
|
||||
if (vars.length === 0) return ' (none)'
|
||||
return vars
|
||||
.map((v) => ` ${v.name}${v.type ? `: ${v.type}` : ''} = ${truncate(v.value)}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function shapeState(): string {
|
||||
const s = get(debugState)
|
||||
const owner = get(debugOwner)
|
||||
const lines: string[] = []
|
||||
|
||||
let status: string
|
||||
if (!s.connected) status = 'not connected'
|
||||
else if (s.stopped) status = `stopped (${s.stoppedReason ?? 'unknown'})`
|
||||
else if (s.running) status = 'running'
|
||||
else status = 'connected/idle'
|
||||
lines.push(`Session: ${status}${owner ? ` — controlled by: ${owner}` : ''}`)
|
||||
|
||||
if (s.currentFile || s.currentLine !== undefined) {
|
||||
lines.push(`Position: ${s.currentFile ?? 'script'}:${s.currentLine ?? '?'}`)
|
||||
}
|
||||
|
||||
if (s.stopped && s.stackFrames.length > 0) {
|
||||
lines.push('Stack (top first):')
|
||||
lines.push(formatFrames(s.stackFrames))
|
||||
if (s.scopes.length > 0) {
|
||||
lines.push(
|
||||
`Scopes at top frame: ${s.scopes.map((sc) => sc.name).join(', ')} ` +
|
||||
'(use debug_evaluate to read values)'
|
||||
)
|
||||
for (const scope of s.scopes) {
|
||||
const cached = s.variables.get(scope.variablesReference)
|
||||
if (cached && cached.length > 0) {
|
||||
lines.push(`${scope.name}:`)
|
||||
lines.push(formatVariables(cached))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (s.breakpoints.size > 0) {
|
||||
const allLines = Array.from(s.breakpoints.values())
|
||||
.flat()
|
||||
.map((b) => b.line)
|
||||
.sort((a, b) => a - b)
|
||||
if (allLines.length > 0) lines.push(`Breakpoints at lines: ${allLines.join(', ')}`)
|
||||
}
|
||||
|
||||
if (s.logs) {
|
||||
const tail = s.logs.length > MAX_LOG_TAIL ? '…' + s.logs.slice(-MAX_LOG_TAIL) : s.logs
|
||||
lines.push(`Output so far:\n${tail}`)
|
||||
}
|
||||
|
||||
if (!s.running && !s.stopped) {
|
||||
if (s.error) lines.push(`Error: ${truncate(s.error, MAX_LOG_TAIL)}`)
|
||||
if (s.result !== undefined)
|
||||
lines.push(`Result: ${truncate(safeStringify(s.result), MAX_LOG_TAIL)}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function shapeLanding(landing: DebugLanding, client: DAPClient): Promise<string> {
|
||||
switch (landing.kind) {
|
||||
case 'stopped': {
|
||||
// The auto-fetch fired inside handleEvent is still in flight; re-fetch (tier-1
|
||||
// reads) so shapeState reads a populated snapshot.
|
||||
try {
|
||||
const frames = await client.getStackTrace()
|
||||
if (frames[0]) await client.getScopes(frames[0].id)
|
||||
} catch (error) {
|
||||
console.error('[debug] failed to refresh stop snapshot:', error)
|
||||
}
|
||||
const reason = landing.reason ? ` (${landing.reason})` : ''
|
||||
return `Execution stopped${reason}.\n${shapeState()}`
|
||||
}
|
||||
case 'terminated':
|
||||
if (landing.error) return `Execution errored out:\n${truncate(landing.error, MAX_LOG_TAIL)}`
|
||||
return `Execution ran to completion.\n${shapeState()}`
|
||||
case 'disconnected':
|
||||
return 'The debug session disconnected (socket closed).'
|
||||
case 'timeout':
|
||||
return 'Still running after the wait window. The code has not stopped or finished yet. Call debug_wait to keep waiting, or debug_stop to abort.'
|
||||
case 'preempted':
|
||||
return CONTROL_TAKEN_MESSAGE
|
||||
case 'torn_down':
|
||||
return 'The debug session was torn down.'
|
||||
case 'turn_ended':
|
||||
return 'This turn ended while waiting; the debug session is left intact. Call debug_get_state to check on it or debug_wait to keep waiting.'
|
||||
case 'error':
|
||||
return `Debug operation failed: ${landing.message}`
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL_TAKEN_MESSAGE =
|
||||
'Control was taken by the user, who is now driving this debug session manually. Stop issuing debug control commands unless the user explicitly asks you to resume. To resume when they ask, call debug_start to take over (they will be asked to confirm).'
|
||||
|
||||
const NO_SESSION_MESSAGE =
|
||||
'No active debug session. Start one with debug_start before continuing, stepping, evaluating, or reading state.'
|
||||
|
||||
// --- Guards shared by the driving (tier-2 / control) operations ---
|
||||
|
||||
function requireLiveClient(): DAPClient | null {
|
||||
const client = peekDAPClient()
|
||||
if (!client || !client.isConnected()) return null
|
||||
return client
|
||||
}
|
||||
|
||||
function ensureAgentControl(): { ok: true } | { ok: false; message: string } {
|
||||
if (get(debugOwner) === 'human') return { ok: false, message: CONTROL_TAKEN_MESSAGE }
|
||||
acquireAgentOwner(false)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// --- High-level agent operations (called by the copilot helpers) ---
|
||||
|
||||
export function agentDebugGetState(): string {
|
||||
if (!peekDAPClient()) return NO_SESSION_MESSAGE
|
||||
return shapeState()
|
||||
}
|
||||
|
||||
export async function agentDebugStart(
|
||||
controller: DebugController,
|
||||
agentArgs: Record<string, unknown> | undefined,
|
||||
timeoutMs: number
|
||||
): Promise<string> {
|
||||
// Taking over a human session is allowed: debug_start is confirmation-gated in the UI.
|
||||
if (!hasTransitionBudget()) return BUDGET_TRANSITION_MESSAGE
|
||||
try {
|
||||
// `start` resets the client and the reset hook clears ownership, so claim the
|
||||
// session in the ready callback: after the reset, before launch.
|
||||
await controller.start(agentArgs, (client) => {
|
||||
ensureTracker(client).latched = null
|
||||
acquireAgentOwner(true)
|
||||
})
|
||||
} catch (error) {
|
||||
if (tracker) {
|
||||
tracker.unsub()
|
||||
tracker = null
|
||||
}
|
||||
releaseAgentOwner()
|
||||
return `Could not start the debugger: ${getDebugErrorMessage(error)}`
|
||||
}
|
||||
chargeTransition()
|
||||
const client = peekDAPClient()
|
||||
if (!client) {
|
||||
releaseAgentOwner()
|
||||
return 'Debugger did not initialize a session.'
|
||||
}
|
||||
return shapeLanding(await awaitTransition(timeoutMs), client)
|
||||
}
|
||||
|
||||
export async function agentDebugContinue(timeoutMs: number): Promise<string> {
|
||||
const control = ensureAgentControl()
|
||||
if (!control.ok) return control.message
|
||||
const client = requireLiveClient()
|
||||
if (!client) return NO_SESSION_MESSAGE
|
||||
if (!client.isStopped()) {
|
||||
return client.isRunning()
|
||||
? 'Execution is already running, not paused — use debug_wait to wait for the next stop.'
|
||||
: 'Execution is not paused (it has finished or not started). Start a new session with debug_start.'
|
||||
}
|
||||
if (!chargeTransition()) return BUDGET_TRANSITION_MESSAGE
|
||||
ensureTracker(client).latched = null
|
||||
try {
|
||||
await client.continue_()
|
||||
} catch (error) {
|
||||
return `Continue failed: ${getDebugErrorMessage(error)}`
|
||||
}
|
||||
return shapeLanding(await awaitTransition(timeoutMs), client)
|
||||
}
|
||||
|
||||
export async function agentDebugStep(
|
||||
kind: 'over' | 'in' | 'out',
|
||||
timeoutMs: number
|
||||
): Promise<string> {
|
||||
const control = ensureAgentControl()
|
||||
if (!control.ok) return control.message
|
||||
const client = requireLiveClient()
|
||||
if (!client) return NO_SESSION_MESSAGE
|
||||
if (!client.isStopped()) return 'Cannot step: execution is not paused at a breakpoint.'
|
||||
if (!chargeTransition()) return BUDGET_TRANSITION_MESSAGE
|
||||
ensureTracker(client).latched = null
|
||||
try {
|
||||
if (kind === 'over') await client.stepOver()
|
||||
else if (kind === 'in') await client.stepIn()
|
||||
else await client.stepOut()
|
||||
} catch (error) {
|
||||
return `Step failed: ${getDebugErrorMessage(error)}`
|
||||
}
|
||||
return shapeLanding(await awaitTransition(timeoutMs), client)
|
||||
}
|
||||
|
||||
export async function agentDebugWait(timeoutMs: number): Promise<string> {
|
||||
const control = ensureAgentControl()
|
||||
if (!control.ok) return control.message
|
||||
const client = requireLiveClient()
|
||||
if (!client) return NO_SESSION_MESSAGE
|
||||
if (client.isStopped()) return `Execution is already paused.\n${shapeState()}`
|
||||
if (!client.isRunning()) return `Execution has finished or has not started.\n${shapeState()}`
|
||||
if (!chargeTransition()) return BUDGET_TRANSITION_MESSAGE
|
||||
return shapeLanding(await awaitTransition(timeoutMs), client)
|
||||
}
|
||||
|
||||
export async function agentDebugEvaluate(
|
||||
controller: DebugController,
|
||||
expression: string,
|
||||
frameId?: number
|
||||
): Promise<string> {
|
||||
const control = ensureAgentControl()
|
||||
if (!control.ok) return control.message
|
||||
const client = requireLiveClient()
|
||||
if (!client) return NO_SESSION_MESSAGE
|
||||
if (!chargeTier2()) return BUDGET_TIER2_MESSAGE
|
||||
const effectiveFrame = frameId ?? get(debugState).stackFrames[0]?.id
|
||||
try {
|
||||
const token = await controller.signExpression(expression)
|
||||
const { result } = await client.evaluate(expression, effectiveFrame, 'repl', token)
|
||||
return truncate(result ?? 'undefined', MAX_LOG_TAIL)
|
||||
} catch (error) {
|
||||
return `Evaluation failed: ${getDebugErrorMessage(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
export async function agentDebugSetBreakpoints(
|
||||
controller: DebugController,
|
||||
lines: number[]
|
||||
): Promise<string> {
|
||||
const control = ensureAgentControl()
|
||||
if (!control.ok) return control.message
|
||||
try {
|
||||
await controller.setBreakpoints(lines)
|
||||
} catch (error) {
|
||||
return `Could not set breakpoints: ${getDebugErrorMessage(error)}`
|
||||
}
|
||||
return lines.length === 0
|
||||
? 'Cleared all breakpoints.'
|
||||
: `Breakpoints set at lines: ${[...lines].sort((a, b) => a - b).join(', ')}.`
|
||||
}
|
||||
|
||||
export async function agentDebugStop(controller: DebugController): Promise<string> {
|
||||
if (getDebugOwner() === 'human') return CONTROL_TAKEN_MESSAGE
|
||||
try {
|
||||
await controller.stop()
|
||||
} catch (error) {
|
||||
return `Could not stop the session: ${getDebugErrorMessage(error)}`
|
||||
}
|
||||
settleWaiter({ kind: 'torn_down' })
|
||||
if (tracker) {
|
||||
tracker.unsub()
|
||||
tracker = null
|
||||
}
|
||||
releaseAgentOwner()
|
||||
return 'Debug session stopped.'
|
||||
}
|
||||
|
||||
// --- Turn / teardown lifecycle ---
|
||||
|
||||
// End of a chat turn: settle any pending wait and hand ownership back. The live
|
||||
// session (socket + token) is left intact — the same as a human pausing mid-debug.
|
||||
export function onAgentTurnEnd(): void {
|
||||
settleWaiter({ kind: 'turn_ended' })
|
||||
releaseAgentOwner()
|
||||
}
|
||||
|
||||
onDAPReset(() => {
|
||||
settleWaiter({ kind: 'torn_down' })
|
||||
if (tracker) {
|
||||
tracker.unsub()
|
||||
tracker = null
|
||||
}
|
||||
debugOwner.set(null)
|
||||
})
|
||||
@@ -90,11 +90,32 @@ export class DAPClient {
|
||||
{ resolve: (value: DAPMessage) => void; reject: (error: Error) => void }
|
||||
> = new Map()
|
||||
private url: string
|
||||
private eventListeners = new Set<(event: DAPMessage) => void>()
|
||||
|
||||
constructor(url: string = 'ws://localhost:3003') {
|
||||
this.url = url
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to raw DAP events. A synthetic `{ event: 'closed' }` is emitted on
|
||||
* socket teardown. The store diff cannot substitute: two consecutive `stopped`
|
||||
* events produce an identical store, so a transition can only be observed here.
|
||||
*/
|
||||
onEvent(listener: (event: DAPMessage) => void): () => void {
|
||||
this.eventListeners.add(listener)
|
||||
return () => this.eventListeners.delete(listener)
|
||||
}
|
||||
|
||||
private emitEvent(event: DAPMessage): void {
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (error) {
|
||||
console.error('[DAP] event listener threw:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the DAP server.
|
||||
*/
|
||||
@@ -121,6 +142,7 @@ export class DAPClient {
|
||||
output: s.output
|
||||
}))
|
||||
this.pendingRequests.clear()
|
||||
this.emitEvent({ seq: 0, type: 'event', event: 'closed' })
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
@@ -280,6 +302,8 @@ export class DAPClient {
|
||||
default:
|
||||
console.log('Unhandled DAP event:', event.event)
|
||||
}
|
||||
|
||||
this.emitEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -538,6 +562,15 @@ export class DAPClient {
|
||||
// Singleton instance
|
||||
let dapClientInstance: DAPClient | null = null
|
||||
|
||||
const resetHooks = new Set<() => void>()
|
||||
|
||||
// Registered via a hook set rather than a direct import so dapClient keeps no upward
|
||||
// dependency on the agent layer that teardown must settle.
|
||||
export function onDAPReset(hook: () => void): () => void {
|
||||
resetHooks.add(hook)
|
||||
return () => resetHooks.delete(hook)
|
||||
}
|
||||
|
||||
export function getDAPClient(url?: string): DAPClient {
|
||||
if (!dapClientInstance) {
|
||||
dapClientInstance = new DAPClient(url)
|
||||
@@ -545,7 +578,19 @@ export function getDAPClient(url?: string): DAPClient {
|
||||
return dapClientInstance
|
||||
}
|
||||
|
||||
// Returns the current singleton without creating one.
|
||||
export function peekDAPClient(): DAPClient | null {
|
||||
return dapClientInstance
|
||||
}
|
||||
|
||||
export function resetDAPClient(): void {
|
||||
for (const hook of resetHooks) {
|
||||
try {
|
||||
hook()
|
||||
} catch (error) {
|
||||
console.error('[DAP] reset hook threw:', error)
|
||||
}
|
||||
}
|
||||
if (dapClientInstance) {
|
||||
dapClientInstance.disconnect()
|
||||
dapClientInstance = null
|
||||
|
||||
@@ -50,7 +50,8 @@ export async function fetchContextualVariables(workspace: string): Promise<Recor
|
||||
export async function signDebugRequest(
|
||||
workspace: string,
|
||||
code: string,
|
||||
language: string
|
||||
language: string,
|
||||
agentOriginated?: boolean
|
||||
): Promise<{ token: string; code: string; job_id: string }> {
|
||||
if (!workspace) {
|
||||
throw new Error('No workspace selected')
|
||||
@@ -59,7 +60,7 @@ export async function signDebugRequest(
|
||||
const response = await fetch(`/api/w/${workspace}/debug/sign`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, language })
|
||||
body: JSON.stringify({ code, language, agent_originated: agentOriginated ?? false })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -48,7 +48,9 @@ export { default as DebugConsole } from './DebugConsole.svelte'
|
||||
export {
|
||||
DAPClient,
|
||||
getDAPClient,
|
||||
peekDAPClient,
|
||||
resetDAPClient,
|
||||
onDAPReset,
|
||||
debugState,
|
||||
type DebugState,
|
||||
type Breakpoint,
|
||||
@@ -57,6 +59,24 @@ export {
|
||||
type Scope
|
||||
} from './dapClient'
|
||||
|
||||
export {
|
||||
debugOwner,
|
||||
getDebugOwner,
|
||||
preemptToHuman,
|
||||
resetDebugBudget,
|
||||
onAgentTurnEnd,
|
||||
agentDebugGetState,
|
||||
agentDebugStart,
|
||||
agentDebugContinue,
|
||||
agentDebugStep,
|
||||
agentDebugWait,
|
||||
agentDebugEvaluate,
|
||||
agentDebugSetBreakpoints,
|
||||
agentDebugStop,
|
||||
type DebugController,
|
||||
type DebugOwner
|
||||
} from './agentDebugSession'
|
||||
|
||||
// Re-export shared utilities
|
||||
export {
|
||||
fetchContextualVariables,
|
||||
|
||||
Reference in New Issue
Block a user