diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml new file mode 100644 index 0000000000..62e42f9ec6 --- /dev/null +++ b/examples/echo-agent/cordis.yml @@ -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' diff --git a/examples/echo-agent/package.json b/examples/echo-agent/package.json new file mode 100644 index 0000000000..00982fa297 --- /dev/null +++ b/examples/echo-agent/package.json @@ -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" +} diff --git a/examples/echo-agent/src/echo-tool.ts b/examples/echo-agent/src/echo-tool.ts new file mode 100644 index 0000000000..89dcebfe6b --- /dev/null +++ b/examples/echo-agent/src/echo-tool.ts @@ -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()}` }] + }, + }) +} diff --git a/examples/echo-agent/src/mock-llm.ts b/examples/echo-agent/src/mock-llm.ts new file mode 100644 index 0000000000..441e5639e3 --- /dev/null +++ b/examples/echo-agent/src/mock-llm.ts @@ -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 { + 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 " 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()) +} diff --git a/examples/echo-agent/src/session-jsonl.ts b/examples/echo-agent/src/session-jsonl.ts new file mode 100644 index 0000000000..8d6ff53285 --- /dev/null +++ b/examples/echo-agent/src/session-jsonl.ts @@ -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() + 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') +} diff --git a/examples/echo-agent/src/stdio-chat.ts b/examples/echo-agent/src/stdio-chat.ts new file mode 100644 index 0000000000..936eeac13c --- /dev/null +++ b/examples/echo-agent/src/stdio-chat.ts @@ -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 " triggers the tool).\n> ') + return () => reader.close() + }, 'stdio-chat') +} diff --git a/examples/echo-agent/start.ts b/examples/echo-agent/start.ts new file mode 100644 index 0000000000..90dba7b5b0 --- /dev/null +++ b/examples/echo-agent/start.ts @@ -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', + }, +})