Skip to content
Open
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
35 changes: 33 additions & 2 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ try {
},
});

assert.equal(output.trim(), `${current.id} completed reviewer`);
assert.equal(
output.trim(),
`<agent id="${current.id}" status="completed" target="reviewer"/>`,
);

const { stdout: jsonOutput } = await execFileAsync(
"node",
Expand Down Expand Up @@ -217,6 +220,31 @@ try {
assert.equal(payload.error.retryable, false);
assert.equal(payload.error.target, "missing");

let xmlCommandFailure: unknown;
try {
await execFileAsync(
"node",
["--import", "tsx", "src/cli.ts", "agents", "run", "missing", "inspect"],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
...cliConfigEnv,
DEVSPACE_WORKSPACE_ID: "ws_current",
DEVSPACE_WORKSPACE_ROOT: projectRoot,
},
},
);
} catch (error) {
xmlCommandFailure = error;
}
assert.ok(xmlCommandFailure, "XML CLI errors should exit non-zero");
assert.equal(
(xmlCommandFailure as { stderr?: string }).stderr,
'<error code="UNKNOWN_TARGET" retryable="false">Unknown subagent profile or provider: missing.</error>\n',
);

await assert.rejects(
execFileAsync(
"node",
Expand All @@ -243,7 +271,10 @@ try {
},
),
(error: unknown) => {
assert.match((error as { stderr?: string }).stderr ?? "", /Unknown option: --unknown/);
assert.equal(
(error as { stderr?: string }).stderr,
'<error code="AGENT_COMMAND_ERROR" retryable="false">Unknown option: --unknown. Use -- before prompt text that starts with a dash.</error>\n',
);
return true;
},
);
Expand Down
71 changes: 48 additions & 23 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { createLocalAgentClient } from "./local-agent-client.js";
import { toAgentErrorPayload, type LocalAgentError } from "./local-agent-errors.js";
import {
formatAgentCommandError,
formatAgentObservation,
formatAgentReceipt,
formatAgentSummary,
Expand Down Expand Up @@ -447,19 +448,19 @@ async function runAgentsCommand(args: string[]): Promise<void> {
switch (subcommand) {
case "ls":
case "list":
await runAgentsList(commandArgs, json);
await runAgentWorkflowCommand(json, () => runAgentsList(commandArgs, json));
return;
case "run":
await runAgentsRun(commandArgs, json);
await runAgentWorkflowCommand(json, () => runAgentsRun(commandArgs, json));
return;
case "continue":
await runAgentsContinue(commandArgs, json);
await runAgentWorkflowCommand(json, () => runAgentsContinue(commandArgs, json));
return;
case "show":
await runAgentsShow(commandArgs, json);
await runAgentWorkflowCommand(json, () => runAgentsShow(commandArgs, json));
return;
case "targets":
await runAgentsTargets(commandArgs, json);
await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json));
return;
case "daemon":
await runAgentsDaemon(commandArgs, json);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand All @@ -471,7 +472,7 @@ async function runAgentsCommand(args: string[]): Promise<void> {
printAgentsHelp();
return;
default:
throw new Error(`Unknown agents command: ${subcommand}`);
writeAgentWorkflowError(`Unknown agents command: ${subcommand}`, json);
}
}

Expand All @@ -487,15 +488,15 @@ async function runAgentsTargets(args: string[], json: boolean): Promise<void> {
const catalog = buildLocalAgentCatalog(config.subagents, profiles, providers);
const output = presentAgentTargetCatalog(catalog);
if (json) printJson(output);
else console.log(formatAgentTargetCatalog(output));
else printAgentXml(formatAgentTargetCatalog(output));
}

async function runAgentsList(args: string[], json: boolean): Promise<void> {
if (args.length > 0) throw new Error("Usage: devspace agents ls [--json]");
const config = loadConfig();
const client = createLocalAgentClient(config);
const result = await client.list(resolveCliWorkspaceContext(config.allowedRoots));
const agents = presentAgentResult(result, json);
const agents = presentAgentWorkflowResult(result, json);
if (!agents) return;

const summaries = agents.map(presentAgentSummary);
Expand All @@ -504,14 +505,7 @@ async function runAgentsList(args: string[], json: boolean): Promise<void> {
return;
}

if (agents.length === 0) {
console.log("No subagent sessions found for this workspace.");
return;
}

for (const summary of summaries) {
console.log(formatAgentSummary(summary));
}
printAgentXml(summaries.map(formatAgentSummary).join("\n"));
}

async function runAgentsRun(args: string[], json: boolean): Promise<void> {
Expand All @@ -527,14 +521,14 @@ async function runAgentsRun(args: string[], json: boolean): Promise<void> {
model: parsed.model,
effort: parsed.effort,
});
const record = presentAgentResult(result, json);
const record = presentAgentWorkflowResult(result, json);
if (!record) return;
const receipt = presentAgentReceipt(record);
if (json) {
printJson(receipt);
return;
}
console.log(formatAgentReceipt(receipt));
printAgentXml(formatAgentReceipt(receipt));
}

async function runAgentsContinue(args: string[], json: boolean): Promise<void> {
Expand All @@ -546,14 +540,14 @@ async function runAgentsContinue(args: string[], json: boolean): Promise<void> {
model: parsed.model,
effort: parsed.effort,
}, scope);
const record = presentAgentResult(result, json);
const record = presentAgentWorkflowResult(result, json);
if (!record) return;
const receipt = presentAgentReceipt(record);
if (json) {
printJson(receipt);
return;
}
console.log(formatAgentReceipt(receipt));
printAgentXml(formatAgentReceipt(receipt));
}

