add project instruction file loading

This commit is contained in:
Yichen Jiang
2026-06-25 16:04:42 +08:00
parent 4cfa22f997
commit d091946fc4
25 changed files with 1314 additions and 25 deletions
+1
View File
@@ -17,6 +17,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
@deepseek-ai/dsh-project-instructions AGENTS.md/CLAUDE.md workspace context loader
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
```
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent-core",
"description": "The providerless/executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop)",
"description": "The providerless/executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + project-instructions + agent-loop)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -27,6 +27,7 @@
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-project-instructions": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
@@ -39,6 +40,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-project-instructions": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
+30 -14
View File
@@ -4,9 +4,9 @@
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
* service, the session store, system-prompt assembly, the tool registry, the
* agent registry, the dev-mode invariants, the model-facing `bash` tool
* schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
* list as its OWN config (default `[]`), so each app supplies its own
* pre-created agents.
* schemas, project instruction loading, and the concrete `agent-loop` — and
* forwards the loop's `agents` list as its OWN config (default `[]`), so each
* app supplies its own pre-created agents.
*
* It is deliberately NOT the whole app: the swappable choices stay OUTSIDE the
* bundle, picked by whatever loads it.
@@ -44,6 +44,7 @@
import type { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
import z from 'schemastery'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -51,29 +52,41 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
import * as projectInstructions from '@deepseek-ai/dsh-project-instructions'
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
export const name = 'agent-core'
/**
* Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
* — an app that pre-creates no agents (the ACP bridge creates them on demand at
* `session/new`) simply omits it; an app that needs a pre-created `main` (the
* stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
* the forwarded shape can never drift.
* Bundle config: the agent-loop `agents` list plus project-instruction loader
* controls. `agents` defaults to `[]` — an app that pre-creates no agents (the
* ACP bridge creates them on demand at `session/new`) simply omits it; an app
* that needs a pre-created `main` (the stdio chat) supplies one.
*/
export type Config = AgentLoopConfig
export interface Config {
agents?: AgentLoopConfig['agents']
projectInstructions?: projectInstructions.Config | false
}
/** Forward the loop's own schema so validation + defaulting stay identical. */
export const Config = AgentLoop.Config
const AgentsConfig = z.array(z.object({
id: z.string().required(),
model: z.string(),
systemPrompt: z.string(),
resumeSessionId: z.string(),
})).default([])
export const Config: z<Config> = z.object({
agents: AgentsConfig,
projectInstructions: z.union([z.const(false), projectInstructions.Config]),
}) as unknown as z<Config>
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
* `agent-loop` receives the forwarded `agents` list. 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.
* vocabulary and core registries first, then extension plugins that wrap the
* request/tool seams, then the loop that drives them.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(Timer)
@@ -84,5 +97,8 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(AgentRegistry)
ctx.plugin(invariants)
ctx.plugin(toolBash)
ctx.plugin(AgentLoop, { agents: config.agents })
if (config.projectInstructions !== false) {
ctx.plugin(projectInstructions, config.projectInstructions ?? {})
}
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
}
@@ -1,8 +1,14 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import * as agentCore from '../src/index.ts'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { MockAdapter, textResponse } from '../../agent-loop/tests/mock-adapter.ts'
import type { Message } from '@deepseek-ai/dsh-llm'
/**
* Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
@@ -23,6 +29,22 @@ async function mount(config?: agentCore.Config): Promise<Context> {
return ctx
}
function waitForMainIdle(ctx: Context): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (agent, status) => {
if (agent.id === 'main' && status === 'idle') {
dispose()
resolve()
}
})
})
}
function firstText(message: Message | undefined): string | undefined {
const block = message?.content[0]
return block?.type === 'text' ? block.text : undefined
}
describe('dsh-agent-core bundle', () => {
it('brings up the full providerless spine', async () => {
const ctx = await mount()
@@ -51,6 +73,61 @@ describe('dsh-agent-core bundle', () => {
await ctx.fiber.dispose()
})
it('loads project instructions into requests through the bundled spine', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-core-project-instructions-'))
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()
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
agentId: AgentId('main'),
sessionId: SessionId('main-session'),
meta: { cwd: root },
agentOptions: { model: 'mock' },
})
const agent = handle.agent
agent.send([{ type: 'text', text: 'hi' }])
await waitForMainIdle(ctx)
expect(adapter.requests[0]?.messages[0]?.role).toBe('user')
expect(firstText(adapter.requests[0]?.messages[0])).toContain('bundled project rule')
expect(adapter.requests[0]?.system).toBeUndefined()
await handle.dispose()
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('forwards project-instructions config to the bundled loader', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-core-project-instructions-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({ projectInstructions: { baselineMaxBytes: 0 } })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
agentId: AgentId('main'),
sessionId: SessionId('main-disabled-session'),
meta: { cwd: root },
agentOptions: { model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'hi' }])
await waitForMainIdle(ctx)
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('re-exports the loop config schema as its own', () => {
expect(agentCore.Config).toBeDefined()
expect(agentCore.name).toBe('agent-core')
+3
View File
@@ -29,6 +29,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/project-instructions"
},
{
"path": "../../core/agent-loop"
},