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: 2 additions & 0 deletions docs/agent-managed-compute/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The core execution model is sound:

Retained provider children and managers can reconcile original invocations after a local coordinator restart.
Durable inputs, ordered admissions, content-addressed results, and usage records support that recovery.
New retained sessions use deterministic, bounded identifiers that are safe for provider workspace paths.
Recovery preserves the recorded session and execution identifiers and validates the original request material.
Recovery restores live descendants and their reservations before the manager resumes.
Managers and children then continue together through the existing Scope lifecycle.
Unresolved provider work remains uncertain until exact identity and completion can be established.
Expand Down
2 changes: 1 addition & 1 deletion docs/api/primitive-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

# Primitive catalog — the never-stale anti-reinvention inventory

> **GENERATED** from `@tangle-network/agent-runtime@0.208.0` and `@tangle-network/agent-eval@0.179.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`.
> **GENERATED** from `@tangle-network/agent-runtime@0.208.1` and `@tangle-network/agent-eval@0.179.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`.

## 1. agent-runtime — own public surface

Expand Down
2 changes: 1 addition & 1 deletion docs/canonical-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Generated signatures and the complete export list live in docs/api/.
Run pnpm docs:freshness after editing this file. -->

> **Version 0.208.0.**
> **Version 0.208.1.**
> [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path.
> `agent-eval` must satisfy `>=0.179.0 <0.180.0`.
> `sandbox` must satisfy `>=0.36.4 <0.39.0`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-runtime",
"version": "0.208.0",
"version": "0.208.1",
"description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.",
"homepage": "https://github.com/tangle-network/agent-runtime#readme",
"repository": {
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/environment-provider-sandbox-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
startRetainedInteractiveRun,
} from './retained-interactive'
import { claimRetainedInteractiveControl } from './retained-interactive-control'
import { mintRetainedIdentity } from './retained-run-start'
import type { SandboxClient } from './types'

const profile: AgentProfile = {
Expand Down Expand Up @@ -423,7 +424,10 @@ function controlFor(
function terminalReady(attachCount: number) {
return {
connectionId: `connection-${attachCount}`,
sessionId: 'retained-session:sandbox-interactive-environment:sandbox-interactive-process',
sessionId: mintRetainedIdentity(
'sandbox-interactive-environment',
'sandbox-interactive-process',
).sessionId,
restored: attachCount > 1,
detachTimeoutMs: 300_000,
attachCount,
Expand Down
176 changes: 171 additions & 5 deletions src/runtime/retained-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
type AgentProfile,
agentInteractiveSessionControlClaimRequestDigest,
agentInteractiveSessionPromptRequestDigest,
agentInteractiveSessionRunRef,
agentInteractiveSessionStopRequestDigest,
canonicalAgentProfileDigest,
canonicalCandidateDigest,
} from '@tangle-network/agent-interface'
import type {
Expand All @@ -28,7 +30,12 @@ import {
startRetainedInteractiveRun,
} from './retained-interactive'
import { claimRetainedInteractiveControl } from './retained-interactive-control'
import type { RetainedInteractiveAdmission } from './retained-run-types'
import { mintRetainedIdentity } from './retained-run-start'
import type {
RetainedInteractiveAdmission,
RetainedInteractiveEnvironmentAdmission,
RetainedInteractiveIntentAdmission,
} from './retained-run-types'

const profile: AgentProfile = {
name: 'Braid product engineer',
Expand Down Expand Up @@ -72,8 +79,7 @@ describe('retained interactive runs', () => {
provider: 'test-provider',
idempotencyKey: 'workspace-1',
interactiveIdempotencyKey: 'native-turn-1',
sessionId: 'retained-session:workspace-1:native-turn-1',
executionId: 'retained-execution:workspace-1:native-turn-1',
...mintRetainedIdentity('workspace-1', 'native-turn-1'),
runId: expect.stringMatching(/^interactive-intent-run:/u),
requestedProfileDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u),
requestDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u),
Expand All @@ -82,7 +88,9 @@ describe('retained interactive runs', () => {
phase: 'interactive_environment',
request: { initialPrompt: 'Inspect this workspace.', cols: 120, rows: 40 },
})
expect(handle.ref.run.sessionId).toBe('retained-session:workspace-1:native-turn-1')
expect(handle.ref.run.sessionId).toBe(
mintRetainedIdentity('workspace-1', 'native-turn-1').sessionId,
)
expect((await handle.status()).state).toBe('running')
const control = await claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' })
const promptAcknowledgement = await handle.sendPrompt(
Expand Down Expand Up @@ -276,7 +284,7 @@ describe('retained interactive runs', () => {
})

expect(recovered?.ref.run.sessionId).toBe(
'retained-session:workspace-intent-crash:native-intent-crash',
mintRetainedIdentity('workspace-intent-crash', 'native-intent-crash').sessionId,
)
expect(fixture.createCalls).toBe(1)
expect(fixture.environmentCreations).toBe(1)
Expand Down Expand Up @@ -371,6 +379,164 @@ describe('retained interactive runs', () => {
expect(fixture.processStarts).toBe(processStartsBeforeReplay)
})

it('replays a historical intent with its recorded identities and rejects changed coordinates', async () => {
const fixture = interactiveProvider()
const intent: RetainedInteractiveIntentAdmission = {
phase: 'interactive_intent',
provider: 'test-provider',
idempotencyKey: 'workspace-history',
interactiveIdempotencyKey: 'native-history',
sessionId: 'retained-session:workspace-history:native-history',
executionId: 'retained-execution:workspace-history:native-history',
runId:
'interactive-intent-run:6c516c44b5b285523175da7526453e3cad0096d4980013bd021c7364adbea0d2',
requestedProfileDigest:
'sha256:f1ed1f46786b31a3ba0038b55d40bdbd3edec5b99a86d8962d017f1253111283',
requestDigest: 'sha256:6c516c44b5b285523175da7526453e3cad0096d4980013bd021c7364adbea0d2',
}
const replay = {
environment: { profile, idempotencyKey: intent.idempotencyKey },
interactiveIdempotencyKey: intent.interactiveIdempotencyKey,
}
for (const changed of [
{ ...intent, sessionId: 'session-other' },
{ ...intent, executionId: 'execution-other' },
]) {
await expect(
recoverRetainedInteractiveRun({
provider: fixture.provider,
admission: changed,
replay,
onAdmission: async () => {},
}),
).rejects.toThrow('interactive intent conflicts with replay material')
}
expect(fixture.createCalls).toBe(0)

const recovered = await recoverRetainedInteractiveRun({
provider: fixture.provider,
admission: intent,
replay,
onAdmission: async () => {},
})

expect(recovered?.ref.run).toMatchObject({
sessionId: intent.sessionId,
executionId: intent.executionId,
})
expect(fixture.createCalls).toBe(1)
expect(fixture.processStarts).toBe(1)
})

it.each(['sessionId', 'executionId'] as const)(
'rejects a digest-consistent malformed intent %s before provider creation',
async (field) => {
const fixture = interactiveProvider()
const identity = {
...mintRetainedIdentity('workspace-malformed', 'native-malformed'),
[field]: ' invalid-coordinate ',
}
const intentMaterial = {
provider: fixture.provider.name,
idempotencyKey: 'workspace-malformed',
interactiveIdempotencyKey: 'native-malformed',
...identity,
requestedProfileDigest: canonicalAgentProfileDigest(profile),
}
const requestDigest = canonicalCandidateDigest({
kind: 'retained-interactive-intent.v1',
...intentMaterial,
create: {},
start: {},
})

await expect(
recoverRetainedInteractiveRun({
provider: fixture.provider,
admission: {
phase: 'interactive_intent',
...intentMaterial,
requestDigest,
runId: `interactive-intent-run:${requestDigest.slice('sha256:'.length)}`,
},
replay: {
environment: { profile, idempotencyKey: intentMaterial.idempotencyKey },
interactiveIdempotencyKey: intentMaterial.interactiveIdempotencyKey,
},
onAdmission: async () => {},
}),
).rejects.toThrow('outer whitespace')
expect(fixture.createCalls).toBe(0)
expect(fixture.startCalls).toBe(0)
},
)

it.each([
{
format: 'readable',
idempotencyKey: 'workspace-history',
sessionId: 'retained-session:workspace-history:native-history',
executionId: 'retained-execution:workspace-history:native-history',
},
{
format: 'digest',
idempotencyKey: `workspace-${'x'.repeat(128)}`,
sessionId:
'retained-session:c9828a31d8ff9eefd907b22f99861d6130a6998d33cc1691b2d1678e7b871a1e',
executionId:
'retained-execution:c9828a31d8ff9eefd907b22f99861d6130a6998d33cc1691b2d1678e7b871a1e',
},
])(
'recovers historical $format environment coordinates without starting another process',
async ({ idempotencyKey, sessionId, executionId }) => {
const fixture = interactiveProvider()
const start = { profile, requestedProfileDigest: canonicalAgentProfileDigest(profile) }
const request: AgentInteractiveSessionStart = {
...start,
run: agentInteractiveSessionRunRef(
{ provider: 'test-provider', environmentId: 'sandbox-1', sessionId, executionId },
start,
),
}
const admission: RetainedInteractiveEnvironmentAdmission = {
phase: 'interactive_environment',
provider: 'test-provider',
environmentId: 'sandbox-1',
idempotencyKey,
interactiveIdempotencyKey: 'native-history',
request,
}
const environment = await fixture.provider.get!('sandbox-1')
await environment!.startInteractive!(request)

for (const changed of [
{ ...admission, idempotencyKey: 'workspace-other' },
{ ...admission, interactiveIdempotencyKey: 'native-other' },
]) {
await expect(
recoverRetainedInteractiveRun({
provider: fixture.provider,
admission: changed,
onAdmission: async () => {},
}),
).rejects.toThrow('does not match its recovery coordinates')
}
expect(fixture.startCalls).toBe(1)

const recovered = await recoverRetainedInteractiveRun({
provider: fixture.provider,
admission,
onAdmission: async () => {},
})

expect(recovered?.ref.run).toEqual(request.run)
expect(fixture.startRequests).toEqual([request, request])
expect(fixture.startCalls).toBe(2)
expect(fixture.processStarts).toBe(1)
expect(fixture.createCalls).toBe(0)
},
)

it('binds the derived workspace cwd to replay identity', async () => {
const fixture = interactiveProvider()
const admissions: RetainedInteractiveAdmission[] = []
Expand Down
41 changes: 35 additions & 6 deletions src/runtime/retained-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,18 @@ export async function startRetainedInteractiveRun(
throw new Error('retained interactive runs require AgentProfile.harness')
}
const requestedProfileDigest = canonicalAgentProfileDigest(profile)
const identity = mintRetainedIdentity(
startOptions.environment.idempotencyKey,
startOptions.interactiveIdempotencyKey,
)
const identity =
startOptions.intent === undefined
? mintRetainedIdentity(
startOptions.environment.idempotencyKey,
startOptions.interactiveIdempotencyKey,
)
: {
sessionId: startOptions.intent.sessionId,
executionId: startOptions.intent.executionId,
}
assertStableText(identity.sessionId, 'retained session id')
assertStableText(identity.executionId, 'retained execution id')
const intent = interactiveIntent(startOptions, profile, identity)
if (startOptions.intent === undefined) {
// This is the only admission that can be written before provider.create.
Expand Down Expand Up @@ -220,14 +228,35 @@ function exactRecoveryRequest(
if (
request.run.provider !== admission.provider ||
request.run.environmentId !== admission.environmentId ||
request.run.sessionId !== identity.sessionId ||
request.run.executionId !== identity.executionId
((request.run.sessionId !== identity.sessionId ||
request.run.executionId !== identity.executionId) &&
!matchesHistoricalInteractiveIdentity(admission, request))
) {
throw new Error('interactive admission does not match its recovery coordinates')
}
return request
}

function matchesHistoricalInteractiveIdentity(
admission: RetainedInteractiveEnvironmentAdmission,
request: AgentInteractiveSessionStart,
): boolean {
// These admissions predate storage-safe IDs and still bind both idempotency keys.
// Reconstruct their coordinates only to validate the stored request, never to replace it.
const base = `${encodeURIComponent(admission.idempotencyKey)}:${encodeURIComponent(admission.interactiveIdempotencyKey)}`
const digest = canonicalCandidateDigest({ kind: 'retained-identity.v1', base }).slice(
'sha256:'.length,
)
const historicalId = (prefix: string): string => {
const readable = `${prefix}:${base}`
return readable.length <= 128 ? readable : `${prefix}:${digest}`
}
return (
request.run.sessionId === historicalId('retained-session') &&
request.run.executionId === historicalId('retained-execution')
)
}

/** Rebuild controls for one exact provider-owned coding-agent process. @stable */
export async function reconnectRetainedInteractiveRun(
options: ReconnectRetainedInteractiveRunOptions,
Expand Down
Loading