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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.241.1

Coordination public-address signals now end when their listener closes or setup fails, as well as on manager cancellation. Hosted ingress adapters can retire a route before its local port is reused, without changing authentication, execution policy or agent capabilities.

## 0.241.0

**`exportEvalRuns` no longer invents an accepted count.** When Tangle Intelligence answered with a non-JSON body (a proxy error page, an empty 5xx), the exporter left the parsed acknowledgement empty and reported `accepted: events.length` on any 2xx, so a consumer asserting that its provenance landed could pass on an acknowledgement that never existed. The acknowledgement is now validated against the batch that was sent: `accepted` must be a bounded integer, every rejection must carry a unique in-range index and a nonempty reason, and `accepted + rejected.length` must account for every submitted event. An unreadable, malformed or incomplete acknowledgement throws with the HTTP status; a failed HTTP response that claims accepted events throws; partial acceptance resolves with `ok: false` and the validated rejections. A whitespace-only API key is refused like a missing one. blueprint-agent deletes its duplicate wire types and ingest client and imports this exporter.
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.241.0` and `@tangle-network/agent-eval@0.182.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.241.1` and `@tangle-network/agent-eval@0.182.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/api/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -13617,7 +13617,7 @@ Caller-owned secret keys. Keep prior keys to verify unexpired credentials after

> `readonly` **signal**: `AbortSignal`

Manager cancellation and deadline; pass this to asynchronous endpoint provisioning.
Listener lifetime: aborts on close, failed setup, manager cancellation, or deadline.

