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
+49 -6
View File
@@ -7,7 +7,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -21,6 +21,8 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { processOutcome } from '../src/background.ts'
import { renderProcessRead, renderResult } from '../src/render.ts'
const testToolSignal = new AbortController().signal
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
/** Foreground-only harness: no task runtime (backgrounding fails loud here). */
@@ -67,7 +69,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un
}
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
function text(result: { content: { type: string; text?: string }[] }): string {
@@ -185,7 +187,11 @@ async function setupSandboxed(withApproval = false) {
return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
}
function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent {
function sandboxAgent(
mode?: 'read-only' | 'workspace-write' | 'danger-full-access',
ctx?: Context,
onAppend?: (type: string) => void,
): Agent {
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
const id = SessionId('sandbox-session')
@@ -199,6 +205,7 @@ function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-acce
append: (type: string, data: Record<string, unknown>) => {
const event = { type, data }
events.push(event)
onAppend?.(type)
return event
},
},
@@ -469,7 +476,7 @@ describe('background execution through the task runtime', () => {
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
})
it('a pre-aborted call refuses to start: isError, no process spawned', async () => {
it('a pre-aborted call is skipped before the process starts', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -488,7 +495,11 @@ describe('background execution through the task runtime', () => {
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('command aborted')
expect(result.error).toEqual({
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(text(result)).toBe('Error: tool call aborted before dispatch')
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
})
@@ -615,6 +626,32 @@ describe('sandbox escalation through the generic task producer', () => {
expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
})
it('does not publish detached work when cancellation follows the escalation grant', async () => {
const { ctx, bash } = await setupSandboxed(true)
const controller = new AbortController()
const agent = sandboxAgent(undefined, ctx, (type) => {
if (type === 'approval/decided') controller.abort()
})
ctx.agents.register(agent)
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const start = vi.spyOn(bash, 'start')
const result = await ctx.tools.execute({
callId: CallId('cancelled-escalation-background'),
name: 'bash',
arguments: { ...escalate, run_in_background: true },
agent,
signal: controller.signal,
})
expect(result.error).toEqual({
message: 'tool call aborted',
info: { name: 'AbortError', code: TOOL_ABORTED },
})
expect(text(result)).toBe('Error: tool call aborted')
expect(start).not.toHaveBeenCalled()
})
it('uses the session override for ordinary calls and evaluates widening against it', async () => {
const { ctx, bash } = await setupSandboxed(true)
const agent = sandboxAgent('workspace-write')
@@ -764,7 +801,7 @@ describe('session-cwd routing (per-session workdir)', () => {
it('falls back to the executor default when the agent has no session cwd', async () => {
const ctx = await setup()
// No exec.agent at all → executor uses its config/process.cwd() default.
const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
expect(result.isError).toBe(false)
expect(text(result).trim().length).toBeGreaterThan(0)
})
@@ -1046,6 +1083,7 @@ describe('the model-facing bash tool builds its request from named args only (no
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('session-env-fg'),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
@@ -1066,6 +1104,7 @@ describe('the model-facing bash tool builds its request from named args only (no
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('session-env-bg'),
name: 'bash',
arguments: {
@@ -1092,6 +1131,7 @@ describe('the model-facing bash tool builds its request from named args only (no
const ambient = process.env.DSH_SESSION_ID
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('session-env-id-only'),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
@@ -1113,6 +1153,7 @@ describe('the model-facing bash tool builds its request from named args only (no
for (const [callId, agent] of [['parent', parent], ['child', child]] as const) {
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`session-env-${callId}`),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
@@ -1143,6 +1184,7 @@ describe('the model-facing bash tool builds its request from named args only (no
// This preserves the request shape; it is not a security boundary because shell syntax can
// already set environment variables or feed stdin.
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('no-forward-1'),
name: 'bash',
arguments: {
@@ -1164,6 +1206,7 @@ describe('the model-facing bash tool builds its request from named args only (no
it('a background bash call likewise carries no trusted-only fields', async () => {
const { ctx, bash } = await setupRecording()
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('no-forward-2'),
name: 'bash',
arguments: {