Merge remote-tracking branch 'origin/master' into codex/cli-one-shot-demo

# Conflicts:
#	AGENTS.md
#	docs/architecture.md
#	docs/capability-seams.md
#	docs/cookbook/extension-cookbook.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	knip.json
#	packages/README.md
#	packages/support/loader-smoke/README.md
#	packages/support/loader-smoke/src/index.ts
#	vitest.snapshot.config.ts
This commit is contained in:
Tianyi Cui
2026-07-19 13:11:50 +08:00
851 changed files with 46937 additions and 13076 deletions

View File

@@ -4,7 +4,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) |
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |

View File

@@ -25,9 +25,12 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| Key | Default | Routed to |
|---|---|---|
| `model` | (required) | the per-session agent template the bridge creates agents from |
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `provider` | (required) | the initial provider route for each per-session agent the bridge creates; ACP model selection may replace it per session |
| `model` | (required) | the initial model for each per-session agent; ACP clients may switch among adapter-advertised models |
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial |
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |

View File

@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
@@ -50,6 +51,7 @@
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.7",

View File

@@ -13,14 +13,16 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
export const name = 'acp-demo'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
/**
* App config: the swappable per-deployment values. `model` configures the
* App config: the swappable per-deployment values. `provider` and `model` configure the
* agent template the ACP bridge creates each session's agent from (NOT a
* pre-created agent — ACP creates agents at `session/new`); `persona` is the
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
@@ -29,16 +31,24 @@ export const name = 'acp-demo'
* through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory.
*/
export interface Config {
/** Provider route for ACP-created agents. */
provider: string
/** Model name for ACP-created agents (must have a registered adapter). */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
@@ -51,16 +61,18 @@ export interface Config {
// the common fields would make two small app contracts depend on a new facade.
/* jscpd:ignore-start */
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persona: z.string(),
// The array default is forced to undefined: ABSENT means "lexicographic
// order" (the owning dsh-system-prompt schema does the same), while
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
// TODO(single-default-literal): share this schema default and the defensive
// apply() fallback through one named constant while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
dshHome: z.string(),
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
@@ -72,18 +84,11 @@ export const Config: z<Config> = z.object({
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
* `persona`; the JSONL backend persists under `persistenceRoot`; the ACP
* bridge owns stdout for JSON-RPC and creates one agent per `session/new`
* from `model`. No logger, no `hmr` — stdout stays pure.
* from the provider/model pair. No logger, no `hmr` — stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentCore, {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
})
ctx.plugin(agentCore, agentCore.pickSpineConfig(config))
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(acp, { model: config.model })
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
ctx.plugin(acp, { provider: config.provider, model: config.model })
}

View File

@@ -11,7 +11,7 @@ import * as acpAgent from '../src/index.ts'
/**
* In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition:
* mounting it brings up the agent-core spine + JSONL persistence + the ACP
* mounting it brings up the agent-spine-demo spine + JSONL persistence + the ACP
* bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO
* Loader-only plugin (no hmr), so it mounts in a plain Context.
*
@@ -70,7 +70,7 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
describe('dsh-acp-demo composition', () => {
it('brings up the spine + persistence + the ACP bridge', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig() })
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig(), workspaceContext: false })
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
@@ -83,22 +83,35 @@ describe('dsh-acp-demo composition', () => {
})
it('defaults the persistence root when omitted', async () => {
// Exercises the `?? './.sessions'` fallback for a direct-apply caller that
// Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that
// bypasses the schema's `.default(...)`: call `apply` directly (not via
// `ctx.plugin`, which validates+defaults the config first) with no
// persistenceRoot, so the runtime fallback is the one that fires.
const ctx = new Context()
// No persona: covers the omitted-persona forwarding branch too.
acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.get('sessionPersistence')).toBeDefined()
await ctx.fiber.dispose()
})
it('forwards explicit project-instruction controls to the bundled spine', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
persona: 'hi',
persistenceRoot: '/tmp/dsh-acp-demo-workspace-context',
workspaceContext: false,
})
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
await ctx.fiber.dispose()
})
it('uses default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()
acpAgent.apply(ctx, { model: 'mock' })
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([])
@@ -106,16 +119,32 @@ describe('dsh-acp-demo composition', () => {
})
})
it('forwards skill config into agent-core', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
it('forwards skill config and dshHome into agent-spine-demo', async () => {
const skills = await isolatedSkillsConfig(6)
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false })
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
await ctx.fiber.dispose()
})
it('forwards maxParallelToolCalls to the bundled agent loop', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
maxParallelToolCalls: 3,
persistenceRoot: '/tmp/dsh-acp-demo-test-parallel',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
await ctx.fiber.dispose()
})
it('forwards bundled tool config into agent-core', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
workspaceContext: false,
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
skills: await isolatedSkillsConfig(),
@@ -131,11 +160,13 @@ describe('dsh-acp-demo composition', () => {
expect(acpAgent.Config).toBeDefined()
})
it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
persistenceRoot: '/tmp/dsh-acp-demo-test-tool-order',
workspaceContext: false,
})
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.

View File

@@ -30,9 +30,9 @@ const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js')
const dshPackages = [
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo',
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths',
]
const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
@@ -78,14 +78,15 @@ async function makeConsumer(): Promise<string> {
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
' config:',
' apiKey: !!js process.env.DEEPSEEK_API_KEY',
' models: [deepseek-v4-flash]',
'- id: bash',
' name: \'@deepseek-ai/dsh-bash-local\'',
'- id: acp-agent',
' name: \'@deepseek-ai/dsh-acp-demo\'',
' config:',
' provider: deepseek',
' model: deepseek-v4-flash',
' persona: \'test agent\'',
' workspaceContext: false',
'',
].join('\n'))
return dir

View File

@@ -36,14 +36,15 @@ const CORDIS_YML = `
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
models: [deepseek-v4-flash]
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek
model: deepseek-v4-flash
persona: 'You are a test agent.'
workspaceContext: false
`
interface Spawned {

View File

@@ -29,6 +29,9 @@
{
"path": "../agent-spine-demo"
},
{
"path": "../../context/workspace-context"
},
{
"path": "../../ui/user-interaction"
},

View File

@@ -20,6 +20,7 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash schema
@deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
@deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
@@ -41,12 +42,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, persona?, toolOrder?, tools?, skills?, toolBash?, toolTasks? }
// The schema intersects the owner schemas,
// so validation and defaulting can never drift from the owners.
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `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; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
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 — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `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); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. 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 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, while `toolTasks` controls generic `task_output` wait bounds; 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.
## Why a code bundle, not a shared YAML include

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent-spine-demo",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + tool-skill + tool-tasks + agent-loop)",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -26,7 +26,9 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-skill-local": "^0.0.1",
@@ -42,8 +44,11 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",

View File

@@ -1,8 +1,8 @@
/**
* Default executor-less, UI-less agent spine. It bundles the common services,
* background-task registry and controls, concrete loop, local skill provider,
* and model-facing bash/skill consumers; deployments still choose the LLM
* adapter, bash executor, and presentation.
* background-task registry and controls, concrete loop, local skill and
* workspace-context providers, and model-facing bash/skill consumers;
* deployments still choose the LLM adapter, bash executor, and presentation.
* The plugin intentionally exposes named exports only because Loader default
* unwrapping would discard its `Config` schema (see docs/postmortem/0001).
* @module @deepseek-ai/dsh-agent-spine-demo
@@ -21,9 +21,11 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
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 { resolveDshHome } from '@deepseek-ai/dsh-home'
export const name = 'agent-spine-demo'
@@ -43,24 +45,30 @@ export interface SkillConfig {
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* and `toolBash`/`toolTasks` to the two model-facing tool plugins this bundle
* owns. Producer opt-in stays producer-local: `toolBash` configures bash only;
* future background-capable tools remain independently composed plugins.
* Every field is optional INPUT here because each owner's schema
* supplies the default (`[]` / `''` / absent — lexicographic / `native`); the
* schema is the INTERSECTION of the owners' own schemas (the registry's
* nested under its `tools` key), so validation and defaulting can never
* drift from them.
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
* plugins this bundle owns. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
* own config.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
agents?: AgentLoopConfig['agents']
/** Agent-loop concurrency cap; `1` is serial. */
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
/** The deployment persona (see dsh-system-prompt's `Config`). */
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig
/** DeepSeek Harness home directory shared by shell context and local skill discovery. */
dshHome?: string
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig
/** Model-facing bash tool config, including this producer's background opt-in. */
@@ -88,22 +96,51 @@ export const Config = z.intersect([
SystemPrompt.Config,
z.object({
tools: ToolRegistry.Config,
dshHome: z.string(),
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: ToolTasksConfigSchema,
}),
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
]) as unknown as z<Config>
/**
* Copy the bundle-owned fields from an app config without leaking front-door settings.
* @param config - App config containing the shared spine fields.
* @returns The fields accepted by this bundle, preserving optional absence.
*/
export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'agents'> {
return {
...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
...config.dshHome !== undefined ? { dshHome: config.dshHome } : {},
workspaceContext: config.workspaceContext,
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
}
}
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
* forwarded `persona` and `toolOrder`. Load order is irrelevant (cordis pends
* each fiber on its `inject` until the services it needs exist), but the
* forwarded `persona` and `toolOrder`. Workspace-context receives its own
* explicitly forwarded config. Load order is irrelevant (cordis
* pends each fiber on its `inject` until the services it needs exist), but the
* listing mirrors the dependency layering for readability: the LLM vocabulary
* and core registries first, then the dev tripwire and the bash tool consumer,
* then the loop that drives them.
* and core registries first, then extension plugins that wrap request/tool
* seams, then the loop that drives them.
*/
export function apply(ctx: Context, config: Config): void {
const nestedDshHome = config.skills?.local?.dshHome
if (config.dshHome !== undefined && nestedDshHome !== undefined
&& resolveDshHome(config.dshHome) !== resolveDshHome(nestedDshHome)) {
throw new Error('agent-core: dshHome and skills.local.dshHome must resolve to the same directory')
}
const dshHome = resolveDshHome(config.dshHome ?? nestedDshHome)
ctx.plugin(Timer)
ctx.plugin(LlmService)
ctx.plugin(SessionStore)
@@ -114,12 +151,20 @@ export function apply(ctx: Context, config: Config): void {
})
ctx.plugin(ToolRegistry, config.tools ?? {})
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, config.skills?.local ?? {})
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
ctx.plugin(AgentRegistry)
ctx.plugin(TaskService)
ctx.plugin(invariants)
ctx.plugin(toolBash, config.toolBash ?? {})
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
if (config.workspaceContext !== false) {
ctx.plugin(workspaceContext, config.workspaceContext)
}
// Both plugins prepend session-prefix messages. Registration order is the
// rendered order, so workspace instructions must precede the skill catalog.
ctx.plugin(toolSkill, config.skills?.tool ?? {})
ctx.plugin(toolTasks, config.toolTasks ?? {})
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
ctx.plugin(AgentLoop, {
agents: config.agents ?? [],
...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},
})
}