***

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.241.0.**
> **Version 0.241.1.**
> [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path.
> `agent-eval` must satisfy `>=0.182.0 <0.183.0`.
> `sandbox` must satisfy `>=0.36.4 <0.42.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.241.0",
"version": "0.241.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
16 changes: 14 additions & 2 deletions src/runtime/supervise/coordination-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export interface CoordinationPublicAddress {
readonly port: number
readonly runId: string
readonly actorId: string
/** Manager cancellation and deadline; pass this to asynchronous endpoint provisioning. */
/** Listener lifetime: aborts on close, failed setup, manager cancellation, or deadline. */
readonly signal: AbortSignal
}

Expand Down Expand Up @@ -543,6 +543,16 @@ export async function serveCoordinationMcp(
})
})

const addressLifetime = new AbortController()
const abortAddress = () => addressLifetime.abort(opts.scope.signal.reason)
opts.scope.signal.addEventListener('abort', abortAddress, { once: true })
if (opts.scope.signal.aborted) abortAddress()
const releaseAddress = () => {
opts.scope.signal.removeEventListener('abort', abortAddress)
if (!addressLifetime.signal.aborted)
addressLifetime.abort(new Error('coordination listener closed'))
}

const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
const localUrl = `http://${urlHost}:${port}/mcp`
let url: string
Expand All @@ -551,7 +561,7 @@ export async function serveCoordinationMcp(
const configured =
typeof resolver === 'function'
? await runAbortable(
async () => resolver({ host, port, ...identity, signal: opts.scope.signal }),
async () => resolver({ host, port, ...identity, signal: addressLifetime.signal }),
opts.scope.signal,
'coordination public address resolution aborted',
)
Expand Down Expand Up @@ -592,6 +602,7 @@ export async function serveCoordinationMcp(
})
}
} catch (error) {
releaseAddress()
closed = true
await new Promise<void>((resolve) => {
server.close(() => resolve())
Expand Down Expand Up @@ -623,6 +634,7 @@ export async function serveCoordinationMcp(
mailHistory: () => mailbox?.history() ?? [],
stopMailThread: (threadId) => mailbox?.stopThread(threadId) ?? false,
close: async () => {
releaseAddress()
closed = true
await new Promise<void>((resolve) => {
server.close(() => resolve())
Expand Down
10 changes: 5 additions & 5 deletions src/testing/fixtures/agent-improvement-proposal.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"changedSurfaces": ["prompt"],
"digest": "sha256:a330797b3a4808392fb26f0f70e74efe098de08e9e454b3eee841dabe9b88edd",
"digest": "sha256:b5fc58845c940d8f7c61bc9cf120b01b76b9d085a5490a1a978844a4134c0616",
"evaluation": {
"decision": {
"contributingChecks": [
Expand Down Expand Up @@ -4882,7 +4882,7 @@
],
"metadata": {
"fixture": "agent-improvement-proposal",
"runtimeVersion": "0.241.0"
"runtimeVersion": "0.241.1"
},
"objectives": [
{
Expand Down Expand Up @@ -4993,8 +4993,8 @@
"baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09",
"candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693",
"kind": "agent-eval-loop",
"recordDigest": "sha256:4597b1a8fcf98875b475a08819c6b1b9bbebf2576085a0b95365b0a236954dd7",
"runId": "agent-runtime-0.241.0-proposal-fixture",
"recordDigest": "sha256:a7cce9d8e6b336c10989e74a75d160715b3a9eb1a2ff0ba0b2e1584f54dc62aa",
"runId": "agent-runtime-0.241.1-proposal-fixture",
"schema": "agent-candidate-experiment"
}
},
Expand All @@ -5021,5 +5021,5 @@
],
"kind": "agent-improvement-proposal",
"proposedAt": "2026-07-10T01:00:00.000Z",
"runId": "agent-runtime-0.241.0-proposal-fixture"
"runId": "agent-runtime-0.241.1-proposal-fixture"
}
6 changes: 3 additions & 3 deletions src/testing/fixtures/agent-profile-improvement-proposal.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"changedSurfaces": ["prompt", "skills"],
"digest": "sha256:4e8ac1bbaaeb4505c19b4dcb1ecefacc7bb2aef02e1df58308aad397c2af0460",
"digest": "sha256:23b992c8fc4146b76e2704f3ba38dc7b13408150439a112b2be9e73fc591aca2",
"evaluation": {
"decision": {
"contributingChecks": [
Expand Down Expand Up @@ -1715,7 +1715,7 @@
],
"metadata": {
"fixture": "agent-profile-improvement-proposal",
"runtimeVersion": "0.241.0"
"runtimeVersion": "0.241.1"
},
"objectives": [
{
Expand Down Expand Up @@ -1826,7 +1826,7 @@
"baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704",
"candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9",
"kind": "agent-eval-loop",
"recordDigest": "sha256:bb2732765df51993e084595a1504ba25a004f86b259b9e3ad4294d3bfaa3f9ec",
"recordDigest": "sha256:2455e1f27e26f1767fd3ad8c78fa00ab7a3928f8268d4b4b3d14dcef75a03270",
"runId": "profile-improvement-1",
"schema": "agent-profile-improvement-experiment"
}
Expand Down
95 changes: 90 additions & 5 deletions tests/kernel/coordination-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP
// to fire before listen; a bind moved after it would leave the first caller with no verbs.
let boundNames: ReadonlyArray<string> | undefined
let boundBeforeFirstCall: boolean | undefined
const scope = {} as Scope<unknown>
const scope = { signal: new AbortController().signal } as Scope<unknown>
const mcp = await serveCoordinationMcp({
scope,
blobs: new InMemoryResultBlobStore(),
Expand Down Expand Up @@ -279,7 +279,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP

it('serves product-owned node tools beside coordination tools over the same HTTP MCP', async () => {
const calls: unknown[] = []
const scope = {} as Scope<unknown>
const scope = { signal: new AbortController().signal } as Scope<unknown>
const mcp = await serveCoordinationMcp({
scope,
blobs: new InMemoryResultBlobStore(),
Expand Down Expand Up @@ -325,7 +325,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP
it('refuses a product tool that shadows spawn_worker before opening a listener', async () => {
await expect(
serveCoordinationMcp({
scope: {} as Scope<unknown>,
scope: { signal: new AbortController().signal } as Scope<unknown>,
blobs: new InMemoryResultBlobStore(),
makeWorkerAgent: () => deliveringLeaf('unused', {}),
perWorker: { maxIterations: 1, maxTokens: 1 },
Expand All @@ -345,7 +345,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP
it('refuses duplicate explicit tool grants before opening a listener', async () => {
await expect(
serveCoordinationMcp({
scope: {} as Scope<unknown>,
scope: { signal: new AbortController().signal } as Scope<unknown>,
blobs: new InMemoryResultBlobStore(),
makeWorkerAgent: () => deliveringLeaf('unused', {}),
perWorker: { maxIterations: 1, maxTokens: 1 },
Expand Down Expand Up @@ -1312,7 +1312,7 @@ async function withMethodTool<T>(
let calls = 0
const runs: Array<ReturnType<typeof deferred<unknown>>> = []
const mcp = await serveCoordinationMcp({
scope: {} as Scope<unknown>,
scope: { signal: new AbortController().signal } as Scope<unknown>,
blobs: new InMemoryResultBlobStore(),
makeWorkerAgent: () => deliveringLeaf('unused', {}),
perWorker: { maxIterations: 1, maxTokens: 1 },
Expand Down Expand Up @@ -1644,3 +1644,88 @@ describe('coordination lifetime validation', () => {
},
)
})

describe('public coordination address lifetime', () => {
it('releases its registered address on normal close while the manager remains live', async () => {
const proxy = await publicProxy()
let addressSignal: AbortSignal | undefined
const blobs = new InMemoryResultBlobStore()
const root: Agent<unknown, unknown> = {
name: 'address-owner',
async act(_task, scope) {
const mcp = await serveCoordinationMcp({
scope,
blobs,
makeWorkerAgent: () => deliveringLeaf('unused', {}),
perWorker: { maxIterations: 1, maxTokens: 1000 },
authentication: true,
toolNames: [],
publicUrl: (address) => {
addressSignal = address.signal
proxy.forwardTo(address.port)
return `${proxy.url}/mcp`
},
})
expect(addressSignal?.aborted).toBe(false)
await mcp.close()
expect(scope.signal.aborted).toBe(false)
expect(addressSignal?.aborted).toBe(true)
await mcp.close()
return { closed: true }
},
}
const result = await createSupervisor().run(
root,
{},
{
runId: 'address-close',
budget: { maxIterations: 10, maxTokens: 10_000 },
journal: new InMemorySpawnJournal(),
blobs,
executors: createExecutorRegistry(),
},
)
if (!addressSignal) throw new Error(JSON.stringify(result))
expect(addressSignal?.aborted).toBe(true)
})

it('releases a registered address when public endpoint setup fails', async () => {
const blobs = new InMemoryResultBlobStore()
let addressSignal: AbortSignal | undefined
const root: Agent<unknown, unknown> = {
name: 'invalid-address',
async act(_task, scope) {
await expect(
serveCoordinationMcp({
scope,
blobs,
makeWorkerAgent: () => deliveringLeaf('unused', {}),
perWorker: { maxIterations: 1, maxTokens: 1000 },
authentication: true,
toolNames: [],
publicUrl: (address) => {
addressSignal = address.signal
return 'http://remote.invalid/mcp'
},
}),
).rejects.toThrow('must use HTTPS')
expect(scope.signal.aborted).toBe(false)
expect(addressSignal?.aborted).toBe(true)
return { refused: true }
},
}
const result = await createSupervisor().run(
root,
{},
{
runId: 'address-setup',
budget: { maxIterations: 10, maxTokens: 10_000 },
journal: new InMemorySpawnJournal(),
blobs,
executors: createExecutorRegistry(),
},
)
if (!addressSignal) throw new Error(JSON.stringify(result))
expect(addressSignal?.aborted).toBe(true)
})
})
Loading