Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/dynamic-remote-subagents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Allow dynamic subagent resolvers to return `defineRemoteAgent(...)`. Session and turn selections can now conditionally expose a remote deployment and change its runtime connection settings.
27 changes: 26 additions & 1 deletion docs/guides/dynamic-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,37 @@ compiled manifest. Each resolution can return a different model or other
runtime agent settings. Runtime-selected models must use string model IDs;
build and Workflow-world configuration cannot be selected at runtime.

A single-file remote subagent uses the same lifecycle. Return
`defineRemoteAgent(...)` to expose the selected deployment, or nil to omit it:

```ts title="agent/subagents/finance.ts"
import { defineDynamic, defineRemoteAgent } from "eve";

export default defineDynamic({
events: {
"session.started": (_event, ctx) =>
ctx.session.auth.current?.attributes.plan === "enterprise"
? defineRemoteAgent({
description: "Analyze financial and accounting data.",
url: "https://finance-agent.example.com",
})
: null,
},
});
```

The returned remote definition can change its URL, path, headers, auth,
principal forwarding, and output schema. Function-valued URLs resolve when the
dynamic event runs. Auth and headers remain lazy and resolve before each
outbound request without entering durable workflow state.

Dynamic subagents support `session.started` and `turn.started`. A turn selection
shadows the session selection for that turn, including when the turn handler
returns nil. If a resolver throws or returns an invalid definition, eve logs the
failure and omits the subagent.

