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
7 changes: 7 additions & 0 deletions .changeset/cli-bridge-continuation-honesty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@tangle-network/agent-provider-cli-bridge": patch
---

State which call can add a turn to a reconstructed environment.
The README carries the two-tier continuation rule, one refusal replaces three near-copies of the same rule, and the message names `session.continueNative` and the capability it needs.
The contract test covers the `already_resolved_different`, `already_resolved_same`, and `cancelled` interaction acknowledgements against the real Bridge.
25 changes: 23 additions & 2 deletions packages/agent-provider-cli-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ The provider rejects any coordinate or digest mismatch instead of attaching to a
If the caller crashes before saving `controlRef`, call `provider.lookupRun()` with the planned run coordinates.
The lookup returns the server-issued digest only when all five planned coordinates match the retained run.

## Which call adds a turn

Continuation has two tiers, and the tier depends on whether the environment object came from `create()` or from `get()`.

In the process that created the environment, `environment.dispatch()`, `environment.stream()`, and `session.prompt()` all add a turn.
Passing the same `sessionId` continues the same CLI conversation.

After `provider.get(environmentId)` reconstructs the environment, only `session.continueNative(request, { turn })` can add a turn.
It requires the `nativeContinuation` capability, which the provider publishes only for the Pi harness today, and only when the running Bridge proves the same contract.
The reconstructed environment deliberately refuses every other way to start work: `environment.stream()` and `environment.dispatch()` throw "a reconstructed cli-bridge environment can only control an existing run", and `session.prompt()` throws "a reconstructed cli-bridge session cannot start another turn".
A reconstructed environment for any other harness therefore controls its existing runs (status, cursor replay, result, cancellation, and interaction responses) but adds no turn.
Restart-safe continuation for those harnesses needs native sessions in the Bridge server itself, which the Bridge does not have yet; it is tracked in the `drewstone/cli-bridge` repository and cannot be reached from this adapter.

The capability document keeps `sessions.continue: true` on a reconstructed environment because the Agent Interface couples that flag to `retainedControl`: a document that reports `sessions.continue: false` while keeping retained run control fails `AgentEnvironmentCapabilitiesSchema` with "retained control requires exact run, result, event, cancellation, replay, detach, turn, and session identity together".
`nativeContinuation` is the flag that states whether a reconstructed environment can add a turn: present means `continueNative` works, absent means no call on that environment starts one.

A runtime caller continues a retained run with `RetainedRunHandle.continueNative`, not with `startRetainedRunInEnvironment`.
`startRetainedRunInEnvironment` requires `provider.list` for its ownership proof, and this provider exposes no `list`; it then calls `environment.dispatch()`, which a reconstructed environment refuses.

The bridge model is selected from run data in this order: the turn, the provider default, or the profile's `harness` plus `model.default`.
Execution fails before network use when none is present.

Expand All @@ -59,7 +78,7 @@ Retained native sessions currently require `kind: 'host'`.
The provider rejects a sandbox default before it creates the retained session; use one-shot execution for `kind: 'sandbox'`.
The Bridge currently refuses `netJail` with `kind: 'sandbox'`; use the sandbox's own egress policy.

Passing the same `sessionId` on later turns continues the same CLI conversation.
Passing the same `sessionId` on later turns continues the same CLI conversation while the creating process still holds the environment.
`executionId` gives a turn stable bridge identity, and `lastEventId` reattaches after a reader failure.
`dispatch()` starts a bridge-owned durable run and returns after detaching its HTTP reader.
The returned `AgentSession` exposes status, cursor-based event replay, the terminal result, continuation, and cancellation.
Expand All @@ -79,7 +98,7 @@ Replay and result reads fail loudly after cli-bridge's configured replay retenti
Stopping a `session.events()` reader detaches only that replay observer.
Stopping a direct `environment.stream()` reader or destroying the environment cancels its active bridge runs and waits for terminal confirmation.

