Merge master into codex/session-title

This commit is contained in:
Tianyi Cui
2026-07-21 22:59:41 +08:00
197 changed files with 3343 additions and 1227 deletions

View File

@@ -57,7 +57,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; `sessionTitle` to the fallback title service; `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. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. 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; `sessionTitle` to the fallback title service; `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. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. 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",
@@ -58,9 +58,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

@@ -34,7 +34,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'
@@ -411,6 +413,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',
@@ -482,11 +485,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

@@ -66,7 +66,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']>> {
@@ -131,6 +133,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',
@@ -148,11 +151,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

@@ -398,9 +398,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')
})
@@ -486,7 +486,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'],