Skip to content
Open
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/remote-empty-output-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Remote-agent dispatch now drops a model-supplied empty `outputSchema: {}` instead of forwarding it, matching local delegation. An empty object previously forced the remote session into structured-output task completion, which failed the call with `OUTPUT_SCHEMA_NOT_FULFILLED` even though the callee produced a correct prose result.
5 changes: 5 additions & 0 deletions .changeset/remote-terminal-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": minor
---

Remote-agent session callbacks now carry an event envelope discriminated by `event.status`. The terminal callback (`status: "termination"`, `session.completed`/`session.failed`) resumes the parked parent turn as before; `"notification"`, `"working"`, and `"input_required"` are reserved on the wire and rejected by the terminal route for now. The callback wire shape changed: caller and callee deployments must both run this version.
6 changes: 6 additions & 0 deletions docs/guides/remote-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ A local subagent runs inline. A remote one runs in its own deployment, so dispat

The parent stream carries the same `subagent.called`, `action.result`, and `subagent.completed` events as local delegation. For a remote call, `subagent.called.data.remote.url` records the target.

Each callback POST carries one event, classed by `event.status`:

- `"termination"` — the remote completed or failed. Exactly one termination event ends every remote call; this is the callback that resumes the parked parent with the result. It is the durable control lane: delivery resumes the parent turn exactly once and is never dropped.

`"notification"` (informational events the parent surfaces without resolving the call), `"working"`, and `"input_required"` are reserved statuses on the wire vocabulary; the terminal callback route rejects them today. The `"notification"` relay lane is added separately.

Cancelling the parent while a remote call is active sends an authenticated `POST /eve/v1/session/:childSessionId/cancel` to the remote and waits for that request to be accepted before the parent settles. eve resolves the remote's `headers` and `auth` again for every cancellation attempt, so rotating credentials work the same way as they do for session creation. Cancellation always uses the standard eve cancel path on `url`, even when `path` customizes only the create-session endpoint. The remote child reports `turn.cancelled` → `session.waiting` on its own stream; an older or unreachable remote is logged but cannot turn the parent's cancellation into a failure.

Both failure paths surface to the parent as a failed tool result, so the caller can explain or recover within the same session. A failed _start_ returns the error inline. A remote that starts and then fails posts a terminal failure callback, which the parent receives as an errored subagent result carrying the remote's error (or `REMOTE_AGENT_FAILED` when none is supplied). Terminal callback delivery runs as a durable step on the underlying workflow engine (see [Execution model & durability](../concepts/execution-model-and-durability)). A failed callback POST is rethrown rather than marking the task complete, so the engine retries it.
Expand Down
24 changes: 13 additions & 11 deletions packages/eve/src/channel/session-callback.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { parseSessionCallback } from "#channel/session-callback.js";
import { parseCallbackMetadata } from "#channel/session-callback.js";

function createCallback(url: string, token = "tok123"): Record<string, unknown> {
return {
Expand All @@ -11,32 +11,32 @@ function createCallback(url: string, token = "tok123"): Record<string, unknown>
};
}

