feat(core): add agent execution context

This commit is contained in:
Yichen Jiang
2026-07-16 16:29:46 +08:00
parent 04df615dd6
commit 7bcae0cd64
93 changed files with 1272 additions and 462 deletions

View File

@@ -0,0 +1,23 @@
# dsh-agent-execution
Process-local ambient Agent identity for asynchronous work initiated by a concrete agent driver. The default export, `AgentExecutionProvider`, installs the mandatory `ctx.agentExecution` service; [`dsh-agent-loop`](../agent-loop/README.md) establishes one boundary around each driver's complete lifetime.
## Service: `AgentExecutionService` (ctx key: `agentExecution`)
- `current()` returns the inherited `AgentExecution` or `undefined` outside a driver and inside an explicit clearing boundary.
- `require()` returns the inherited execution or throws `no agent execution context is active`.
- `run(execution, operation)` returns the exact synchronous value or Promise from `operation`. Passing `undefined` establishes a real boundary that hides an inherited Agent.
The store contains only `{ readonly agent: Agent }`. A Session is available through `agent.session`; turn, step, signal, cwd, sandbox, authorization, and other capability state remain with their explicit owners. Ambient presence identifies the initiator but does not prove that the Agent is live or that an operation is authorized.
## Lifetime and detached work
Provider teardown rejects new `run()` boundaries, removes the service so injected dependents drain, waits for returned Promise boundaries, then disables its `AsyncLocalStorage`. In-flight code retaining the service can call `current()` and `require()` while it drains; after disposal, all three methods throw `agent execution service is disposed`.
Async resources created inside `run()` inherit its Agent even when the operation does not await them. Agent-owned foreground work may inherit the boundary but keeps using the explicit cancellation and disposal contract of its execution seam. Unrelated timers, queues, and deployment infrastructure start under `run(undefined, operation)` and own an explicit stop. Queue, worker, process, and wire boundaries serialize any identity they need instead of relying on ALS propagation.
## Known Limitations and Deferred Work
- **Process-local only** — ALS does not cross workers, child processes, HTTP, durable queues, or restarts; each boundary materializes a typed identity explicitly.
- **Agent identity only** — turn, step, signal, cwd, sandbox, and authorization stay outside the frame until a concrete cross-cutting consumer justifies a separate design.
- **Ambient references may outlive liveness** — consumers still check `agent.status`, their explicit signal, and the owning capability contract before lifecycle-sensitive work.

View File

