mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user