feat(bash): generalize managed shell environment

This commit is contained in:
Yichen Jiang
2026-07-12 15:41:42 +08:00
parent acdbe7b828
commit df9617aaff
40 changed files with 790 additions and 197 deletions

View File

@@ -39,11 +39,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-core'
// { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas,
// { agents?, persona?, toolOrder?, tools?, dshHome?, skills? } — the schema intersects the owner schemas,
// so validation and defaulting can never drift from the owners.
```
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; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. 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` 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; `dshHome` to tool-bash's managed environment and the local skill provider; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly.
## Why a code bundle, not a shared YAML include

View File

@@ -46,6 +46,7 @@
*/
import type { Context } from 'cordis'
import { resolve as resolvePath } from 'node:path'
import Timer from '@cordisjs/plugin-timer'
import z from 'schemastery'
import LlmService from '@deepseek-ai/dsh-llm'
@@ -78,7 +79,8 @@ 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 `skills` to the skill registry/local provider/tool consumer. Every field
* `dshHome` to the bash environment registry and local skill provider, and
* `skills` to the skill registry/local provider/tool consumer. Every field
* is optional INPUT here because each owner's schema supplies the default;
* the schema is the INTERSECTION of the owners' own schemas (with registry
* schemas nested under their bundle keys), so validation and defaulting can
@@ -93,6 +95,8 @@ export interface 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
/** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig
}
@@ -108,7 +112,7 @@ export const SkillConfigSchema: z<SkillConfig> = z.object({
export const Config = z.intersect([
AgentLoop.Config,
SystemPrompt.Config,
z.object({ tools: ToolRegistry.Config, skills: SkillConfigSchema }),
z.object({ tools: ToolRegistry.Config, dshHome: z.string(), skills: SkillConfigSchema }),
]) as unknown as z<Config>
/**
@@ -121,6 +125,13 @@ export const Config = z.intersect([
* 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
&& resolvePath(config.dshHome) !== resolvePath(nestedDshHome)) {
throw new Error('agent-core: dshHome and skills.local.dshHome must resolve to the same directory')
}
const dshHome = config.dshHome ?? nestedDshHome
ctx.plugin(Timer)
ctx.plugin(LlmService)
ctx.plugin(SessionStore)
@@ -136,10 +147,14 @@ 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 === undefined ? {} : { dshHome },
))
ctx.plugin(AgentRegistry)
ctx.plugin(invariants)
ctx.plugin(toolBash)
ctx.plugin(toolBash, dshHome === undefined ? {} : { dshHome })
ctx.plugin(toolSkill, config.skills?.tool ?? {})
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
}

View File

@@ -2,12 +2,24 @@ import { describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import { Context, Service } 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 } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
/** Minimal service that lets the executor-less bundle activate tool-bash in config-forwarding tests. */
class StubBashService extends Service {
constructor(ctx: Context) {
super(ctx, 'bash')
}
onTaskDone(): () => void {
return () => undefined
}
}
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const empty: Message[] = []
@@ -152,6 +164,39 @@ describe('dsh-agent-core 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 = new Context()
await ctx.plugin(StubBashService)
await ctx.plugin(agentCore, {
dshHome: home,
skills: { local: { agentsHome } },
})
await new Promise(resolve => setTimeout(resolve, 50))
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill'])
const execution: ToolExecution = {
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',
skills: { local: { dshHome: '/nested-dsh-home' } },
})
}).toThrow(/must resolve to the same directory/)
})
it('uses the default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()