The resolved set applies to direct delegation and the `Workflow` tool. eve
The resolved set applies to local and remote direct delegation and the
`Workflow` tool. eve
also checks availability again before starting the child, so a stale or
manually constructed call fails with `SUBAGENT_UNAVAILABLE`. Treat conditional
availability as capability composition, not as the only authorization
Expand Down
33 changes: 33 additions & 0 deletions docs/guides/remote-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,39 @@ export default defineRemoteAgent({
| `path` | `string` | No | `/eve/v1/session` | Route appended to `url` for the create-session request. |
| `outputSchema` | `StandardSchema \| JSON Schema` | No | none | Structured return type the caller requires. Lowered to JSON Schema at compile time and enforced by the remote like any task-mode output schema. |

## Dynamic remote agents

Wrap the file in `defineDynamic` when the target or its availability depends on
the current session. Return `defineRemoteAgent(...)` to expose it and nil to
omit it:

```ts title="agent/subagents/weather.ts"
import { defineDynamic, defineRemoteAgent } from "eve";

export default defineDynamic({
events: {
"session.started": (_event, ctx) =>
ctx.session.auth.current?.attributes.region === "us"
? defineRemoteAgent({
description: "Answers weather questions for US customers.",
url: "https://us-weather-agent.example.com",
})
: null,
},
});
```

Dynamic remote subagents support `session.started` and `turn.started`. The
returned definition may select different remote settings at either scope. eve
resolves function-valued URLs when the event handler runs. Auth and headers
remain lazy and resolve before each outbound request without entering durable
workflow state.

Author `auth` and `headers` directly in the `defineRemoteAgent({ ... })` object
and keep their functions self-contained with module imports or environment
variables. They are rehydrated outside the event handler, so they cannot close
over `_event`, `ctx`, or handler-local values.

## Runtime URLs

A string `url` is read at compile time and frozen into the build. When the target comes from a runtime env var — known only once the deployment runs — pass a function instead. eve calls it when it resolves the agent graph at runtime, so it can read `process.env`:
Expand Down
45 changes: 25 additions & 20 deletions e2e/fixtures/agent-subagents/agent/subagents/remote-loopback.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineRemoteAgent } from "eve";
import { defineDynamic, defineRemoteAgent } from "eve";

/**
* A remote agent pointing back at this same deployment, so one fixture plays
Expand All @@ -10,24 +10,29 @@ import { defineRemoteAgent } from "eve";
* The URL resolves at runtime to the deployment's own address: `VERCEL_URL`
* on Vercel, or the dev server's self-published origin locally.
*/
export default defineRemoteAgent({
description:
"Remote loopback agent. Call this only when the user explicitly asks to use the remote-loopback agent, passing the user's requested message through unchanged. Call it with only the `message` argument — never pass `outputSchema`.",
url: () =>
process.env.VERCEL_URL !== undefined && process.env.VERCEL_URL !== ""
? `https://${process.env.VERCEL_URL}`
: (process.env.WORKFLOW_LOCAL_BASE_URL ?? "http://127.0.0.1:3000"),
headers: () => {
const headers: Record<string, string> = {
authorization: "Bearer e2e-principal-forwarding-router",
};
// Preview deployments behind Vercel deployment protection need the
// bypass header on the self-call; harmless when protection is off.
const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypass !== undefined && bypass !== "") {
headers["x-vercel-protection-bypass"] = bypass;
}
return headers;
export default defineDynamic({
events: {
"session.started": () =>
defineRemoteAgent({
description:
"Remote loopback agent. Call this only when the user explicitly asks to use the remote-loopback agent, passing the user's requested message through unchanged. Call it with only the `message` argument — never pass `outputSchema`.",
url: () =>
process.env.VERCEL_URL !== undefined && process.env.VERCEL_URL !== ""
? `https://${process.env.VERCEL_URL}`
: (process.env.WORKFLOW_LOCAL_BASE_URL ?? "http://127.0.0.1:3000"),
headers: () => {
const headers: Record<string, string> = {
authorization: "Bearer e2e-principal-forwarding-router",
};
// Preview deployments behind Vercel deployment protection need the
// bypass header on the self-call; harmless when protection is off.
const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypass !== undefined && bypass !== "") {
headers["x-vercel-protection-bypass"] = bypass;
}
return headers;
},
forwardPrincipal: true,
}),
},
forwardPrincipal: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { defineEval } from "eve/evals";

/**
* Principal forwarding across a real remote-agent hop, end to end. The
* fixture deployment plays both sides: `remote-loopback` is a
* `defineRemoteAgent({ forwardPrincipal: true })` pointing back at this
* fixture deployment plays both sides: `remote-loopback` dynamically selects
* a `defineRemoteAgent({ forwardPrincipal: true })` pointing back at this
* deployment, whose authored eve channel trusts principals only from the
* hop's `router-app` bearer (`trustedForwarders`).
*
Expand All @@ -24,7 +24,7 @@ const FORWARDED_MARKER =
export default defineEval({
tags: ["real-model"],
description:
"Remote-agent principal forwarding: the child session runs as the parent's end user, with the distinct initiator preserved and the forwarder stamped.",
"Dynamic remote-agent selection and principal forwarding: the child session runs as the parent's end user, with the distinct initiator preserved and the forwarder stamped.",
async test(t) {
await t.send("Reply with the single word: ready.");

Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/compiler/normalize-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ function normalizeDynamicSubagentDefinition(
const record = expectObjectRecord(value, message);
if (Object.hasOwn(record, "fallback")) {
throw new Error(
`${message} Dynamic subagent definitions do not support "fallback". Return defineAgent(...) from an event handler instead.`,
`${message} Dynamic subagent definitions do not support "fallback". Return defineAgent(...) or defineRemoteAgent(...) from an event handler instead.`,
);
}
expectOnlyKnownKeys(record, ["events", "kind"], message);
Expand Down
74 changes: 70 additions & 4 deletions packages/eve/src/context/dynamic-subagent-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "#context/dynamic-subagent-lifecycle.js";
import { SessionDynamicSubagentRuntimeRevisionKey, SessionIdKey } from "#context/keys.js";
import { defineAgent } from "#public/definitions/agent.js";
import { defineRemoteAgent } from "#public/definitions/remote-agent.js";
import { createSessionStartedEvent, createTurnStartedEvent } from "#protocol/message.js";
import type { ResolvedDynamicSubagentResolver } from "#runtime/subagents/registry.js";

Expand Down Expand Up @@ -115,22 +116,87 @@ describe("dynamic subagent lifecycle", () => {
messages: [],
resolvers: [resolver],
});
expect(getDynamicSubagentSelection(ctx, resolver.nodeId)?.agentConfig.model.id).toBe(
"anthropic/claude-sonnet-4.5",
);
const sessionSelection = getDynamicSubagentSelection(ctx, resolver.nodeId);
expect(sessionSelection?.kind).toBe("subagent");
expect(
sessionSelection?.kind === "subagent" ? sessionSelection.agentConfig.model.id : null,
).toBe("anthropic/claude-sonnet-4.5");

await dispatchDynamicSubagentEvent({
ctx,
event: createTurnStartedEvent({ sequence: 0, turnId: "turn-1" }),
messages: [],
resolvers: [resolver],
});
expect(getDynamicSubagentSelection(ctx, resolver.nodeId)?.agentConfig).toMatchObject({
const turnSelection = getDynamicSubagentSelection(ctx, resolver.nodeId);
expect(turnSelection?.kind).toBe("subagent");
expect(turnSelection?.kind === "subagent" ? turnSelection.agentConfig : null).toMatchObject({
description: "Research the request deeply.",
model: { id: "anthropic/claude-opus-4.6" },
});
});

it("exposes a remote subagent with the returned remote config", async () => {
const ctx = createContext();
const created = createResolver();
const auth = vi.fn(async () => ({ headers: { authorization: "Bearer selected" } }));
const headers = vi.fn(async () => ({ "x-tenant": "acme" }));
const remoteAgent = defineRemoteAgent({
auth,
description: "Research on the remote deployment.",
forwardPrincipal: true,
headers,
outputSchema: { properties: { answer: { type: "string" } }, type: "object" },
url: async () => "https://research.example.com",
});
const credentialsFactory = Object.assign(
() => ({ auth: remoteAgent.auth, headers: remoteAgent.headers }),
{ stepId: "eve:dynamic-remote-agent//researcher" },
);
Object.defineProperty(remoteAgent, "__eveResolveRemoteAgentCredentials", {
value: credentialsFactory,
});
const resolver: ResolvedDynamicSubagentResolver = {
...created.resolver,
events: {
"session.started": () => remoteAgent,
},
};

await dispatchDynamicSubagentEvent({
ctx,
event: createSessionStartedEvent(),
messages: [],
resolvers: [resolver],
});

expect(buildDynamicSubagentTools(ctx)).toMatchObject([
{
description: "Research on the remote deployment.",
name: "researcher",
runtimeAction: {
kind: "remote-agent-call",
nodeId: "subagents/researcher",
remoteAgentName: "researcher",
},
},
]);
expect(getDynamicSubagentSelection(ctx, resolver.nodeId)).toMatchObject({
kind: "remote",
remoteAgent: {
credentialsStepId: "eve:dynamic-remote-agent//researcher",
forwardPrincipal: true,
path: "/eve/v1/session",
url: "https://research.example.com",
},
});
expect(auth).not.toHaveBeenCalled();
expect(headers).not.toHaveBeenCalled();
expect(JSON.stringify(getDynamicSubagentSelection(ctx, resolver.nodeId))).not.toContain(
"Bearer selected",
);
});

it("omits an invalid non-null result", async () => {
const ctx = createContext();
const { resolver } = createResolver({
Expand Down
34 changes: 32 additions & 2 deletions packages/eve/src/context/dynamic-subagent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { SessionStartedStreamEvent, UnstampedMessageStreamEvent } from "#pr
import type { ResolvedDynamicSubagentResolver } from "#runtime/subagents/registry.js";
import { createPreparedRuntimeSubagentTool } from "#runtime/subagents/registry.js";
import { normalizeDynamicSubagentAgentConfig } from "#runtime/subagents/dynamic-agent-config.js";
import { normalizeDynamicRemoteAgentConfig } from "#runtime/subagents/dynamic-remote-agent-config.js";
import { toErrorMessage } from "#shared/errors.js";

const log = createLogger("dynamic-subagents");
Expand All @@ -40,21 +41,42 @@ async function resolveSelections(input: {
if (result === null || result === undefined) {
return [resolver.nodeId, null] as const;
}
if (isRemoteAgentDefinition(result)) {
const remoteAgent = await normalizeDynamicRemoteAgentConfig({
name: resolver.name,
value: result,
});
const prepared = createPreparedRuntimeSubagentTool({
description: remoteAgent.description,
kind: "remote",
logicalPath: resolver.logicalPath,
name: resolver.name,
nodeId: resolver.nodeId,
outputSchema: remoteAgent.outputSchema,
path: remoteAgent.path,
sourceId: resolver.sourceId,
sourceKind: resolver.sourceKind,
url: remoteAgent.url,
});

return [resolver.nodeId, { kind: "remote", prepared, remoteAgent }] as const;
}

const agentConfig = normalizeDynamicSubagentAgentConfig({
name: resolver.name,
value: result,
});
const prepared = createPreparedRuntimeSubagentTool({
description: agentConfig.description,
kind: resolver.kind,
kind: "subagent",
logicalPath: resolver.logicalPath,
name: resolver.name,
nodeId: resolver.nodeId,
sourceId: resolver.sourceId,
sourceKind: resolver.sourceKind,
});

return [resolver.nodeId, { agentConfig, prepared }] as const;
return [resolver.nodeId, { agentConfig, kind: "subagent", prepared }] as const;
}),
);
const selections: Record<string, DurableDynamicSubagentSelection> = {};
Expand All @@ -76,6 +98,14 @@ async function resolveSelections(input: {
return selections;
}

function isRemoteAgentDefinition(value: unknown): boolean {
return (
typeof value === "object" &&
value !== null &&
(value as { readonly kind?: unknown }).kind === "remote"
);
}

export async function dispatchDynamicSubagentEvent(input: {
readonly ctx: ContextContainer;
readonly event: UnstampedMessageStreamEvent;
Expand Down
19 changes: 15 additions & 4 deletions packages/eve/src/context/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
import { ContextKey } from "#context/key.js";
import type { HarnessToolDefinition } from "#harness/execute-tool.js";
import type { DynamicSubagentAgentConfig } from "#runtime/subagents/dynamic-agent-config.js";
import type { DynamicRemoteAgentConfig } from "#runtime/subagents/dynamic-remote-agent-config.js";
import type { SandboxAccess } from "#sandbox/state.js";
import type { RunMode } from "#shared/run-mode.js";
import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js";
Expand Down Expand Up @@ -167,10 +168,20 @@ export const TurnDynamicToolMetadataKey = new ContextKey<readonly DurableDynamic
*/
export const LiveStepToolsKey = new ContextKey<HarnessToolDefinition[]>("eve.liveStepTools");

export type DurableDynamicSubagentSelection = {
readonly agentConfig: DynamicSubagentAgentConfig;
readonly prepared: PreparedRuntimeDelegationTool;
} | null;
export type DurableDynamicSubagentSelection =
| {
readonly agentConfig: DynamicSubagentAgentConfig;
readonly kind: "subagent";
readonly prepared: PreparedRuntimeDelegationTool;
readonly remoteAgent?: never;
}
| {
readonly agentConfig?: never;
readonly kind: "remote";
readonly prepared: PreparedRuntimeDelegationTool;
readonly remoteAgent: DynamicRemoteAgentConfig;
}
| null;

export const SessionDynamicSubagentSelectionsKey = new ContextKey<
Readonly<Record<string, DurableDynamicSubagentSelection>>
Expand Down
Loading
Loading