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 <id> 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>
This commit is contained in:
paseo-bot[bot]
2026-09-25 14:50:04 +02:00
committed by GitHub
parent dc9799f6fc
commit e68553f759
2 changed files with 72 additions and 11 deletions
+43 -4
View File
@@ -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<never> {
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();
});
});
+29 -7
View File
@@ -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 <id> -> 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 <kind> -> 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<RunWorkspace> {
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<string | undefined> {
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;
}