Merge remote-tracking branch 'origin/master' into fs-tool-clean

# Conflicts:
#	docs/architecture.md
#	docs/cordis-catalog/events-and-services.md
#	docs/module-graph.md
#	packages/README.md
This commit is contained in:
Tianyi Cui
2026-07-02 01:41:58 +08:00
90 changed files with 5710 additions and 293 deletions

View File

@@ -1,7 +1,6 @@
# coding-agent
The first REAL agent wiring: DeepSeek V4 + the bash tool suite + stdio chat
+ JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this example is a usable coding assistant.
The real stdio coding-agent wiring: DeepSeek V4 + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this example is a usable coding assistant.
## Run it
@@ -12,7 +11,7 @@ The first REAL agent wiring: DeepSeek V4 + the bash tool suite + stdio chat
pnpm run demo:coding
```
Type a coding task. The agent's only tools are `bash` (+ `bash_output` / `bash_kill` for background tasks): file reads, writes, searches, and test runs all happen through shell commands, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Reasoning streams dimmed; tool calls/results render inline.
Type a coding task. The agent works through `bash` (+ `bash_output` / `bash_kill` for background tasks): file reads, writes, searches, and test runs all happen through shell commands, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline.
```
> fix the failing test in /path/to/project
@@ -34,7 +33,7 @@ The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_
## What each leaf entry demonstrates
This example is a thin leaf `cordis.yml`: it picks the swappable backends and loads one app package. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (console logger, JSONL persistence, readline UI, the pre-created `main` agent) all live inside the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app and the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle it loads — so the leaf has only four entries:
This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (console logger, JSONL persistence, readline UI, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app and the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle it loads; the leaf wires the backends and model-facing optional tools:
| Entry | Demonstrates |
|---|---|
@@ -42,11 +41,16 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends and lo
| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin |
| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash`/`bash_output`/`bash_kill` tool schemas (`tool-bash`) come from `agent-core`, so only the executor is a leaf choice |
| `stdio-agent` (`@deepseek-ai/dsh-stdio-agent`) | the app bundle: the agent-core spine + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins |
| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio |
## End-to-end tests (`pnpm run test:e2e`, key-gated)
- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer.
- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted.
- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log.
- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction.
- `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event.
Both self-skip without `DEEPSEEK_API_KEY`.
These self-skip without `DEEPSEEK_API_KEY`. The keyless boot smoke is `tests/keyless-smoke.e2e.ts` (boots the full real tree with a dummy key and no prompt, so no model call), which runs in the default e2e gate.

View File

@@ -28,7 +28,9 @@
- deepseek-v4-pro
- deepseek-v4-flash
# Local bash executor (the model's only tool, via agent-core's tool-bash schema).
# Local bash executor for agent-core's tool-bash schema.
# FIXME(config-comments): keep this executor note from implying bash is the
# whole tool set; subagent and todo_write are loaded below.
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
@@ -44,7 +46,7 @@
# under ./.sessions); unset starts a fresh session each run.
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
welcome: 'coding-agent ready. Give it a coding task (its tools are bash and subagent).'
welcome: 'coding-agent ready. Give it a coding task (its tools are bash, subagent, and todo_write).'
systemPrompt: |
You are coding-agent, a CLI coding assistant.
@@ -65,6 +67,26 @@
failures before moving on. Verify your work by running the code or
tests. Keep answers brief and factual.
For multi-step work, use the todo_write tool to track a task list:
send the WHOLE list each call (it replaces the previous one), keep at
most one task in_progress (exactly one while work remains), and mark a
task completed as soon as it is done. Skip it for trivial single-step
tasks.
# Automatic context compaction: when the derived history approaches the model's
# context window, summarize an older range into a checkpoint so a long-running
# or tool-heavy session keeps fitting. A leaf entry (needs ctx.llm + the
# agent-loop's `agent/pre-step` seam from the app above).
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
config:
contextWindow: 128000
thresholdRatio: 0.8
retainTokens: 20480
summarizationModel: ''
maxTokens: 8192
compactionRetries: 1
# The subagent seam + BOTH in-process backends + two model-facing tools, as leaf
# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh
# child) and fork (a child seeded with the parent's completed-turn prefix) are
@@ -96,3 +118,8 @@
config:
provider: fork
toolName: subagent_fork
# The model-facing todo_write tool: whole-list task tracking written to the
# session log (todo/write), rendered as a stdio checklist / ACP plan.
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'

View File

@@ -0,0 +1,108 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
/**
* The compaction smoke test: a real model runs a multi-step bash task with a
* deliberately tiny context window, so the auto-compaction listener fires
* MID-SESSION and summarizes the older history into a checkpoint. This is the
* first end-to-end exercise of the compaction seam (it is wired nowhere else),
* and the runaway-survival regression net — it proves a session that grows past
* the window keeps running rather than overflowing. Key-gated.
*
* Verifies the WORLD, not the agent's self-report: a compact/start…end pair
* landed in the real session log, the surface actually shrank (a replace node
* exists and shadowed older nodes), and the agent still produced a final answer
* after compaction (so the summarized history did not break the conversation).
*
* FIXME(compaction-snapshot): this key-gated e2e is the ONLY coverage of runaway
* compaction — there is no keyless full-transcript snapshot of it. dsh-llm-replay
* reconstructs one model call per (turn, step) from `assistant/chunk` events, but
* `summarize()` assembles its stream into a local BlockAssembler and appends no
* `assistant/chunk`, so the interleaved summarization call is unreplayable. A
* snapshot needs replay-harness work to serve that call; deferred as a follow-up.
*/
let workdir: string | undefined
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compacts mid-flight and keeps running', () => {
it('summarizes older history into a checkpoint without breaking the task', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-compaction-'))
// A handful of files for the model to read, so multiple bash steps
// accumulate surface nodes (tool calls + results) and grow the history past
// the (deliberately tiny) window.
for (let i = 1; i <= 6; i++) {
await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40))
}
// Tiny window so a couple of steps crosses the threshold. The generation
// cap is deliberately larger than the final checkpoint because
// reasoning-capable APIs count reasoning tokens against the provider output
// budget even though those blocks are stripped before the checkpoint is
// stored.
ctx = await codingHarness(workdir, {
compact: {
contextWindow: 2400,
thresholdRatio: 0.5,
retainTokens: 500,
summarizationModel: '',
maxTokens: 2048,
compactionRetries: 1,
},
persistenceRoot: './.sessions',
})
const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), {
model: 'deepseek-v4-flash',
systemPrompt: SYSTEM_PROMPT,
})
agent.send([{
type: 'text',
text: 'Read file1.txt, file2.txt, file3.txt, file4.txt, file5.txt, and file6.txt one at a '
+ 'time using cat (a separate bash command for each). After reading all six, tell me how '
+ 'many files you read and the number mentioned in file1.txt.',
}])
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
// A compaction ran: the start…end bracket landed in the real log.
const starts = events.filter(e => e.type === 'compact/start')
const ends = events.filter(e => e.type === 'compact/end')
expect(starts.length).toBeGreaterThan(0)
expect(ends.length).toBe(starts.length) // every start was released
// It succeeded at least once: a compact/summary provenance event and a
// replace-op user/message (the surface mutation) both landed.
const summaries = events.filter(e => e.type === 'compact/summary')
expect(summaries.length).toBeGreaterThan(0)
const replaceNode = events.find((e) => {
const se = e as unknown as { type: string; surfaceOp?: unknown }
return se.type === 'user/message' && typeof se.surfaceOp === 'object' && se.surfaceOp !== null
})
expect(replaceNode).toBeDefined()
// The summary shadowed real older nodes (the surface shrank vs. the raw
// message-producing event count).
const summaryData = summaries[0]!.data as { shadowedSeqs: number[] }
expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0)
// The conversation survived compaction: the agent produced a final answer
// that reflects the work (it read six files).
const answer = finalText(events).toLowerCase()
expect(answer.length).toBeGreaterThan(0)
expect(answer).toMatch(/\b(6|six)\b/)
}, 240_000)
})

