mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
* Let agents steer a running turn from send_agent_prompt and paseo send When an agent used the send_agent_prompt MCP tool on a target that was mid-turn, sendPromptToAgent ran without activeTurnBehavior, so the prompt path fell through to replaceAgentRun. That cancelled the target's active turn and every provider subagent it had in flight. The web UI, child finish notifications and schedules already pass "steer"; the MCP tool and the CLI had no way to ask for it. Add an optional activeTurnBehavior input to send_agent_prompt. It defaults to "steer" for agent-scoped callers and to "interrupt" for top-level callers, so existing top-level behaviour is unchanged. Add --steer to paseo send. Document both and cover them with tests. * fix(agents): retain finish notifications during steering acknowledgement Observe completion before dispatch and hold delivery until acceptance. A replacement discards the displaced turn observation and starts a fresh listener before its iterator runs. * fix(agents): preserve notification ownership when follow-ups fail Keep the previous observation until dispatch succeeds and suppress duplicate delivery during acknowledgement. Decide whether to retain the running turn from the dispatch result, including side commands requested with interrupt. * fix(agents): deduplicate notifications across overlapping follow-ups --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
249 lines
7.3 KiB
TypeScript
249 lines
7.3 KiB
TypeScript
import { type Command, Option } from "commander";
|
|
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
|
import { collectMultiple } from "../../utils/command-options.js";
|
|
import { connectToDaemon } from "../../utils/client.js";
|
|
import type {
|
|
CommandOptions,
|
|
SingleResult,
|
|
OutputSchema,
|
|
CommandError,
|
|
} from "../../output/index.js";
|
|
import { readFile } from "node:fs/promises";
|
|
import { extname, resolve } from "node:path";
|
|
|
|
/** Result type for agent send command */
|
|
export interface AgentSendResult {
|
|
agentId: string;
|
|
status: "sent" | "completed" | "timeout" | "permission" | "error";
|
|
message: string;
|
|
}
|
|
|
|
/** Schema for agent send output */
|
|
export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
|
idField: "agentId",
|
|
columns: [
|
|
{ header: "AGENT ID", field: "agentId", width: 12 },
|
|
{ header: "STATUS", field: "status", width: 12 },
|
|
{ header: "MESSAGE", field: "message", width: 40 },
|
|
],
|
|
};
|
|
|
|
export interface AgentSendOptions extends CommandOptions {
|
|
behavior?: ActiveTurnBehavior;
|
|
wait?: boolean;
|
|
image?: string[];
|
|
prompt?: string;
|
|
promptFile?: string;
|
|
}
|
|
|
|
export function addSendOptions(cmd: Command): Command {
|
|
return cmd
|
|
.description("Send a message/task to an existing agent")
|
|
.argument("<id>", "Agent ID (or prefix)")
|
|
.argument("[prompt]", "The message to send")
|
|
.option("--prompt <text>", "Provide the message inline as a flag")
|
|
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
|
|
.option("--image <path>", "Attach image(s) to the message", collectMultiple, [])
|
|
.addOption(
|
|
new Option(
|
|
"--behavior <behavior>",
|
|
"Active-turn behavior (agent default: steer; otherwise: interrupt)",
|
|
).choices(["interrupt", "steer"]),
|
|
)
|
|
.option("--no-wait", "Return immediately without waiting for completion");
|
|
}
|
|
|
|
/**
|
|
* Read image files and convert them to base64 data URIs
|
|
*/
|
|
async function readImageFiles(
|
|
imagePaths: string[],
|
|
): Promise<Array<{ data: string; mimeType: string }>> {
|
|
return Promise.all(
|
|
imagePaths.map(async (path) => {
|
|
try {
|
|
const buffer = await readFile(path);
|
|
const ext = extname(path).toLowerCase();
|
|
|
|
let mimeType = "image/jpeg";
|
|
switch (ext) {
|
|
case ".png":
|
|
mimeType = "image/png";
|
|
break;
|
|
case ".jpg":
|
|
case ".jpeg":
|
|
mimeType = "image/jpeg";
|
|
break;
|
|
case ".gif":
|
|
mimeType = "image/gif";
|
|
break;
|
|
case ".webp":
|
|
mimeType = "image/webp";
|
|
break;
|
|
default:
|
|
mimeType = "image/jpeg";
|
|
}
|
|
|
|
return { data: buffer.toString("base64"), mimeType };
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && "code" in err) throw err;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw {
|
|
code: "IMAGE_READ_ERROR",
|
|
message: `Failed to read image file: ${path}`,
|
|
details: message,
|
|
} satisfies CommandError;
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function resolvePromptInput(options: {
|
|
promptArgument: string | undefined;
|
|
promptOption: string | undefined;
|
|
promptFile: string | undefined;
|
|
}): Promise<string> {
|
|
const promptText = options.promptArgument?.trim();
|
|
const promptOptionText = options.promptOption?.trim();
|
|
const promptFilePath = options.promptFile?.trim();
|
|
const providedSourceCount = [promptText, promptOptionText, promptFilePath].filter(Boolean).length;
|
|
|
|
if (providedSourceCount > 1) {
|
|
const error: CommandError = {
|
|
code: "CONFLICTING_PROMPT_INPUT",
|
|
message: "Provide exactly one of prompt argument, --prompt, or --prompt-file",
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
if (promptText) {
|
|
return options.promptArgument as string;
|
|
}
|
|
|
|
if (promptOptionText) {
|
|
return options.promptOption as string;
|
|
}
|
|
|
|
if (!promptFilePath) {
|
|
const error: CommandError = {
|
|
code: "MISSING_PROMPT",
|
|
message: "A prompt is required",
|
|
details:
|
|
"Usage: paseo agent send [options] <id> [prompt] | --prompt <text> | --prompt-file <path>",
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
return await readFile(resolve(promptFilePath), "utf8");
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && "code" in err) throw err;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const error: CommandError = {
|
|
code: "PROMPT_FILE_READ_ERROR",
|
|
message: `Failed to read prompt file: ${promptFilePath}`,
|
|
details: message,
|
|
};
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
type SendWaitState = Awaited<
|
|
ReturnType<Awaited<ReturnType<typeof connectToDaemon>>["waitForFinish"]>
|
|
>;
|
|
|
|
function buildSendResult(agentIdArg: string, state: SendWaitState): AgentSendResult {
|
|
const agentId = state.final?.id ?? agentIdArg;
|
|
if (state.status === "timeout") {
|
|
return { agentId, status: "timeout", message: "Timed out waiting for agent to finish" };
|
|
}
|
|
if (state.status === "permission") {
|
|
return { agentId, status: "permission", message: "Agent is waiting for permission" };
|
|
}
|
|
if (state.status === "error") {
|
|
return {
|
|
agentId,
|
|
status: "error",
|
|
message: state.error ?? "Agent finished with error",
|
|
};
|
|
}
|
|
return { agentId, status: "completed", message: "Agent completed processing the message" };
|
|
}
|
|
|
|
export async function runSendCommand(
|
|
agentIdArg: string,
|
|
prompt: string | undefined,
|
|
options: AgentSendOptions,
|
|
_command: Command,
|
|
): Promise<SingleResult<AgentSendResult>> {
|
|
// Validate arguments
|
|
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
|
const error: CommandError = {
|
|
code: "MISSING_AGENT_ID",
|
|
message: "Agent ID is required",
|
|
details: "Usage: paseo agent send [options] <id> [prompt]",
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
const promptInput = await resolvePromptInput({
|
|
promptArgument: prompt,
|
|
promptOption: options.prompt,
|
|
promptFile: options.promptFile,
|
|
});
|
|
|
|
const client = await connectToDaemon({ target: options.daemonTarget });
|
|
|
|
try {
|
|
// Read image files if provided
|
|
const images =
|
|
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
|
|
|
|
const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined;
|
|
// Send the message
|
|
await client.sendAgentMessage(agentIdArg, promptInput, {
|
|
images,
|
|
sourceAgentId,
|
|
activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"),
|
|
});
|
|
|
|
// If --no-wait, return immediately
|
|
if (options.wait === false) {
|
|
await client.close();
|
|
|
|
return {
|
|
type: "single",
|
|
data: {
|
|
agentId: agentIdArg,
|
|
status: "sent",
|
|
message: "Message sent, not waiting for completion",
|
|
},
|
|
schema: agentSendSchema,
|
|
};
|
|
}
|
|
|
|
const state = await client.waitForFinish(agentIdArg, 600000); // 10 minute timeout
|
|
await client.close();
|
|
|
|
return {
|
|
type: "single",
|
|
data: buildSendResult(agentIdArg, state),
|
|
schema: agentSendSchema,
|
|
};
|
|
} catch (err) {
|
|
await client.close().catch(() => {});
|
|
|
|
// Re-throw CommandError as-is
|
|
if (err && typeof err === "object" && "code" in err) {
|
|
throw err;
|
|
}
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const error: CommandError = {
|
|
code: "SEND_FAILED",
|
|
message: `Failed to send message: ${message}`,
|
|
};
|
|
throw error;
|
|
}
|
|
}
|