feat(core): add agent execution context
This commit is contained in:
23
packages/core/agent-execution/README.md
Normal file
23
packages/core/agent-execution/README.md
Normal 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.
|
||||
31
packages/core/agent-execution/package.json
Normal file
31
packages/core/agent-execution/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
139
packages/core/agent-execution/src/index.ts
Normal file
139
packages/core/agent-execution/src/index.ts
Normal 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
|
||||
12
packages/core/agent-execution/src/types.ts
Normal file
12
packages/core/agent-execution/src/types.ts
Normal 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
|
||||
}
|
||||
136
packages/core/agent-execution/tests/agent-execution.spec.ts
Normal file
136
packages/core/agent-execution/tests/agent-execution.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
21
packages/core/agent-execution/tsconfig.json
Normal file
21
packages/core/agent-execution/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user