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
71 changes: 70 additions & 1 deletion packages/sidecar-toolkit/src/cli-run-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { ExecutionOverlaySink } from '@dialogram/shared';
import { resumeStepFor } from './resume-at-step.js';
import { appendTail, failureHeadline, fixTask, type FixTask, type RunFailure } from './run-failure.js';
import { appendTail, failureHeadline, failureWhere, fixTask, type FixTask, type RunFailure } from './run-failure.js';
import { requestWorkflowStop, spawnWorkflowProcess } from './process-control.js';
import { RunEventStreamClient, type RunStreamEvent } from './run-event-stream-client.js';

Expand All @@ -51,8 +51,15 @@ type RunWorkflowArgs = {
atStep?: number;
/** With `resumeFrom`: resume at the step before this node's last firing. */
resumeAtActor?: string;
/** With `resumeFrom`: resume at its last completed step -- where it failed. */
resumeAtLastStep?: boolean;
};

/** The command the chat's `resume_failed_run` tool runs (see `CliRunDriver.resumeFailedRun`). */
export function resumeFailedRunCommandId(runWorkflowCommandId: string): string {
return `${runWorkflowCommandId}.resumeFailed`;
}

/**
* Per-entity agent tool-calling settings, keyed by entity instance name.
* Persisted (via {@link CliRunDriverConfig.overrideState}) so it survives VS
Expand Down Expand Up @@ -177,6 +184,9 @@ export interface LiveOverlaySubscription {

export class CliRunDriver {
private activeRun: ActiveWorkflowRun | undefined;
/** The last failed run of each workflow file (by fsPath), until it runs clean. */
private readonly failedRuns = new Map<string, { sourceUri: vscode.Uri; workflowName?: string; runDir: string; failure?: RunFailure }>();
private lastFailedFile: string | undefined;
/** Per-entity agent-tool overrides; mutated by the config commands and read
* when building run args. Persisted through `config.overrideState`. */
private readonly agentToolOverrides = new Map<string, AgentToolEntitySettings>();
Expand Down Expand Up @@ -211,6 +221,15 @@ export class CliRunDriver {
vscode.commands.registerCommand(this.config.stopWorkflowCommandId, async () => this.stopWorkflow())
);

// Command: resume the last failed run, at the user's word -- what the
// chat's `resume_failed_run` tool runs once the agent's fix is in.
context.subscriptions.push(
vscode.commands.registerCommand(
resumeFailedRunCommandId(this.config.runWorkflowCommandId),
async (args?: { file?: string }) => this.resumeFailedRun(args?.file)
)
);

// Command: Run workflow (invokes the configured runtime CLI)
context.subscriptions.push(
vscode.commands.registerCommand(this.config.runWorkflowCommandId, async (args?: RunWorkflowArgs) => this.runWorkflow(args))
Expand Down Expand Up @@ -728,6 +747,48 @@ export class CliRunDriver {
});
}

/**
* Resume the last failed run of `file`'s workflow from where it failed, if
* the person agrees. Returns what happened, in words, for the agent that
* asked: it proposed this after fixing the failure.
*
* `file` is the chat session's file. Navigating a hierarchy in place that
* can be a nested workflow's file, not the root that ran; then the last run
* that failed is the one meant.
*/
async resumeFailedRun(file?: string): Promise<string> {
if (!this.config.cliResumeArgs) {
return 'This runtime cannot resume a run; ask the user to run the workflow again.';
}
const failed = (file ? this.failedRuns.get(file) : undefined)
?? (this.lastFailedFile ? this.failedRuns.get(this.lastFailedFile) : undefined);
if (!failed) {
return 'There is no failed run to resume: the workflow has run clean since, or has not failed in this session.';
}
if (this.activeRun) {
return 'A run is already in progress; the failed run can be resumed once it ends.';
}
const RESUME = 'Resume';
const where = failureWhere(failed.failure);
const choice = await vscode.window.showInformationMessage(
`The chat's fix is in. Resume the run${failed.workflowName ? ` of ${failed.workflowName}` : ''} from where it failed${where ? ` (${where})` : ''}? What ran before is replayed, not run again.`,
RESUME,
'Not now'
);
if (choice !== RESUME) {
return 'The user chose not to resume the run now.';
}
// Not awaited: a run can outlast a tool call. Its outcome shows on the
// diagram, and a new failure is offered for fixing again.
void this.runWorkflow({
sourceUri: failed.sourceUri.toString(),
workflowName: failed.workflowName,
resumeFrom: failed.runDir,
resumeAtLastStep: true
});
return 'The run is resuming from where it failed. Its result will show on the diagram; if it fails again, the user is offered a new fix.';
}

