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:
@@ -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'
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()}` }]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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