@@ -0,0 +1,31 @@
{
"name": "@deepseek-ai/dsh-agent-execution",
"description": "Agent-scoped asynchronous execution context for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,139 @@
/**
* Process-local Agent execution context backed by Node AsyncLocalStorage.
*
* @module @deepseek-ai/dsh-agent-execution
*/
import type { Context } from 'cordis'
import { AsyncLocalStorage } from 'node:async_hooks'
import type { AgentExecution } from './types.ts'
export type { AgentExecution } from './types.ts'
const NO_ACTIVE_EXECUTION = 'no agent execution context is active'
const DISPOSED_SERVICE = 'agent execution service is disposed'
/** Ambient Agent identity within one process-local asynchronous chain. */
export interface AgentExecutionService {
/**
* Read the active execution without requiring one.
* @returns the inherited execution, or `undefined` outside/inside a cleared boundary.
* @throws when this service instance has been disposed.
*/
current(): AgentExecution | undefined
/**
* Read the active execution and fail when no boundary is active.
* @returns the inherited execution.
* @throws when no execution is active or this service instance has been disposed.
*/
require(): AgentExecution
/**
* Run an operation inside an execution boundary. Passing `undefined` clears
* an inherited execution; the exact synchronous value or Promise is returned.
* @param execution - execution to inherit, or `undefined` for a clearing boundary.
* @param operation - synchronous or asynchronous operation to invoke.
* @returns the exact value returned by `operation`.
* @throws when this service is closing/disposed, or when `operation` throws.
*/
run<T>(execution: AgentExecution | undefined, operation: () => T): T
}
declare module 'cordis' {
interface Context {
agentExecution: AgentExecutionService
}
}
/** One provider-owned ALS instance with quiescent shutdown. */
class DefaultAgentExecutionService implements AgentExecutionService {
private readonly storage = new AsyncLocalStorage<AgentExecution | undefined>()
private state: 'active' | 'closing' | 'disposed' = 'active'
private activeRuns = 0
private drainWaiter: PromiseWithResolvers<void> | undefined
private disposalTask: Promise<void> | undefined
current(): AgentExecution | undefined {
this.assertReadable()
return this.storage.getStore()
}
require(): AgentExecution {
const execution = this.current()
if (execution === undefined) throw new Error(NO_ACTIVE_EXECUTION)
return execution
}
run<T>(execution: AgentExecution | undefined, operation: () => T): T {
if (this.state !== 'active') throw new Error(DISPOSED_SERVICE)
this.activeRuns += 1
let result: T
try {
result = this.storage.run(execution, operation)
} catch (error: unknown) {
this.releaseRun()
throw error
}
if (result instanceof Promise) {
void result.then(
() => { this.releaseRun() },
() => { this.releaseRun() },
)
} else {
this.releaseRun()
}
return result
}
/** Reject new boundaries while existing continuations remain readable. */
close(): void {
if (this.state === 'active') this.state = 'closing'
}
/** Wait for every returned Promise boundary, then invalidate retained references. */
dispose(): Promise<void> {
return (this.disposalTask ??= (async () => {
this.close()
if (this.activeRuns !== 0) {
this.drainWaiter ??= Promise.withResolvers<void>()
await this.drainWaiter.promise
}
this.state = 'disposed'
this.storage.disable()
})())
}
private assertReadable(): void {
if (this.state === 'disposed') throw new Error(DISPOSED_SERVICE)
}
private releaseRun(): void {
this.activeRuns -= 1
if (this.activeRuns !== 0) return
this.drainWaiter?.resolve()
this.drainWaiter = undefined
}
}
/** Cordis provider for the mandatory `ctx.agentExecution` service. */
export class AgentExecutionProvider {
private readonly service = new DefaultAgentExecutionService()
/**
* Install one isolated execution service and its ordered lifecycle.
* @param ctx - provider-owning Cordis context.
*/
constructor(ctx: Context) {
const service = this.service
ctx.effect(function* () {
// First yielded, disposed last: invalidate ALS only after dependents and active runs drain.
yield () => service.dispose()
yield ctx.provide('agentExecution', service)
// Last yielded, disposed first: prevent a teardown race from opening another boundary.
yield () => { service.close() }
}, 'agentExecution.lifecycle()')
}
}
export default AgentExecutionProvider

View File

@@ -0,0 +1,12 @@
/**
* Public Agent execution-context types.
*
* @module @deepseek-ai/dsh-agent-execution/types
*/
import type { Agent } from '@deepseek-ai/dsh-agent'
/** The exact live Agent associated with one asynchronous execution chain. */
export interface AgentExecution {
readonly agent: Agent
}

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import type { AgentExecution, AgentExecutionService } from '@deepseek-ai/dsh-agent-execution'
function execution(id: string): AgentExecution {
return { agent: { id: AgentId(id) } as Agent }
}
async function harness(): Promise<{
ctx: Context
service: AgentExecutionService
dispose: () => Promise<void>
}> {
const ctx = new Context()
const fiber = await ctx.plugin(AgentExecutionProvider)
return {
ctx,
service: ctx.agentExecution,
dispose: fiber.dispose,
}
}
describe('AgentExecutionProvider', () => {
it('reports an absent boundary and requires an active execution', async () => {
const { service, dispose } = await harness()
expect(service.current()).toBeUndefined()
expect(() => service.require()).toThrow('no agent execution context is active')
await dispose()
})
it('preserves exact synchronous and Promise return identities across await', async () => {
const { service, dispose } = await harness()
const active = execution('identity')
const value = { result: true }
expect(service.run(active, () => {
expect(service.require()).toBe(active)
return value
})).toBe(value)
const promise = service.run(active, async () => {
expect(service.require()).toBe(active)
await Promise.resolve()
expect(service.require()).toBe(active)
return value
})
expect(service.run(active, () => promise)).toBe(promise)
await expect(promise).resolves.toBe(value)
expect(service.current()).toBeUndefined()
await dispose()
})
it('isolates overlapping executions', async () => {
const { service, dispose } = await harness()
const a = execution('a')
const b = execution('b')
const bothStarted = Promise.withResolvers<boolean>()
const release = Promise.withResolvers<boolean>()
let starts = 0
const run = (active: AgentExecution): Promise<void> => service.run(active, async () => {
expect(service.require()).toBe(active)
starts += 1
if (starts === 2) bothStarted.resolve(true)
await release.promise
expect(service.require()).toBe(active)
})
const pending = [run(a), run(b)]
await bothStarted.promise
expect(service.current()).toBeUndefined()
release.resolve(true)
await Promise.all(pending)
await dispose()
})
it('restores nested and explicitly cleared boundaries', async () => {
const { service, dispose } = await harness()
const parent = execution('parent')
const child = execution('child')
service.run(parent, () => {
expect(service.require()).toBe(parent)
service.run(child, () => { expect(service.require()).toBe(child) })
expect(service.require()).toBe(parent)
service.run(undefined, () => {
expect(service.current()).toBeUndefined()
expect(() => service.require()).toThrow('no agent execution context is active')
})
expect(service.require()).toBe(parent)
})
expect(service.current()).toBeUndefined()
await dispose()
})
it('restores context after synchronous throws and rejected operations', async () => {
const { service, dispose } = await harness()
const parent = execution('parent')
const child = execution('child')
const syncError = new Error('sync failure')
const asyncError = new Error('async failure')
service.run(parent, () => {
expect(() => service.run(child, () => { throw syncError })).toThrow(syncError)
expect(service.require()).toBe(parent)
})
await expect(service.run(child, async () => {
await Promise.resolve()
throw asyncError
})).rejects.toBe(asyncError)
expect(service.current()).toBeUndefined()
await dispose()
})
it('stops new boundaries, drains active Promises, and invalidates retained references', async () => {
const { ctx, service, dispose } = await harness()
const active = execution('draining')
const release = Promise.withResolvers<boolean>()
const pending = service.run(active, async () => {
await release.promise
expect(service.require()).toBe(active)
})
let disposed = false
const disposal = dispose().then(() => { disposed = true })
await Promise.resolve()
expect(() => service.run(active, () => 1)).toThrow('agent execution service is disposed')
expect(disposed).toBe(false)
expect(ctx.get('agentExecution')).toBeUndefined()
release.resolve(true)
await pending
await disposal
expect(() => service.current()).toThrow('agent execution service is disposed')
expect(() => service.require()).toThrow('agent execution service is disposed')
})
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
}
]
}