/** The error a run recorded, if its record has one. */
private async readRunFailure(runDir: string): Promise<RunFailure | undefined> {
try {
Expand Down Expand Up @@ -862,6 +923,7 @@ export class CliRunDriver {
}
const resolved = await resumeStepFor(resumeFrom, {
atStep: args?.atStep,
atLastStep: args?.resumeAtLastStep === true,
actor: typeof args?.resumeAtActor === 'string' && args.resumeAtActor.trim() ? args.resumeAtActor.trim() : undefined
});
if ('error' in resolved) {
Expand Down Expand Up @@ -1123,12 +1185,19 @@ export class CliRunDriver {
workflowName
});
const failure = runDir ? await this.readRunFailure(runDir) : undefined;
if (runDir) {
// What the agent's fix resumes (`resume_failed_run`).
this.failedRuns.set(sourceUri.fsPath, { sourceUri, workflowName, runDir, failure });
this.lastFailedFile = sourceUri.fsPath;
}
void this.reportFailure({ sourceUri, workflowName, failure, exitCode, stderrTail, runDir, output });
return { error: failureHeadline(failure, exitCode) };
}

// Refresh diagram so overlay decorations can pick up the new run artifacts.
await refreshDiagram(true);
// It ran clean: nothing left to resume for it.
this.failedRuns.delete(sourceUri.fsPath);

const queueTracePath = await this.findLatestQueueTracePath({
baseOutDir: runOutDir,
Expand Down
1 change: 1 addition & 0 deletions packages/sidecar-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export {

export {
CliRunDriver,
resumeFailedRunCommandId,
type CliRunDriverConfig,
type CliRunDriverHost,
type AgentToolEntitySettings,
Expand Down
6 changes: 6 additions & 0 deletions packages/sidecar-toolkit/src/resume-at-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface ResumeRequest {
atStep?: number;
/** A node: resume at the step before its last firing, so that firing happens again. */
actor?: string;
/** The run's last completed step: where it stopped, every firing before replayed. */
atLastStep?: boolean;
}

export type ResumeStep = { step: number } | { error: string };
Expand Down Expand Up @@ -34,6 +36,10 @@ export async function resumeStepFor(runDir: string, request: ResumeRequest): Pro
return { error: `The run in ${runDir} was made by a runtime that cannot resume at a step.` };
}

if (request.atLastStep) {
return { step: steps.length };
}

if (request.actor !== undefined) {
// The last step at which it fired; resuming at the step before runs it again.
for (let i = steps.length - 1; i >= 0; i--) {
Expand Down
18 changes: 11 additions & 7 deletions packages/sidecar-toolkit/src/run-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export interface FixTask {
prompt: string;
}

/** The chat tool that resumes the failed run once a fix is in. */
export const RESUME_TOOL = 'resume_failed_run';

/** At most this much of the run's stderr goes to the agent: the end, where the traceback is. */
export const STDERR_TAIL_CHARS = 8000;

Expand Down Expand Up @@ -81,13 +84,14 @@ export function fixTask(opts: {
if (tail) {
lines.push('', 'The end of the run\'s error output:', '', '```text', tail, '```');
}
lines.push(
'',
'Explain the cause first, then propose the change, and say which file it goes in.'
+ (opts.canResume
? ' Once it is fixed, the run can be resumed from where it failed rather than started over.'
: '')
);
lines.push('', 'Explain the cause first, then propose the change, and say which file it goes in.');
if (opts.canResume) {
lines.push(
'',
`Once the fix is applied, call the \`${RESUME_TOOL}\` tool: it offers the user to resume the run from where it failed, `
+ 'with what ran before replayed rather than run again.'
);
}
const where = failureWhere(opts.failure);
return {
name: `Fix: ${where ?? opts.workflowName ?? 'failed run'}`,
Expand Down
34 changes: 33 additions & 1 deletion packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
import type { EntityPaletteItemSpec, NodeFamilySpec } from '@dialogram/shared';
import * as vscode from 'vscode';
import { invokeSidecarOp } from './sidecar-graph-export.js';
import { createRegistryChatTools } from './registry-tools.js';
import { createRegistryChatTools, type RegistryChatTool } from './registry-tools.js';
import { RESUME_TOOL } from './run-failure.js';
import { getCliInvocation,
getSidecarCommand,
type SidecarRuntimeConfig,
Expand All @@ -32,6 +33,7 @@ import { registerSidecarCommands } from './sidecar-commands.js';
import { registerNewSourceFileCommand } from './new-source-file-command.js';
import {
CliRunDriver,
resumeFailedRunCommandId,
type CliRunDriverConfig,
type CliRunDriverHost,
type AgentToolEntitySettings
Expand Down Expand Up @@ -326,6 +328,12 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) {
args
)
});
// A product that can resume runs gives the chat a way to offer it: the
// agent that fixed a failed run proposes resuming it, and the person says
// yes or no.
if (input.cliResumeArgs) {
chatTools.push(resumeFailedRunTool(input.commands.runWorkflow));
}

const runDriver = (context: vscode.ExtensionContext, host: RunHost): vscode.Disposable => {
const config: CliRunDriverConfig = {
Expand Down Expand Up @@ -462,3 +470,27 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) {
});
return profile;
}


/**
* The chat tool that resumes a failed run once the agent's fix is in. It asks
* the person first (the run driver's `resumeFailedRun`); the agent learns the
* answer, and the run's outcome shows on the diagram.
*/
export function resumeFailedRunTool(runWorkflowCommandId: string): RegistryChatTool {
return {
name: RESUME_TOOL,
description:
'After fixing the cause of a failed workflow run, offer the user to resume that run from where it failed. '
+ 'The user is asked to confirm; what ran before the failure is replayed, not run again. '
+ 'Call it once the fix is applied to the files -- not before, and not to start a new run.',
inputSchema: { type: 'object', properties: {} },
handler: async (file) => {
const result = await vscode.commands.executeCommand<string>(
resumeFailedRunCommandId(runWorkflowCommandId),
{ file }
);
return typeof result === 'string' ? result : 'The resume could not be offered.';
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe('a run that fails', () => {
expect(task.name).toBe('Fix: x (in m › i)');
expect(task.prompt).toContain('instance path `m/i/x`');
expect(task.prompt).toContain('Traceback (most recent call last):');
expect(task.prompt).toContain('resumed from where it failed');
expect(task.prompt).toContain('call the `resume_failed_run` tool');
});

it('offers no fix without a chat behind the diagram', async () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/sidecar-toolkit/test/cli-run-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@ describe('CliRunDriver agent-tool overrides', () => {
vi.restoreAllMocks();
});

it('registers both config commands alongside run/stop', () => {
it('registers both config commands alongside run/stop/resume', () => {
const overrideState = makeOverrideState();
const driver = new CliRunDriver(makeConfig(overrideState), makeHost() as any);
const context = makeContext();
driver.registerCommands(context);
// run + stop + set + get
expect(context.subscriptions.length).toBe(4);
// run + stop + resume-failed + set + get
expect(context.subscriptions.length).toBe(5);
});

it('set mutates + persists via the accessor, get reads current', async () => {
Expand Down
Loading
Loading