Merge master into worktree-windows-runtime
This commit is contained in:
@@ -4,7 +4,9 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag
|
||||
|
||||
## Start and ownership
|
||||
|
||||
`start(request)` performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped.
|
||||
`start(request)` resolves the child's working directory, then performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
|
||||
|
||||
The working directory is the configured `cwd` override when set, else the delegating parent session's cwd — never the server process's own cwd, because one server process serves sessions from many workspaces. The parent-derived value must be an absolute path naming a directory the harness can enter (search permission — what a subprocess cwd needs), and the same resolved path becomes both the subprocess cwd and the ACP `session/new` workspace.
|
||||
|
||||
The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent.
|
||||
|
||||
@@ -14,7 +16,7 @@ After publication, the provider sends the prompt and collects streamed `agent_me
|
||||
|
||||
## Capabilities and context
|
||||
|
||||
ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh and ignores `request.parent` beyond the seam's required attribution field.
|
||||
ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above — no conversation context crosses the process boundary.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -23,7 +25,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
|
||||
| `providerName` | `acp` | Registry name on `ctx.subagents`. |
|
||||
| `command` | required | Executable spawned for each run. |
|
||||
| `args` | `[]` | Command arguments. |
|
||||
| `cwd` | process cwd | Child process and ACP session working directory. |
|
||||
| `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. |
|
||||
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
|
||||
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
|
||||
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
|
||||
@@ -57,7 +59,7 @@ The child environment is built by [`buildChildEnv`](../subagent-subprocess/READM
|
||||
|
||||
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
|
||||
Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.
|
||||
Keyless tests drive a scripted ACP subprocess over real stdio, including a Loader-composed stdio app proving parent-session cwd inheritance end to end. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -92,6 +94,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A fresh process per run** — persistent-process pooling is a future optimization ([the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)).
|
||||
- **Local workspaces only** — the resolved cwd is a local path handed to a child on the same machine; workspace mapping for a remote ACP agent would need its own backend capability and is not designed here.
|
||||
- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them.
|
||||
- **Only `agent_message_chunk` text is collected** — the child's tool-call activity, thought chunks, and plan updates are not surfaced to the parent.
|
||||
- **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission` in this cut.
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
@@ -34,13 +40,14 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Out-of-process ACP subagent backend. Each child has its own process, session, model, and
|
||||
* tools, so it shares no Cordis context, ignores `request.parent`, and advertises no parent-
|
||||
* enforced start capabilities. This plugin uses named exports only; a default would hide its
|
||||
* tools, so it shares no Cordis context and advertises no parent-enforced start capabilities;
|
||||
* the ONE thing it reads off `request.parent` is the session's workspace cwd (see
|
||||
* {@link resolveCwd}). This plugin uses named exports only; a default would hide its
|
||||
* loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`).
|
||||
* @module @deepseek-ai/dsh-subagent-acp
|
||||
*/
|
||||
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -23,8 +26,11 @@ export interface Config {
|
||||
/** Arguments passed to {@link command}. */
|
||||
args: string[]
|
||||
/**
|
||||
* Working directory for the child process and its ACP session. Defaults to
|
||||
* the parent process's cwd when omitted.
|
||||
* Working directory override for the child process and its ACP session.
|
||||
* Must be non-empty; a relative path resolves against the harness launch
|
||||
* directory at load, and the result must be an existing directory. When
|
||||
* omitted, each child inherits its delegating parent session's cwd — and
|
||||
* starting one from a parent session that has no cwd fails.
|
||||
*/
|
||||
cwd?: string
|
||||
/**
|
||||
@@ -71,6 +77,60 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
/** The shape after schemastery applied the defaults (cwd has none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
|
||||
/**
|
||||
* Whether `path` names an existing directory the harness can ENTER. The
|
||||
* search-permission probe matters: `statSync().isDirectory()` is true for a
|
||||
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
|
||||
*/
|
||||
function isDirectory(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
// statSync/accessSync throw only filesystem access errors here
|
||||
// (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
|
||||
// serve as the child's cwd.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert `cwd` can actually host the child: absolute (it doubles as the ACP
|
||||
* session workspace, and a relative path would be re-anchored to the server
|
||||
* process's launch directory) and an existing directory (fail here, before the
|
||||
* process boundary, instead of as an ambiguous spawn ENOENT).
|
||||
* @param label - which source supplied the value, for the diagnostic.
|
||||
* @param cwd - the candidate working directory.
|
||||
* @returns `cwd`, validated.
|
||||
*/
|
||||
function assertUsableCwd(label: string, cwd: string): string {
|
||||
if (!isAbsolute(cwd)) {
|
||||
throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`)
|
||||
}
|
||||
if (!isDirectory(cwd)) {
|
||||
throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the child's working directory: the deployment `cwd` override when
|
||||
* configured (already validated at load), else the parent session's workspace
|
||||
* cwd (validated here, its earliest resolvable point). Fails loud when neither
|
||||
* exists — falling back to the harness process cwd would silently bind the
|
||||
* child to the server's launch directory instead of the delegating session's
|
||||
* workspace (one server process serves many sessions, each with its own cwd).
|
||||
*/
|
||||
function resolveCwd(configured: string | undefined, request: SubagentStartRequest): string {
|
||||
if (configured !== undefined) return configured
|
||||
const parentCwd = request.parent.session.header.cwd
|
||||
if (parentCwd === undefined) {
|
||||
throw new Error('subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one')
|
||||
}
|
||||
return assertUsableCwd('parent session cwd', parentCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
|
||||
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
|
||||
@@ -87,7 +147,7 @@ class AcpProvider implements SubagentProvider {
|
||||
const spec: AcpRunSpec = {
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
cwd: this.config.cwd ?? process.cwd(),
|
||||
cwd: resolveCwd(this.config.cwd, request),
|
||||
permission: this.config.permission,
|
||||
env: this.config.env,
|
||||
disposeEofGraceMs: this.config.disposeEofGraceMs,
|
||||
@@ -107,5 +167,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
|
||||
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
|
||||
ctx.subagents.registerProvider(new AcpProvider(resolved.providerName, ctx, resolved))
|
||||
// `path.resolve('')` is the process cwd — an empty string would silently
|
||||
// reintroduce the launch-directory fallback this resolution removed.
|
||||
if (resolved.cwd === '') {
|
||||
throw new Error('subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd')
|
||||
}
|
||||
// Interpret a relative configured cwd against the harness launch directory
|
||||
// ONCE, at load, and fail a misconfigured directory here — not per start.
|
||||
const validated: ResolvedConfig = resolved.cwd === undefined
|
||||
? resolved
|
||||
: { ...resolved, cwd: assertUsableCwd('config cwd', resolve(resolved.cwd)) }
|
||||
ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated))
|
||||
}
|
||||
|
||||
30
packages/subagent/subagent-acp/src/invariant.ts
Normal file
30
packages/subagent/subagent-acp/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-acp`.
|
||||
* @module @deepseek-ai/dsh-subagent-acp/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-acp-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -37,7 +37,11 @@ export interface AcpRunSpec {
|
||||
command: string
|
||||
/** Arguments passed to {@link command}. */
|
||||
args: string[]
|
||||
/** Working directory for the child process AND its ACP session `cwd`. */
|
||||
/**
|
||||
* Absolute working directory for the child process AND its ACP session
|
||||
* `cwd`. The provider resolves it before this spec exists: config override,
|
||||
* else the delegating parent session's workspace.
|
||||
*/
|
||||
cwd: string
|
||||
/** How to auto-answer the child's permission prompts. */
|
||||
permission: PermissionPolicy
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
/**
|
||||
* Keyless REAL-composition coverage for parent-session cwd inheritance: a
|
||||
* test-only cordis.yml boots the headless app through the Loader with the ACP
|
||||
* backend's `cwd` omitted, a scripted model delegates once, and the scripted
|
||||
* mock ACP child echoes where it actually ran plus the workspace it was
|
||||
* announced — both must be the parent session's cwd. Mock-only composition, so
|
||||
* only this keyless tier applies (the with-key tier lives in subagent-acp.e2e.ts).
|
||||
*/
|
||||
|
||||
const driver = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts',
|
||||
import.meta.url,
|
||||
))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
describe('ACP subagent cwd inheritance through a real cordis.yml', () => {
|
||||
it('runs the child in the parent session workspace and announces it as the ACP session cwd', async () => {
|
||||
let events: SessionEvent[] = []
|
||||
let workspace = ''
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'acp-subagent cwd composition smoke',
|
||||
tempDirPrefix: 'acp-subagent-cwd-e2e-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
env: { DSH_TEST_MOCK_ACP_SERVER: mockServer },
|
||||
inspect: async (cwd) => {
|
||||
// The child reports realpaths; canonicalize the temp workspace to match.
|
||||
workspace = realpathSync(cwd)
|
||||
const logs = await jsonlFiles(join(cwd, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
|
||||
// The tool result carries the child's two-line echo: its real process.cwd()
|
||||
// and the cwd the backend announced in `session/new` — both the parent
|
||||
// session's workspace, never the harness process's launch directory.
|
||||
const results = events.filter(event => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(1)
|
||||
const resultText = results[0]!.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
expect(resultText).toBe(`${workspace}\n${workspace}`)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -15,6 +15,11 @@
|
||||
* `dispose()` must still kill the process.
|
||||
* - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
|
||||
* before answering, to exercise the client's auto-answer.
|
||||
* - `MOCK_ECHO_CWD` — if `1`, ignore MOCK_TEXT and stream two lines instead:
|
||||
* the agent PROCESS's `process.cwd()` and the `cwd` the
|
||||
* client announced in `session/new` — so a test can assert
|
||||
* where the child actually ran and what workspace it was
|
||||
* told it has.
|
||||
* - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt`
|
||||
* handler is in flight (it has streamed its chunk). A test
|
||||
* polls for this file to cancel on a CONDITION rather than
|
||||
@@ -63,6 +68,7 @@ import {
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
const TEXT = process.env.MOCK_TEXT ?? 'mock child answer'
|
||||
const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1'
|
||||
const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason
|
||||
const HANG = process.env.MOCK_HANG === '1'
|
||||
const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
|
||||
@@ -83,6 +89,8 @@ function makeAgent(conn: AgentSideConnection): Agent {
|
||||
// Pending cancel resolver for the HANG path: a `session/cancel` resolves the
|
||||
// prompt with `cancelled`.
|
||||
let resolveCancel: ((reason: StopReason) => void) | undefined
|
||||
// The cwd the client announced in `session/new`, echoed under MOCK_ECHO_CWD.
|
||||
let sessionCwd: string | undefined
|
||||
|
||||
return {
|
||||
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
@@ -92,7 +100,8 @@ function makeAgent(conn: AgentSideConnection): Agent {
|
||||
authMethods: [],
|
||||
})
|
||||
},
|
||||
async newSession(_params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
sessionCwd = params.cwd
|
||||
// Optionally signal "newSession reached" and block until released, so a
|
||||
// test can cancel DURING newSession (the early-cancel race window) on a
|
||||
// condition rather than a timeout.
|
||||
@@ -136,10 +145,14 @@ function makeAgent(conn: AgentSideConnection): Agent {
|
||||
update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } },
|
||||
})
|
||||
}
|
||||
// Stream the canned assistant text as one chunk.
|
||||
// Stream the canned assistant text as one chunk (or, under MOCK_ECHO_CWD,
|
||||
// the observable process cwd + announced session cwd).
|
||||
await conn.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: TEXT } },
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: ECHO_CWD ? `${process.cwd()}\n${sessionCwd ?? ''}` : TEXT },
|
||||
},
|
||||
})
|
||||
// Signal "prompt is in flight" by touching the readiness file, so a test
|
||||
// can wait on a CONDITION (file exists) rather than an arbitrary timeout
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
@@ -22,8 +22,8 @@ import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DI
|
||||
|
||||
const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
|
||||
|
||||
/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
|
||||
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
/** A parent Agent stub. The ACP backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */
|
||||
const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
|
||||
|
||||
function request(text = 'p', signal = new AbortController().signal) {
|
||||
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
|
||||
@@ -115,6 +115,184 @@ describe('buildChildEnv', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cwd resolution', () => {
|
||||
it('falls back to the parent session cwd for the child process AND its ACP session', async () => {
|
||||
// realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var),
|
||||
// and the child reports its REAL process.cwd() — compare canonical paths.
|
||||
const workdir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
|
||||
try {
|
||||
const ctx = await setup({ MOCK_ECHO_CWD: '1' })
|
||||
const parent = { id: 'parent', session: { header: { cwd: workdir } } } as unknown as Agent
|
||||
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
// Line 1: where the child process actually ran; line 2: the workspace the
|
||||
// backend announced in `session/new`. Both must be the parent's workspace.
|
||||
expect(text(result.output)).toBe(`${workdir}\n${workdir}`)
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects before spawning when neither config.cwd nor the parent session provides one', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-no-cwd-'))
|
||||
const sentinel = join(tmp, 'spawned')
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
// A command that would create the sentinel if the child were ever spawned.
|
||||
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
||||
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('no working directory')
|
||||
// Resolution failed BEFORE the process boundary — nothing was launched.
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('prefers the configured cwd override to the parent session cwd', async () => {
|
||||
const configured = realpathSync(mkdtempSync(join(tmpdir(), 'acp-cfg-cwd-')))
|
||||
const parentDir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: configured,
|
||||
permission: 'reject',
|
||||
env: { MOCK_ECHO_CWD: '1' },
|
||||
})
|
||||
const parent = { id: 'parent', session: { header: { cwd: parentDir } } } as unknown as Agent
|
||||
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
expect(text(result.output)).toBe(`${configured}\n${configured}`)
|
||||
} finally {
|
||||
rmSync(configured, { recursive: true, force: true })
|
||||
rmSync(parentDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a relative config cwd against the launch directory at load', async () => {
|
||||
// The child process AND its announced ACP session cwd must both get the
|
||||
// ABSOLUTE form — DSH's own ACP server rejects a relative session cwd, and
|
||||
// deferring resolution to spawn would hide the launch-dir dependency.
|
||||
const relative = 'packages/subagent/subagent-acp'
|
||||
const absolute = resolve(relative)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: relative,
|
||||
permission: 'reject',
|
||||
env: { MOCK_ECHO_CWD: '1' },
|
||||
})
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
expect(text(result.output)).toBe(`${realpathSync(absolute)}\n${absolute}`)
|
||||
})
|
||||
|
||||
it('rejects an empty config cwd at load', async () => {
|
||||
// `path.resolve('')` is the process cwd, so an empty string would silently
|
||||
// reintroduce the launch-directory fallback this resolution removed.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: 'true',
|
||||
args: [],
|
||||
cwd: '',
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})).rejects.toThrow('config cwd must not be empty')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a config cwd directory without search permission at load', async () => {
|
||||
// statSync().isDirectory() is true for a mode-600 directory, but a
|
||||
// subprocess cwd needs SEARCH permission — spawn would fail EACCES.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-'))
|
||||
chmodSync(tmp, 0o600)
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: 'true',
|
||||
args: [],
|
||||
cwd: tmp,
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})).rejects.toThrow('not an accessible directory')
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
chmodSync(tmp, 0o700)
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a config cwd that is not an accessible directory at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: 'true',
|
||||
args: [],
|
||||
cwd: '/nonexistent/acp-child-workspace',
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})).rejects.toThrow('not an accessible directory')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a parent session cwd that is not absolute', async () => {
|
||||
// SessionHeader documents cwd as absolute; a relative value here is a broken
|
||||
// header, and resolving it against the server process cwd would silently
|
||||
// re-introduce the launch-directory dependency this resolution removes.
|
||||
const ctx = await setup({})
|
||||
const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('must be an absolute path')
|
||||
})
|
||||
|
||||
it('rejects a parent session cwd that names a FILE, not a directory', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-file-cwd-'))
|
||||
const file = join(tmp, 'a-file')
|
||||
writeFileSync(file, 'x')
|
||||
try {
|
||||
const ctx = await setup({})
|
||||
const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('not an accessible directory')
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a parent session cwd that is not an accessible directory, before spawning', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-bad-parent-cwd-'))
|
||||
const sentinel = join(tmp, 'spawned')
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
||||
const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('not an accessible directory')
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-subagent-acp', () => {
|
||||
it('drives child processes with parent-unique run ids and returns streamed output', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' })
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/loader-smoke"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-inprocess": "^0.0.1",
|
||||
@@ -32,6 +38,7 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
@@ -41,7 +48,6 @@
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-inprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/subagent/subagent-fork/src/invariant.ts
Normal file
30
packages/subagent/subagent-fork/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-fork`.
|
||||
* @module @deepseek-ai/dsh-subagent-fork/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-fork-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -3,7 +3,10 @@ import { Context } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
@@ -11,6 +14,13 @@ import * as fork from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
}
|
||||
@@ -24,7 +34,7 @@ function start(ctx: Context, provider: string, request: Omit<SubagentStartReques
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(Spawn, { providerName: 'spawn' })
|
||||
|
||||
@@ -5,7 +5,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
@@ -14,6 +17,13 @@ import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
}
|
||||
@@ -24,14 +34,14 @@ const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
|
||||
|
||||
/**
|
||||
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the
|
||||
* real dsh-invariants plugin. The plugin replays a seeded child log on
|
||||
* real invariant service and package companions. The session contribution replays a seeded child log on
|
||||
* `session/created`, so a malformed (unbalanced) fork seed makes these tests
|
||||
* THROW — that is the regression guard for the completed-turn-prefix boundary.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(fork, { providerName: 'fork' })
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../subagent-inprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ The driver follows this sequence:
|
||||
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
|
||||
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
|
||||
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`.
|
||||
5. Read the child's own last assistant message and terminal turn reason, excluding any fork seed.
|
||||
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -135,7 +135,7 @@ export async function startInProcessRun(
|
||||
|
||||
const onAbort = (): void => {
|
||||
flags.cancelled = true
|
||||
child.cancel('subagent request aborted')
|
||||
child.cancel({ kind: 'parent' })
|
||||
}
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
@@ -175,7 +175,7 @@ function readResult(
|
||||
): SubagentResult {
|
||||
const own = child.session.events.slice(seedLength)
|
||||
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
|
||||
const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
|
||||
const lastEnd = findLastMessageTurnEnd(own)
|
||||
const output: ContentBlock[] = lastMessage?.data.content ?? []
|
||||
const recorded = toStopReason(lastEnd?.data.reason)
|
||||
// Disposal can tear the owner down before the loop records its ordinary
|
||||
|
||||
30
packages/subagent/subagent-inprocess/src/invariant.ts
Normal file
30
packages/subagent/subagent-inprocess/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-inprocess`.
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-inprocess-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -96,7 +96,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
// Stop the child's turn once its output is captured. This monotonic serial
|
||||
// checkpoint runs after the ordinary continuation waterfall, its reason,
|
||||
// and late-steering folding, so no ordering trick can resume a finished run.
|
||||
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
|
||||
childCtx.on('agent/turn-stop', function (this: unknown, _agent, _turn, _signal): ContinuationStop | undefined {
|
||||
return captured === undefined ? undefined : { action: 'stop' }
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
@@ -16,8 +19,17 @@ import {
|
||||
STRUCTURED_OUTPUT_TOOL,
|
||||
} from '../src/structured.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
interface CodeRunRequestLike {
|
||||
bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
|
||||
}
|
||||
@@ -51,7 +63,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
|
||||
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
|
||||
} as never)
|
||||
}
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const disposeProvider = ctx.subagents.registerProvider({
|
||||
@@ -215,7 +227,7 @@ describe('in-process structured output', () => {
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child === parent) return
|
||||
wrapperInstalled = true
|
||||
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
|
||||
const downstream = await next()
|
||||
expect(downstream).toEqual({ action: 'stop' })
|
||||
return { action: 'continue' }
|
||||
@@ -241,7 +253,7 @@ describe('in-process structured output', () => {
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child.id !== run.id) return
|
||||
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
|
||||
const downstream = await next()
|
||||
expect(downstream).toEqual({ action: 'stop' })
|
||||
subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
|
||||
@@ -640,6 +652,7 @@ describe('in-process structured output', () => {
|
||||
it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: 'x' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
@@ -652,6 +665,7 @@ describe('in-process structured output', () => {
|
||||
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: 'x' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
@@ -684,6 +698,7 @@ describe('in-process structured output', () => {
|
||||
// …and a LATER invalid call (its own body staged nothing) must not
|
||||
// resurrect c1's discarded value: drive the pipeline directly.
|
||||
const invalid = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: 'c2' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 'not-a-number' },
|
||||
@@ -692,6 +707,7 @@ describe('in-process structured output', () => {
|
||||
expect(invalid.isError).toBe(true)
|
||||
// A fresh valid call still captures ITS OWN value.
|
||||
const valid = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: 'c3' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 9 },
|
||||
@@ -722,6 +738,7 @@ describe('in-process structured output', () => {
|
||||
// (invalid args throw before the stage): the discarded value must not ride
|
||||
// its acceptance.
|
||||
const reused = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 'not-a-number' },
|
||||
@@ -730,6 +747,7 @@ describe('in-process structured output', () => {
|
||||
expect(reused.isError).toBe(true)
|
||||
// Nothing was ever committed: a fresh valid call is still required.
|
||||
const valid = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 5 },
|
||||
@@ -764,6 +782,7 @@ describe('in-process structured output', () => {
|
||||
return undefined as never
|
||||
}, { prepend: true })
|
||||
const denied = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 2 },
|
||||
@@ -774,6 +793,7 @@ describe('in-process structured output', () => {
|
||||
// The discarded value was never promoted: a fresh valid call is required
|
||||
// (and succeeds, proving the runtime is not wedged).
|
||||
const valid = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 5 },
|
||||
|
||||
@@ -4,22 +4,33 @@ import { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const adapter = new MockAdapter(script)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
function request(parent: Agent, signal = new AbortController().signal) {
|
||||
@@ -44,6 +55,36 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports the message-turn outcome when a later non-message turn completes during flush', async () => {
|
||||
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
|
||||
let injected = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (injected || session.header.parentSession === undefined) return
|
||||
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
|
||||
if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return
|
||||
injected = true
|
||||
const turn = lastEnd.data.turn + 1
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
|
||||
expect(child.session.events.findLast(event => event.type === 'turn/end'))
|
||||
.toMatchObject({ data: { reason: { kind: 'completed' } } })
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('seeds a forked child but reads only the child-owned output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
|
||||
parent.send([{ type: 'text', text: 'parent question' }])
|
||||
@@ -123,12 +164,16 @@ describe('startInProcessRun', () => {
|
||||
})
|
||||
|
||||
it('uses the request signal after publication and dispose as cancellation paths', async () => {
|
||||
const { parent } = await setup(['hang', 'hang'])
|
||||
const { parent, adapter } = await setup(['hang', 'hang'])
|
||||
const controller = new AbortController()
|
||||
const signalled = await startInProcessRun(request(parent, controller.signal), {})
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
controller.abort('stop child')
|
||||
await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
||||
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
|
||||
const child = parent.ctx.agents.get(signalled.id)
|
||||
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
await signalled.dispose()
|
||||
|
||||
const disposed = await startInProcessRun(request(parent), {})
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,17 +11,23 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-inprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -30,6 +36,7 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
@@ -42,7 +49,6 @@
|
||||
"@deepseek-ai/dsh-subagent-inprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/subagent/subagent-spawn/src/invariant.ts
Normal file
30
packages/subagent/subagent-spawn/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-spawn`.
|
||||
* @module @deepseek-ai/dsh-subagent-spawn/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-spawn-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -5,7 +5,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as spawn from '../src/index.ts'
|
||||
@@ -13,10 +16,17 @@ import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock
|
||||
* MODEL (the only mocked boundary) + the real SubagentService + the real
|
||||
* dsh-invariants plugin (so a malformed child session log would fail the test).
|
||||
* invariant service plus package companions (so a malformed child session log would fail the test).
|
||||
* The parent is a real config agent; the spawn provider creates a real child
|
||||
* agent on the same context and we assert its output.
|
||||
*/
|
||||
@@ -24,7 +34,7 @@ async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(script)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
@@ -295,7 +305,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../subagent-inprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,20 +11,27 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/subagent/subagent-subprocess/src/invariant.ts
Normal file
30
packages/subagent/subagent-subprocess/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-subprocess`.
|
||||
* @module @deepseek-ai/dsh-subagent-subprocess/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-subprocess-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -7,5 +7,9 @@
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": []
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -33,6 +39,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
91
packages/subagent/subagent/src/invariant.ts
Normal file
91
packages/subagent/subagent/src/invariant.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/** Package-owned subagent registry and lifecycle invariants. @module @deepseek-ai/dsh-subagent/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { SubagentProvider } from './types.ts'
|
||||
import type { SubagentRunEndInfo, SubagentRunInfo } from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Assert that a terminal lifecycle payload matches its start identity. */
|
||||
function validateRunEnd(start: SubagentRunInfo, end: SubagentRunEndInfo, fail: InvariantFailure): void {
|
||||
if (start.provider !== end.provider || start.id !== end.id || start.local !== end.local) {
|
||||
fail(`subagent/end identity diverges from subagent/start for run ${JSON.stringify(end.runId)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install provider-registry and start/end pairing checks. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const providers = new Set(ctx.subagents.list())
|
||||
const runs = new Map<string, SubagentRunInfo>()
|
||||
const stagedProviders = new WeakSet<SubagentProvider>()
|
||||
const stagedRemovals = new Set<string>()
|
||||
const stagedStarts = new WeakSet<SubagentRunInfo>()
|
||||
const stagedEnds = new WeakSet<SubagentRunEndInfo>()
|
||||
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName === 'subagent/provider-added') {
|
||||
const provider = args[0] as SubagentProvider
|
||||
if (provider.name.length === 0) fail('subagent provider names must be non-empty')
|
||||
if (providers.has(provider.name)) fail(`subagent/provider-added repeated ${JSON.stringify(provider.name)}`)
|
||||
stagedProviders.add(provider)
|
||||
return
|
||||
}
|
||||
if (eventName === 'subagent/provider-removed') {
|
||||
const providerName = args[0] as string
|
||||
if (!providers.has(providerName)) fail(`subagent/provider-removed names unknown provider ${JSON.stringify(providerName)}`)
|
||||
stagedRemovals.add(providerName)
|
||||
return
|
||||
}
|
||||
if (eventName === 'subagent/start') {
|
||||
const info = args[0] as SubagentRunInfo
|
||||
if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`)
|
||||
if (String(info.runId).length === 0 || String(info.id).length === 0) {
|
||||
fail('subagent/start runId and child id must be non-empty')
|
||||
}
|
||||
if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`)
|
||||
stagedStarts.add(info)
|
||||
return
|
||||
}
|
||||
if (eventName !== 'subagent/end') return
|
||||
const info = args[0] as SubagentRunEndInfo
|
||||
const start = runs.get(info.runId)
|
||||
if (start === undefined) fail(`subagent/end has no matching subagent/start for run ${JSON.stringify(info.runId)}`)
|
||||
validateRunEnd(start, info, fail)
|
||||
stagedEnds.add(info)
|
||||
}, { global: true })
|
||||
|
||||
ctx.on('subagent/provider-added', (provider) => {
|
||||
/* v8 ignore next -- internal/dispatch stages the same provider object */
|
||||
if (!stagedProviders.delete(provider)) return
|
||||
providers.add(provider.name)
|
||||
}, { global: true })
|
||||
ctx.on('subagent/provider-removed', (providerName) => {
|
||||
/* v8 ignore next -- internal/dispatch stages the same provider name */
|
||||
if (!stagedRemovals.delete(providerName)) return
|
||||
providers.delete(providerName)
|
||||
}, { global: true })
|
||||
ctx.on('subagent/start', (info) => {
|
||||
/* v8 ignore next -- internal/dispatch stages the same lifecycle object */
|
||||
if (!stagedStarts.delete(info)) return
|
||||
runs.set(info.runId, info)
|
||||
}, { global: true })
|
||||
ctx.on('subagent/end', (info) => {
|
||||
/* v8 ignore next -- internal/dispatch stages the same lifecycle object */
|
||||
if (!stagedEnds.delete(info)) return
|
||||
runs.delete(info.runId)
|
||||
}, { global: true })
|
||||
}, { inject: ['subagents'] })
|
||||
|
||||
/**
|
||||
* Register the subagent invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -56,7 +56,10 @@ export interface SubagentStartRequest {
|
||||
* The spawning ("parent") agent — the one whose tool call started this
|
||||
* subagent. REQUIRED: in-process backends read `parent.session.header` for
|
||||
* the working directory, the `parentSession` lineage to stamp on the child,
|
||||
* and the parent's delegation depth. Out-of-process backends (ACP) ignore it.
|
||||
* and the parent's delegation depth. The out-of-process backend (ACP) reads
|
||||
* exactly one field — the session header's cwd, the child's workspace when
|
||||
* no deployment `cwd` override is configured; nothing else crosses the
|
||||
* process boundary.
|
||||
*/
|
||||
readonly parent: Agent
|
||||
/**
|
||||
|
||||
82
packages/subagent/subagent/tests/invariant.spec.ts
Normal file
82
packages/subagent/subagent/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
SubagentProvider,
|
||||
SubagentRunEndInfo,
|
||||
SubagentRunInfo,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentInvariant from '@deepseek-ai/dsh-subagent/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SubagentInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const provider = (name: string): SubagentProvider => ({
|
||||
name,
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async () => { throw new Error('not used') },
|
||||
})
|
||||
|
||||
const start = (overrides: Partial<SubagentRunInfo> = {}): SubagentRunInfo => ({
|
||||
runId: SubagentRunId('run-1'),
|
||||
provider: 'mock',
|
||||
id: SessionId('child-1'),
|
||||
local: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const end = (overrides: Partial<SubagentRunEndInfo> = {}): SubagentRunEndInfo => ({
|
||||
...start(),
|
||||
stopReason: 'completed',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
function emitRun(ctx: Context, name: 'subagent/start', info: SubagentRunInfo): void
|
||||
function emitRun(ctx: Context, name: 'subagent/end', info: SubagentRunEndInfo): void
|
||||
function emitRun(ctx: Context, name: 'subagent/start' | 'subagent/end', info: SubagentRunInfo | SubagentRunEndInfo): void {
|
||||
ctx.emit(scopeTarget(ctx.subagents, {}), name as 'subagent/start', info)
|
||||
}
|
||||
|
||||
describe('subagent invariants', () => {
|
||||
it('accepts provider and run lifecycle pairs', async () => {
|
||||
const ctx = await setup()
|
||||
const mock = provider('mock')
|
||||
ctx.emit('subagent/provider-added', mock)
|
||||
emitRun(ctx, 'subagent/start', start())
|
||||
emitRun(ctx, 'subagent/end', end())
|
||||
ctx.emit('subagent/provider-removed', 'mock')
|
||||
ctx.emit('tools/change')
|
||||
})
|
||||
|
||||
it('rejects malformed provider transitions', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('subagent/provider-added', provider('')) }).toThrow(/names must be non-empty/)
|
||||
const mock = provider('mock')
|
||||
ctx.emit('subagent/provider-added', mock)
|
||||
expect(() => { ctx.emit('subagent/provider-added', mock) }).toThrow(/repeated "mock"/)
|
||||
expect(() => { ctx.emit('subagent/provider-removed', 'missing') }).toThrow(/unknown provider/)
|
||||
})
|
||||
|
||||
it('rejects malformed and unpaired run transitions', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/)
|
||||
ctx.emit('subagent/provider-added', provider('mock'))
|
||||
expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) })
|
||||
.toThrow(/runId and child id must be non-empty/)
|
||||
emitRun(ctx, 'subagent/start', start())
|
||||
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/)
|
||||
expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) })
|
||||
.toThrow(/no matching subagent\/start/)
|
||||
expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) })
|
||||
.toThrow(/identity diverges/)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
@@ -35,6 +41,7 @@
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -285,9 +285,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// Reject cancellation before spawning; after return, the task-owned
|
||||
// signal covers both pending startup and the ready child.
|
||||
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
|
||||
// Task preflight finishes before the starter can spawn a child.
|
||||
const id = tasks.start({
|
||||
kind: 'subagent',
|
||||
@@ -315,7 +312,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
config,
|
||||
args.prompt,
|
||||
parent,
|
||||
exec.signal ?? new AbortController().signal,
|
||||
exec.signal,
|
||||
)
|
||||
|
||||
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
|
||||
|
||||
30
packages/subagent/tool-subagent/src/invariant.ts
Normal file
30
packages/subagent/tool-subagent/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent`.
|
||||
* @module @deepseek-ai/dsh-tool-subagent/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-subagent-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
@@ -15,6 +15,8 @@ import * as tool from '../src/index.ts'
|
||||
import { runOutcome, settleRun } from '../src/index.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/**
|
||||
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
|
||||
* `ToolRegistry` + `SubagentService`, with a package-local scripted child
|
||||
@@ -45,6 +47,7 @@ function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undef
|
||||
// exactOptionalPropertyTypes the key is omitted rather than set to undefined.
|
||||
const agent = 'agent' in over ? over.agent : fakeAgent()
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name: 'subagent',
|
||||
arguments: args,
|
||||
@@ -100,11 +103,13 @@ describe('dsh-tool-subagent', () => {
|
||||
it('keeps foreground and background calls exclusive', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
expect(ctx.tools.executionMode({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('subagent-foreground'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'do work', prompt: 'Reply OK' },
|
||||
})).toEqual({ kind: 'exclusive' })
|
||||
expect(ctx.tools.executionMode({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('subagent-background'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
|
||||
@@ -139,8 +144,8 @@ describe('dsh-tool-subagent', () => {
|
||||
const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort()
|
||||
expect(names).toEqual(['subagent', 'subagent_acp'])
|
||||
|
||||
const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
|
||||
const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
|
||||
const viaSpawn = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
|
||||
const viaAcp = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
|
||||
expect(text(viaSpawn)).toBe('from spawn')
|
||||
expect(text(viaAcp)).toBe('from acp')
|
||||
})
|
||||
@@ -418,7 +423,7 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('passes an already-aborted signal so provider startup rejects', async () => {
|
||||
it('skips provider startup for an already-aborted signal', async () => {
|
||||
const sawAborted = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -438,8 +443,9 @@ describe('dsh-tool-subagent', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort() // already aborted BEFORE the tool runs
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
|
||||
expect(sawAborted).toHaveBeenCalledTimes(1)
|
||||
expect(sawAborted).not.toHaveBeenCalled()
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
|
||||
})
|
||||
|
||||
it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => {
|
||||
@@ -640,6 +646,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
expect(text(start)).toBe('started background subagent task subagent-1')
|
||||
|
||||
const collected = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('collect-1'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1', wait: true },
|
||||
@@ -649,6 +656,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
|
||||
// Final-output reads are idempotent (not consumed).
|
||||
const again = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('collect-2'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1' },
|
||||
@@ -664,14 +672,15 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
|
||||
})
|
||||
|
||||
it('refuses to start when the tool signal is already aborted', async () => {
|
||||
it('skips background startup when the tool signal is already aborted', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('subagent delegation aborted')
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
|
||||
expect(text(result)).toBe('Error: tool call aborted before dispatch')
|
||||
})
|
||||
|
||||
it('settles an asynchronous provider-start failure as a failed task', async () => {
|
||||
@@ -686,6 +695,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
tool.apply(ctx, { provider: 'broken-start', toolName: 'subagent_broken' })
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('broken-start'),
|
||||
name: 'subagent_broken',
|
||||
arguments: { description: 'broken', prompt: 'p', run_in_background: true },
|
||||
@@ -693,6 +703,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
})
|
||||
expect(text(started)).toBe('started background subagent task subagent-1')
|
||||
const output = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('broken-output'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1', wait: true },
|
||||
@@ -715,18 +726,21 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
tool.apply(ctx, { provider: 'pending-start', toolName: 'subagent_pending' })
|
||||
|
||||
await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('pending-start'),
|
||||
name: 'subagent_pending',
|
||||
arguments: { description: 'pending', prompt: 'p', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('pending-kill'),
|
||||
name: 'task_kill',
|
||||
arguments: { task_id: 'subagent-1', reason: 'no longer needed' },
|
||||
agent: parent,
|
||||
})
|
||||
const output = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('pending-output'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1', wait: true },
|
||||
@@ -764,19 +778,19 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
// Direct apply preserves omitted agentOptions instead of applying schema defaults.
|
||||
tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' })
|
||||
|
||||
const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
const startOne = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
const startTwo = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
expect(text(startOne)).toBe('started background subagent task subagent-1')
|
||||
expect(text(startTwo)).toBe('started background subagent task subagent-2')
|
||||
|
||||
const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
|
||||
const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
|
||||
const withReason = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
|
||||
const withoutReason = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
|
||||
expect(text(withReason)).toBe('requested cancellation of task subagent-1')
|
||||
expect(text(withoutReason)).toBe('requested cancellation of task subagent-2')
|
||||
expect(cancels).toEqual(['superseded', 'background subagent task killed'])
|
||||
|
||||
// The aborted children settle as killed tasks.
|
||||
const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
|
||||
const killed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
|
||||
expect(text(killed)).toBe('(no new output)\n[status: killed]')
|
||||
})
|
||||
|
||||
@@ -870,6 +884,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
|
||||
tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('probe-1'),
|
||||
name: 'subagent_probe',
|
||||
arguments: { description: 'd', prompt: 'p', run_in_background: true },
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user