View File

@@ -1,13 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
@@ -34,7 +38,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
* Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
* bin smokes; here we assert the composition + config forwarding.
*/
async function mount(config?: agentCore.Config, withBash = false): Promise<Context> {
async function mount(config: agentCore.Config, withBash = false): Promise<Context> {
const oldDshHome = process.env.DSH_HOME
const oldAgentsHome = process.env.DSH_AGENTS_HOME
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-home-'))
@@ -82,9 +86,24 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
}
}
function waitForIdle(ctx: Context, target: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (agent, status) => {
if (agent === target && status === 'idle') {
dispose()
resolve()
}
})
})
}
function messageText(message: Message | undefined): string {
return message?.content.map(block => block.type === 'text' ? block.text : '').join('\n') ?? ''
}
describe('dsh-agent-spine-demo bundle', () => {
it('brings up the full default spine', async () => {
const ctx = await mount()
const ctx = await mount({ workspaceContext: false })
// One service from each layer of the spine proves the children loaded.
expect(ctx.get('timer')).toBeDefined()
expect(ctx.get('llm')).toBeDefined()
@@ -99,7 +118,7 @@ describe('dsh-agent-spine-demo bundle', () => {
})
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
const ctx = await mount()
const ctx = await mount({ workspaceContext: false })
expect(ctx.skills).toBeDefined()
expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill')
@@ -109,27 +128,40 @@ describe('dsh-agent-spine-demo bundle', () => {
})
it('defaults the agents list to empty (no pre-created agents)', async () => {
const ctx = await mount()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
const ctx = await mount({ workspaceContext: false })
expect(ctx.get('agents')?.get(SessionId('main'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => {
const ctx = await mount({
agents: [{ id: AgentId('main'), model: 'mock' }],
agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock' }],
persona: 'You are main.',
workspaceContext: false,
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
const agent = ctx.get('agents')?.list()[0]
expect(agent?.id).toBe(agent?.session.id)
expect(agent?.id).toMatch(/^main-session-/)
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.')
await ctx.fiber.dispose()
})
it('forwards the global maxParallelToolCalls config to agent-loop', async () => {
const ctx = await mount({
agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock' }],
maxParallelToolCalls: 3,
workspaceContext: false,
})
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
await ctx.fiber.dispose()
})
it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => {
// ctx.plugin validates + defaults the bundle config first; a direct apply
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
const ctx = new Context()
agentCore.apply(ctx, {})
agentCore.apply(ctx, { workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('agents')?.list()).toHaveLength(0)
@@ -138,6 +170,62 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('loads workspace instructions into requests through the bundled spine', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-'))
try {
await mkdir(join(root, '.git'), { recursive: true })
await writeFile(join(root, 'AGENTS.md'), 'bundled project rule')
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount({ workspaceContext: { maxBytes: 65536 } })
await ctx.plugin(LocalFileSystem, { cwd: '/' })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('main-session'),
meta: { cwd: root },
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent
agent.send([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, agent)
const sentText = adapter.requests[0]?.messages.map(messageText).join('\n')
expect(sentText).toContain('hi')
expect(sentText).toContain('bundled project rule')
expect(adapter.requests[0]?.system).toContain('You are an AI agent powered by the DeepSeek Harness SDK.')
expect(adapter.requests[0]?.system).not.toContain('bundled project rule')
await handle.dispose()
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('forwards workspace-context config to the bundled loader', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-disabled-'))
try {
await mkdir(join(root, '.git'), { recursive: true })
await writeFile(join(root, 'AGENTS.md'), 'must not be injected')
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount({ workspaceContext: { maxBytes: 0 } })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('main-disabled-session'),
meta: { cwd: root },
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, handle.agent)
expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
await handle.dispose()
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('forwards skill config to the registry, local provider, and model-facing consumer', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-agents-'))
@@ -146,6 +234,7 @@ describe('dsh-agent-spine-demo bundle', () => {
await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n')
const ctx = await mount({
agents: [],
workspaceContext: false,
skills: {
registry: { collectCacheMaxEntries: 4 },
local: {
@@ -161,8 +250,75 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('shares top-level dshHome between local skills and the managed bash environment', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-'))
await mkdir(join(home, 'skills'), { recursive: true })
await writeFile(join(home, 'skills', 'shared-skill.md'), '---\nname: shared-skill\ndescription: Shared home skill\n---\n\nShared body.\n')
const ctx = await mount({
dshHome: home,
workspaceContext: false,
skills: { local: { agentsHome } },
}, true)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill'])
const execution: ToolExecution = {
token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'],
callId: CallId('agent-core-dsh-home'),
name: 'bash',
arguments: { command: 'true' },
}
expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: home, DSH_SHELL: '1' })
await ctx.fiber.dispose()
})
it('rejects conflicting global and nested DSH home directories', () => {
expect(() => {
agentCore.apply(new Context(), {
dshHome: '/global-dsh-home',
workspaceContext: false,
skills: { local: { dshHome: '/nested-dsh-home' } },
})
}).toThrow(/must resolve to the same directory/)
})
it('places workspace instructions before the skill catalog in the session prefix', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-prefix-order-'))
try {
await mkdir(join(root, '.git'), { recursive: true })
await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills')
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await mount({ workspaceContext: { maxBytes: 65536 } })
await ctx.plugin(LocalFileSystem, { cwd: '/' })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.skills.register({
name: 'prefix-order-skill',
description: 'Skill catalog after workspace rules',
source: 'runtime',
content: 'body',
})
const handle = await ctx.agents.create({
sessionId: SessionId('prefix-order-session'),
meta: { cwd: root },
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, handle.agent)
expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills')
expect(messageText(adapter.requests[0]?.messages[1])).toContain('prefix-order-skill')
await handle.dispose()
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('forwards its bundled tool configs to tool-bash and tool-tasks', async () => {
const ctx = await mount({
workspaceContext: false,
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
}, true)
@@ -188,10 +344,36 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('picks shared spine config without leaking front-door fields', () => {
const appConfig = {
model: 'front-door-only',
persona: 'You are merged.',
toolOrder: ['zulu'],
tools: { mode: 'native' as const },
dshHome: '/tmp/dsh-home',
workspaceContext: false as const,
skills: {},
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
}
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
persona: appConfig.persona,
toolOrder: appConfig.toolOrder,
tools: appConfig.tools,
dshHome: appConfig.dshHome,
workspaceContext: false,
skills: {},
toolBash: appConfig.toolBash,
toolTasks: appConfig.toolTasks,
})
expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false })
})
it('uses the default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()
agentCore.apply(ctx, { agents: [] })
agentCore.apply(ctx, { agents: [], workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([])
@@ -200,7 +382,7 @@ describe('dsh-agent-spine-demo bundle', () => {
})
it('forwards toolOrder to the system-prompt assembly', async () => {
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST], workspaceContext: false })
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.
for (const name of ['alpha', 'zulu']) {
@@ -216,6 +398,16 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('supports direct apply with workspace instructions disabled and no forwarded agents', async () => {
const ctx = new Context()
agentCore.apply(ctx, { workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.get('agents')?.list()).toEqual([])
expect(ctx.get('systemPrompt')).toBeDefined()
await ctx.fiber.dispose()
})
it('re-exports the loop config schema as its own', () => {
expect(agentCore.Config).toBeDefined()
expect(agentCore.name).toBe('agent-spine-demo')

View File

@@ -41,12 +41,18 @@
{
"path": "../../core/agent"
},
{
"path": "../../context/workspace-context"
},
{
"path": "../../core/agent-loop"
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/home"
},
{
"path": "../../bash/tool-bash"
},

View File

@@ -8,12 +8,14 @@ The package mounts no console logger, readline UI, user-interaction service, or
| Key | Default | Routed to |
|---|---|---|
| `provider` | required | the pre-created `main` agent's provider route |
| `model` | required | the pre-created `main` agent's model |
| `persona` | — | the deployment persona in `dsh-system-prompt` |
| `toolOrder` | lexicographic | explicit model-facing tool order in `dsh-system-prompt` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `dsh-agent-spine-demo` |
| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool |
| `persistenceRoot` | `./.sessions` | JSONL session root |
| `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading |
Each process creates a new session whose workspace cwd is the launch directory. The app has no resume setting.
@@ -51,7 +53,7 @@ The headless-agent leaf supplies local bash, filesystem, skill, subagent, workfl
### One-shot task turn
**What the model sees**: The positional task becomes one user message. Through `dsh-agent-spine-demo`, the `main` agent also receives the configured persona, skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn.
**What the model sees**: The positional task becomes one user message. Through `dsh-agent-spine-demo`, the `main` agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn.
**Token effect**: The task, prompt sections, tool schemas, assistant output, and tool results consume tokens on each model step. JSON event streaming and final rendering add no model tokens; delegated child work has its own model usage and is not included in the parent result's `usage` total.

View File

@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
},
@@ -53,6 +54,7 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
}

View File

@@ -6,7 +6,7 @@
import { parseArgs } from 'node:util'
import type { Context } from 'cordis'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
@@ -40,7 +40,7 @@ export interface CliResult {
readonly usage?: TokenUsage
}
/** Options for one turn against the pre-created `main` agent. */
/** Options for one turn against the configured top-level agent. */
export interface OneShotOptions {
/** Exactly one nonblank user task. */
readonly task: string
@@ -186,17 +186,20 @@ async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise<v
}
/**
* Run one message-triggered turn on the pre-created `main` agent, aggregate its
* Run one message-triggered turn on the configured top-level agent, aggregate its
* final text and model usage, wait for idle plus an explicit persistence flush,
* and return its durable ending. Only the exact main-session task turn reaches
* and return its durable ending. Only the selected agent's task turn reaches
* `onEvent`; startup injections and unrelated sessions are ignored.
* @param ctx - settled Loader root containing `ctx.agents` and `ctx.sessions`.
* @param options - task, optional cancellation, and optional stream observer.
* @returns the DSH-native result envelope after durable quiescence.
*/
export async function runOneShot(ctx: Context, options: OneShotOptions): Promise<CliResult> {
const agent = ctx.get('agents')?.get(AgentId('main'))
if (agent === undefined) throw new Error('config did not create the required "main" agent')
const agents = ctx.get('agents')?.roots() ?? []
const [agent] = agents
if (agent === undefined || agents.length !== 1) {
throw new Error(`config must create exactly one top-level agent, found ${agents.length}`)
}
await waitForStartupIdle(agent, options.signal)
let targetTurn: number | undefined

View File

@@ -8,10 +8,11 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
@@ -19,6 +20,8 @@ export const name = 'cli-demo'
/** App config forwarded to the spine, pre-created agent, and JSONL backend. */
export interface Config {
/** Provider route for the `main` agent. */
provider: string
/** Model name for the `main` agent; a matching adapter must be registered. */
model: string
/** Deployment persona forwarded to the system-prompt plugin. */
@@ -31,9 +34,12 @@ export interface Config {
persistenceRoot?: string
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persona: z.string(),
@@ -41,6 +47,7 @@ export const Config: z<Config> = z.object({
// Absent means lexicographic order; schemastery's native array default is [].
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/**
@@ -52,7 +59,8 @@ export const Config: z<Config> = z.object({
*/
export function apply(ctx: Context, config: Config): void {
const spineConfig: agentCore.Config = {
agents: [{ id: AgentId('main'), model: config.model, cwd: process.cwd() }],
agents: [{ id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd() }],
workspaceContext: config.workspaceContext,
}
if (config.persona !== undefined) spineConfig.persona = config.persona
if (config.toolOrder !== undefined) spineConfig.toolOrder = config.toolOrder

View File

@@ -13,6 +13,7 @@ const dshPackages = [
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl',
'context/workspace-context',
]
const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
@@ -67,9 +68,11 @@ async function makeConsumer(): Promise<string> {
'- id: cli-agent',
" name: '@deepseek-ai/dsh-cli-demo'",
' config:',
' provider: built-cli-mock',
' model: built-cli-mock',
" persona: 'built CLI test'",
" persistenceRoot: './.sessions'",
' workspaceContext: false',
'',
].join('\n'))
return dir

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import { afterEach, describe, expect, it } from 'vitest'
@@ -44,13 +44,15 @@ describe('dsh-cli-demo app composition', () => {
it('composes the UI-less spine, JSONL persistence, and a main agent', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-compose-'))
const ctx = await mount({
provider: 'mock',
model: 'mock',
persona: 'Headless.',
tools: { mode: 'native' },
persistenceRoot: root,
skills: await skillConfig(),
workspaceContext: false,
})
const agent = ctx.get('agents')?.get(AgentId('main'))
const [agent] = ctx.get('agents')?.roots() ?? []
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(agent?.session.header.cwd).toBe(process.cwd())
@@ -67,10 +69,11 @@ describe('dsh-cli-demo app composition', () => {
try {
const ctx = new Context()
contexts.push(ctx)
cliDemo.apply(ctx, { model: 'mock' })
cliDemo.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
const [agent] = ctx.get('agents')?.roots() ?? []
expect(agent?.session.id).toMatch(/^main-session-/)
expect(await ctx.skills.list()).toEqual([])
} finally {
if (oldDshHome === undefined) delete process.env.DSH_HOME
@@ -80,9 +83,11 @@ describe('dsh-cli-demo app composition', () => {
}
const ctx = await mount({
provider: 'mock',
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
skills: await skillConfig(6),
workspaceContext: false,
})
ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' })
for (const name of ['alpha', 'zulu']) {

View File

@@ -2,7 +2,7 @@ import { readdir, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk, type TokenUsage } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { afterEach, describe, expect, it } from 'vitest'
@@ -93,9 +93,11 @@ async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
const ctx = new Context()
liveContexts.push(ctx)
await ctx.plugin(cliDemo, {
provider: 'mock',
model: 'mock',
persistenceRoot: root,
skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } },
workspaceContext: false,
})
await new Promise(resolve => setTimeout(resolve, 80))
ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script))
@@ -105,7 +107,7 @@ async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
parameters: { text: { type: 'string', required: true } },
execute: async args => [{ type: 'text', text: `ECHO: ${(args as { text: string }).text}` }],
})
const agent = ctx.agents.get(AgentId('main'))
const [agent] = ctx.agents.roots()
if (agent === undefined) throw new Error('test main agent missing')
return { ctx, agent, persistenceRoot: root }
}
@@ -304,7 +306,7 @@ describe('runOneShot and executeCli', () => {
const empty = new Context()
liveContexts.push(empty)
await expect(runOneShot(empty, { task: 'task' })).rejects.toThrow('required "main" agent')
await expect(runOneShot(empty, { task: 'task' })).rejects.toThrow('exactly one top-level agent')
const final = await harness([textResponse('answer')])
const output = await invoke(final.ctx, ['task'], { failStdout: true })

View File

@@ -11,23 +11,26 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| Plugin | Why it is here |
|---|---|
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` |
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the `main` agent |
| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the exact app-owned agent/session identity and rendering it as `main` |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends.
The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-spine-demo`, `hmr`, and the two leaf backends.
## Config
| Key | Default | Routed to |
|---|---|---|
| `provider` | (required) | the pre-created `main` agent's registered provider route |
| `model` | (required) | the pre-created `main` agent's model |
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial |
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
@@ -36,7 +39,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| `welcome` | `ready.` | the stdin-chat banner |
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-demo` was started. Resumed sessions keep the cwd stored in the persisted session header.
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt; `agent-loop/config-start-failed` instead drains and reports buffered input so a missing or corrupt persisted session cannot hang EOF. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
## The bin
@@ -54,7 +57,6 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
models: [deepseek-v4-flash]
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
@@ -62,6 +64,7 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
config:
provider: deepseek
model: deepseek-v4-flash
persona: 'You are a coding assistant powered by the {{model}} model.'
```

View File

@@ -35,8 +35,10 @@
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@cordisjs/plugin-logger-console": "^1.0.0",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-stdio": "^0.0.1",
@@ -52,9 +54,11 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@cordisjs/plugin-logger-console": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-stdio": "workspace:^",

View File

@@ -2,7 +2,8 @@
* The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the
* coupled front-door cluster a terminal chat needs — a console logger, the independently
* packaged readline UI, JSONL session persistence, the user-interaction seam with its
* `ask_user_question` tool, and a pre-created `main` agent the UI drives.
* `ask_user_question` tool, and one pre-created agent whose exact shared
* agent/session identity the UI drives under its `main` display label.
* Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
* Loader plugin intentionally exposes named exports only; a default export
* would hide its `Config` schema (see docs/postmortem/0001).
@@ -10,22 +11,25 @@
*/
import type { Context } from 'cordis'
import { randomUUID } from 'node:crypto'
import ConsoleExporter from '@cordisjs/plugin-logger-console'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiStdio from '@deepseek-ai/dsh-stdio'
export const name = 'stdio-demo'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
const DEFAULT_WELCOME = 'ready.'
/**
* App config: the swappable per-demo values, each routed to where the app wires
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
* it. `provider`/`model`/`resumeSessionId` configure the pre-created `main` agent (through
* {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
@@ -34,14 +38,20 @@ export const name = 'stdio-demo'
* `welcome` is the UI banner.
*/
export interface Config {
/** Provider route for the `main` agent. */
provider: string
/** Model name for the `main` agent (must have a registered adapter). */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -53,56 +63,62 @@ export interface Config {
/** Generic background-task control-tool config forwarded through agent-core. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/**
* If set, the `main` agent RESUMES this persisted session id instead of
* If set, the pre-created agent RESUMES this persisted session id instead of
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
*/
resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persona: z.string(),
// The array default is forced to undefined: ABSENT means "lexicographic
// order" (the owning dsh-system-prompt schema does the same), while
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
// TODO(single-default-literal): share these schema defaults and defensive
// apply() fallbacks through named constants while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
welcome: z.string().default('ready.'),
dshHome: z.string(),
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
welcome: z.string().default(DEFAULT_WELCOME),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/**
* Compose the spine with the stdio front door. The console logger comes first
* (infra), then the agent-spine-demo bundle pre-creating the `main` agent from this
* app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
* backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is
* a leaf concern (see the module doc), so it is not mounted here.
* Compose the spine with the stdio front door. Console logging, persistence,
* and user interaction mount first; the readline UI then waits on the agent
* registry and subscribes to config-start failures before agent-core can start
* the configured identity. The ask-user tool waits on the completed spine.
* The `hmr` dev-reload plugin is a leaf concern (see the module doc), so it is
* not mounted here.
*/
export function apply(ctx: Context, config: Config): void {
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
ctx.plugin(ConsoleExporter)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
ctx.plugin(UserInteractionService)
ctx.plugin(uiStdio, {
welcome: config.welcome ?? DEFAULT_WELCOME,
sessionId,
})
ctx.plugin(agentCore, {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
...agentCore.pickSpineConfig(config),
agents: [{
id: AgentId('main'),
id: SessionId('main'),
provider: config.provider,
model: config.model,
cwd: process.cwd(),
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
}],
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(UserInteractionService)
ctx.plugin(toolAskUser)
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
}

View File

@@ -21,9 +21,9 @@ const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js')
const dshPackages = [
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'examples/stdio-demo',
'session-persistence/session-persistence-jsonl', 'examples/stdio-demo', 'util/paths',
'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction',
]
const vendorPackages = [
@@ -36,20 +36,37 @@ async function pkgName(absDir: string): Promise<string> {
return json.name
}
async function installWorkspacePackageCopy(absDir: string, target: string): Promise<void> {
await mkdir(dirname(target), { recursive: true })
await cp(absDir, target, {
recursive: true,
filter: source => !source.split('/').includes('node_modules'),
})
}
/**
* Build a temporary external consumer with built workspace/vendor links and a mock-backed config.
* The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less
* entries rather than treating them as import failures.
*/
async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise<string> {
async function makeConsumer(
welcome: string,
disabledBrokenEntry = false,
extraDshPackages: string[] = [],
extraEntries: string[] = [],
): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
const nm = join(dir, 'node_modules')
for (const rel of dshPackages) {
for (const rel of [...dshPackages, ...extraDshPackages]) {
const abs = join(repoRoot, 'packages', rel)
const name = await pkgName(abs)
const target = join(nm, name)
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
if (extraDshPackages.includes(rel)) {
await installWorkspacePackageCopy(abs, target)
} else {
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
}
for (const v of vendorPackages) {
const abs = join(repoRoot, 'vendor', v)
@@ -73,9 +90,12 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi
'- id: stdio-agent',
' name: \'@deepseek-ai/dsh-stdio-demo\'',
' config:',
' provider: mock',
' model: mock-echo',
' persona: \'demo\'',
' workspaceContext: false',
` welcome: '${welcome}'`,
...extraEntries,
...disabledBrokenEntry
? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true']
: [],
@@ -147,6 +167,27 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.j
expect(code).toBe(0)
}, 30_000)
it('boots when optional spill plugins are loaded from a built consumer install', async () => {
consumer = await makeConsumer(
'SPILL-OK ready.',
false,
['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'],
[
'- id: spill-local',
' name: \'@deepseek-ai/dsh-spill-local\'',
'- id: spill-policy',
' name: \'@deepseek-ai/dsh-spill-policy\'',
' config:',
' maxInlineBytes: 50000',
],
)
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '')
expect(stderr).not.toContain('failed to load')
expect(stderr).not.toContain('Cannot find package')
expect(stdout).toContain('SPILL-OK ready.')
expect(code).toBe(0)
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
// boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
// directory cannot break its import; the include plugin's own read must fail loud instead.

View File

@@ -4,14 +4,15 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as stdioAgent from '../src/index.ts'
/**
* Unit coverage for app composition and config forwarding: console logger, pre-created main agent,
* agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the
* agent-spine-demo spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the
* keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
* survive namespace collapse while silently losing its schema.
*/
@@ -66,38 +67,71 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
describe('dsh-stdio-demo app', () => {
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig() })
// The spine services (brought up by the agent-core bundle) are all present.
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false })
// The spine services (brought up by the agent-spine-demo bundle) are all present.
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
// The pre-created `main` agent the UI drives.
const agent = ctx.get('agents')?.get(AgentId('main'))
// The sole pre-created agent the UI drives. `main` is its stable config
// label; each fresh process mints a durable combined agent/session id.
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
const agent = ctx.get('agents')?.list()[0]
expect(agent).toBeDefined()
expect(agent?.id).toBe(agent?.session.id)
expect(agent?.id).toMatch(/^main-session-/)
expect(agent?.session.header.cwd).toBe(process.cwd())
await ctx.fiber.dispose()
})
it('normalizes an empty resume id to a fresh exact app identity', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
resumeSessionId: '',
persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
const agent = ctx.get('agents')?.list()[0]
expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/)
expect(agent?.id).toBe(agent?.session.id)
await ctx.fiber.dispose()
})
it('defaults persistenceRoot and welcome when omitted', async () => {
// Direct apply (NOT via ctx.plugin, which validates+defaults the config
// first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on
// first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on
// apply()'s last two lines are the ones that fire — covering a
// schema-bypassing direct-mount caller.
const ctx = new Context()
// No persona: covers the omitted-persona forwarding branch too.
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
await new Promise(resolve => setTimeout(resolve, 80))
stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/)
await ctx.fiber.dispose()
})
it('forwards explicit project-instruction controls to the bundled spine', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
persona: 'hi',
persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context',
workspaceContext: false,
})
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/)
await ctx.fiber.dispose()
})
it('uses default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()
stdioAgent.apply(ctx, { model: 'mock' })
stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([])
@@ -107,29 +141,47 @@ describe('dsh-stdio-demo app', () => {
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
// A resume id defers agent creation until persistence loads; with no backing
// session the resume is contained + logged, so no `main` agent registers —
// session the resume is contained + logged, so no agent registers —
// the branch that maps resumeSessionId through is what this covers.
const ctx = await mount({
provider: 'mock',
model: 'mock',
persona: 'hi',
persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume',
resumeSessionId: 'no-such-session',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
expect(ctx.get('agents')?.list()).toEqual([])
await ctx.fiber.dispose()
})
it('forwards skill config into agent-core', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
it('forwards skill config and dshHome into agent-spine-demo', async () => {
const skills = await isolatedSkillsConfig(6)
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false })
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
await ctx.fiber.dispose()
})
it('forwards maxParallelToolCalls to the bundled agent loop', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
maxParallelToolCalls: 3,
persistenceRoot: '/tmp/dsh-stdio-demo-spec-parallel',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
await ctx.fiber.dispose()
})
it('forwards bundled tool config into agent-core', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
workspaceContext: false,
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
skills: await isolatedSkillsConfig(),
@@ -145,11 +197,13 @@ describe('dsh-stdio-demo app', () => {
expect(stdioAgent.Config).toBeDefined()
})
it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order',
workspaceContext: false,
})
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.

View File

@@ -32,6 +32,9 @@
{
"path": "../agent-spine-demo"
},
{
"path": "../../context/workspace-context"
},
{
"path": "../../ui/user-interaction"
},