describe("parseSessionCallback callback-URL token extraction", () => {
describe("parseCallbackMetadata callback-URL token extraction", () => {
it("accepts a callback URL mounted at the origin root", () => {
expect(
parseSessionCallback(createCallback("https://agent.example.com/eve/v1/callback/tok123")),
parseCallbackMetadata(createCallback("https://agent.example.com/eve/v1/callback/tok123")),
).toMatchObject({ ok: true });
});

it("accepts a callback URL mounted behind a public route prefix", () => {
expect(
parseSessionCallback(
parseCallbackMetadata(
createCallback("https://agent.example.com/eve/agents/support/eve/v1/callback/tok123"),
),
).toMatchObject({ ok: true });
});

it("reads the token from the last callback route segment", () => {
expect(
parseSessionCallback(
parseCallbackMetadata(
createCallback("https://agent.example.com/eve/v1/callback/x/eve/v1/callback/tok123"),
),
).toMatchObject({ ok: true });
});

it("decodes the encoded token segment before comparing", () => {
expect(
parseSessionCallback(
parseCallbackMetadata(
createCallback(
"https://agent.example.com/eve/agents/support/eve/v1/callback/eve%3Aparent-token",
"eve:parent-token",
Expand All @@ -47,7 +47,7 @@ describe("parseSessionCallback callback-URL token extraction", () => {

it("rejects a URL without the callback route", () => {
expect(
parseSessionCallback(
parseCallbackMetadata(
createCallback("https://agent.example.com/eve/agents/support/eve/v1/session"),
),
).toMatchObject({
Expand All @@ -58,27 +58,29 @@ describe("parseSessionCallback callback-URL token extraction", () => {

it("rejects a URL whose token segment does not match the token field", () => {
expect(
parseSessionCallback(
parseCallbackMetadata(
createCallback("https://agent.example.com/eve/agents/support/eve/v1/callback/other-token"),
),
).toMatchObject({ ok: false });
});

it("rejects an empty token segment", () => {
expect(
parseSessionCallback(createCallback("https://agent.example.com/eve/v1/callback/")),
parseCallbackMetadata(createCallback("https://agent.example.com/eve/v1/callback/")),
).toMatchObject({ ok: false });
});

it("rejects extra path segments after the token", () => {
expect(
parseSessionCallback(createCallback("https://agent.example.com/eve/v1/callback/tok123/more")),
parseCallbackMetadata(
createCallback("https://agent.example.com/eve/v1/callback/tok123/more"),
),
).toMatchObject({ ok: false });
});

it("rejects a token segment with invalid percent-encoding", () => {
expect(
parseSessionCallback(createCallback("https://agent.example.com/eve/v1/callback/%E0%A4%A")),
parseCallbackMetadata(createCallback("https://agent.example.com/eve/v1/callback/%E0%A4%A")),
).toMatchObject({ ok: false });
});
});
118 changes: 116 additions & 2 deletions packages/eve/src/channel/session-callback.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,122 @@
import { z } from "#compiled/zod/index.js";

import type { SessionCallback } from "#channel/types.js";
import type { SessionCallback, SubagentAuthorizationEvent } from "#channel/types.js";
import { createEveCallbackRoutePath } from "#protocol/routes.js";
import type { JsonValue } from "#shared/json.js";
import { isReservedIpAddress } from "#shared/network-address.js";
import type { TokenUsage } from "#shared/token-usage.js";

/**
* Status classes a session callback event can carry.
*
* The callee POSTs one callback per event to the caller's callback URL;
* `status` is the coarse class the caller routes on. `"working"`
* (progress) and `"input_required"` (proxied HITL) are declared so the
* vocabulary is stable, but are not yet emitted or accepted.
*/
export type SessionCallbackEventStatus =
| "input_required"
| "notification"
| "termination"
| "working";

/**
* Terminal callback event: the callee session completed or failed.
* Exactly one termination event ends every callback stream.
*
* `usage` — the callee session's token totals — rides along on completed
* events so the caller can attribute the callee's spend. Failed events
* never carry usage.
*/
export type SessionCallbackTerminationEvent =
| {
readonly kind: "session.completed";
readonly output: JsonValue;
readonly status: "termination";
readonly usage?: TokenUsage;
}
| {
readonly error: JsonValue;
readonly kind: "session.failed";
readonly status: "termination";
};

/**
* Informational callback event: a stream event the caller should surface
* without resolving or blocking the pending call. Carries the callee's
* authorization lifecycle events — the callback-URL analog of the local
* subagent adapter's `subagent-authorization-event` forwarding.
*/
export type SessionCallbackNotificationEvent = SubagentAuthorizationEvent & {
readonly status: "notification";
};

/**
* One event on the session callback wire contract, discriminated by
* {@link SessionCallbackEventStatus}.
*/
export type SessionCallbackEvent =
| SessionCallbackNotificationEvent
| SessionCallbackTerminationEvent;

/**
* Body of one callback POST from a callee session to its caller's
* callback URL. `callId` and `subagentName` correlate the event to the
* pending caller tool call; `sessionId` is the callee session.
*/
export interface SessionCallbackPayload {
readonly callId: string;
readonly event: SessionCallbackEvent;
readonly sessionId: string;
readonly subagentName: string;
}

const authorizationChallengeSchema = z.object({
displayName: z.string().optional(),
expiresAt: z.string().optional(),
instructions: z.string().optional(),
url: z.string().optional(),
userCode: z.string().optional(),
});

const authorizationRequiredNotificationSchema = z.object({
data: z.object({
authorization: authorizationChallengeSchema.optional(),
description: z.string(),
name: z.string(),
sequence: z.number(),
stepIndex: z.number(),
turnId: z.string(),
webhookUrl: z.string().optional(),
}),
status: z.literal("notification"),
type: z.literal("authorization.required"),
});

const authorizationCompletedNotificationSchema = z.object({
data: z.object({
authorization: authorizationChallengeSchema.optional(),
name: z.string(),
outcome: z.enum(["authorized", "declined", "failed", "timed-out"]),
reason: z.string().optional(),
sequence: z.number(),
stepIndex: z.number(),
turnId: z.string(),
}),
status: z.literal("notification"),
type: z.literal("authorization.completed"),
});

/**
* Schema for one `status: "notification"` callback event.
*
* The event arrives from a remote callee that may run a different eve
* version and is re-emitted on the caller's stream, so it is validated
* to the exact shapes the caller knows how to re-emit; unknown keys are
* stripped rather than passed through.
*/
export const sessionCallbackNotificationEventSchema: z.ZodType<SessionCallbackNotificationEvent> =
z.union([authorizationRequiredNotificationSchema, authorizationCompletedNotificationSchema]);

export type SessionCallbackParseResult =
| {
Expand Down Expand Up @@ -56,7 +170,7 @@ const sessionCallbackSchema = z
}
});

export function parseSessionCallback(value: unknown): SessionCallbackParseResult {
export function parseCallbackMetadata(value: unknown): SessionCallbackParseResult {
const parsed = sessionCallbackSchema.safeParse(value);
if (parsed.success) {
return { callback: parsed.data, ok: true };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { join } from "node:path";

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";

import { parseSessionCallback } from "#channel/session-callback.js";
import { parseCallbackMetadata } from "#channel/session-callback.js";
import { ContextContainer, contextStorage } from "#context/container.js";
import { SessionCallbackKey, SessionIdKey } from "#context/keys.js";
import { fireSessionCallbackStep } from "#execution/session-callback-step.js";
Expand Down Expand Up @@ -35,7 +35,7 @@ import type { HarnessSession } from "#harness/types.js";
* remote-subagent session callback (`startRemoteAgentSession`) and the
* connection hook URL (`getHookUrl`);
* 3. the remote side validates the callback metadata with the real
* create-session parser (`parseSessionCallback`) and posts the terminal
* create-session parser (`parseCallbackMetadata`) and posts the terminal
* result with the real durable step (`fireSessionCallbackStep`);
* 4. each parent service only serves its own prefix-stripped `/eve/v1/*`
* callback routes — anything else 404s, matching production.
Expand Down Expand Up @@ -313,7 +313,7 @@ describe("multi-agent callback routing", () => {

// Remote-side create-session validation (public/channels/eve.ts):
// the prefixed URL must parse, or the remote rejects with HTTP 400.
expect(parseSessionCallback(callback)).toMatchObject({ ok: true });
expect(parseCallbackMetadata(callback)).toMatchObject({ ok: true });

// Remote-side terminal POST (the step that failed with HTTP 404 in
// production) must reach the parent service through the deployment
Expand Down
66 changes: 66 additions & 0 deletions packages/eve/src/execution/remote-agent-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,72 @@ describe("startRemoteAgentSession", () => {
expect(body.mode).toBe("task");
});

it("drops a model-supplied empty outputSchema instead of forwarding it", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true, sessionId: "remote-session" }), {
headers: { "x-eve-session-id": "remote-session-header" },
status: 202,
}),
);
vi.stubGlobal("fetch", fetchMock);

await startRemoteAgentSession({
action: {
...createAction(),
input: { message: "find the marker", outputSchema: {} },
},
callbackBaseUrl: "https://caller.example.com",
remote: createRemoteAgent(),
session: {
agent: { modelReference: { id: "mock/test" }, system: "", tools: [] },
compaction: { recentWindowSize: 10, threshold: 100000 },
continuationToken: "eve:parent-token",
history: [],
sessionId: "parent-session",
state: {},
},
});

const body = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
expect("outputSchema" in body).toBe(false);
});

it("falls back to the declared outputSchema when the model sends an empty one", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true, sessionId: "remote-session" }), {
headers: { "x-eve-session-id": "remote-session-header" },
status: 202,
}),
);
vi.stubGlobal("fetch", fetchMock);

const outputSchema = {
properties: { answer: { type: "string" } },
required: ["answer"],
type: "object",
} as const;

await startRemoteAgentSession({
action: {
...createAction(),
input: { message: "find the marker", outputSchema: {} },
},
callbackBaseUrl: "https://caller.example.com",
remote: { ...createRemoteAgent(), outputSchema },
session: {
agent: { modelReference: { id: "mock/test" }, system: "", tools: [] },
compaction: { recentWindowSize: 10, threshold: 100000 },
continuationToken: "eve:parent-token",
history: [],
sessionId: "parent-session",
state: {},
},
});

const body = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
expect(body.outputSchema).toEqual(outputSchema);
});

it("targets an active turn inbox when a callback token is supplied", async () => {
const fetchMock = vi
.fn()
Expand Down
13 changes: 12 additions & 1 deletion packages/eve/src/execution/remote-agent-dispatch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EVE_SESSION_ID_HEADER } from "#protocol/message.js";
import { isJsonObjectValue, type JsonValue } from "#shared/json.js";
import { CancelTurnResponseSchema } from "#protocol/cancel-turn.js";
import { createEveCallbackRoutePath, createEveCancelTurnRoutePath } from "#protocol/routes.js";
import type { CancelTurnResult } from "#channel/types.js";
Expand Down Expand Up @@ -49,7 +50,7 @@ export async function startRemoteAgentSession(input: {
message: formatRemoteAgentCallInputMessage({ action: input.action, remote: input.remote }),
mode: "task",
outputSchema:
(input.action.input.outputSchema as object | undefined) ?? input.remote.outputSchema,
requestedRemoteOutputSchema(input.action.input.outputSchema) ?? input.remote.outputSchema,
}),
headers: {
"content-type": "application/json",
Expand Down Expand Up @@ -185,6 +186,16 @@ function formatRemoteAgentCallInputMessage(input: {
}).message;
}

/**
* A model-supplied per-call schema only counts when it is a non-empty JSON
* object — models routinely emit `outputSchema: {}` for the optional param.
* Mirrors the local-delegation filter in `subagent-tool.ts`, so an empty
* object never forces the remote into structured-output task completion.
*/
function requestedRemoteOutputSchema(value: JsonValue | undefined): object | undefined {
return isJsonObjectValue(value) && Object.keys(value).length > 0 ? value : undefined;
}

function trimTrailingSlash(value: string): string {
return value.endsWith("/") ? value.slice(0, -1) : value;
}
Loading