From e68553f759880e43899479bac421083610fa4f6d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:50:04 +0200 Subject: [PATCH] Run agents on another daemon from inside an agent session (#5392) * Run agents on another daemon from inside an agent session paseo run sent PASEO_AGENT_ID as the caller to whichever daemon --host or --home pointed at. That id names an agent on the daemon that launched the shell, so another daemon rejected the run with "Caller agent not found". The run now claims the caller only when the target daemon has that agent, and otherwise runs as a top-level agent there. Closes #5313 * Keep the caller when the agent lookup loses its connection Only the daemon's answer that it has no such agent drops the caller. A lost or timed-out connection now fails the run instead of creating a top-level agent on the caller's own daemon. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/cli/src/commands/agent/run.test.ts | 47 +++++++++++++++++++-- packages/cli/src/commands/agent/run.ts | 36 +++++++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 8f707c5e0c..b4e3b20bde 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { resolveExistingRunWorkspace, resolveRunCallerAgentId, @@ -8,13 +9,51 @@ import { const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; +// Answers fetchAgent the way a daemon does: an unknown id is an error. +function daemonWithAgents(...agentIds: string[]) { + return { + async fetchAgent({ agentId }: { agentId: string }) { + if (!agentIds.includes(agentId)) { + throw new Error(`Agent not found: ${agentId}`); + } + return { agent: { id: agentId } }; + }, + }; +} + describe("managed agent caller context", () => { - it("propagates a trimmed PASEO_AGENT_ID", () => { - expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent"); + it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents("parent-agent"), { + PASEO_AGENT_ID: " parent-agent ", + }), + ).resolves.toBe("parent-agent"); }); - it("omits blank caller ids", () => { - expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined(); + it("runs without a caller when PASEO_AGENT_ID belongs to another daemon", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents("other-agent"), { + PASEO_AGENT_ID: "parent-agent", + }), + ).resolves.toBeUndefined(); + }); + + it("fails instead of dropping the caller when the lookup loses its connection", async () => { + const disconnectedDaemon = { + async fetchAgent(): Promise { + throw new DaemonConnectionError("Connection lost before message could be sent"); + }, + }; + + await expect( + resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }), + ).rejects.toBeInstanceOf(DaemonConnectionError); + }); + + it("omits blank caller ids", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents(), { PASEO_AGENT_ID: " " }), + ).resolves.toBeUndefined(); }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index a8b140de1e..82817684c2 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -4,6 +4,7 @@ import { StructuredAgentResponseError, } from "@getpaseo/server/agent-response"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, @@ -526,7 +527,7 @@ export async function resolveExistingRunWorkspace( // Workspace policy for `paseo run`. Precedence: // 1. --workspace -> run in that existing workspace -// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace +// 2. caller agent -> daemon resolves the caller's workspace // 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals // 4. --new-workspace -> mint a new workspace explicitly // 5. bare run -> mint a new local-backed workspace for cwd @@ -534,6 +535,7 @@ async function resolveRunWorkspace( client: ConnectedDaemonClient, options: AgentRunOptions, cwd: string, + callerAgentId: string | undefined, ): Promise { const newWorkspace = resolveNewWorkspaceKind(options); const explicit = newWorkspace ? undefined : options.workspace?.trim(); @@ -542,7 +544,7 @@ async function resolveRunWorkspace( return resolveExistingRunWorkspace(client, explicit); } - if (!newWorkspace && resolveRunCallerAgentId()) { + if (!newWorkspace && callerAgentId) { return { cwd }; } @@ -609,9 +611,9 @@ export async function runRunCommand( const env = parseRunEnv(options.env); const requestEnv = Object.keys(env).length > 0 ? env : undefined; - const workspace = await resolveRunWorkspace(client, options, cwd); + const callerAgentId = await resolveRunCallerAgentId(client); + const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId); const workspaceId = workspace.id; - const callerAgentId = resolveRunCallerAgentId(); const runCwd = workspace.cwd; if (outputSchema) { @@ -743,8 +745,28 @@ export async function runRunCommand( } } -export function resolveRunCallerAgentId( +export interface RunCallerLookupClient { + fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>; +} + +// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run +// sent to another daemon (--host or --home) has no caller there, so it runs as +// a top-level agent instead of failing on an unknown caller. +export async function resolveRunCallerAgentId( + client: RunCallerLookupClient, env: { PASEO_AGENT_ID?: string } = process.env, -): string | undefined { - return env.PASEO_AGENT_ID?.trim() || undefined; +): Promise { + const agentId = env.PASEO_AGENT_ID?.trim(); + if (!agentId) { + return undefined; + } + const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => { + // A daemon without this agent answers with an error. A lost or timed-out + // connection is not an answer, so it must not drop the caller. + if (error instanceof DaemonConnectionError) { + throw error; + } + return null; + }); + return caller?.agent.id === agentId ? agentId : undefined; }