Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.md
#	docs/config-catalog.md
#	docs/cookbook/adding-a-tool.i18n.yaml
#	docs/cookbook/adding-a-tool.md
#	docs/cookbook/adding-a-tool.zh.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/tools.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/bash/tool-bash/src/index.ts
#	packages/core/agent-loop/src/tool-calls.ts
#	packages/core/agent-loop/tests/cancel.spec.ts
#	packages/core/agent-loop/tests/contract-regressions.spec.ts
#	packages/core/agent-loop/tests/tool-calls.spec.ts
#	packages/core/tools/README.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/tests/code-mode.spec.ts
#	packages/core/tools/tests/tools.spec.ts
#	packages/fs/tool-fs-search/tests/integration.spec.ts
#	packages/fs/tool-fs-search/tests/tools.spec.ts
#	packages/fs/tool-fs/tests/integration.spec.ts
#	packages/mcp/mcp-client/src/tools.ts
#	packages/timeout/timeout-policy/tests/timeout-policy.spec.ts
#	packages/web/tool-web/tests/integration.spec.ts
#	packages/web/tool-web/tests/tool-web.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-21 23:39:03 +08:00
194 changed files with 3387 additions and 1222 deletions

View File

@@ -55,7 +55,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.

View File

@@ -32,9 +32,9 @@
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-goal-session": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -57,9 +57,9 @@
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -33,7 +33,7 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
import * as llmRetry from '@deepseek-ai/dsh-llm-retry'
import { resolveDshHome } from '@deepseek-ai/dsh-home'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
export const name = 'agent-spine-demo'

View File

@@ -17,6 +17,8 @@ import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant'
import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
const testToolSignal = new AbortController().signal
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
probe: 'probe'
@@ -385,6 +387,7 @@ describe('dsh-agent-spine-demo bundle', () => {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill'])
const execution: ToolExecution = {
signal: testToolSignal,
token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'],
callId: CallId('agent-core-dsh-home'),
name: 'bash',
@@ -456,11 +459,12 @@ describe('dsh-agent-spine-demo bundle', () => {
})
const wait = vi.spyOn(ctx.tasks, 'wait')
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('task-config-forwarding'),
name: 'task_output',
arguments: { task_id: id, wait: true },
})
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal)
await ctx.fiber.dispose()
})

View File

@@ -63,7 +63,7 @@
"path": "../../support/invariants"
},
{
"path": "../../util/home"
"path": "../../util/paths"
},
{
"path": "../../bash/tool-bash"

View File

@@ -181,12 +181,12 @@ async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise<v
return
}
if (signal.aborted) {
agent.cancel(interruptionReason(signal))
agent.cancel({ kind: 'user' })
throw new CliInterruptedError(interruptionReason(signal))
}
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
agent.cancel(interruptionReason(signal))
agent.cancel({ kind: 'user' })
reject(new CliInterruptedError(interruptionReason(signal)))
}
signal.addEventListener('abort', onAbort, { once: true })
@@ -243,7 +243,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
options.onEvent(sessionId, event)
} catch (error: unknown) {
outputError = toError(error)
agent.cancel('stream output failed')
agent.cancel({ kind: 'user' })
}
}
@@ -273,7 +273,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
let onAbort: (() => void) | undefined
if (signal !== undefined) {
onAbort = (): void => {
agent.cancel(interruptionReason(signal))
agent.cancel({ kind: 'user' })
if (targetTurn === undefined) settleRejected(new CliInterruptedError(interruptionReason(signal)))
}
signal.addEventListener('abort', onAbort, { once: true })
@@ -370,7 +370,7 @@ async function bootInterruptibly(
export function formatTurnFailure(reason: TurnEndReason): string {
switch (reason.kind) {
case 'completed': return 'completed'
case 'aborted': return reason.reason === undefined ? 'was aborted' : `was aborted: ${reason.reason}`
case 'aborted': return 'was aborted'
case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}`
case 'disposed': return 'was disposed'
case 'max-tokens': return 'reached the model output-token limit'

View File

@@ -179,7 +179,7 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => {
)
expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null })
expect(result.stdout).toContain('"kind":"aborted"')
expect(result.stderr).toContain(`received ${signal}`)
expect(result.stderr).toContain('turn 1 was aborted')
}, 30_000)
})
})

View File

@@ -10,6 +10,8 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as cliDemo from '../src/index.ts'
const testToolSignal = new AbortController().signal
const contexts: Context[] = []
async function skillConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<cliDemo.Config['skills']>> {
@@ -137,6 +139,7 @@ describe('dsh-cli-demo app composition', () => {
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
const execution: ToolExecution = {
signal: testToolSignal,
token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'],
callId: CallId('cli-demo-dsh-home'),
name: 'bash',
@@ -154,11 +157,12 @@ describe('dsh-cli-demo app composition', () => {
})
const wait = vi.spyOn(ctx.tasks, 'wait')
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('cli-demo-task-config'),
name: 'task_output',
arguments: { task_id: id, wait: true },
})
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal)
})
it('accepts false to keep task services without model-facing task controls', async () => {

View File

@@ -402,9 +402,9 @@ describe('runOneShot and executeCli', () => {
await running
abort.abort('received SIGINT')
const output = await outcome
expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted', reason: 'received SIGINT' } })
expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted' } })
expect(output.code).toBe(1)
expect(output.stderr).toContain('was aborted: received SIGINT')
expect(output.stderr).toContain('turn 1 was aborted')
expect(agent.status).toBe('disposed')
})
@@ -490,7 +490,7 @@ describe('formatTurnFailure', () => {
const cases: [TurnEndReason, string][] = [
[{ kind: 'completed' }, 'completed'],
[{ kind: 'aborted' }, 'was aborted'],
[{ kind: 'aborted', reason: 'stop' }, 'was aborted: stop'],
[{ kind: 'aborted' }, 'was aborted'],
[{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'],
[{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'],
[{ kind: 'disposed' }, 'was disposed'],