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:
48
examples/echo-agent/cordis.yml
Normal file
48
examples/echo-agent/cordis.yml
Normal 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'
|
||||
7
examples/echo-agent/package.json
Normal file
7
examples/echo-agent/package.json
Normal 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"
|
||||
}
|
||||
19
examples/echo-agent/src/echo-tool.ts
Normal file
19
examples/echo-agent/src/echo-tool.ts
Normal 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()}` }]
|
||||
},
|
||||
})
|
||||
}
|
||||
59
examples/echo-agent/src/mock-llm.ts
Normal file
59
examples/echo-agent/src/mock-llm.ts
Normal 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())
|
||||
}
|
||||
36
examples/echo-agent/src/session-jsonl.ts
Normal file
36
examples/echo-agent/src/session-jsonl.ts
Normal 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')
|
||||
}
|
||||
60
examples/echo-agent/src/stdio-chat.ts
Normal file
60
examples/echo-agent/src/stdio-chat.ts
Normal 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')
|
||||
}
|
||||
16
examples/echo-agent/start.ts
Normal file
16
examples/echo-agent/start.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user