Add runnable echo-agent example

cordis.yml-wired demo proving the full stack end to end: mock-echo
LlmAdapter (streams text; calls the echo tool on "echo <text>"),
echo tool, stdio chat UI plugin (consumes only the agent/* taxonomy),
and a JSONL persistence plugin demonstrating the write-behind +
session/flush checkpoint pattern. Runs unbuilt via tsx with loader,
include, and HMR live-reload all active (yarn demo).
This commit is contained in:
Tianyi Cui
2026-06-11 10:55:05 +08:00
parent 43f4258277
commit 53d1ef4a74
7 changed files with 245 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
# The echo-agent plugin tree, loaded via @cordisjs/plugin-include.
# Core services first, then the demo plugins, then the agent itself.
- id: logger
name: '@cordisjs/plugin-logger-console'
- id: timer
name: '@cordisjs/plugin-timer'
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
root: ['.']
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: sessions
name: '@deepseek-ai/dsh-session'
- id: system-prompt
name: '@deepseek-ai/dsh-system-prompt'
- id: tools
name: '@deepseek-ai/dsh-tools'
- id: agents
name: '@deepseek-ai/dsh-agent'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents:
- id: main
model: mock-echo
systemPrompt: 'You are echo-agent, a demo agent.'
- id: mock-llm
name: './src/mock-llm.ts'
- id: echo-tool
name: './src/echo-tool.ts'
- id: session-jsonl
name: './src/session-jsonl.ts'
- id: stdio-chat
name: './src/stdio-chat.ts'

View File

@@ -0,0 +1,7 @@
{
"name": "echo-agent-example",
"private": true,
"version": "0.0.1",
"type": "module",
"description": "Runnable demo: stdin chat with a scripted mock model + echo tool"
}

View File

@@ -0,0 +1,19 @@
import type { Context } from 'cordis'
export const name = 'echo-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register({
name: 'echo',
description: 'Echo the given text back, uppercased.',
parameters: {
type: 'object',
properties: { text: { type: 'string' } },
required: ['text'],
},
async execute(args: any) {
return [{ type: 'text', text: `ECHO: ${String(args?.text ?? '').toUpperCase()}` }]
},
})
}

View File

@@ -0,0 +1,59 @@
import type { Context } from 'cordis'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
/**
* Demo adapter for the `mock-echo` model.
*
* Behavior: if the last user text starts with "echo ", it calls the `echo`
* tool with the rest of the line (exercising the tool round-trip), otherwise
* it streams a canned reply quoting the input.
*/
class MockEchoAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const lastUserText = [...options.messages].reverse()
.filter(message => message.role === 'user')
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
.find(text => !text.startsWith('<')) ?? ''
const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result')
if (lastUserText.startsWith('echo ') && !hasToolResult) {
const payload = lastUserText.slice(5)
const args = JSON.stringify({ text: payload })
yield { type: 'block-start', index: 0, blockType: 'text' }
for (const char of 'Let me echo that for you.') {
yield { type: 'text-delta', index: 0, text: char }
await new Promise(resolve => setTimeout(resolve, 2))
}
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Let me echo that for you.' } }
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 1, id: 'call-echo', name: 'echo', argumentsDelta: args }
yield { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'call-echo', name: 'echo', arguments: args } }
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } }
yield { type: 'finish', reason: { kind: 'tool-calls' } }
return
}
const reply = hasToolResult
? 'The echo tool has spoken.'
: `You said: "${lastUserText}". Try "echo <something>" to see a tool call.`
yield { type: 'block-start', index: 0, blockType: 'text' }
for (const char of reply) {
yield { type: 'text-delta', index: 0, text: char }
await new Promise(resolve => setTimeout(resolve, 2))
}
yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: reply.length } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'mock-llm'
export const inject = ['llm']
export function apply(ctx: Context) {
ctx.llm.registerAdapter(['mock-echo'], new MockEchoAdapter())
}

View File

@@ -0,0 +1,36 @@
import { appendFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
export const name = 'session-jsonl'
export const inject = ['sessions']
/**
* Minimal persistence plugin: buffers session events (write-behind) and
* drains to a JSONL file at every `session/flush` checkpoint — the pattern a
* real JSONL/sqlite persistence plugin would follow.
*/
export function apply(ctx: Context) {
const buffers = new Map<Session, SessionEvent[]>()
const path = (session: Session) => join(import.meta.dirname, '..', `${session.id}.jsonl`)
ctx.on('session/event', (session, event) => {
let buffer = buffers.get(session)
if (!buffer) buffers.set(session, buffer = [])
buffer.push(event)
})
const flush = async (session: Session) => {
const buffer = buffers.get(session)
if (!buffer?.length) return
const lines = buffer.splice(0).map(event => JSON.stringify(event) + '\n').join('')
await appendFile(path(session), lines)
}
ctx.on('session/flush', flush)
ctx.effect(() => () => {
// drain remaining buffers on dispose
for (const session of buffers.keys()) void flush(session)
}, 'session-jsonl')
}

View File

@@ -0,0 +1,60 @@
import { createInterface } from 'node:readline'
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-agent'
export const name = 'stdio-chat'
export const inject = ['agents']
/**
* Minimal UI plugin: reads lines from stdin → agent.send(); renders the
* agent's stream chunks and tool activity to stdout. Demonstrates that a UI
* is "just a plugin" — it only consumes the agent/* event taxonomy.
*/
export function apply(ctx: Context) {
ctx.on('agent/stream-chunk', (agent, _turn, _step, chunk) => {
if (chunk.type === 'text-delta') process.stdout.write(chunk.text)
})
ctx.on('agent/turn-start', (agent, turn) => {
process.stdout.write(`\n[${agent.id} turn ${turn}] `)
})
ctx.on('agent/turn-end', () => {
process.stdout.write('\n> ')
})
ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/call') {
const { name: toolName, arguments: args } = event.data as any
process.stdout.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
const { content } = event.data as any
const text = content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
process.stdout.write(`\n [tool result] ${text}\n `)
}
})
ctx.effect(() => {
const reader = createInterface({ input: process.stdin })
reader.on('line', (line) => {
const text = line.trim()
if (!text) return
const agent = ctx.agents.get('main')
if (!agent) {
console.error('agent "main" is not running')
return
}
if (agent.status === 'running') {
agent.steer([{ type: 'text', text }])
} else {
agent.send([{ type: 'text', text }])
}
})
reader.on('close', () => {
// allow the process to exit when stdin ends (piped input)
setTimeout(() => process.exit(0), 200)
})
process.stdout.write('echo-agent ready. Type a message ("echo <text>" triggers the tool).\n> ')
return () => reader.close()
}, 'stdio-chat')
}

View File

@@ -0,0 +1,16 @@
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
// Boot a Cordis app from this example's cordis.yml — the same shape as the
// upstream `cordis` bin, pinned to this directory.
const ctx = new Context()
ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: {
path: './cordis.yml',
},
})