Pi retained sessions also expose typed permission interactions.
Pi retained sessions also expose typed permission interactions and native continuation.
The provider stores the selected harness, exact model route, and canonical create digest inside its opaque environment identifier.
This identifier lets `provider.get()` reconstruct profile-selected routes after process death without a cache or repeated configuration.
The create digest prevents an altered profile or workspace from reusing the previous retained identity.
Expand All @@ -96,6 +115,8 @@ The environment and session then expose `respondToInteraction()` for exact respo
Each command carries its run, session, execution, request digest, and stable operation identifier.
Native turns require caller-stable `turnId` and `executionId` values so an ambiguous admission cannot create another run.
The bridge records repeated operations and rejects a different answer for an existing operation.
Repeating one operation identifier returns its stored acknowledgement; a new operation identifier for an interaction that is already resolved returns `already_resolved_same` when the answer matches and `already_resolved_different` when it does not, and answering an interaction that a run cancellation closed returns `cancelled`.
The `expired` acknowledgement stays in the contract, but the Bridge never emits it, so only the unit contradiction table covers it.
The provider reports an unconfirmed network result as retryable and never reports it as accepted.
Native replay reads `/v1/runs/:runId/events` and validates each canonical envelope through Agent Interface before exposing it.
When a turn supplies `interactions`, the provider carries its exact map, including an explicit `{}`; an omitted map remains omitted.
Expand Down
65 changes: 65 additions & 0 deletions packages/agent-provider-cli-bridge/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type AgentProfile,
} from "@tangle-network/agent-interface";
import type { AgentEnvironment } from "@tangle-network/agent-interface/environment-provider";
import { AgentEnvironmentCapabilitiesSchema } from "@tangle-network/agent-interface/environment-provider";
import { describe, expect, it } from "vitest";
import { createCliBridgeProvider, defaultCliBridgeCapabilities } from "./index.js";
import { cliBridgeEnvironmentId } from "./environment-identity.js";
Expand Down Expand Up @@ -803,6 +804,70 @@ describe("createCliBridgeProvider", () => {
expect(called).toBe(false);
});

it.each([
{ harness: "codex" as const, addsTurn: false },
{ harness: "pi" as const, addsTurn: true },
])(
"publishes on a reconstructed $harness environment exactly which call can add a turn",
async ({ harness, addsTurn }) => {
const model = `${harness}/reconstruction-model`;
const provider = createCliBridgeProvider({
baseUrl: "http://bridge.local",
defaultModel: model,
fetch: async (url) => {
if (new URL(String(url)).pathname === "/v1/capabilities") {
return Response.json(defaultCliBridgeCapabilities(harness));
}
throw new Error(`unexpected request to ${String(url)}`);
},
});
const created = await provider.create({
idempotencyKey: "reconstruction-environment",
profile: { name: "worker", harness },
});

const reconstructed = await provider.get!(created.id);
if (!reconstructed) throw new Error("the provider did not reconstruct the environment");
const capabilities = reconstructed.capabilities;
if (!capabilities) throw new Error("the reconstructed environment published no capabilities");

// `nativeContinuation` is the flag that states whether a reconstructed
// environment can add a turn.
expect(capabilities.nativeContinuation).toEqual(
addsTurn ? { atomicBoundary: true, requestIdempotency: true } : undefined,
);
// `sessions.continue` cannot carry that answer: the Agent Interface
// couples it to retained run control, which this environment keeps.
expect(capabilities.sessions.continue).toBe(true);
expect(capabilities.retainedControl).toBeDefined();
expect(
AgentEnvironmentCapabilitiesSchema.safeParse({
...capabilities,
sessions: { ...capabilities.sessions, continue: false },
}).success,
).toBe(false);

const session = reconstructed.session!("reconstruction-session", {
controlRef: {
runId: "reconstruction-run",
provider: "cli-bridge",
environmentId: reconstructed.id,
sessionId: "reconstruction-session",
executionId: "reconstruction-execution",
requestDigest: testDigest("reconstruction-run"),
},
});
await expect(reconstructed.dispatch!({ prompt: "add a turn" })).rejects.toThrow(
/cannot start a turn/,
);
await expect(consume(reconstructed)).rejects.toThrow(/cannot start a turn/);
await expect(session.prompt!({ prompt: "add a turn" })).rejects.toThrow(
/cannot start a turn/,
);
expect(typeof session.continueNative === "function").toBe(addsTurn);
},
);

