feat(invariants): add package-owned service seam
This commit is contained in:
@@ -18,7 +18,12 @@ Read this package for the whole plugin tree and its composition order.
|
||||
@deepseek-ai/dsh-skill-local local filesystem skill provider
|
||||
@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events
|
||||
@deepseek-ai/dsh-tasks generic background-task registry
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-invariants configurable invariant registry service
|
||||
@deepseek-ai/dsh-session/invariant
|
||||
@deepseek-ai/dsh-agent/invariant
|
||||
@deepseek-ai/dsh-scope/invariant
|
||||
@deepseek-ai/dsh-agent-loop/invariant
|
||||
package-owned relational checks
|
||||
@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
|
||||
@@ -42,11 +47,13 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
|
||||
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, invariants? }
|
||||
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — 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.
|
||||
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); `invariants` to the invariant service; 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.
|
||||
|
||||
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
@@ -59,4 +66,4 @@ Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle.
|
||||
- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.
|
||||
- **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@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-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
@@ -50,6 +51,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -19,7 +19,11 @@ import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/d
|
||||
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 InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants'
|
||||
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant'
|
||||
import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
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'
|
||||
@@ -48,7 +52,8 @@ export interface SkillConfig {
|
||||
* `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;
|
||||
* plugins this bundle owns. `invariants` configures global and package-filtered
|
||||
* relational checks. 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
|
||||
@@ -75,6 +80,8 @@ export interface Config {
|
||||
toolBash?: toolBash.Config
|
||||
/** Generic background-task control-tool wait bounds. */
|
||||
toolTasks?: toolTasks.Config
|
||||
/** Global enablement and package-name filters for invariant companions. */
|
||||
invariants?: InvariantConfig
|
||||
}
|
||||
|
||||
/** The skill config schema exported for app packages that forward `skills`. */
|
||||
@@ -101,7 +108,8 @@ export const Config = z.intersect([
|
||||
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'>>,
|
||||
invariants: InvariantService.Config,
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants'>>,
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
@@ -120,6 +128,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
|
||||
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
|
||||
...config.invariants !== undefined ? { invariants: config.invariants } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +163,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(TaskService)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(InvariantService, config.invariants ?? {})
|
||||
ctx.plugin(sessionInvariant)
|
||||
ctx.plugin(agentInvariant)
|
||||
ctx.plugin(scopeInvariant)
|
||||
ctx.plugin(agentLoopInvariant)
|
||||
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
|
||||
if (config.workspaceContext !== false) {
|
||||
ctx.plugin(workspaceContext, config.workspaceContext)
|
||||
|
||||
@@ -12,6 +12,10 @@ 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'
|
||||
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant'
|
||||
import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
@@ -113,10 +117,33 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
expect(ctx.get('skills')).toBeDefined()
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('tasks')).toBeDefined()
|
||||
expect(ctx.get('invariants')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('mounts package companions and forwards invariant selection config', async () => {
|
||||
const nestedTurn = (ctx: Context): void => {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}
|
||||
|
||||
const enabled = await mount({ workspaceContext: false })
|
||||
expect(() => { nestedTurn(enabled) }).toThrow(/turn 1 is still open/)
|
||||
await enabled.fiber.dispose()
|
||||
|
||||
for (const invariants of [
|
||||
{ enabled: false },
|
||||
{ package_allowlist: ['^@deepseek-ai/dsh-agent$'] },
|
||||
{ package_blocklist: ['^@deepseek-ai/dsh-session$'] },
|
||||
]) {
|
||||
const filtered = await mount({ workspaceContext: false, invariants })
|
||||
expect(() => { nestedTurn(filtered) }).not.toThrow()
|
||||
await filtered.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
|
||||
const ctx = await mount({ workspaceContext: false })
|
||||
|
||||
@@ -355,6 +382,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
skills: {},
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
invariants: { enabled: false },
|
||||
}
|
||||
|
||||
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
|
||||
@@ -366,6 +394,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
skills: {},
|
||||
toolBash: appConfig.toolBash,
|
||||
toolTasks: appConfig.toolTasks,
|
||||
invariants: appConfig.invariants,
|
||||
})
|
||||
expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false })
|
||||
})
|
||||
@@ -426,4 +455,16 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
it('keeps every invariant companion loadable through the real Loader unwrap path', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) {
|
||||
expect('default' in companion).toBe(false)
|
||||
const unwrapped = loader.unwrapExports(companion) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(companion)
|
||||
expect(typeof unwrapped.name).toBe('string')
|
||||
expect(unwrapped.inject).toContain('invariants')
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js')
|
||||
// Symlink each required workspace package by package name so plain Node resolves its built `main`,
|
||||
// matching an installed dependency rather than tsconfig paths.
|
||||
const dshPackages = [
|
||||
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/scope', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
|
||||
Reference in New Issue
Block a user