View File

@@ -8,20 +8,42 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
/**
* Shared harness for the coding-agent e2e suites: the full plugin stack
* with the real DeepSeek adapter and the real bash tool. Lives outside the
* *.e2e.ts pattern so importing it never re-registers another file's tests.
* with the real DeepSeek adapter and the real bash + todo_write tools. Lives
* outside the *.e2e.ts pattern so importing it never re-registers another
* file's tests.
*/
export const SYSTEM_PROMPT = 'You are a coding agent. Your only tool is bash; '
+ 'do file operations with cat/grep/heredocs, check [exit code: N] markers, '
export const SYSTEM_PROMPT = 'You are a coding agent. Use bash for file operations '
+ 'with cat/grep/heredocs; check [exit code: N] markers, '
+ 'and report results briefly.'
export async function codingHarness(workdir: string, persistenceRoot?: string): Promise<Context> {
/** System prompt for the todo_write e2e: nudges the model to plan with the tool. */
export const TODO_SYSTEM_PROMPT = 'You are a coding agent. For multi-step work, '
+ 'use the todo_write tool to track a task list: send the WHOLE list each call, '
+ 'keep at most one task in_progress (exactly one while work remains), and mark '
+ 'a task completed as soon as it is done.'
/** Options for {@link codingHarness}. */
export interface CodingHarnessOptions {
/** Durable JSONL persistence root (the resume suite needs it; others stay file-free). */
persistenceRoot?: string
/**
* Load {@link BasicCompactService} with this config so the compaction e2e can
* trigger compaction at a small, controlled history size. Omitted ⇒ no
* compaction plugin (the default suites run without it).
*/
compact?: BasicCompactConfig
}
export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -32,10 +54,14 @@ export async function codingHarness(workdir: string, persistenceRoot?: string):
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
await ctx.plugin(ToolBash)
await ctx.plugin(ToolTodo)
// Compaction is opt-in: only the compaction e2e loads it, with a lowered
// contextWindow/retainTokens so a short real session crosses the threshold.
if (options.compact !== undefined) await ctx.plugin(BasicCompactService, options.compact)
// Durable JSONL persistence is opt-in: only the resume e2e needs it, and the
// other suites stay file-free. Loaded last so a resume's deferred
// `ctx.inject(['sessionPersistence'])` resolves once this is present.
if (persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot })
if (options.persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
return ctx
}

