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
2 changes: 1 addition & 1 deletion docs/api/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -6316,7 +6316,7 @@ Stable provider environment identifier used by `provider.get`.

> `readonly` **idempotencyKey**: `string`

Original environment key retained for deterministic run identity and recovery records.
Original environment key. The provider must return the matching retained metadata.

##### turn

Expand Down
2 changes: 1 addition & 1 deletion docs/canonical-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ A general "loop" primitive is the single most common modelling error in this rep
| Run **agent-eval fixture folders** through Runtime `runAgentRounds` | agent-eval fixture loading/planning, then `loopCampaignDispatch(...)`: `/kernel`; it starts the Runtime cell inside Eval's paid-call lifecycle | a one-off `runCampaign` dispatch, or attaching a completed `LoopResult` after paid work already ran |
| Run a **recursive `supervise()` tree** through an agent-eval profile matrix | `superviseDispatch({ toTask, toSuperviseOptions, ... })`: `/kernel`; it admits the tree through Eval before Runtime spends, then records its receipt only when Runtime proves one model. Mixed or unknown trees fail instead of being relabelled. | a Lab receipt mapper, a second scheduler, or attaching a completed `SupervisedResult` after paid work already ran |
| Run + **resume** ONE persistent box across turns | `openSandboxRun(client, opts, deliverable)`: `/kernel` | a per-domain `new Sandbox`+`box.fs.read`+delete copy |
| Start a retry-safe detached run in a new environment, or a fresh harness chat in one existing environment | `startRetainedRun(...)` or `startRetainedRunInEnvironment(...)`: `/kernel`; both persist exact coordinates before and after dispatch, while only `continueNative(...)` may claim same-chat continuity | calling `provider.create/get/dispatch` directly, reusing an environment as proof of chat continuity, or appending to an unverified native session |
| Start a retry-safe detached run in a new environment, or a fresh harness chat in one existing environment | `startRetainedRun(...)` or `startRetainedRunInEnvironment(...)`: `/kernel`; both persist exact coordinates before and after dispatch; the existing-environment path also verifies its retained key through provider metadata; only `continueNative(...)` may claim same-chat continuity | calling `provider.create/get/dispatch` directly, reusing an environment as proof of chat continuity, or appending to an unverified native session |
| Run **ONE agent turn** on any substrate: box (`streamPrompt`), cli-bridge/router `Executor`, or in-process chat backend: as ONE normalized `RuntimeStreamEvent` stream with a guaranteed terminal result+usage event; opt into in-stream `tool_call`/`tool_result` with `preserveToolParts`, or tap the raw sandbox events with `onRawEvent` | `streamAgentTurn(backend, prompt, { signal, timeoutMs, preserveToolParts?, onRawEvent? })` + `collectAgentTurn(stream)`: `/kernel` | a per-provider stream→event mapper zoo, a hand-faked box around a non-box executor, or raw fetch leaking through the turn abstraction |
| Use an exact profile and Runtime executor where `runAgentTaskStream` or a conversation expects an `AgentExecutionBackend` | `createProfileExecutionBackend({ profile, executor: createExecutor(config) })`: root `.`; the adapter preserves conversation authorization, recursion-depth, and trace headers | a provider-specific backend constructor or an adapter that reads a second model/prompt configuration |
| Pick the **execution transport a driven loop runs on** (`sandbox` box / cli-bridge / router) from a product flag | `resolveSandboxClient({ backend })`: `/kernel` | a per-product `if (backend === 'router') …` branch re-wiring `createExecutor` + `inlineSandboxClient` |
Expand Down
28 changes: 28 additions & 0 deletions src/runtime/retained-run-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ export async function startRetainedRunInEnvironment(
if (!environment.dispatch || !environment.session) {
throw new Error(`provider "${options.provider.name}" does not expose detached session control`)
}
await assertRetainedEnvironmentOwnership(
options.provider,
environment.id,
options.environment.idempotencyKey,
)

return dispatchRetainedRun({
provider: options.provider,
Expand All @@ -175,6 +180,29 @@ export async function startRetainedRunInEnvironment(
})
}

async function assertRetainedEnvironmentOwnership(
provider: AgentEnvironmentProvider,
environmentId: string,
idempotencyKey: string,
): Promise<void> {
if (!provider.list) {
throw new Error(
`provider "${provider.name}" cannot prove retained environment ownership by metadata`,
)
}
const summaries = await provider.list({
metadata: { retainedIdempotencyKey: idempotencyKey },
})
const matches = summaries.filter(
(summary) => summary.id === environmentId && summary.provider === provider.name,
)
if (matches.length !== 1 || matches[0]?.metadata?.retainedIdempotencyKey !== idempotencyKey) {
throw new Error(
`provider "${provider.name}" could not bind environment "${environmentId}" to its retained idempotency key`,
)
}
}

interface DispatchRetainedRunOptions {
readonly provider: AgentEnvironmentProvider
readonly environment: AgentEnvironment
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/retained-run-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export interface StartRetainedRunInEnvironmentOptions {
readonly environment: {
/** Stable provider environment identifier used by `provider.get`. */
readonly id: string
/** Original environment key retained for deterministic run identity and recovery records. */
/** Original environment key. The provider must return the matching retained metadata. */
readonly idempotencyKey: string
}
readonly turn: AgentTurnInput & { turnId: string }
Expand Down
52 changes: 52 additions & 0 deletions src/runtime/retained-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ describe('retained runtime run control', () => {
let createCalls = 0
let destroyCalls = 0
const getIds: string[] = []
const listQueries: Array<Record<string, unknown> | undefined> = []
let dispatched: AgentTurnInput | undefined
const provider = providerWithEnvironment({
async dispatch(input) {
Expand All @@ -303,6 +304,16 @@ describe('retained runtime run control', () => {
getIds.push(id)
return get(id)
}
provider.list = async (query) => {
listQueries.push(query?.metadata)
return [
{
id: 'environment-1',
provider: 'test-provider',
metadata: { retainedIdempotencyKey: 'durable-environment-key' },
},
]
}
const recorder = recordedAdmissions()

const run = await startRetainedRunInEnvironment({
Expand All @@ -315,6 +326,7 @@ describe('retained runtime run control', () => {
expect(createCalls).toBe(0)
expect(destroyCalls).toBe(0)
expect(getIds).toEqual(['environment-1'])
expect(listQueries).toEqual([{ retainedIdempotencyKey: 'durable-environment-key' }])
expect(dispatched).toEqual({
prompt: 'inspect the existing workspace',
turnId: 'fresh-workspace-turn',
Expand Down Expand Up @@ -394,6 +406,46 @@ describe('retained runtime run control', () => {
expect(foreignRecorder.admissions).toEqual([])
})

it('fails before admission when retained environment ownership is not proven', async () => {
let dispatchCalls = 0
const mismatched = providerWithEnvironment({
async dispatch() {
dispatchCalls += 1
throw new Error('dispatch must not run for a mismatched owner')
},
})
mismatched.list = async () => [
{
id: 'environment-1',
provider: 'test-provider',
metadata: { retainedIdempotencyKey: 'owner-key' },
},
]
const mismatchedRecorder = recordedAdmissions()
await expect(
startRetainedRunInEnvironment({
provider: mismatched,
environment: { id: 'environment-1', idempotencyKey: 'attacker-key' },
turn: { prompt: 'go', turnId: 'fresh-turn' },
onAdmission: mismatchedRecorder.onAdmission,
}),
).rejects.toThrow('could not bind environment "environment-1"')
expect(dispatchCalls).toBe(0)
expect(mismatchedRecorder.admissions).toEqual([])

const unobservable = providerWithEnvironment({})
const unobservableRecorder = recordedAdmissions()
await expect(
startRetainedRunInEnvironment({
provider: unobservable,
environment: { id: 'environment-1', idempotencyKey: 'owner-key' },
turn: { prompt: 'go', turnId: 'fresh-turn' },
onAdmission: unobservableRecorder.onAdmission,
}),
).rejects.toThrow('cannot prove retained environment ownership')
expect(unobservableRecorder.admissions).toEqual([])
})

it('allowlists a fresh retained start when JavaScript supplies stale run fields', async () => {
const controlRef = {
runId: 'fresh-run',
Expand Down