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:
@@ -451,11 +451,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
subagentProvider: resolved.subagentProvider,
|
||||
maxTotalAgents: maxRounds,
|
||||
parent,
|
||||
...exec.signal === undefined ? {} : { signal: exec.signal },
|
||||
signal: exec.signal,
|
||||
})
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
exec.signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (exec.signal.aborted) run.cancel('parent step aborted')
|
||||
|
||||
try {
|
||||
const settled = await run.result
|
||||
@@ -469,7 +469,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
result: value as unknown as JsonValue,
|
||||
}
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onAbort)
|
||||
exec.signal.removeEventListener('abort', onAbort)
|
||||
await run.dispose()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '
|
||||
import * as toolRalph from '../src/index.ts'
|
||||
|
||||
type MockScript = ConstructorParameters<typeof MockAdapter>[0]
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/** Mount the shipped Ralph execution stack around one keyless model script. */
|
||||
async function mountRalph(script: MockScript, config: toolRalph.Config) {
|
||||
@@ -81,6 +82,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
|
||||
children.push(agent!)
|
||||
})
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ralph-integration'),
|
||||
name: 'ralph',
|
||||
arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
|
||||
@@ -132,6 +134,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ralph-child-failure'),
|
||||
name: 'ralph',
|
||||
arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
|
||||
@@ -220,6 +223,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
|
||||
], config)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ralph-script-enforcement'),
|
||||
name: 'ralph',
|
||||
arguments: { objective: 'Complete the scoped work.', maxRounds: config.maxRounds },
|
||||
|
||||
@@ -7,23 +7,27 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
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 { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import * as toolRalph from '../src/index.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
class StubEngine extends WorkflowService {
|
||||
requests: WorkflowStartRequest[] = []
|
||||
cancels: string[] = []
|
||||
disposed = 0
|
||||
settle!: (result: WorkflowResult) => void
|
||||
startError: Error | undefined
|
||||
onStart: (() => void) | undefined
|
||||
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
if (this.startError !== undefined) throw this.startError
|
||||
this.requests.push(request)
|
||||
const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
|
||||
this.onStart?.()
|
||||
return {
|
||||
id: WorkflowRunId(`ralph-${this.requests.length}`),
|
||||
meta: request.meta,
|
||||
@@ -94,11 +98,11 @@ function execute(
|
||||
extra?: { agent?: Agent; signal?: AbortSignal },
|
||||
): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({
|
||||
signal: extra?.signal ?? testToolSignal,
|
||||
callId: CallId('ralph-call'),
|
||||
name: 'ralph',
|
||||
arguments: args,
|
||||
...extra?.agent === undefined ? {} : { agent: extra.agent },
|
||||
...extra?.signal === undefined ? {} : { signal: extra.signal },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -262,7 +266,7 @@ describe('dsh-tool-ralph', () => {
|
||||
expect(engine.disposed).toBe(4)
|
||||
})
|
||||
|
||||
it('bridges mid-flight and already-aborted parent signals to cancellation', async () => {
|
||||
it('bridges mid-flight cancellation and skips dispatch for an already-aborted parent signal', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = execute(ctx, { objective: 'Work.' }, { agent: parent, signal: controller.signal })
|
||||
@@ -272,9 +276,24 @@ describe('dsh-tool-ralph', () => {
|
||||
|
||||
const already = new AbortController()
|
||||
already.abort()
|
||||
expect((await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: already.signal })).isError).toBe(true)
|
||||
expect(engine.cancels).toEqual(['parent step aborted', 'parent step aborted'])
|
||||
expect(engine.disposed).toBe(2)
|
||||
const skipped = await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: already.signal })
|
||||
expect(skipped.error?.info?.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH)
|
||||
expect(engine.requests).toHaveLength(1)
|
||||
expect(engine.cancels).toEqual(['parent step aborted'])
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('bridges cancellation that arrives while the workflow is starting', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
engine.onStart = () => { controller.abort() }
|
||||
|
||||
const result = await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: controller.signal })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(engine.requests[0]?.signal).toBe(controller.signal)
|
||||
expect(engine.cancels).toEqual(['parent step aborted'])
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects absent authority, empty objectives, bad round caps, and schema-invalid calls before start', async () => {
|
||||
|
||||
@@ -193,17 +193,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
meta: args.meta,
|
||||
...args.args !== undefined ? { args: args.args } : {},
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
signal: exec.signal,
|
||||
})
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is aborted while the
|
||||
// script is in flight, cancel the whole run. The signal also enters the engine directly, but
|
||||
// this local bridge preserves the tool contract even if an implementation ignores it.
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before
|
||||
// this line — cancel explicitly in that case.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
exec.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
const result = await run.result
|
||||
@@ -219,7 +216,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
result: result.value as JsonValue,
|
||||
}
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onAbort)
|
||||
exec.signal.removeEventListener('abort', onAbort)
|
||||
// Always reach run quiescence — never leak a live script or children.
|
||||
await run.dispose()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
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 { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
|
||||
@@ -13,6 +13,8 @@ import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
|
||||
import * as toolWorkflow from '../src/index.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
|
||||
class StubEngine extends WorkflowService {
|
||||
requests: WorkflowStartRequest[] = []
|
||||
@@ -60,6 +62,7 @@ const META = { name: 'audit', description: 'd' }
|
||||
|
||||
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('call-1'),
|
||||
name: 'workflow',
|
||||
arguments: args,
|
||||
@@ -156,14 +159,19 @@ describe('dsh-tool-workflow', () => {
|
||||
expect(result.error?.info?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
|
||||
it('cancels the run when exec.signal is ALREADY aborted at call time', async () => {
|
||||
it('skips workflow startup when exec.signal is already aborted', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(engine.cancels).toContain('parent step aborted')
|
||||
expect(engine.disposed).toBe(1)
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
})
|
||||
expect(engine.requests).toHaveLength(0)
|
||||
expect(engine.cancels).toHaveLength(0)
|
||||
expect(engine.disposed).toBe(0)
|
||||
})
|
||||
|
||||
it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user