View File

@@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
// Run 1: a fresh agent on a KNOWN session id learns a secret, then we
// dispose the whole context (simulating process exit) so only the JSONL
// log on disk survives.
ctx = await codingHarness(process.cwd(), root)
ctx = await codingHarness(process.cwd(), { persistenceRoot: root })
const first = ctx.agents.create({
agentId: AgentId('resume-1'),
sessionId: SESSION_ID,
@@ -52,7 +52,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
// Run 2: a brand-new context over the SAME root resumes the persisted
// session. The loaded event log seeds the live session, so the model sees
// run 1's exchange as conversation history.
ctx = await codingHarness(process.cwd(), root)
ctx = await codingHarness(process.cwd(), { persistenceRoot: root })
const resumed = (await ctx.agents.resume({
agentId: AgentId('resume-2'),
resumeSessionId: SESSION_ID,

View File

@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts'
/**
* A REAL model drives the REAL todo_write tool: verify the WORLD (the session
* log gains a todo/write event whose snapshot the model actually produced), not
* the agent's self-report. Key-gated (see vitest.e2e.config.ts).
*/
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a plan', () => {
it('appends a todo/write event with the model-produced task list', async () => {
ctx = await codingHarness(process.cwd())
const agent = ctx.agentLoop.create(AgentId('e2e-todo'), {
model: 'deepseek-v4-flash',
systemPrompt: TODO_SYSTEM_PROMPT,
})
agent.send([{ type: 'text', text:
'Use the todo_write tool to record a plan of exactly two steps: first '
+ '"inspect the failing test" (in_progress), then "apply the fix" (pending). '
+ 'Send both in one todo_write call, then reply with the single word DONE.' }])
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
// The model actually called the tool.
const calls = events.filter(event => event.type === 'tool/call')
expect(calls.some(event => event.data.name === 'todo_write')).toBe(true)
// And the tool wrote a todo/write event to the log — verify the WORLD.
const todoEvents = events.filter(event => event.type === 'todo/write')
expect(todoEvents.length).toBeGreaterThan(0)
const todos = (todoEvents.at(-1)!).data.todos
expect(todos).toEqual([
{ content: 'inspect the failing test', status: 'in_progress' },
{ content: 'apply the fix', status: 'pending' },
])
}, 120_000)
})