fix: harden skill discovery
This commit is contained in:
@@ -38,6 +38,15 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
export const name = 'acp-agent'
|
||||
|
||||
const SkillConfigSchema: z<agentCore.SkillConfig> = z.object({
|
||||
dshHome: z.string(),
|
||||
agentsHome: z.string(),
|
||||
extraRoots: z.array(z.string()).default([]),
|
||||
installSystemSkills: z.boolean().default(true),
|
||||
promptFieldMaxLength: z.number().default(500),
|
||||
collectCacheMaxEntries: z.number().default(128),
|
||||
})
|
||||
|
||||
/**
|
||||
* App config: the swappable per-deployment values. `model`/`systemPrompt`
|
||||
* configure the agent template the ACP bridge creates each session's agent from
|
||||
@@ -51,12 +60,15 @@ export interface Config {
|
||||
systemPrompt: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill discovery config forwarded to the shared agent-core spine. */
|
||||
skills?: agentCore.SkillConfig
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
model: z.string().required(),
|
||||
systemPrompt: z.string().required(),
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
skills: SkillConfigSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -67,7 +79,7 @@ export const Config: z<Config> = z.object({
|
||||
* stdout stays pure.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(agentCore)
|
||||
ctx.plugin(agentCore, { agents: [], ...config.skills === undefined ? {} : { skills: config.skills } })
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtemp } 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 * as acpAgent from '../src/index.ts'
|
||||
@@ -22,9 +25,14 @@ async function mount(config: acpAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(): Promise<NonNullable<acpAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-skills-'))
|
||||
return { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }
|
||||
}
|
||||
|
||||
describe('dsh-acp-agent composition', () => {
|
||||
it('brings up the spine + persistence + the ACP bridge', async () => {
|
||||
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' })
|
||||
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() })
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
@@ -40,12 +48,18 @@ describe('dsh-acp-agent composition', () => {
|
||||
// `ctx.plugin`, which validates+defaults the config first) with no
|
||||
// persistenceRoot, so the runtime fallback is the one that fires.
|
||||
const ctx = new Context()
|
||||
acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
|
||||
acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its plugin shape', () => {
|
||||
expect(acpAgent.name).toBe('acp-agent')
|
||||
expect(acpAgent.Config).toBeDefined()
|
||||
|
||||
@@ -120,7 +120,12 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
|
||||
cwd: consumer,
|
||||
// Dummy key: initialize never reaches the model, so it is never used.
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(consumer, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(consumer, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const stderr: string[] = []
|
||||
@@ -180,7 +185,12 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
|
||||
cwd,
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
|
||||
@@ -90,6 +90,8 @@ async function boot(): Promise<Spawned & { cwd: string }> {
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
// Key-present check only; no prompt is sent, so the model is never called.
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
|
||||
@@ -49,6 +49,15 @@ import * as uiStdio from './stdio-chat.ts'
|
||||
|
||||
export const name = 'stdio-agent'
|
||||
|
||||
const SkillConfigSchema: z<agentCore.SkillConfig> = z.object({
|
||||
dshHome: z.string(),
|
||||
agentsHome: z.string(),
|
||||
extraRoots: z.array(z.string()).default([]),
|
||||
installSystemSkills: z.boolean().default(true),
|
||||
promptFieldMaxLength: z.number().default(500),
|
||||
collectCacheMaxEntries: z.number().default(128),
|
||||
})
|
||||
|
||||
/**
|
||||
* App config: the swappable per-demo values, each routed to where the app wires
|
||||
* it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main`
|
||||
@@ -66,6 +75,8 @@ export interface Config {
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Skill discovery config forwarded to the shared agent-core spine. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/**
|
||||
* If set, the `main` agent RESUMES this persisted session id instead of
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
@@ -79,6 +90,7 @@ export const Config: z<Config> = z.object({
|
||||
systemPrompt: z.string().required(),
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
welcome: z.string().default('ready.'),
|
||||
skills: SkillConfigSchema,
|
||||
resumeSessionId: z.string(),
|
||||
})
|
||||
|
||||
@@ -99,6 +111,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
cwd: process.cwd(),
|
||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
||||
}],
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
|
||||
|
||||
@@ -110,7 +110,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st
|
||||
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
|
||||
cwd,
|
||||
// Mock model: never calls the network, so no key needed.
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mkdtemp } 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 { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -28,9 +31,14 @@ async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
|
||||
return { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }
|
||||
}
|
||||
|
||||
describe('dsh-stdio-agent app', () => {
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' })
|
||||
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
|
||||
// The spine services (brought up by the agent-core bundle) are all present.
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
@@ -48,7 +56,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
// apply()'s last two lines are the ones that fire — covering a
|
||||
// schema-bypassing direct-mount caller.
|
||||
const ctx = new Context()
|
||||
stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
|
||||
stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
@@ -64,11 +72,18 @@ describe('dsh-stdio-agent app', () => {
|
||||
systemPrompt: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
|
||||
resumeSessionId: 'no-such-session',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
})
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-agent')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
|
||||
Reference in New Issue
Block a user