Merge current master into PR #106
This commit is contained in:
@@ -30,6 +30,8 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface Config {
|
||||
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. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task control-tool config forwarded through agent-core. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
}
|
||||
|
||||
// Each front door owns a complete, directly readable config schema; extracting
|
||||
@@ -62,6 +66,8 @@ export const Config: z<Config> = z.object({
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: agentCore.ToolTasksConfigSchema,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -73,13 +79,7 @@ export const Config: z<Config> = z.object({
|
||||
* from `model`. 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 } : {},
|
||||
workspaceContext: config.workspaceContext,
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(agentCore, agentCore.pickSpineConfig(config))
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(acp, { model: config.model })
|
||||
|
||||
@@ -19,8 +19,9 @@ import * as acpAgent from '../src/index.ts'
|
||||
* ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`;
|
||||
* this spec asserts the composition and the persistenceRoot default branch.
|
||||
*/
|
||||
async function mount(config: acpAgent.Config): Promise<Context> {
|
||||
async function mount(config: acpAgent.Config, withBash = false): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
await ctx.plugin(acpAgent, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services are ready.
|
||||
@@ -124,6 +125,20 @@ describe('dsh-acp-demo composition', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards bundled tool config into agent-core', async () => {
|
||||
const ctx = await mount({
|
||||
model: 'mock',
|
||||
workspaceContext: false,
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
skills: await isolatedSkillsConfig(),
|
||||
}, true)
|
||||
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
|
||||
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
|
||||
.not.toContain('run_in_background')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its plugin shape', () => {
|
||||
expect(acpAgent.name).toBe('acp-demo')
|
||||
expect(acpAgent.Config).toBeDefined()
|
||||
@@ -147,7 +162,7 @@ describe('dsh-acp-demo composition', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -96,8 +96,19 @@ let consumer: string | undefined
|
||||
let child: ReturnType<typeof spawn> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (child !== undefined) { child.kill('SIGKILL'); child = undefined }
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true })
|
||||
if (child !== undefined) {
|
||||
const proc = child
|
||||
child = undefined
|
||||
// Windows retains the child's cwd and session-log handles until process
|
||||
// teardown completes, so await exit before removing the temp directory.
|
||||
if (proc.exitCode === null && proc.signalCode === null) {
|
||||
const exited = new Promise<void>((resolve) => { proc.once('exit', () => { resolve() }) })
|
||||
proc.kill('SIGKILL')
|
||||
await exited
|
||||
}
|
||||
}
|
||||
// Windows can briefly retain released handles after exit; retry removal.
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ Read this package for the whole plugin tree and its composition order.
|
||||
@deepseek-ai/dsh-skill skill provider registry
|
||||
@deepseek-ai/dsh-skill-local local filesystem skill provider
|
||||
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
|
||||
@deepseek-ai/dsh-invariants runtime event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
|
||||
@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`)
|
||||
(dsh-system-prompt gets the forwarded `persona`)
|
||||
```
|
||||
@@ -40,11 +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?, workspaceContext } — workspaceContext requires { maxBytes } or false;
|
||||
// so validation and defaulting can never drift from the owners.
|
||||
// { agents?, persona?, toolOrder?, tools?, 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 the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it). Workspace instructions are registered before the skill catalog so their session-prefix message renders first. 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 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. `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
|
||||
|
||||
|
||||
@@ -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 + invariants + tool-bash + tool-skill + workspace-context + 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",
|
||||
@@ -32,8 +32,10 @@
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -49,8 +51,10 @@
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Default executor-less, UI-less agent spine. It bundles the common services,
|
||||
* concrete loop, local skill and workspace-context providers, and model-facing
|
||||
* bash/skill consumers;
|
||||
* 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).
|
||||
@@ -18,10 +18,12 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
|
||||
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
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'
|
||||
|
||||
export const name = 'agent-spine-demo'
|
||||
@@ -37,15 +39,18 @@ export interface SkillConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it — `agents` to the
|
||||
* agent loop (an app that pre-creates no agents, like the ACP bridge, 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`), `skills` to the skill registry/local provider/tool consumer, and
|
||||
* `workspaceContext` to the workspace-context loader.
|
||||
* The schema intersects the owners' schemas, which supply defaults for every
|
||||
* optional input and keep validation from drifting; workspace context instead requires an
|
||||
* explicit byte budget or `false` because it changes model-visible input.
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it —
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* 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`),
|
||||
* `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`). */
|
||||
@@ -60,6 +65,10 @@ export interface Config {
|
||||
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. */
|
||||
toolBash?: toolBash.Config
|
||||
/** Generic background-task control-tool wait bounds. */
|
||||
toolTasks?: toolTasks.Config
|
||||
}
|
||||
|
||||
/** The skill config schema exported for app packages that forward `skills`. */
|
||||
@@ -69,6 +78,12 @@ export const SkillConfigSchema: z<SkillConfig> = z.object({
|
||||
tool: toolSkill.Config,
|
||||
})
|
||||
|
||||
/** The bash-tool config schema exported for app packages that forward `toolBash`. */
|
||||
export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
|
||||
|
||||
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
|
||||
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
|
||||
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical. */
|
||||
export const Config = z.intersect([
|
||||
AgentLoop.Config,
|
||||
@@ -77,9 +92,28 @@ export const Config = z.intersect([
|
||||
tools: ToolRegistry.Config,
|
||||
skills: SkillConfigSchema,
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'skills' | 'workspaceContext'>>,
|
||||
toolBash: ToolBashConfigSchema,
|
||||
toolTasks: ToolTasksConfigSchema,
|
||||
}) as unknown as z<Pick<Config, 'tools' | '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.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
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
|
||||
@@ -103,13 +137,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(SkillService, config.skills?.registry ?? {})
|
||||
ctx.plugin(SkillLocal, config.skills?.local ?? {})
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(TaskService)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(toolBash, config.toolBash ?? {})
|
||||
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 ?? [] })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -10,7 +10,13 @@ import { AgentId, 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 type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
probe: 'probe'
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
const agent = { session: { header: { cwd } } } as unknown as Agent
|
||||
@@ -31,12 +37,13 @@ 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): 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-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-agents-'))
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
try {
|
||||
await ctx.plugin(agentCore, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
@@ -104,6 +111,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
expect(ctx.get('tools')).toBeDefined()
|
||||
expect(ctx.get('skills')).toBeDefined()
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('tasks')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -265,6 +273,58 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
|
||||
expect(bash).toBeDefined()
|
||||
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
|
||||
.not.toContain('run_in_background')
|
||||
|
||||
const id = ctx.tasks.start({
|
||||
kind: 'probe',
|
||||
label: 'config forwarding probe',
|
||||
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
|
||||
})
|
||||
const wait = vi.spyOn(ctx.tasks, 'wait')
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('task-config-forwarding'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: id, wait: true },
|
||||
})
|
||||
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
|
||||
|
||||
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 },
|
||||
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,
|
||||
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()
|
||||
@@ -289,7 +349,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
@@ -55,6 +52,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/tool-bash"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tool-tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `welcome` | `ready.` | the stdin-chat banner |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
@@ -49,6 +49,10 @@ export interface Config {
|
||||
welcome?: string
|
||||
/** 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. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** 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
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
@@ -72,6 +76,8 @@ export const Config: z<Config> = z.object({
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
welcome: z.string().default('ready.'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: agentCore.ToolTasksConfigSchema,
|
||||
resumeSessionId: z.string(),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
})
|
||||
@@ -86,17 +92,13 @@ export const Config: z<Config> = z.object({
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(ConsoleExporter)
|
||||
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'),
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
||||
}],
|
||||
workspaceContext: config.workspaceContext,
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(UserInteractionService)
|
||||
|
||||
@@ -117,7 +117,8 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st
|
||||
let consumer: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true })
|
||||
// Windows can briefly retain released handles after exit; retry removal.
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ import * as stdioAgent from '../src/index.ts'
|
||||
* keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
|
||||
* survive namespace collapse while silently losing its schema.
|
||||
*/
|
||||
async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
async function mount(config: stdioAgent.Config, withBash = false): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
await ctx.plugin(stdioAgent, config)
|
||||
// The app mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services + the pre-created agent are ready.
|
||||
@@ -138,6 +139,20 @@ describe('dsh-stdio-demo app', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards bundled tool config into agent-core', async () => {
|
||||
const ctx = await mount({
|
||||
model: 'mock',
|
||||
workspaceContext: false,
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
skills: await isolatedSkillsConfig(),
|
||||
}, true)
|
||||
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
|
||||
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
|
||||
.not.toContain('run_in_background')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-demo')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
@@ -161,7 +176,7 @@ describe('dsh-stdio-demo app', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user