it("returns null when an exact retained run does not exist", async () => {
const provider = createCliBridgeProvider({
baseUrl: "http://bridge.local",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,10 @@ describe("CLI Bridge interaction responses", () => {

it.each([
[400, "invalid_response", false],
[409, "already_resolved_different", false],
[409, "binding_mismatch", false],
[409, "cancelled", false],
[409, "expired", false],
[429, "transport_failure", true],
[502, "transport_failure", true],
] as const)("classifies HTTP %i %s as retryable=%s", async (status, acknowledgementStatus, retryable) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,14 @@ describe("cli-bridge native continuation", () => {
expect(environment.creation).toBe("created");
expect(reconstructedEnvironment.creation).toBeUndefined();
await expect(reconstructedEnvironment.dispatch!({ prompt: "must not dispatch" })).rejects.toThrow(
/cannot dispatch new work/,
/cannot start a turn through environment\.dispatch\(\)/,
);
const reconstructed = reconstructedEnvironment.session!(initialControlRef.sessionId, {
controlRef: initialControlRef,
});
expect(reconstructed.prompt).toBeDefined();
await expect(reconstructed.prompt!({ prompt: "must not dispatch" })).rejects.toThrow(
/cannot start another turn/,
/cannot start a turn through session\.prompt\(\)/,
);
await expect(reconstructed.contextBoundary!()).resolves.toEqual(boundary);

Expand Down
26 changes: 17 additions & 9 deletions packages/agent-provider-cli-bridge/src/retained-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ export interface CreateCliBridgeEnvironmentArgs {
readonly selectedModel?: string;
}

/**
* One refusal for every call that would add a turn to a reconstructed
* environment. `provider.get()` rebuilds control of existing runs from the
* opaque environment id alone, so it holds no create request that a new native
* session could reuse. Only `session.continueNative` advances a retained run
* after reconstruction, and only where the Bridge proves `nativeContinuation`.
*/
function assertCliBridgeTurnAllowed(allowed: boolean, surface: string): void {
if (allowed) return;
throw new Error(
`a reconstructed cli-bridge environment cannot start a turn through ${surface}; use session.continueNative, which requires the nativeContinuation capability`,
);
}

export function createCliBridgeEnvironment(
args: CreateCliBridgeEnvironmentArgs,
): AgentEnvironment {
Expand Down Expand Up @@ -112,9 +126,7 @@ export function createCliBridgeEnvironment(
turn: AgentTurnInput,
): AsyncIterable<AgentEnvironmentEvent> {
if (destroyed) throw new Error("cli-bridge environment is destroyed");
if (!args.allowDispatch) {
throw new Error("a reconstructed cli-bridge environment can only control an existing run");
}
assertCliBridgeTurnAllowed(args.allowDispatch, "environment.stream()");
if (
args.selectedModel !== undefined &&
args.capabilities.retainedControl !== undefined
Expand Down Expand Up @@ -168,9 +180,7 @@ export function createCliBridgeEnvironment(
stream,
async dispatch(turn) {
if (destroyed) throw new Error("cli-bridge environment is destroyed");
if (!args.allowDispatch) {
throw new Error("a reconstructed cli-bridge environment cannot dispatch new work");
}
assertCliBridgeTurnAllowed(args.allowDispatch, "environment.dispatch()");
if (
args.selectedModel !== undefined &&
args.capabilities.retainedControl !== undefined
Expand Down Expand Up @@ -419,9 +429,7 @@ function createCliBridgeSession(args: CreateCliBridgeSessionArgs): AgentSession
return result;
},
async prompt(input: AgentTurnInput): Promise<AgentTurnResult> {
if (!args.allowPrompt) {
throw new Error("a reconstructed cli-bridge session cannot start another turn");
}
assertCliBridgeTurnAllowed(args.allowPrompt, "session.prompt()");
if (input.sessionId && input.sessionId !== args.id) {
throw new Error(
`cli-bridge session "${args.id}" cannot prompt session "${input.sessionId}"`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,40 @@ describeActualBridge("actual cli-bridge native interaction contract", () => {
"durable interaction response request",
).toBe(true);

// The same interaction, answered again under a new operation identifier.
// The Bridge compares the response digest, not the operation, so a
// different answer is a conflict and an identical answer is the same
// resolution.
const resolvedAgain = async (
operationId: string,
response: InteractionResponse,
): Promise<InteractionAcknowledgement> => {
const binding = command.binding;
return environment.respondToInteraction!({
operationId,
binding,
response,
commandDigest: interactionResponseCommandDigest({ binding, response }),
});
};
await expect(
resolvedAgain("sdk-cli-bridge-integration-conflicting-response", {
id: interaction.id,
outcome: "accepted",
data: { grant: ["deny"] },
}),
).resolves.toMatchObject({
status: "already_resolved_different",
operationId: "sdk-cli-bridge-integration-conflicting-response",
retryable: false,
});
await expect(
resolvedAgain("sdk-cli-bridge-integration-identical-response", command.response),
).resolves.toMatchObject({
status: "already_resolved_same",
operationId: "sdk-cli-bridge-integration-identical-response",
});

const replayed: AgentEnvironmentEvent[] = [];
for await (const event of session.events({ since: "0" })) replayed.push(event);
expect(replayed.map((event) => event.id)).toEqual(firstEvents.map((event) => event.id));
Expand Down Expand Up @@ -304,6 +338,70 @@ describeActualBridge("actual cli-bridge native interaction contract", () => {
}
}, 30_000);

it("answers a cancelled interaction with the cancelled acknowledgement", async () => {
const bridge = await startActualBridge();
try {
const sessionId = "sdk-cli-bridge-cancelled-session";
const provider = createCliBridgeProvider({
baseUrl: bridge.baseUrl,
defaultModel: "pi/tangle-router/sdk-integration-model",
});
const environment = await provider.create({
idempotencyKey: "sdk-cli-bridge-cancelled-environment",
profile: { name: "integration", harness: "pi" },
workspace: { cwd: bridge.projectDir },
});
const reference = await environment.dispatch!({
prompt: "pause on a permission this run never answers",
sessionId,
turnId: "sdk-cli-bridge-cancelled-turn",
executionId: "sdk-cli-bridge-cancelled-run",
interactions: { permission: true },
});
const controlRef = reference.controlRef as AgentExactRunControlRef | undefined;
if (!controlRef) throw new Error("the actual Bridge did not return an exact native control reference");
const session = environment.session!(sessionId, { controlRef });

let interaction: InteractionRequest | undefined;
for await (const event of session.events({ since: "0" })) {
if (event.normalized?.type !== "interaction") continue;
interaction = event.normalized.request;
break;
}
if (!interaction) throw new Error("the actual Bridge did not pause on a native interaction");

// The permission is still outstanding, so cancelling the run closes it
// before any response can take effect.
await session.cancel();
await expect(session.status()).resolves.toBe("cancelled");

const binding = { ...interaction.binding, requestDigest: interaction.requestDigest };
const response: InteractionResponse = {
id: interaction.id,
outcome: "accepted",
data: { grant: ["allow_once"] },
};
await expect(
environment.respondToInteraction!({
operationId: "sdk-cli-bridge-cancelled-response",
binding,
response,
commandDigest: interactionResponseCommandDigest({ binding, response }),
}),
).resolves.toMatchObject({
status: "cancelled",
operationId: "sdk-cli-bridge-cancelled-response",
retryable: false,
});
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nActual Bridge output:\n${bridge.logs()}`,
);
} finally {
await bridge.stop();
}
}, 30_000);

it("continues one live Pi session after a provider restart and replays the exact request", async () => {
const bridge = await startActualBridge();
const requests: Array<{
Expand Down
Loading