async function runAgentsShow(args: string[], json: boolean): Promise<void> {
Expand All @@ -564,20 +558,20 @@ async function runAgentsShow(args: string[], json: boolean): Promise<void> {
const client = createLocalAgentClient(config);
const scope = resolveCliWorkspaceContext(config.allowedRoots);
const initial = await client.get(id, scope);
let record = presentAgentResult(initial, json);
let record = presentAgentWorkflowResult(initial, json);
if (!record) return;

const deadline = Date.now() + 15_000;
while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) {
await sleep(500);
const refreshed = presentAgentResult(await client.get(id, scope), json);
const refreshed = presentAgentWorkflowResult(await client.get(id, scope), json);
if (!refreshed) return;
record = refreshed;
}

const observation = presentAgentObservation(record);
if (json) printJson(observation);
else console.log(formatAgentObservation(observation));
else printAgentXml(formatAgentObservation(observation));
}

async function runAgentsDaemon(args: string[], json: boolean): Promise<void> {
Expand Down Expand Up @@ -643,6 +637,37 @@ function presentAgentResult<T, E extends LocalAgentError>(
throw new Error(result.error.message);
}

function presentAgentWorkflowResult<T, E extends LocalAgentError>(
result: BetterResult<T, E>,
json: boolean,
): T | undefined {
if (result.isOk()) return result.value;
const error = toAgentErrorPayload(result.error);
if (json) printJson({ error });
else console.error(formatAgentCommandError(error));
process.exitCode = 1;
return undefined;
}

async function runAgentWorkflowCommand(json: boolean, command: () => Promise<void>): Promise<void> {
try {
await command();
} catch (error) {
writeAgentWorkflowError(error instanceof Error ? error.message : String(error), json);
}
}

function writeAgentWorkflowError(message: string, json: boolean): void {
const error = { code: "AGENT_COMMAND_ERROR", message, retryable: false };
if (json) printJson({ error });
else console.error(formatAgentCommandError(error));
process.exitCode = 1;
}

function printAgentXml(fragment: string): void {
if (fragment) console.log(fragment);
}

function printJson(value: unknown): void {
console.log(JSON.stringify(value));
}
Expand Down
57 changes: 43 additions & 14 deletions src/local-agent-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export interface AgentFailureOutput {
retryable: boolean;
}

export interface AgentCommandErrorOutput {
code: string;
message: string;
retryable?: boolean;
agentId?: string;
}

export type AgentObservationOutput =
| { id: string; status: "running" }
| { id: string; status: "completed"; response?: string }
Expand Down Expand Up @@ -98,37 +105,59 @@ export function presentAgentObservation(record: LocalAgentRecord): AgentObservat
}

export function formatAgentTargetCatalog(catalog: AgentTargetCatalogOutput): string {
if (catalog.targets.length === 0) return "No usable subagent targets.";
return catalog.targets.map((target) => {
const settings = [
target.model ? `model=${target.model}` : undefined,
target.effort ? `effort=${target.effort}` : undefined,
].filter(Boolean).join(" ");
const settings = xmlAttributes({ model: target.model, effort: target.effort });
if (target.kind === "provider") {
return `${target.name} [provider]${settings ? ` ${settings}` : ""}`;
return `<provider name="${escapeXmlAttribute(target.name)}"${settings}/>`;
}
return `${target.name} [profile, ${target.provider}]${settings ? ` ${settings}` : ""} - ${target.description}`;
return `<profile name="${escapeXmlAttribute(target.name)}" provider="${escapeXmlAttribute(target.provider)}"${settings}>${escapeXmlText(target.description)}</profile>`;
}).join("\n");
}

export function formatAgentReceipt(receipt: AgentReceiptOutput): string {
return `${receipt.id} ${receipt.status}`;
return `<agent id="${escapeXmlAttribute(receipt.id)}" status="${receipt.status}"/>`;
}

export function formatAgentSummary(summary: AgentSummaryOutput): string {
return `${formatAgentReceipt(summary)} ${summary.target}`;
return `<agent id="${escapeXmlAttribute(summary.id)}" status="${summary.status}" target="${escapeXmlAttribute(summary.target)}"/>`;
}

export function formatAgentObservation(observation: AgentObservationOutput): string {
const line = formatAgentReceipt(observation);
if (observation.status === "completed" && observation.response !== undefined) {
return `${line}\n\n${observation.response}`;
return `<agent id="${escapeXmlAttribute(observation.id)}" status="completed">${escapeXmlText(observation.response)}</agent>`;
}
if ((observation.status === "failed" || observation.status === "stopped") && observation.error) {
const retryable = observation.error.retryable ? " [retryable]" : "";
return `${line} ${observation.error.code}: ${observation.error.message}${retryable}`;
return `<agent id="${escapeXmlAttribute(observation.id)}" status="${observation.status}" code="${escapeXmlAttribute(observation.error.code)}" retryable="${observation.error.retryable}">${escapeXmlText(observation.error.message)}</agent>`;
}
return line;
return formatAgentReceipt(observation);
}

export function formatAgentCommandError(error: AgentCommandErrorOutput): string {
const agentId = error.agentId ? ` agent-id="${escapeXmlAttribute(error.agentId)}"` : "";
return `<error code="${escapeXmlAttribute(error.code)}" retryable="${error.retryable ?? false}"${agentId}>${escapeXmlText(error.message)}</error>`;
}

function xmlAttributes(values: Record<string, string | undefined>): string {
return Object.entries(values)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(([name, value]) => ` ${name}="${escapeXmlAttribute(value)}"`)
.join("");
}

function escapeXmlAttribute(value: string): string {
return escapeXml(value).replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}

function escapeXmlText(value: string): string {
return escapeXml(value);
}

function escapeXml(value: string): string {
return value
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "\uFFFD")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}

function presentAgentStatus(status: LocalAgentStatus): AgentCommandStatus {
Expand Down
Loading