From 9d7cc15f909f78875a5318bd2b05e708a79bc57d Mon Sep 17 00:00:00 2001 From: jerry Date: Fri, 27 Feb 2026 14:31:20 +0800 Subject: [PATCH 01/12] feat(tegg): add AgentController for building AI agent HTTP APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new @AgentController decorator and runtime that provides OpenAI Assistants API-compatible HTTP endpoints for building conversational AI applications within the Tegg framework. Key additions: - @AgentController decorator auto-generates 7 HTTP routes (threads, runs, streaming) - AgentHandler interface with smart defaults — only execRun() is required - @eggjs/agent-runtime package with FileAgentStore and pluggable storage abstraction - SSE streaming support, async/sync execution modes, and cancellation via AbortSignal - Unified exports via @eggjs/tegg/agent entry point - Full test coverage: unit tests, integration tests for both manual and default modes Co-Authored-By: Claude --- pnpm-lock.yaml | 27 + tegg/core/agent-runtime/package.json | 55 ++ tegg/core/agent-runtime/src/AgentStore.ts | 43 ++ tegg/core/agent-runtime/src/FileAgentStore.ts | 123 +++++ tegg/core/agent-runtime/src/agentDefaults.ts | 507 ++++++++++++++++++ .../src/enhanceAgentController.ts | 96 ++++ tegg/core/agent-runtime/src/index.ts | 4 + .../agent-runtime/test/FileAgentStore.test.ts | 164 ++++++ .../agent-runtime/test/agentDefaults.test.ts | 310 +++++++++++ .../test/enhanceAgentController.test.ts | 273 ++++++++++ tegg/core/agent-runtime/tsconfig.json | 3 + .../src/decorator/agent/AgentController.ts | 137 +++++ .../src/decorator/agent/AgentHandler.ts | 22 + .../src/decorator/index.ts | 2 + .../src/model/AgentControllerTypes.ts | 107 ++++ .../controller-decorator/src/model/index.ts | 1 + .../test/AgentController.test.ts | 205 +++++++ .../test/fixtures/AgentFooController.ts | 18 + tegg/core/tegg/package.json | 3 + tegg/core/tegg/src/agent.ts | 30 ++ tegg/plugin/controller/package.json | 1 + .../src/lib/EggControllerPrototypeHook.ts | 5 + .../app/controller/AgentTestController.ts | 201 +++++++ .../config/config.default.ts | 9 + .../agent-controller-app/config/plugin.ts | 18 + .../apps/agent-controller-app/package.json | 4 + .../app/controller/BaseAgentController.ts | 42 ++ .../config/config.default.ts | 9 + .../config/plugin.ts | 18 + .../base-agent-controller-app/package.json | 4 + .../plugin/controller/test/http/agent.test.ts | 328 +++++++++++ .../controller/test/http/base-agent.test.ts | 440 +++++++++++++++ 32 files changed, 3209 insertions(+) create mode 100644 tegg/core/agent-runtime/package.json create mode 100644 tegg/core/agent-runtime/src/AgentStore.ts create mode 100644 tegg/core/agent-runtime/src/FileAgentStore.ts create mode 100644 tegg/core/agent-runtime/src/agentDefaults.ts create mode 100644 tegg/core/agent-runtime/src/enhanceAgentController.ts create mode 100644 tegg/core/agent-runtime/src/index.ts create mode 100644 tegg/core/agent-runtime/test/FileAgentStore.test.ts create mode 100644 tegg/core/agent-runtime/test/agentDefaults.test.ts create mode 100644 tegg/core/agent-runtime/test/enhanceAgentController.test.ts create mode 100644 tegg/core/agent-runtime/tsconfig.json create mode 100644 tegg/core/controller-decorator/src/decorator/agent/AgentController.ts create mode 100644 tegg/core/controller-decorator/src/decorator/agent/AgentHandler.ts create mode 100644 tegg/core/controller-decorator/src/model/AgentControllerTypes.ts create mode 100644 tegg/core/controller-decorator/test/AgentController.test.ts create mode 100644 tegg/core/controller-decorator/test/fixtures/AgentFooController.ts create mode 100644 tegg/core/tegg/src/agent.ts create mode 100644 tegg/plugin/controller/test/fixtures/apps/agent-controller-app/app/controller/AgentTestController.ts create mode 100644 tegg/plugin/controller/test/fixtures/apps/agent-controller-app/config/config.default.ts create mode 100644 tegg/plugin/controller/test/fixtures/apps/agent-controller-app/config/plugin.ts create mode 100644 tegg/plugin/controller/test/fixtures/apps/agent-controller-app/package.json create mode 100644 tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/app/controller/BaseAgentController.ts create mode 100644 tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/config/config.default.ts create mode 100644 tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/config/plugin.ts create mode 100644 tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/package.json create mode 100644 tegg/plugin/controller/test/http/agent.test.ts create mode 100644 tegg/plugin/controller/test/http/base-agent.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9820f9dcbf..4b76b693dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1396,6 +1396,8 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/skills: {} + packages/supertest: dependencies: '@types/superagent': @@ -2050,6 +2052,25 @@ importers: specifier: 'catalog:' version: 0.105.0 + tegg/core/agent-runtime: + dependencies: + '@eggjs/tegg-runtime': + specifier: workspace:* + version: link:../runtime + '@eggjs/tegg-types': + specifier: workspace:* + version: link:../types + devDependencies: + '@eggjs/controller-decorator': + specifier: workspace:* + version: link:../controller-decorator + '@types/node': + specifier: 'catalog:' + version: 24.10.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 + tegg/core/ajv-decorator: dependencies: ajv: @@ -2679,6 +2700,9 @@ importers: tegg/core/tegg: dependencies: + '@eggjs/agent-runtime': + specifier: workspace:* + version: link:../agent-runtime '@eggjs/ajv-decorator': specifier: workspace:* version: link:../ajv-decorator @@ -2954,6 +2978,9 @@ importers: tegg/plugin/controller: dependencies: + '@eggjs/agent-runtime': + specifier: workspace:* + version: link:../../core/agent-runtime '@eggjs/controller-decorator': specifier: workspace:* version: link:../../core/controller-decorator diff --git a/tegg/core/agent-runtime/package.json b/tegg/core/agent-runtime/package.json new file mode 100644 index 0000000000..2e3885c811 --- /dev/null +++ b/tegg/core/agent-runtime/package.json @@ -0,0 +1,55 @@ +{ + "name": "@eggjs/agent-runtime", + "version": "4.0.2-beta.1", + "description": "Smart default runtime for @AgentController in tegg", + "keywords": [ + "agent", + "egg", + "tegg", + "typescript" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/tegg/core/agent-runtime", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "author": "killagu ", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "tegg/core/agent-runtime" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@eggjs/tegg-runtime": "workspace:*", + "@eggjs/tegg-types": "workspace:*" + }, + "devDependencies": { + "@eggjs/controller-decorator": "workspace:*", + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/tegg/core/agent-runtime/src/AgentStore.ts b/tegg/core/agent-runtime/src/AgentStore.ts new file mode 100644 index 0000000000..69670bdd10 --- /dev/null +++ b/tegg/core/agent-runtime/src/AgentStore.ts @@ -0,0 +1,43 @@ +import type { InputMessage, MessageObject, AgentRunConfig, RunStatus } from '@eggjs/controller-decorator'; + +export interface ThreadRecord { + id: string; + object: 'thread'; + messages: MessageObject[]; + metadata: Record; + created_at: number; // Unix seconds +} + +export interface RunRecord { + id: string; + object: 'thread.run'; + thread_id?: string; + status: RunStatus; + input: InputMessage[]; + output?: MessageObject[]; + last_error?: { code: string; message: string } | null; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null; + config?: AgentRunConfig; + metadata?: Record; + created_at: number; + started_at?: number | null; + completed_at?: number | null; + cancelled_at?: number | null; + failed_at?: number | null; +} + +export interface AgentStore { + init?(): Promise; + destroy?(): Promise; + createThread(metadata?: Record): Promise; + getThread(threadId: string): Promise; + appendMessages(threadId: string, messages: MessageObject[]): Promise; + createRun( + input: InputMessage[], + threadId?: string, + config?: AgentRunConfig, + metadata?: Record, + ): Promise; + getRun(runId: string): Promise; + updateRun(runId: string, updates: Partial): Promise; +} diff --git a/tegg/core/agent-runtime/src/FileAgentStore.ts b/tegg/core/agent-runtime/src/FileAgentStore.ts new file mode 100644 index 0000000000..3d0c238b43 --- /dev/null +++ b/tegg/core/agent-runtime/src/FileAgentStore.ts @@ -0,0 +1,123 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { InputMessage, MessageObject, AgentRunConfig } from '@eggjs/controller-decorator'; + +import type { AgentStore, ThreadRecord, RunRecord } from './AgentStore.ts'; + +export interface FileAgentStoreOptions { + dataDir: string; +} + +export class FileAgentStore implements AgentStore { + private readonly dataDir: string; + private readonly threadsDir: string; + private readonly runsDir: string; + + constructor(options: FileAgentStoreOptions) { + this.dataDir = options.dataDir; + this.threadsDir = path.join(this.dataDir, 'threads'); + this.runsDir = path.join(this.dataDir, 'runs'); + } + + private safePath(baseDir: string, id: string): string { + if (!id) { + throw new Error('Invalid id: id must not be empty'); + } + const filePath = path.join(baseDir, `${id}.json`); + if (!filePath.startsWith(baseDir + path.sep)) { + throw new Error(`Invalid id: ${id}`); + } + return filePath; + } + + async init(): Promise { + await fs.mkdir(this.threadsDir, { recursive: true }); + await fs.mkdir(this.runsDir, { recursive: true }); + } + + async createThread(metadata?: Record): Promise { + const threadId = `thread_${crypto.randomUUID()}`; + const record: ThreadRecord = { + id: threadId, + object: 'thread', + messages: [], + metadata: metadata ?? {}, + created_at: Math.floor(Date.now() / 1000), + }; + await this.writeFile(this.safePath(this.threadsDir, threadId), record); + return record; + } + + async getThread(threadId: string): Promise { + const filePath = this.safePath(this.threadsDir, threadId); + const data = await this.readFile(filePath); + if (!data) { + throw new Error(`Thread ${threadId} not found`); + } + return data as ThreadRecord; + } + + // Note: read-modify-write without locking. Concurrent appends to the same thread may lose messages. + // This is acceptable for a default file-based store; production stores should implement proper locking. + async appendMessages(threadId: string, messages: MessageObject[]): Promise { + const thread = await this.getThread(threadId); + thread.messages.push(...messages); + await this.writeFile(this.safePath(this.threadsDir, threadId), thread); + } + + async createRun( + input: InputMessage[], + threadId?: string, + config?: AgentRunConfig, + metadata?: Record, + ): Promise { + const runId = `run_${crypto.randomUUID()}`; + const record: RunRecord = { + id: runId, + object: 'thread.run', + thread_id: threadId, + status: 'queued', + input, + config, + metadata, + created_at: Math.floor(Date.now() / 1000), + }; + await this.writeFile(this.safePath(this.runsDir, runId), record); + return record; + } + + async getRun(runId: string): Promise { + const filePath = this.safePath(this.runsDir, runId); + const data = await this.readFile(filePath); + if (!data) { + throw new Error(`Run ${runId} not found`); + } + return data as RunRecord; + } + + async updateRun(runId: string, updates: Partial): Promise { + const run = await this.getRun(runId); + Object.assign(run, updates); + await this.writeFile(this.safePath(this.runsDir, runId), run); + } + + private async writeFile(filePath: string, data: unknown): Promise { + const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(data), 'utf-8'); + await fs.rename(tmpPath, filePath); + } + + private async readFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content); + } catch (err: any) { + if (err.code === 'ENOENT') { + return null; + } + throw err; + } + } +} diff --git a/tegg/core/agent-runtime/src/agentDefaults.ts b/tegg/core/agent-runtime/src/agentDefaults.ts new file mode 100644 index 0000000000..a70d964208 --- /dev/null +++ b/tegg/core/agent-runtime/src/agentDefaults.ts @@ -0,0 +1,507 @@ +import crypto from 'node:crypto'; + +import type { + CreateRunInput, + ThreadObject, + ThreadObjectWithMessages, + RunObject, + MessageObject, + MessageContentBlock, + MessageDeltaObject, + AgentStreamMessage, +} from '@eggjs/controller-decorator'; +import { ContextHandler } from '@eggjs/tegg-runtime'; + +import type { AgentStore } from './AgentStore.ts'; + +interface AgentInstance { + __agentStore: AgentStore; + __runningTasks: Map; abortController: AbortController }>; + execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator; +} + +function nowUnix(): number { + return Math.floor(Date.now() / 1000); +} + +function newMsgId(): string { + return `msg_${crypto.randomUUID()}`; +} + +/** + * Convert an AgentStreamMessage's message field into OpenAI MessageContentBlock[]. + */ +function toContentBlocks(msg: AgentStreamMessage['message']): MessageContentBlock[] { + if (!msg) return []; + const content = msg.content; + if (typeof content === 'string') { + return [{ type: 'text', text: { value: content, annotations: [] } }]; + } + if (Array.isArray(content)) { + return content + .filter((part) => part.type === 'text') + .map((part) => ({ type: 'text' as const, text: { value: part.text, annotations: [] } })); + } + return []; +} + +/** + * Build a completed MessageObject from an AgentStreamMessage. + */ +function toMessageObject(msg: AgentStreamMessage['message'], runId?: string): MessageObject { + return { + id: newMsgId(), + object: 'thread.message', + created_at: nowUnix(), + run_id: runId, + role: 'assistant', + status: 'completed', + content: toContentBlocks(msg), + }; +} + +/** + * Extract MessageObjects and accumulated usage from AgentStreamMessage objects. + */ +function extractFromStreamMessages( + messages: AgentStreamMessage[], + runId?: string, +): { + output: MessageObject[]; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; +} { + const output: MessageObject[] = []; + let promptTokens = 0; + let completionTokens = 0; + let totalTokens = 0; + let hasUsage = false; + + for (const msg of messages) { + if (msg.message) { + output.push(toMessageObject(msg.message, runId)); + } + if (msg.usage) { + hasUsage = true; + promptTokens += msg.usage.prompt_tokens ?? 0; + completionTokens += msg.usage.completion_tokens ?? 0; + totalTokens += msg.usage.total_tokens ?? 0; + } + } + + let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined; + if (hasUsage) { + usage = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: Math.max(promptTokens + completionTokens, totalTokens), + }; + } + + return { output, usage }; +} + +/** + * Convert input messages to MessageObjects for thread history. + * System messages are filtered out — they are transient instructions, not conversation history. + */ +function toInputMessageObjects(messages: CreateRunInput['input']['messages'], threadId?: string): MessageObject[] { + return messages + .filter((m) => m.role !== 'system') + .map((m) => ({ + id: newMsgId(), + object: 'thread.message' as const, + created_at: nowUnix(), + thread_id: threadId, + role: m.role as 'user' | 'assistant', + status: 'completed' as const, + content: + typeof m.content === 'string' + ? [{ type: 'text' as const, text: { value: m.content, annotations: [] } }] + : m.content.map((p) => ({ type: 'text' as const, text: { value: p.text, annotations: [] } })), + })); +} + +function defaultCreateThread() { + return async function (this: AgentInstance): Promise { + const thread = await this.__agentStore.createThread(); + return { + id: thread.id, + object: 'thread', + created_at: thread.created_at, + metadata: thread.metadata ?? {}, + }; + }; +} + +function defaultGetThread() { + return async function (this: AgentInstance, threadId: string): Promise { + const thread = await this.__agentStore.getThread(threadId); + return { + id: thread.id, + object: 'thread', + created_at: thread.created_at, + metadata: thread.metadata ?? {}, + messages: thread.messages, + }; + }; +} + +function defaultSyncRun() { + return async function (this: AgentInstance, input: CreateRunInput): Promise { + let threadId = input.thread_id; + if (!threadId) { + const thread = await this.__agentStore.createThread(); + threadId = thread.id; + input = { ...input, thread_id: threadId }; + } + + const run = await this.__agentStore.createRun(input.input.messages, threadId, input.config, input.metadata); + + try { + const startedAt = nowUnix(); + await this.__agentStore.updateRun(run.id, { status: 'in_progress', started_at: startedAt }); + + const streamMessages: AgentStreamMessage[] = []; + for await (const msg of this.execRun(input)) { + streamMessages.push(msg); + } + const { output, usage } = extractFromStreamMessages(streamMessages, run.id); + + const completedAt = nowUnix(); + await this.__agentStore.updateRun(run.id, { + status: 'completed', + output, + usage, + completed_at: completedAt, + }); + + await this.__agentStore.appendMessages(threadId, [ + ...toInputMessageObjects(input.input.messages, threadId), + ...output, + ]); + + return { + id: run.id, + object: 'thread.run', + created_at: run.created_at, + thread_id: threadId, + status: 'completed', + started_at: startedAt, + completed_at: completedAt, + output, + usage, + metadata: run.metadata, + }; + } catch (err: any) { + const failedAt = nowUnix(); + await this.__agentStore.updateRun(run.id, { + status: 'failed', + last_error: { code: 'EXEC_ERROR', message: err.message }, + failed_at: failedAt, + }); + throw err; + } + }; +} + +function defaultAsyncRun() { + return async function (this: AgentInstance, input: CreateRunInput): Promise { + let threadId = input.thread_id; + if (!threadId) { + const thread = await this.__agentStore.createThread(); + threadId = thread.id; + input = { ...input, thread_id: threadId }; + } + + const run = await this.__agentStore.createRun(input.input.messages, threadId, input.config, input.metadata); + + const abortController = new AbortController(); + + const promise = (async () => { + try { + await this.__agentStore.updateRun(run.id, { status: 'in_progress', started_at: nowUnix() }); + + const streamMessages: AgentStreamMessage[] = []; + for await (const msg of this.execRun(input, abortController.signal)) { + if (abortController.signal.aborted) break; + streamMessages.push(msg); + } + + if (abortController.signal.aborted) return; + + const { output, usage } = extractFromStreamMessages(streamMessages, run.id); + + await this.__agentStore.updateRun(run.id, { + status: 'completed', + output, + usage, + completed_at: nowUnix(), + }); + + await this.__agentStore.appendMessages(threadId!, [ + ...toInputMessageObjects(input.input.messages, threadId), + ...output, + ]); + } catch (err: any) { + if (!abortController.signal.aborted) { + try { + await this.__agentStore.updateRun(run.id, { + status: 'failed', + last_error: { code: 'EXEC_ERROR', message: err.message }, + failed_at: nowUnix(), + }); + } catch { + // Ignore store update failure to avoid swallowing the original error + } + } + } finally { + this.__runningTasks.delete(run.id); + } + })(); + + this.__runningTasks.set(run.id, { promise, abortController }); + + return { + id: run.id, + object: 'thread.run', + created_at: run.created_at, + thread_id: threadId, + status: 'queued', + metadata: run.metadata, + }; + }; +} + +function defaultStreamRun() { + return async function (this: AgentInstance, input: CreateRunInput): Promise { + const runtimeCtx = ContextHandler.getContext(); + if (!runtimeCtx) { + throw new Error('streamRun must be called within a request context'); + } + const ctx = runtimeCtx.get(Symbol.for('context#eggContext')); + + // Bypass Koa response handling — write SSE directly to the raw response + ctx.respond = false; + const res = ctx.res; + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + // Abort execRun generator when client disconnects + const abortController = new AbortController(); + res.on('close', () => abortController.abort()); + + let threadId = input.thread_id; + if (!threadId) { + const thread = await this.__agentStore.createThread(); + threadId = thread.id; + input = { ...input, thread_id: threadId }; + } + + const run = await this.__agentStore.createRun(input.input.messages, threadId, input.config, input.metadata); + + const runObj: RunObject = { + id: run.id, + object: 'thread.run', + created_at: run.created_at, + thread_id: threadId, + status: 'queued', + metadata: run.metadata, + }; + + // event: thread.run.created + res.write(`event: thread.run.created\ndata: ${JSON.stringify(runObj)}\n\n`); + + // event: thread.run.in_progress + runObj.status = 'in_progress'; + runObj.started_at = nowUnix(); + await this.__agentStore.updateRun(run.id, { status: 'in_progress', started_at: runObj.started_at }); + res.write(`event: thread.run.in_progress\ndata: ${JSON.stringify(runObj)}\n\n`); + + const msgId = newMsgId(); + const accumulatedContent: MessageObject['content'] = []; + + // event: thread.message.created + const msgObj: MessageObject = { + id: msgId, + object: 'thread.message', + created_at: nowUnix(), + run_id: run.id, + role: 'assistant', + status: 'in_progress', + content: [], + }; + res.write(`event: thread.message.created\ndata: ${JSON.stringify(msgObj)}\n\n`); + + let promptTokens = 0; + let completionTokens = 0; + let totalTokens = 0; + let hasUsage = false; + + try { + for await (const msg of this.execRun(input, abortController.signal)) { + if (abortController.signal.aborted) break; + if (msg.message) { + const contentBlocks = toContentBlocks(msg.message); + accumulatedContent.push(...contentBlocks); + + // event: thread.message.delta + const delta: MessageDeltaObject = { + id: msgId, + object: 'thread.message.delta', + delta: { content: contentBlocks }, + }; + res.write(`event: thread.message.delta\ndata: ${JSON.stringify(delta)}\n\n`); + } + if (msg.usage) { + hasUsage = true; + promptTokens += msg.usage.prompt_tokens ?? 0; + completionTokens += msg.usage.completion_tokens ?? 0; + totalTokens += msg.usage.total_tokens ?? 0; + } + } + + // If client disconnected / abort signaled, emit cancelled and return + if (abortController.signal.aborted) { + const cancelledAt = nowUnix(); + try { + await this.__agentStore.updateRun(run.id, { status: 'cancelled', cancelled_at: cancelledAt }); + } catch { + // Ignore store update failure during abort + } + runObj.status = 'cancelled'; + runObj.cancelled_at = cancelledAt; + res.write(`event: thread.run.cancelled\ndata: ${JSON.stringify(runObj)}\n\n`); + return; + } + + // event: thread.message.completed + msgObj.status = 'completed'; + msgObj.content = accumulatedContent; + res.write(`event: thread.message.completed\ndata: ${JSON.stringify(msgObj)}\n\n`); + + // Build final output + const output: MessageObject[] = accumulatedContent.length > 0 ? [msgObj] : []; + let usage: RunObject['usage']; + if (hasUsage) { + usage = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: Math.max(promptTokens + completionTokens, totalTokens), + }; + } + + const completedAt = nowUnix(); + await this.__agentStore.updateRun(run.id, { + status: 'completed', + output, + usage, + completed_at: completedAt, + }); + + await this.__agentStore.appendMessages(threadId!, [ + ...toInputMessageObjects(input.input.messages, threadId), + ...output, + ]); + + // event: thread.run.completed + runObj.status = 'completed'; + runObj.completed_at = completedAt; + runObj.usage = usage; + runObj.output = output; + res.write(`event: thread.run.completed\ndata: ${JSON.stringify(runObj)}\n\n`); + } catch (err: any) { + const failedAt = nowUnix(); + try { + await this.__agentStore.updateRun(run.id, { + status: 'failed', + last_error: { code: 'EXEC_ERROR', message: err.message }, + failed_at: failedAt, + }); + } catch { + // Ignore store update failure to avoid swallowing the original error + } + + // event: thread.run.failed + runObj.status = 'failed'; + runObj.failed_at = failedAt; + runObj.last_error = { code: 'EXEC_ERROR', message: err.message }; + res.write(`event: thread.run.failed\ndata: ${JSON.stringify(runObj)}\n\n`); + } finally { + // event: done + res.write('event: done\ndata: [DONE]\n\n'); + res.end(); + } + }; +} + +function defaultGetRun() { + return async function (this: AgentInstance, runId: string): Promise { + const run = await this.__agentStore.getRun(runId); + return { + id: run.id, + object: 'thread.run', + created_at: run.created_at, + thread_id: run.thread_id, + status: run.status, + last_error: run.last_error, + started_at: run.started_at, + completed_at: run.completed_at, + cancelled_at: run.cancelled_at, + failed_at: run.failed_at, + usage: run.usage, + output: run.output, + config: run.config, + metadata: run.metadata, + }; + }; +} + +const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); + +function defaultCancelRun() { + return async function (this: AgentInstance, runId: string): Promise { + // Abort running task first to prevent it from writing completed status + const task = this.__runningTasks.get(runId); + if (task) { + task.abortController.abort(); + // Wait for the background task to finish so it won't race with our update + await task.promise.catch(() => { + /* ignore */ + }); + } + + // Re-read run status after background task has settled + const run = await this.__agentStore.getRun(runId); + if (TERMINAL_RUN_STATUSES.has(run.status)) { + throw new Error(`Cannot cancel run with status '${run.status}'`); + } + + const cancelledAt = nowUnix(); + await this.__agentStore.updateRun(runId, { + status: 'cancelled', + cancelled_at: cancelledAt, + }); + + return { + id: run.id, + object: 'thread.run', + created_at: run.created_at, + thread_id: run.thread_id, + status: 'cancelled', + cancelled_at: cancelledAt, + }; + }; +} + +export const AGENT_DEFAULT_FACTORIES: Record Function> = { + createThread: defaultCreateThread, + getThread: defaultGetThread, + syncRun: defaultSyncRun, + asyncRun: defaultAsyncRun, + streamRun: defaultStreamRun, + getRun: defaultGetRun, + cancelRun: defaultCancelRun, +}; diff --git a/tegg/core/agent-runtime/src/enhanceAgentController.ts b/tegg/core/agent-runtime/src/enhanceAgentController.ts new file mode 100644 index 0000000000..616a81e817 --- /dev/null +++ b/tegg/core/agent-runtime/src/enhanceAgentController.ts @@ -0,0 +1,96 @@ +import path from 'node:path'; + +import type { EggProtoImplClass } from '@eggjs/tegg-types'; + +import { AGENT_DEFAULT_FACTORIES } from './agentDefaults.ts'; +import type { AgentStore } from './AgentStore.ts'; +import { FileAgentStore } from './FileAgentStore.ts'; + +const AGENT_METHOD_NAMES = ['createThread', 'getThread', 'asyncRun', 'streamRun', 'syncRun', 'getRun', 'cancelRun']; + +const NOT_IMPLEMENTED = Symbol.for('AGENT_NOT_IMPLEMENTED'); +const AGENT_ENHANCED = Symbol.for('AGENT_CONTROLLER_ENHANCED'); + +// Enhance an AgentController class with smart default implementations. +// +// Called by the plugin/controller lifecycle hook AFTER the decorator has set +// HTTP metadata and injected stub methods. Detects which methods are +// user-defined vs stubs (via Symbol.for('AGENT_NOT_IMPLEMENTED') marker) +// and replaces stubs with store-backed default implementations. +// Also wraps init()/destroy() to manage the AgentStore lifecycle. +// +// Prerequisites: +// - The class must be marked with Symbol.for('AGENT_CONTROLLER') (otherwise this is a no-op). +// - Stub methods must be marked with Symbol.for('AGENT_NOT_IMPLEMENTED'). +export function enhanceAgentController(clazz: EggProtoImplClass): void { + // Only enhance classes marked by @AgentController decorator + if (!(clazz as any)[Symbol.for('AGENT_CONTROLLER')]) { + return; + } + + // Guard against repeated enhancement (e.g., multiple lifecycle hook calls) + if ((clazz as any)[AGENT_ENHANCED]) { + return; + } + + // Identify which methods are stubs vs user-defined + const stubMethods = new Set(); + for (const name of AGENT_METHOD_NAMES) { + const method = clazz.prototype[name]; + if (!method || (method as any)[NOT_IMPLEMENTED]) { + stubMethods.add(name); + } + } + + // Wrap init() lifecycle to create store and task tracking + const originalInit = clazz.prototype.init; + clazz.prototype.init = async function () { + // Allow user to provide custom store via createStore() + if (typeof this.createStore === 'function') { + this.__agentStore = await this.createStore(); + } else { + const dataDir = process.env.TEGG_AGENT_DATA_DIR || path.join(process.cwd(), '.agent-data'); + this.__agentStore = new FileAgentStore({ dataDir }); + } + + if (this.__agentStore.init) { + await (this.__agentStore as AgentStore).init!(); + } + + this.__runningTasks = new Map(); + + if (originalInit) { + await originalInit.call(this); + } + }; + + // Wrap destroy() lifecycle to wait for in-flight tasks and cleanup + const originalDestroy = clazz.prototype.destroy; + clazz.prototype.destroy = async function () { + // Wait for in-flight background tasks + if (this.__runningTasks?.size) { + const pending = Array.from(this.__runningTasks.values()).map((t: any) => t.promise); + await Promise.allSettled(pending); + } + + // Destroy store + if (this.__agentStore?.destroy) { + await this.__agentStore.destroy(); + } + + if (originalDestroy) { + await originalDestroy.call(this); + } + }; + + // Inject smart defaults for stub methods + for (const methodName of AGENT_METHOD_NAMES) { + if (!stubMethods.has(methodName)) continue; + const factory = AGENT_DEFAULT_FACTORIES[methodName]; + if (factory) { + clazz.prototype[methodName] = factory(); + } + } + + (clazz as any)[AGENT_ENHANCED] = true; +} diff --git a/tegg/core/agent-runtime/src/index.ts b/tegg/core/agent-runtime/src/index.ts new file mode 100644 index 0000000000..a662eb24a7 --- /dev/null +++ b/tegg/core/agent-runtime/src/index.ts @@ -0,0 +1,4 @@ +export * from './AgentStore.ts'; +export * from './FileAgentStore.ts'; +export { AGENT_DEFAULT_FACTORIES } from './agentDefaults.ts'; +export { enhanceAgentController } from './enhanceAgentController.ts'; diff --git a/tegg/core/agent-runtime/test/FileAgentStore.test.ts b/tegg/core/agent-runtime/test/FileAgentStore.test.ts new file mode 100644 index 0000000000..3fecee95d2 --- /dev/null +++ b/tegg/core/agent-runtime/test/FileAgentStore.test.ts @@ -0,0 +1,164 @@ +import { strict as assert } from 'node:assert'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { describe, it, beforeEach, afterEach } from 'vitest'; + +import { FileAgentStore } from '../src/FileAgentStore.ts'; + +describe('core/agent-runtime/test/FileAgentStore.test.ts', () => { + const dataDir = path.join(import.meta.dirname, '.agent-test-data'); + let store: FileAgentStore; + + beforeEach(async () => { + store = new FileAgentStore({ dataDir }); + await store.init(); + }); + + afterEach(async () => { + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + describe('threads', () => { + it('should create a thread', async () => { + const thread = await store.createThread(); + assert(thread.id.startsWith('thread_')); + assert.equal(thread.object, 'thread'); + assert(Array.isArray(thread.messages)); + assert.equal(thread.messages.length, 0); + assert(typeof thread.created_at === 'number'); + // Unix seconds — should be much smaller than Date.now() + assert(thread.created_at < Date.now()); + }); + + it('should create a thread with metadata', async () => { + const thread = await store.createThread({ key: 'value' }); + assert.deepEqual(thread.metadata, { key: 'value' }); + }); + + it('should create a thread with empty metadata by default', async () => { + const thread = await store.createThread(); + assert.deepEqual(thread.metadata, {}); + }); + + it('should get a thread by id', async () => { + const created = await store.createThread(); + const fetched = await store.getThread(created.id); + assert.equal(fetched.id, created.id); + assert.equal(fetched.object, 'thread'); + assert.equal(fetched.created_at, created.created_at); + }); + + it('should throw for non-existent thread', async () => { + await assert.rejects(() => store.getThread('thread_non_existent'), /Thread thread_non_existent not found/); + }); + + it('should append messages to a thread', async () => { + const thread = await store.createThread(); + await store.appendMessages(thread.id, [ + { + id: 'msg_1', + object: 'thread.message', + created_at: Math.floor(Date.now() / 1000), + role: 'user', + status: 'completed', + content: [{ type: 'text', text: { value: 'Hello', annotations: [] } }], + }, + { + id: 'msg_2', + object: 'thread.message', + created_at: Math.floor(Date.now() / 1000), + role: 'assistant', + status: 'completed', + content: [{ type: 'text', text: { value: 'Hi!', annotations: [] } }], + }, + ]); + const fetched = await store.getThread(thread.id); + assert.equal(fetched.messages.length, 2); + assert.equal(fetched.messages[0].content[0].text.value, 'Hello'); + assert.equal(fetched.messages[1].content[0].text.value, 'Hi!'); + }); + }); + + describe('runs', () => { + it('should create a run', async () => { + const run = await store.createRun([{ role: 'user', content: 'Hello' }]); + assert(run.id.startsWith('run_')); + assert.equal(run.object, 'thread.run'); + assert.equal(run.status, 'queued'); + assert.equal(run.input.length, 1); + assert(typeof run.created_at === 'number'); + // Unix seconds + assert(run.created_at < Date.now()); + }); + + it('should create a run with thread_id and config', async () => { + const run = await store.createRun([{ role: 'user', content: 'Hello' }], 'thread_123', { timeout_ms: 5000 }); + assert.equal(run.thread_id, 'thread_123'); + assert.deepEqual(run.config, { timeout_ms: 5000 }); + }); + + it('should create a run with metadata', async () => { + const meta = { user_id: 'u_1', session: 'abc' }; + const run = await store.createRun([{ role: 'user', content: 'Hello' }], 'thread_123', undefined, meta); + assert.deepEqual(run.metadata, meta); + + // Verify metadata persists through getRun + const fetched = await store.getRun(run.id); + assert.deepEqual(fetched.metadata, meta); + }); + + it('should preserve metadata across updateRun', async () => { + const meta = { tag: 'test' }; + const run = await store.createRun([{ role: 'user', content: 'Hello' }], undefined, undefined, meta); + await store.updateRun(run.id, { status: 'in_progress', started_at: Math.floor(Date.now() / 1000) }); + const fetched = await store.getRun(run.id); + assert.equal(fetched.status, 'in_progress'); + assert.deepEqual(fetched.metadata, meta); + }); + + it('should get a run by id', async () => { + const created = await store.createRun([{ role: 'user', content: 'Hello' }]); + const fetched = await store.getRun(created.id); + assert.equal(fetched.id, created.id); + assert.equal(fetched.status, 'queued'); + }); + + it('should throw for non-existent run', async () => { + await assert.rejects(() => store.getRun('run_non_existent'), /Run run_non_existent not found/); + }); + + it('should update a run', async () => { + const run = await store.createRun([{ role: 'user', content: 'Hello' }]); + await store.updateRun(run.id, { + status: 'completed', + output: [ + { + id: 'msg_1', + object: 'thread.message', + created_at: Math.floor(Date.now() / 1000), + role: 'assistant', + status: 'completed', + content: [{ type: 'text', text: { value: 'World', annotations: [] } }], + }, + ], + completed_at: Math.floor(Date.now() / 1000), + }); + const fetched = await store.getRun(run.id); + assert.equal(fetched.status, 'completed'); + assert.equal(fetched.output![0].content[0].text.value, 'World'); + assert(typeof fetched.completed_at === 'number'); + }); + }); + + describe('data directory', () => { + it('should create subdirectories on init', async () => { + const threadsDir = path.join(dataDir, 'threads'); + const runsDir = path.join(dataDir, 'runs'); + const threadsStat = await fs.stat(threadsDir); + const runsStat = await fs.stat(runsDir); + assert(threadsStat.isDirectory()); + assert(runsStat.isDirectory()); + }); + }); +}); diff --git a/tegg/core/agent-runtime/test/agentDefaults.test.ts b/tegg/core/agent-runtime/test/agentDefaults.test.ts new file mode 100644 index 0000000000..9857e2dcb2 --- /dev/null +++ b/tegg/core/agent-runtime/test/agentDefaults.test.ts @@ -0,0 +1,310 @@ +import { strict as assert } from 'node:assert'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { describe, it, beforeEach, afterEach } from 'vitest'; + +import { AGENT_DEFAULT_FACTORIES } from '../src/agentDefaults.ts'; +import { FileAgentStore } from '../src/FileAgentStore.ts'; + +describe('core/agent-runtime/test/agentDefaults.test.ts', () => { + const dataDir = path.join(import.meta.dirname, '.agent-defaults-test-data'); + let mockInstance: any; + + beforeEach(async () => { + const store = new FileAgentStore({ dataDir }); + await store.init(); + mockInstance = { + __agentStore: store, + __runningTasks: new Map(), + async *execRun(input: any) { + const messages = input.input.messages; + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: `Hello ${messages.length} messages` }], + }, + }; + yield { + type: 'result', + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }; + }, + }; + }); + + afterEach(async () => { + // Wait for any in-flight tasks + if (mockInstance.__runningTasks.size) { + const pending = Array.from(mockInstance.__runningTasks.values()).map((t: any) => t.promise); + await Promise.allSettled(pending); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + describe('createThread', () => { + it('should create a thread and return OpenAI ThreadObject', async () => { + const fn = AGENT_DEFAULT_FACTORIES.createThread(); + const result = await fn.call(mockInstance); + assert(result.id.startsWith('thread_')); + assert.equal(result.object, 'thread'); + assert(typeof result.created_at === 'number'); + // Unix seconds — should be much smaller than Date.now() + assert(result.created_at < Date.now()); + assert(typeof result.metadata === 'object'); + }); + }); + + describe('getThread', () => { + it('should get a thread by id', async () => { + const createFn = AGENT_DEFAULT_FACTORIES.createThread(); + const created = await createFn.call(mockInstance); + + const getFn = AGENT_DEFAULT_FACTORIES.getThread(); + const result = await getFn.call(mockInstance, created.id); + assert.equal(result.id, created.id); + assert.equal(result.object, 'thread'); + assert(Array.isArray(result.messages)); + }); + + it('should throw for non-existent thread', async () => { + const getFn = AGENT_DEFAULT_FACTORIES.getThread(); + await assert.rejects(() => getFn.call(mockInstance, 'thread_xxx'), /Thread thread_xxx not found/); + }); + }); + + describe('syncRun', () => { + it('should collect all chunks and return completed RunObject', async () => { + const fn = AGENT_DEFAULT_FACTORIES.syncRun(); + const result = await fn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert(result.id.startsWith('run_')); + assert.equal(result.object, 'thread.run'); + assert.equal(result.status, 'completed'); + assert(result.thread_id); + assert(result.thread_id.startsWith('thread_')); + assert.equal(result.output.length, 1); + assert.equal(result.output[0].object, 'thread.message'); + assert.equal(result.output[0].role, 'assistant'); + assert.equal(result.output[0].status, 'completed'); + assert.equal(result.output[0].content[0].type, 'text'); + assert.equal(result.output[0].content[0].text.value, 'Hello 1 messages'); + assert(Array.isArray(result.output[0].content[0].text.annotations)); + assert.equal(result.usage.prompt_tokens, 10); + assert.equal(result.usage.completion_tokens, 5); + assert.equal(result.usage.total_tokens, 15); + assert(result.started_at >= result.created_at, 'started_at should be >= created_at'); + }); + + it('should pass metadata through to store and return it', async () => { + const fn = AGENT_DEFAULT_FACTORIES.syncRun(); + const meta = { user_id: 'u_1', trace: 'xyz' }; + const result = await fn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + metadata: meta, + }); + assert.deepEqual(result.metadata, meta); + + // Verify stored in store + const run = await mockInstance.__agentStore.getRun(result.id); + assert.deepEqual(run.metadata, meta); + }); + + it('should store the run in the store', async () => { + const fn = AGENT_DEFAULT_FACTORIES.syncRun(); + const result = await fn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + const run = await mockInstance.__agentStore.getRun(result.id); + assert.equal(run.status, 'completed'); + assert(run.completed_at); + }); + + it('should append messages to thread when thread_id provided', async () => { + const createFn = AGENT_DEFAULT_FACTORIES.createThread(); + const thread = await createFn.call(mockInstance); + + const fn = AGENT_DEFAULT_FACTORIES.syncRun(); + await fn.call(mockInstance, { + thread_id: thread.id, + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + const getThreadFn = AGENT_DEFAULT_FACTORIES.getThread(); + const updated = await getThreadFn.call(mockInstance, thread.id); + assert.equal(updated.messages.length, 2); // user + assistant + assert.equal(updated.messages[0].role, 'user'); + assert.equal(updated.messages[1].role, 'assistant'); + }); + + it('should auto-create thread and append messages when thread_id not provided', async () => { + const fn = AGENT_DEFAULT_FACTORIES.syncRun(); + const result = await fn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert(result.thread_id); + assert(result.thread_id.startsWith('thread_')); + + // Verify thread was created and messages were appended + const getThreadFn = AGENT_DEFAULT_FACTORIES.getThread(); + const thread = await getThreadFn.call(mockInstance, result.thread_id); + assert.equal(thread.messages.length, 2); // user + assistant + assert.equal(thread.messages[0].role, 'user'); + assert.equal(thread.messages[1].role, 'assistant'); + }); + }); + + describe('asyncRun', () => { + it('should return queued status immediately with auto-created thread_id', async () => { + const fn = AGENT_DEFAULT_FACTORIES.asyncRun(); + const result = await fn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert(result.id.startsWith('run_')); + assert.equal(result.object, 'thread.run'); + assert.equal(result.status, 'queued'); + assert(result.thread_id); + assert(result.thread_id.startsWith('thread_')); + }); + + it('should complete the run in the background', async () => { + const fn = AGENT_DEFAULT_FACTORIES.asyncRun(); + const result = await fn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + // Wait for background task to complete + const task = mockInstance.__runningTasks.get(result.id); + assert(task); + await task.promise; + + const run = await mockInstance.__agentStore.getRun(result.id); + assert.equal(run.status, 'completed'); + assert.equal(run.output![0].content[0].text.value, 'Hello 1 messages'); + }); + + it('should auto-create thread and append messages when thread_id not provided', async () => { + const fn = AGENT_DEFAULT_FACTORIES.asyncRun(); + const result = await fn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert(result.thread_id); + + // Wait for background task to complete + const task = mockInstance.__runningTasks.get(result.id); + assert(task); + await task.promise; + + // Verify thread was created and messages were appended + const getThreadFn = AGENT_DEFAULT_FACTORIES.getThread(); + const thread = await getThreadFn.call(mockInstance, result.thread_id); + assert.equal(thread.messages.length, 2); // user + assistant + assert.equal(thread.messages[0].role, 'user'); + assert.equal(thread.messages[1].role, 'assistant'); + }); + + it('should pass metadata through to store and return it', async () => { + const fn = AGENT_DEFAULT_FACTORIES.asyncRun(); + const meta = { session: 'sess_1' }; + const result = await fn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + metadata: meta, + }); + assert.deepEqual(result.metadata, meta); + + // Wait for background task to complete + const task = mockInstance.__runningTasks.get(result.id); + assert(task); + await task.promise; + + // Verify stored in store + const run = await mockInstance.__agentStore.getRun(result.id); + assert.deepEqual(run.metadata, meta); + }); + }); + + describe('getRun', () => { + it('should get a run by id', async () => { + const syncFn = AGENT_DEFAULT_FACTORIES.syncRun(); + const syncResult = await syncFn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + const getRunFn = AGENT_DEFAULT_FACTORIES.getRun(); + const result = await getRunFn.call(mockInstance, syncResult.id); + assert.equal(result.id, syncResult.id); + assert.equal(result.object, 'thread.run'); + assert.equal(result.status, 'completed'); + assert(typeof result.created_at === 'number'); + }); + + it('should return metadata from getRun', async () => { + const syncFn = AGENT_DEFAULT_FACTORIES.syncRun(); + const meta = { source: 'api' }; + const syncResult = await syncFn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + metadata: meta, + }); + + const getRunFn = AGENT_DEFAULT_FACTORIES.getRun(); + const result = await getRunFn.call(mockInstance, syncResult.id); + assert.deepEqual(result.metadata, meta); + }); + }); + + describe('cancelRun', () => { + it('should cancel a run', async () => { + const asyncFn = AGENT_DEFAULT_FACTORIES.asyncRun(); + // Use a signal-aware execRun so abort takes effect + mockInstance.execRun = async function* (_input: any, signal?: AbortSignal) { + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'start' }] }, + }; + // Wait but check abort signal + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 5000); + if (signal) { + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new Error('aborted')); + }, + { once: true }, + ); + } + }); + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'end' }] }, + }; + }; + + const result = await asyncFn.call(mockInstance, { + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + // Let background task start running + await new Promise((resolve) => setTimeout(resolve, 50)); + + const cancelFn = AGENT_DEFAULT_FACTORIES.cancelRun(); + const cancelResult = await cancelFn.call(mockInstance, result.id); + assert.equal(cancelResult.id, result.id); + assert.equal(cancelResult.object, 'thread.run'); + assert.equal(cancelResult.status, 'cancelled'); + + // Wait for background task to finish + const task = mockInstance.__runningTasks.get(result.id); + if (task) { + await task.promise; + } + + const run = await mockInstance.__agentStore.getRun(result.id); + assert.equal(run.status, 'cancelled'); + assert(run.cancelled_at); + }); + }); +}); diff --git a/tegg/core/agent-runtime/test/enhanceAgentController.test.ts b/tegg/core/agent-runtime/test/enhanceAgentController.test.ts new file mode 100644 index 0000000000..02e7bcf015 --- /dev/null +++ b/tegg/core/agent-runtime/test/enhanceAgentController.test.ts @@ -0,0 +1,273 @@ +import { strict as assert } from 'node:assert'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { describe, it, beforeEach, afterEach } from 'vitest'; + +import { enhanceAgentController } from '../src/enhanceAgentController.ts'; + +const NOT_IMPLEMENTED = Symbol.for('AGENT_NOT_IMPLEMENTED'); + +// Helper: create a stub function like the @AgentController decorator does +function createStub(hasParam: boolean) { + let fn; + if (hasParam) { + fn = async function (_arg: unknown) { + throw new Error('not implemented'); + }; + } else { + fn = async function () { + throw new Error('not implemented'); + }; + } + (fn as any)[NOT_IMPLEMENTED] = true; + return fn; +} + +describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { + const dataDir = path.join(import.meta.dirname, '.enhance-test-data'); + + beforeEach(() => { + process.env.TEGG_AGENT_DATA_DIR = dataDir; + }); + + afterEach(async () => { + delete process.env.TEGG_AGENT_DATA_DIR; + await fs.rm(dataDir, { recursive: true, force: true }).catch(() => { + /* ignore */ + }); + }); + + it('should skip classes without AGENT_CONTROLLER symbol', () => { + class NoMarker { + async *execRun() { + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, + }; + } + } + (NoMarker.prototype as any)['syncRun'] = createStub(true); + // Should not throw — class has execRun but no Symbol marker + enhanceAgentController(NoMarker as any); + // syncRun should remain unchanged (still the stub) + assert((NoMarker.prototype as any).syncRun[NOT_IMPLEMENTED]); + }); + + it('should replace stub methods with smart defaults', async () => { + class MyAgent { + async *execRun() { + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, + }; + } + } + (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + // Simulate stubs set by @AgentController + (MyAgent.prototype as any)['createThread'] = createStub(false); + (MyAgent.prototype as any)['getThread'] = createStub(true); + (MyAgent.prototype as any)['syncRun'] = createStub(true); + (MyAgent.prototype as any)['asyncRun'] = createStub(true); + (MyAgent.prototype as any)['streamRun'] = createStub(true); + (MyAgent.prototype as any)['getRun'] = createStub(true); + (MyAgent.prototype as any)['cancelRun'] = createStub(true); + + enhanceAgentController(MyAgent as any); + + // Stubs should be replaced — no longer marked + assert(!(MyAgent.prototype as any).createThread[NOT_IMPLEMENTED]); + assert(!(MyAgent.prototype as any).syncRun[NOT_IMPLEMENTED]); + + // init/destroy should be wrapped + assert(typeof (MyAgent.prototype as any).init === 'function'); + assert(typeof (MyAgent.prototype as any).destroy === 'function'); + + // Actually call init to verify store is created + const instance = new MyAgent() as any; + await instance.init(); + assert(instance.__agentStore); + assert(instance.__runningTasks instanceof Map); + + // createThread should work and return OpenAI format + const thread = await instance.createThread(); + assert(thread.id.startsWith('thread_')); + assert.equal(thread.object, 'thread'); + + await instance.destroy(); + }); + + it('should preserve user-defined methods (not stubs)', async () => { + const customResult = { id: 'custom', object: 'thread.run', created_at: 1, status: 'completed', output: [] }; + + class MyAgent { + async *execRun() { + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, + }; + } + + // User-defined syncRun — no NOT_IMPLEMENTED marker + async syncRun() { + return customResult; + } + } + (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + // All other methods are stubs + (MyAgent.prototype as any)['createThread'] = createStub(false); + (MyAgent.prototype as any)['getThread'] = createStub(true); + (MyAgent.prototype as any)['asyncRun'] = createStub(true); + (MyAgent.prototype as any)['streamRun'] = createStub(true); + (MyAgent.prototype as any)['getRun'] = createStub(true); + (MyAgent.prototype as any)['cancelRun'] = createStub(true); + + enhanceAgentController(MyAgent as any); + + // User syncRun should be preserved + const instance = new MyAgent() as any; + await instance.init(); + const result = await instance.syncRun(); + assert.deepEqual(result, customResult); + + // Stubs should be replaced + assert(!(instance as any).createThread[NOT_IMPLEMENTED]); + + await instance.destroy(); + }); + + it('should wrap init() and call original init', async () => { + let originalInitCalled = false; + + class MyAgent { + async *execRun() { + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, + }; + } + + async init() { + originalInitCalled = true; + } + } + (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + (MyAgent.prototype as any)['syncRun'] = createStub(true); + + enhanceAgentController(MyAgent as any); + + const instance = new MyAgent() as any; + await instance.init(); + assert(originalInitCalled); + assert(instance.__agentStore); + + await instance.destroy(); + }); + + it('should wrap destroy() and call original destroy', async () => { + let originalDestroyCalled = false; + + class MyAgent { + async *execRun() { + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, + }; + } + + async destroy() { + originalDestroyCalled = true; + } + } + (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + (MyAgent.prototype as any)['syncRun'] = createStub(true); + + enhanceAgentController(MyAgent as any); + + const instance = new MyAgent() as any; + await instance.init(); + await instance.destroy(); + assert(originalDestroyCalled); + }); + + it('should support custom store via createStore()', async () => { + const customStore = { + createThread: async () => ({ + id: 'custom_t', + object: 'thread' as const, + messages: [], + metadata: {}, + created_at: 1, + }), + getThread: async () => ({ id: 'custom_t', object: 'thread' as const, messages: [], metadata: {}, created_at: 1 }), + appendMessages: async () => { + /* noop */ + }, + createRun: async () => ({ + id: 'custom_r', + object: 'thread.run' as const, + status: 'queued' as const, + input: [], + created_at: 1, + }), + getRun: async () => ({ + id: 'custom_r', + object: 'thread.run' as const, + status: 'queued' as const, + input: [], + created_at: 1, + }), + updateRun: async () => { + /* noop */ + }, + }; + + class MyAgent { + async createStore() { + return customStore; + } + + async *execRun() { + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, + }; + } + } + (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + (MyAgent.prototype as any)['syncRun'] = createStub(true); + + enhanceAgentController(MyAgent as any); + + const instance = new MyAgent() as any; + await instance.init(); + assert.strictEqual(instance.__agentStore, customStore); + + await instance.destroy(); + }); + + it('should treat missing methods the same as stubs', async () => { + class MyAgent { + async *execRun() { + yield { + type: 'assistant', + message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, + }; + } + // No methods defined at all — no stubs either + } + (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + + enhanceAgentController(MyAgent as any); + + const instance = new MyAgent() as any; + await instance.init(); + + // Default createThread should be injected and return OpenAI format + const thread = await instance.createThread(); + assert(thread.id.startsWith('thread_')); + assert.equal(thread.object, 'thread'); + + await instance.destroy(); + }); +}); diff --git a/tegg/core/agent-runtime/tsconfig.json b/tegg/core/agent-runtime/tsconfig.json new file mode 100644 index 0000000000..618c6c3e97 --- /dev/null +++ b/tegg/core/agent-runtime/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts b/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts new file mode 100644 index 0000000000..ded7206314 --- /dev/null +++ b/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts @@ -0,0 +1,137 @@ +import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator'; +import { StackUtil } from '@eggjs/tegg-common-util'; +import type { EggProtoImplClass } from '@eggjs/tegg-types'; +import { AccessLevel, ControllerType, HTTPMethodEnum, HTTPParamType } from '@eggjs/tegg-types'; + +import { ControllerInfoUtil } from '../../util/ControllerInfoUtil.ts'; +import { HTTPInfoUtil } from '../../util/HTTPInfoUtil.ts'; +import { MethodInfoUtil } from '../../util/MethodInfoUtil.ts'; + +interface AgentRouteDefinition { + methodName: string; + httpMethod: HTTPMethodEnum; + path: string; + paramType?: 'body' | 'pathParam'; + paramName?: string; + hasParam: boolean; +} + +// Default implementations for unimplemented methods. +// Methods with hasParam=true need function.length === 1 for param validation. +// Stubs are marked with Symbol.for('AGENT_NOT_IMPLEMENTED') so agent-runtime +// can distinguish them from user-defined methods at enhancement time. +function createNotImplemented(methodName: string, hasParam: boolean) { + let fn; + if (hasParam) { + fn = async function (_arg: unknown) { + throw new Error(`${methodName} not implemented`); + }; + } else { + fn = async function () { + throw new Error(`${methodName} not implemented`); + }; + } + (fn as any)[Symbol.for('AGENT_NOT_IMPLEMENTED')] = true; + return fn; +} + +const AGENT_ROUTES: AgentRouteDefinition[] = [ + { + methodName: 'createThread', + httpMethod: HTTPMethodEnum.POST, + path: '/threads', + hasParam: false, + }, + { + methodName: 'getThread', + httpMethod: HTTPMethodEnum.GET, + path: '/threads/:id', + paramType: 'pathParam', + paramName: 'id', + hasParam: true, + }, + { + methodName: 'asyncRun', + httpMethod: HTTPMethodEnum.POST, + path: '/runs', + paramType: 'body', + hasParam: true, + }, + { + methodName: 'streamRun', + httpMethod: HTTPMethodEnum.POST, + path: '/runs/stream', + paramType: 'body', + hasParam: true, + }, + { + methodName: 'syncRun', + httpMethod: HTTPMethodEnum.POST, + path: '/runs/wait', + paramType: 'body', + hasParam: true, + }, + { + methodName: 'getRun', + httpMethod: HTTPMethodEnum.GET, + path: '/runs/:id', + paramType: 'pathParam', + paramName: 'id', + hasParam: true, + }, + { + methodName: 'cancelRun', + httpMethod: HTTPMethodEnum.POST, + path: '/runs/:id/cancel', + paramType: 'pathParam', + paramName: 'id', + hasParam: true, + }, +]; + +export function AgentController() { + return function (constructor: EggProtoImplClass) { + // Set controller type as HTTP so existing infrastructure handles it + ControllerInfoUtil.setControllerType(constructor, ControllerType.HTTP); + + // Set the fixed base HTTP path + HTTPInfoUtil.setHTTPPath('/api/v1', constructor); + + // Apply SingletonProto + const func = SingletonProto({ + accessLevel: AccessLevel.PUBLIC, + }); + func(constructor); + + // Set file path for prototype + PrototypeUtil.setFilePath(constructor, StackUtil.getCalleeFromStack(false, 5)); + + // Register each agent route + for (const route of AGENT_ROUTES) { + // Inject default implementation if method not defined + if (!constructor.prototype[route.methodName]) { + constructor.prototype[route.methodName] = createNotImplemented(route.methodName, route.hasParam); + } + + // Set method controller type + MethodInfoUtil.setMethodControllerType(constructor, route.methodName, ControllerType.HTTP); + + // Set HTTP method (GET/POST) + HTTPInfoUtil.setHTTPMethodMethod(route.httpMethod, constructor, route.methodName); + + // Set HTTP path + HTTPInfoUtil.setHTTPMethodPath(route.path, constructor, route.methodName); + + // Set parameter metadata + if (route.paramType === 'body') { + HTTPInfoUtil.setHTTPMethodParamType(HTTPParamType.BODY, 0, constructor, route.methodName); + } else if (route.paramType === 'pathParam') { + HTTPInfoUtil.setHTTPMethodParamType(HTTPParamType.PARAM, 0, constructor, route.methodName); + HTTPInfoUtil.setHTTPMethodParamName(route.paramName!, 0, constructor, route.methodName); + } + } + + // Mark the class as an AgentController for precise detection + (constructor as any)[Symbol.for('AGENT_CONTROLLER')] = true; + }; +} diff --git a/tegg/core/controller-decorator/src/decorator/agent/AgentHandler.ts b/tegg/core/controller-decorator/src/decorator/agent/AgentHandler.ts new file mode 100644 index 0000000000..923b9df36c --- /dev/null +++ b/tegg/core/controller-decorator/src/decorator/agent/AgentHandler.ts @@ -0,0 +1,22 @@ +import type { + ThreadObject, + ThreadObjectWithMessages, + CreateRunInput, + RunObject, + AgentStreamMessage, +} from '../../model/AgentControllerTypes.ts'; + +// Interface for AgentController classes. The `execRun` method is required — +// the framework uses it to auto-wire thread/run management, store persistence, +// SSE streaming, async execution, and cancellation via smart defaults. +export interface AgentHandler { + execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator; + createStore?(): Promise; + createThread?(): Promise; + getThread?(threadId: string): Promise; + asyncRun?(input: CreateRunInput): Promise; + streamRun?(input: CreateRunInput): Promise; + syncRun?(input: CreateRunInput): Promise; + getRun?(runId: string): Promise; + cancelRun?(runId: string): Promise; +} diff --git a/tegg/core/controller-decorator/src/decorator/index.ts b/tegg/core/controller-decorator/src/decorator/index.ts index 27fef617f7..14c0ffa0c1 100644 --- a/tegg/core/controller-decorator/src/decorator/index.ts +++ b/tegg/core/controller-decorator/src/decorator/index.ts @@ -1,5 +1,7 @@ export * from './http/index.ts'; export * from './mcp/index.ts'; +export * from './agent/AgentController.ts'; +export * from './agent/AgentHandler.ts'; export * from './Acl.ts'; export * from './Context.ts'; export * from './Middleware.ts'; diff --git a/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts b/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts new file mode 100644 index 0000000000..1dca91a363 --- /dev/null +++ b/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts @@ -0,0 +1,107 @@ +// ===== Input Message (what clients send in request body) ===== + +export interface InputMessage { + role: 'user' | 'assistant' | 'system'; + content: string | InputContentPart[]; + metadata?: Record; +} + +export interface InputContentPart { + type: 'text'; + text: string; +} + +// ===== Output Message (OpenAI thread.message object) ===== + +export interface MessageObject { + id: string; // "msg_xxx" + object: 'thread.message'; + created_at: number; // Unix seconds + thread_id?: string; + run_id?: string; + role: 'user' | 'assistant'; + status: 'in_progress' | 'incomplete' | 'completed'; + content: MessageContentBlock[]; + metadata?: Record; +} + +export interface TextContentBlock { + type: 'text'; + text: { value: string; annotations: unknown[] }; +} + +export type MessageContentBlock = TextContentBlock; + +// ===== Thread types ===== + +export interface ThreadObject { + id: string; // "thread_xxx" + object: 'thread'; + created_at: number; // Unix seconds + metadata: Record; +} + +export interface ThreadObjectWithMessages extends ThreadObject { + messages: MessageObject[]; +} + +// ===== Run types ===== + +export type RunStatus = 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'cancelling' | 'expired'; + +export interface RunObject { + id: string; // "run_xxx" + object: 'thread.run'; + created_at: number; // Unix seconds + thread_id?: string; + status: RunStatus; + last_error?: { code: string; message: string } | null; + started_at?: number | null; + completed_at?: number | null; + cancelled_at?: number | null; + failed_at?: number | null; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null; + metadata?: Record; + output?: MessageObject[]; + config?: AgentRunConfig; +} + +// ===== Request types ===== + +export interface CreateRunInput { + thread_id?: string; + input: { + messages: InputMessage[]; + }; + config?: AgentRunConfig; + metadata?: Record; +} + +// ===== SSE Delta type ===== + +export interface MessageDeltaObject { + id: string; + object: 'thread.message.delta'; + delta: { content: MessageContentBlock[] }; +} + +// ===== Internal types ===== + +export interface AgentRunUsage { + total_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + duration_ms?: number; +} + +export interface AgentRunConfig { + max_iterations?: number; + timeout_ms?: number; +} + +export interface AgentStreamMessage { + type: string; + message?: { role: string; content: string | { type: string; text: string }[] }; + usage?: AgentRunUsage; + [key: string]: unknown; +} diff --git a/tegg/core/controller-decorator/src/model/index.ts b/tegg/core/controller-decorator/src/model/index.ts index f637c18ae6..d3280fa4fb 100644 --- a/tegg/core/controller-decorator/src/model/index.ts +++ b/tegg/core/controller-decorator/src/model/index.ts @@ -1,3 +1,4 @@ +export * from './AgentControllerTypes.ts'; export * from './HTTPControllerMeta.ts'; export * from './HTTPCookies.ts'; export * from './HTTPMethodMeta.ts'; diff --git a/tegg/core/controller-decorator/test/AgentController.test.ts b/tegg/core/controller-decorator/test/AgentController.test.ts new file mode 100644 index 0000000000..06afb6c501 --- /dev/null +++ b/tegg/core/controller-decorator/test/AgentController.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; + +import { ControllerType, HTTPMethodEnum } from '@eggjs/tegg-types'; +import { describe, it } from 'vitest'; + +import { + ControllerMetaBuilderFactory, + BodyParamMeta, + PathParamMeta, + ControllerInfoUtil, + MethodInfoUtil, + HTTPInfoUtil, +} from '../src/index.ts'; +import { HTTPControllerMeta } from '../src/model/index.ts'; +import { AgentFooController } from './fixtures/AgentFooController.js'; + +describe('core/controller-decorator/test/AgentController.test.ts', () => { + describe('decorator metadata', () => { + it('should set ControllerType.HTTP on the class', () => { + const controllerType = ControllerInfoUtil.getControllerType(AgentFooController); + assert.strictEqual(controllerType, ControllerType.HTTP); + }); + + it('should set AGENT_CONTROLLER symbol on the class', () => { + assert.strictEqual((AgentFooController as any)[Symbol.for('AGENT_CONTROLLER')], true); + }); + + it('should set fixed base path /api/v1', () => { + const httpPath = HTTPInfoUtil.getHTTPPath(AgentFooController); + assert.strictEqual(httpPath, '/api/v1'); + }); + }); + + describe('method HTTP metadata', () => { + const methodRoutes = [ + { methodName: 'createThread', httpMethod: HTTPMethodEnum.POST, path: '/threads' }, + { methodName: 'getThread', httpMethod: HTTPMethodEnum.GET, path: '/threads/:id' }, + { methodName: 'asyncRun', httpMethod: HTTPMethodEnum.POST, path: '/runs' }, + { methodName: 'streamRun', httpMethod: HTTPMethodEnum.POST, path: '/runs/stream' }, + { methodName: 'syncRun', httpMethod: HTTPMethodEnum.POST, path: '/runs/wait' }, + { methodName: 'getRun', httpMethod: HTTPMethodEnum.GET, path: '/runs/:id' }, + { methodName: 'cancelRun', httpMethod: HTTPMethodEnum.POST, path: '/runs/:id/cancel' }, + ]; + + for (const route of methodRoutes) { + it(`should set correct HTTP method for ${route.methodName}`, () => { + const method = HTTPInfoUtil.getHTTPMethodMethod(AgentFooController, route.methodName); + assert.strictEqual(method, route.httpMethod); + }); + + it(`should set correct HTTP path for ${route.methodName}`, () => { + const path = HTTPInfoUtil.getHTTPMethodPath(AgentFooController, route.methodName); + assert.strictEqual(path, route.path); + }); + + it(`should set ControllerType.HTTP on method ${route.methodName}`, () => { + const controllerType = MethodInfoUtil.getMethodControllerType(AgentFooController, route.methodName); + assert.strictEqual(controllerType, ControllerType.HTTP); + }); + } + }); + + describe('parameter metadata', () => { + it('should set BODY param at index 0 for asyncRun', () => { + const paramType = HTTPInfoUtil.getHTTPMethodParamType(0, AgentFooController, 'asyncRun'); + assert.strictEqual(paramType, 'BODY'); + }); + + it('should set BODY param at index 0 for streamRun', () => { + const paramType = HTTPInfoUtil.getHTTPMethodParamType(0, AgentFooController, 'streamRun'); + assert.strictEqual(paramType, 'BODY'); + }); + + it('should set BODY param at index 0 for syncRun', () => { + const paramType = HTTPInfoUtil.getHTTPMethodParamType(0, AgentFooController, 'syncRun'); + assert.strictEqual(paramType, 'BODY'); + }); + + it('should set PARAM at index 0 with name "id" for getThread', () => { + const paramType = HTTPInfoUtil.getHTTPMethodParamType(0, AgentFooController, 'getThread'); + assert.strictEqual(paramType, 'PARAM'); + const paramName = HTTPInfoUtil.getHTTPMethodParamName(0, AgentFooController, 'getThread'); + assert.strictEqual(paramName, 'id'); + }); + + it('should set PARAM at index 0 with name "id" for getRun', () => { + const paramType = HTTPInfoUtil.getHTTPMethodParamType(0, AgentFooController, 'getRun'); + assert.strictEqual(paramType, 'PARAM'); + const paramName = HTTPInfoUtil.getHTTPMethodParamName(0, AgentFooController, 'getRun'); + assert.strictEqual(paramName, 'id'); + }); + + it('should set PARAM at index 0 with name "id" for cancelRun', () => { + const paramType = HTTPInfoUtil.getHTTPMethodParamType(0, AgentFooController, 'cancelRun'); + assert.strictEqual(paramType, 'PARAM'); + const paramName = HTTPInfoUtil.getHTTPMethodParamName(0, AgentFooController, 'cancelRun'); + assert.strictEqual(paramName, 'id'); + }); + + it('should not have params for createThread', () => { + const paramIndexList = HTTPInfoUtil.getParamIndexList(AgentFooController, 'createThread'); + assert.strictEqual(paramIndexList.length, 0); + }); + }); + + describe('context index', () => { + it('should not set contextIndex on any method', () => { + const methods = ['createThread', 'getThread', 'asyncRun', 'streamRun', 'syncRun', 'getRun', 'cancelRun']; + for (const methodName of methods) { + const contextIndex = MethodInfoUtil.getMethodContextIndex(AgentFooController, methodName); + assert.strictEqual(contextIndex, undefined, `${methodName} should not have contextIndex`); + } + }); + }); + + describe('default implementations', () => { + it('should inject default stubs for all 7 route methods', () => { + // AgentFooController only implements execRun (smart defaults pattern) + // All 7 route methods should have stub defaults that throw + const proto = AgentFooController.prototype as any; + const routeMethods = ['createThread', 'getThread', 'asyncRun', 'streamRun', 'syncRun', 'getRun', 'cancelRun']; + for (const methodName of routeMethods) { + assert(typeof proto[methodName] === 'function', `${methodName} should be a function`); + assert.strictEqual( + proto[methodName][Symbol.for('AGENT_NOT_IMPLEMENTED')], + true, + `${methodName} should be marked as AGENT_NOT_IMPLEMENTED`, + ); + } + }); + + const stubMethods = [ + { name: 'createThread', args: [] }, + { name: 'getThread', args: ['thread_1'] }, + { name: 'asyncRun', args: [{ input: { messages: [] } }] }, + { name: 'streamRun', args: [{ input: { messages: [] } }] }, + { name: 'syncRun', args: [{ input: { messages: [] } }] }, + { name: 'getRun', args: ['run_1'] }, + { name: 'cancelRun', args: ['run_1'] }, + ]; + + for (const { name, args } of stubMethods) { + it(`should throw for unimplemented ${name}`, async () => { + const instance = new AgentFooController() as any; + await assert.rejects(() => instance[name](...args), new RegExp(`${name} not implemented`)); + }); + } + }); + + describe('HTTPControllerMetaBuilder integration', () => { + it('should build metadata with 7 HTTPMethodMeta entries', () => { + const meta = ControllerMetaBuilderFactory.build(AgentFooController, ControllerType.HTTP) as HTTPControllerMeta; + assert(meta); + assert.strictEqual(meta.methods.length, 7); + assert.strictEqual(meta.path, '/api/v1'); + }); + + it('should produce correct route metadata for each method', () => { + const meta = ControllerMetaBuilderFactory.build(AgentFooController, ControllerType.HTTP) as HTTPControllerMeta; + + const createThread = meta.methods.find((m) => m.name === 'createThread')!; + assert.strictEqual(createThread.path, '/threads'); + assert.strictEqual(createThread.method, HTTPMethodEnum.POST); + assert.strictEqual(createThread.paramMap.size, 0); + + const getThread = meta.methods.find((m) => m.name === 'getThread')!; + assert.strictEqual(getThread.path, '/threads/:id'); + assert.strictEqual(getThread.method, HTTPMethodEnum.GET); + assert.deepStrictEqual(getThread.paramMap, new Map([[0, new PathParamMeta('id')]])); + + const asyncRun = meta.methods.find((m) => m.name === 'asyncRun')!; + assert.strictEqual(asyncRun.path, '/runs'); + assert.strictEqual(asyncRun.method, HTTPMethodEnum.POST); + assert.deepStrictEqual(asyncRun.paramMap, new Map([[0, new BodyParamMeta()]])); + + const streamRun = meta.methods.find((m) => m.name === 'streamRun')!; + assert.strictEqual(streamRun.path, '/runs/stream'); + assert.strictEqual(streamRun.method, HTTPMethodEnum.POST); + assert.deepStrictEqual(streamRun.paramMap, new Map([[0, new BodyParamMeta()]])); + + const syncRun = meta.methods.find((m) => m.name === 'syncRun')!; + assert.strictEqual(syncRun.path, '/runs/wait'); + assert.strictEqual(syncRun.method, HTTPMethodEnum.POST); + assert.deepStrictEqual(syncRun.paramMap, new Map([[0, new BodyParamMeta()]])); + + const getRun = meta.methods.find((m) => m.name === 'getRun')!; + assert.strictEqual(getRun.path, '/runs/:id'); + assert.strictEqual(getRun.method, HTTPMethodEnum.GET); + assert.deepStrictEqual(getRun.paramMap, new Map([[0, new PathParamMeta('id')]])); + + const cancelRun = meta.methods.find((m) => m.name === 'cancelRun')!; + assert.strictEqual(cancelRun.path, '/runs/:id/cancel'); + assert.strictEqual(cancelRun.method, HTTPMethodEnum.POST); + assert.deepStrictEqual(cancelRun.paramMap, new Map([[0, new PathParamMeta('id')]])); + }); + + it('should have all real paths start with /', () => { + const meta = ControllerMetaBuilderFactory.build(AgentFooController, ControllerType.HTTP) as HTTPControllerMeta; + for (const method of meta.methods) { + const realPath = meta.getMethodRealPath(method); + assert(realPath.startsWith('/'), `${method.name} real path "${realPath}" should start with /`); + } + }); + }); +}); diff --git a/tegg/core/controller-decorator/test/fixtures/AgentFooController.ts b/tegg/core/controller-decorator/test/fixtures/AgentFooController.ts new file mode 100644 index 0000000000..2187c3a962 --- /dev/null +++ b/tegg/core/controller-decorator/test/fixtures/AgentFooController.ts @@ -0,0 +1,18 @@ +import { AgentController } from '../../src/decorator/agent/AgentController.ts'; +import type { AgentHandler } from '../../src/decorator/agent/AgentHandler.ts'; +import type { CreateRunInput, AgentStreamMessage } from '../../src/model/AgentControllerTypes.ts'; + +// AgentController that only implements execRun (smart defaults pattern) +@AgentController() +export class AgentFooController implements AgentHandler { + async *execRun(input: CreateRunInput): AsyncGenerator { + const messages = input.input.messages; + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: `Processed ${messages.length} messages` }], + }, + }; + } +} diff --git a/tegg/core/tegg/package.json b/tegg/core/tegg/package.json index 39efdb5940..b7e1721927 100644 --- a/tegg/core/tegg/package.json +++ b/tegg/core/tegg/package.json @@ -35,6 +35,7 @@ "./orm": "./src/orm.ts", "./schedule": "./src/schedule.ts", "./standalone": "./src/standalone.ts", + "./agent": "./src/agent.ts", "./transaction": "./src/transaction.ts", "./package.json": "./package.json" }, @@ -42,6 +43,7 @@ "access": "public", "exports": { ".": "./dist/index.js", + "./agent": "./dist/agent.js", "./ajv": "./dist/ajv.js", "./aop": "./dist/aop.js", "./dal": "./dist/dal.js", @@ -57,6 +59,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@eggjs/agent-runtime": "workspace:*", "@eggjs/ajv-decorator": "workspace:*", "@eggjs/aop-decorator": "workspace:*", "@eggjs/background-task": "workspace:*", diff --git a/tegg/core/tegg/src/agent.ts b/tegg/core/tegg/src/agent.ts new file mode 100644 index 0000000000..41491c5328 --- /dev/null +++ b/tegg/core/tegg/src/agent.ts @@ -0,0 +1,30 @@ +// AgentController decorator from controller-decorator (no wrapper needed) +export { AgentController } from '@eggjs/controller-decorator'; + +// Utility types and classes from agent-runtime +export type { AgentStore, ThreadRecord, RunRecord } from '@eggjs/agent-runtime'; +export { FileAgentStore } from '@eggjs/agent-runtime'; + +// Original types and interfaces from controller-decorator +export type { AgentHandler } from '@eggjs/controller-decorator'; +export type { + // Input types + InputMessage, + InputContentPart, + // Output types (OpenAI-aligned) + MessageObject, + MessageContentBlock, + TextContentBlock, + MessageDeltaObject, + ThreadObject, + ThreadObjectWithMessages, + RunObject, + RunStatus, + // Internal types + AgentRunConfig, + AgentRunUsage, + // Request types + CreateRunInput, + // Stream types + AgentStreamMessage, +} from '@eggjs/controller-decorator'; diff --git a/tegg/plugin/controller/package.json b/tegg/plugin/controller/package.json index b3861d837b..bcb96a84a2 100644 --- a/tegg/plugin/controller/package.json +++ b/tegg/plugin/controller/package.json @@ -88,6 +88,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@eggjs/agent-runtime": "workspace:*", "@eggjs/controller-decorator": "workspace:*", "@eggjs/core-decorator": "workspace:*", "@eggjs/lifecycle": "workspace:*", diff --git a/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts b/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts index e6f5743c4a..465c12cdb8 100644 --- a/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts +++ b/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts @@ -4,6 +4,11 @@ import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/metadata export class EggControllerPrototypeHook implements LifecycleHook { async postCreate(ctx: EggPrototypeLifecycleContext): Promise { + // Enhance @AgentController classes with smart defaults before metadata build. + if ((ctx.clazz as any)[Symbol.for('AGENT_CONTROLLER')]) { + const { enhanceAgentController } = await import('@eggjs/agent-runtime'); + enhanceAgentController(ctx.clazz); + } const metadata = ControllerMetaBuilderFactory.build(ctx.clazz); if (metadata) { ControllerMetadataUtil.setControllerMetadata(ctx.clazz, metadata); diff --git a/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/app/controller/AgentTestController.ts b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/app/controller/AgentTestController.ts new file mode 100644 index 0000000000..108df4e45e --- /dev/null +++ b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/app/controller/AgentTestController.ts @@ -0,0 +1,201 @@ +import { AgentController } from '@eggjs/tegg/agent'; +import type { + AgentHandler, + CreateRunInput, + RunObject, + ThreadObject, + ThreadObjectWithMessages, + MessageObject, + MessageDeltaObject, + AgentStreamMessage, +} from '@eggjs/tegg/agent'; +import { ContextHandler } from '@eggjs/tegg/helper'; + +// In-memory store for threads and runs +const threads = new Map< + string, + { id: string; messages: MessageObject[]; created_at: number; metadata: Record } +>(); +const runs = new Map< + string, + { id: string; thread_id?: string; status: string; input: any[]; output?: MessageObject[]; created_at: number } +>(); + +let threadCounter = 0; +let runCounter = 0; + +function nowUnix(): number { + return Math.floor(Date.now() / 1000); +} + +@AgentController() +export class AgentTestController implements AgentHandler { + // Required by AgentHandler — noop since all route methods are overridden + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async *execRun(_input: CreateRunInput): AsyncGenerator { + // All routes are manually implemented; this is never called. + } + + async createThread(): Promise { + const threadId = `thread_${++threadCounter}`; + const now = nowUnix(); + threads.set(threadId, { id: threadId, messages: [], created_at: now, metadata: {} }); + return { id: threadId, object: 'thread', created_at: now, metadata: {} }; + } + + async getThread(threadId: string): Promise { + const thread = threads.get(threadId); + if (!thread) { + throw new Error(`Thread ${threadId} not found`); + } + return { + id: thread.id, + object: 'thread', + messages: thread.messages, + created_at: thread.created_at, + metadata: thread.metadata, + }; + } + + async asyncRun(input: CreateRunInput): Promise { + const runId = `run_${++runCounter}`; + const now = nowUnix(); + runs.set(runId, { + id: runId, + thread_id: input.thread_id, + status: 'queued', + input: input.input.messages, + created_at: now, + }); + return { id: runId, object: 'thread.run', created_at: now, status: 'queued' }; + } + + async streamRun(input: CreateRunInput): Promise { + const runtimeCtx = ContextHandler.getContext()!; + const ctx = runtimeCtx.get(Symbol.for('context#eggContext')); + + // Bypass Koa response handling — write SSE directly to the raw response + ctx.respond = false; + const res = ctx.res; + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const runId = `run_${++runCounter}`; + const messages = input.input.messages; + const outputContent = `Streamed ${messages.length} messages`; + const now = nowUnix(); + + const runObj: RunObject = { id: runId, object: 'thread.run', created_at: now, status: 'queued' }; + res.write(`event: thread.run.created\ndata: ${JSON.stringify(runObj)}\n\n`); + + runObj.status = 'in_progress'; + res.write(`event: thread.run.in_progress\ndata: ${JSON.stringify(runObj)}\n\n`); + + const msgId = `msg_${runCounter}`; + const msgObj: MessageObject = { + id: msgId, + object: 'thread.message', + created_at: now, + run_id: runId, + role: 'assistant', + status: 'in_progress', + content: [], + }; + res.write(`event: thread.message.created\ndata: ${JSON.stringify(msgObj)}\n\n`); + + const contentBlock = { type: 'text' as const, text: { value: outputContent, annotations: [] as unknown[] } }; + const delta: MessageDeltaObject = { + id: msgId, + object: 'thread.message.delta', + delta: { content: [contentBlock] }, + }; + res.write(`event: thread.message.delta\ndata: ${JSON.stringify(delta)}\n\n`); + + msgObj.status = 'completed'; + msgObj.content = [contentBlock]; + res.write(`event: thread.message.completed\ndata: ${JSON.stringify(msgObj)}\n\n`); + + const outputMsg: MessageObject = { ...msgObj }; + runObj.status = 'completed'; + runObj.output = [outputMsg]; + res.write(`event: thread.run.completed\ndata: ${JSON.stringify(runObj)}\n\n`); + + res.write('event: done\ndata: [DONE]\n\n'); + res.end(); + + runs.set(runId, { + id: runId, + status: 'completed', + input: messages, + output: [outputMsg], + created_at: now, + }); + } + + async syncRun(input: CreateRunInput): Promise { + const runId = `run_${++runCounter}`; + const messages = input.input.messages; + const now = nowUnix(); + const output: MessageObject[] = [ + { + id: `msg_${runCounter}`, + object: 'thread.message', + created_at: now, + role: 'assistant', + status: 'completed', + content: [ + { + type: 'text', + text: { value: `Processed ${messages.length} messages`, annotations: [] }, + }, + ], + }, + ]; + runs.set(runId, { + id: runId, + thread_id: input.thread_id, + status: 'completed', + input: messages, + output, + created_at: now, + }); + return { + id: runId, + object: 'thread.run', + created_at: now, + status: 'completed', + output, + }; + } + + async getRun(runId: string): Promise { + const run = runs.get(runId); + if (!run) { + throw new Error(`Run ${runId} not found`); + } + return { + id: run.id, + object: 'thread.run', + created_at: run.created_at, + thread_id: run.thread_id, + status: run.status as any, + output: run.output, + }; + } + + async cancelRun(runId: string): Promise { + const run = runs.get(runId); + if (run) { + run.status = 'cancelled'; + } + return { + id: runId, + object: 'thread.run', + created_at: run?.created_at ?? nowUnix(), + status: 'cancelled', + }; + } +} diff --git a/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/config/config.default.ts b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/config/config.default.ts new file mode 100644 index 0000000000..39984fbf2e --- /dev/null +++ b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/config/config.default.ts @@ -0,0 +1,9 @@ +export default () => { + const config = { + keys: 'test key', + security: { + csrf: false, + }, + }; + return config; +}; diff --git a/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/config/plugin.ts b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/config/plugin.ts new file mode 100644 index 0000000000..31a66b8e4f --- /dev/null +++ b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/config/plugin.ts @@ -0,0 +1,18 @@ +export default { + tracer: { + package: '@eggjs/tracer', + enable: true, + }, + tegg: { + package: '@eggjs/tegg-plugin', + enable: true, + }, + teggConfig: { + package: '@eggjs/tegg-config', + enable: true, + }, + teggController: { + package: '@eggjs/controller-plugin', + enable: true, + }, +}; diff --git a/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/package.json b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/package.json new file mode 100644 index 0000000000..0b8e77503f --- /dev/null +++ b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/package.json @@ -0,0 +1,4 @@ +{ + "name": "agent-controller-app", + "type": "module" +} diff --git a/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/app/controller/BaseAgentController.ts b/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/app/controller/BaseAgentController.ts new file mode 100644 index 0000000000..2aa189e670 --- /dev/null +++ b/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/app/controller/BaseAgentController.ts @@ -0,0 +1,42 @@ +import { AgentController } from '@eggjs/tegg/agent'; +import type { AgentHandler, CreateRunInput, AgentStreamMessage } from '@eggjs/tegg/agent'; + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + const err = new Error('Aborted'); + err.name = 'AbortError'; + reject(err); + }, + { once: true }, + ); + }); +} + +@AgentController() +export class BaseAgentController implements AgentHandler { + async *execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator { + const messages = input.input.messages; + + // If the first message asks to cancel, add a delay so cancel tests can catch it + if (messages[0]?.content === 'cancel me') { + await sleep(2000, signal); + } + + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: `Processed ${messages.length} messages` }], + }, + }; + yield { + type: 'result', + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }; + } +} diff --git a/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/config/config.default.ts b/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/config/config.default.ts new file mode 100644 index 0000000000..39984fbf2e --- /dev/null +++ b/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/config/config.default.ts @@ -0,0 +1,9 @@ +export default () => { + const config = { + keys: 'test key', + security: { + csrf: false, + }, + }; + return config; +}; diff --git a/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/config/plugin.ts b/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/config/plugin.ts new file mode 100644 index 0000000000..31a66b8e4f --- /dev/null +++ b/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/config/plugin.ts @@ -0,0 +1,18 @@ +export default { + tracer: { + package: '@eggjs/tracer', + enable: true, + }, + tegg: { + package: '@eggjs/tegg-plugin', + enable: true, + }, + teggConfig: { + package: '@eggjs/tegg-config', + enable: true, + }, + teggController: { + package: '@eggjs/controller-plugin', + enable: true, + }, +}; diff --git a/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/package.json b/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/package.json new file mode 100644 index 0000000000..9b54c42a62 --- /dev/null +++ b/tegg/plugin/controller/test/fixtures/apps/base-agent-controller-app/package.json @@ -0,0 +1,4 @@ +{ + "name": "base-agent-controller-app", + "type": "module" +} diff --git a/tegg/plugin/controller/test/http/agent.test.ts b/tegg/plugin/controller/test/http/agent.test.ts new file mode 100644 index 0000000000..cb4cb643ff --- /dev/null +++ b/tegg/plugin/controller/test/http/agent.test.ts @@ -0,0 +1,328 @@ +import { strict as assert } from 'node:assert'; +import { rm } from 'node:fs/promises'; +import path from 'node:path'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { describe, it, afterEach, beforeAll, afterAll } from 'vitest'; + +import { getFixtures } from '../utils.ts'; + +describe('plugin/controller/test/http/agent.test.ts', () => { + let app: MockApplication; + const agentDataDir = path.join(getFixtures('apps/agent-controller-app'), '.agent-data'); + + afterEach(() => { + return mm.restore(); + }); + + beforeAll(async () => { + mm(process.env, 'TEGG_AGENT_DATA_DIR', agentDataDir); + app = mm.app({ + baseDir: getFixtures('apps/agent-controller-app'), + }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + await rm(agentDataDir, { recursive: true, force: true }).catch(() => { + /* ignore */ + }); + }); + + describe('POST /api/v1/threads (createThread)', () => { + it('should create a new thread', async () => { + const res = await app.httpRequest().post('/api/v1/threads').send({}).expect(200); + assert(res.body.id); + assert(typeof res.body.id === 'string'); + assert.equal(res.body.object, 'thread'); + assert(typeof res.body.created_at === 'number'); + assert(typeof res.body.metadata === 'object'); + }); + }); + + describe('GET /api/v1/threads/:id (getThread)', () => { + it('should get a thread by id', async () => { + // First create a thread + const createRes = await app.httpRequest().post('/api/v1/threads').send({}).expect(200); + const threadId = createRes.body.id; + + // Then get the thread + const res = await app.httpRequest().get(`/api/v1/threads/${threadId}`).expect(200); + assert.equal(res.body.id, threadId); + assert.equal(res.body.object, 'thread'); + assert(Array.isArray(res.body.messages)); + assert(typeof res.body.created_at === 'number'); + }); + + it('should return 500 for non-existent thread', async () => { + await app.httpRequest().get('/api/v1/threads/non_existent').expect(500); + }); + }); + + describe('POST /api/v1/runs (asyncRun)', () => { + it('should create an async run', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + input: { + messages: [{ role: 'user', content: 'Hello' }], + }, + }) + .expect(200); + assert(res.body.id); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'queued'); + }); + + it('should create an async run with thread_id', async () => { + const createRes = await app.httpRequest().post('/api/v1/threads').send({}).expect(200); + + const res = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + thread_id: createRes.body.id, + input: { + messages: [{ role: 'user', content: 'Hello from thread' }], + }, + }) + .expect(200); + assert(res.body.id); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'queued'); + }); + }); + + describe('POST /api/v1/runs/stream (streamRun)', () => { + it('should stream SSE events with OpenAI format', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs/stream') + .send({ + input: { + messages: [{ role: 'user', content: 'Stream me' }], + }, + }) + .buffer(true) + .expect(200) + .expect('Content-Type', /text\/event-stream/); + + // Parse SSE events from response text + const events: { event: string; data: any }[] = []; + const rawEvents = res.text.split('\n\n').filter(Boolean); + for (const raw of rawEvents) { + const lines = raw.split('\n'); + let event = ''; + let data = ''; + for (const line of lines) { + if (line.startsWith('event: ')) event = line.slice(7); + if (line.startsWith('data: ')) data = line.slice(6); + } + if (event && data) { + try { + events.push({ event, data: JSON.parse(data) }); + } catch { + events.push({ event, data }); + } + } + } + + // Verify SSE events in OpenAI format + assert(events.length >= 6); // at least: run.created, run.in_progress, msg.created, msg.delta, msg.completed, run.completed, done + + assert.equal(events[0].event, 'thread.run.created'); + assert(events[0].data.id); + assert.equal(events[0].data.object, 'thread.run'); + assert.equal(events[0].data.status, 'queued'); + + assert.equal(events[1].event, 'thread.run.in_progress'); + assert.equal(events[1].data.status, 'in_progress'); + + assert.equal(events[2].event, 'thread.message.created'); + assert.equal(events[2].data.object, 'thread.message'); + assert.equal(events[2].data.status, 'in_progress'); + + assert.equal(events[3].event, 'thread.message.delta'); + assert.equal(events[3].data.object, 'thread.message.delta'); + assert(events[3].data.delta.content[0].text.value.includes('Streamed')); + + assert.equal(events[4].event, 'thread.message.completed'); + assert.equal(events[4].data.status, 'completed'); + + assert.equal(events[5].event, 'thread.run.completed'); + assert.equal(events[5].data.status, 'completed'); + assert(events[5].data.output[0].content[0].text.value.includes('Streamed')); + }); + + it('should stream SSE events with multiple messages', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs/stream') + .send({ + input: { + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + { role: 'user', content: 'How are you?' }, + ], + }, + }) + .buffer(true) + .expect(200); + + // Verify message count is reflected in the streamed content + assert(res.text.includes('Streamed 3 messages')); + }); + }); + + describe('POST /api/v1/runs/wait (syncRun)', () => { + it('should create a sync run and wait for completion', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + input: { + messages: [{ role: 'user', content: 'What is 2+2?' }], + }, + }) + .expect(200); + assert(res.body.id); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'completed'); + assert(Array.isArray(res.body.output)); + assert.equal(res.body.output.length, 1); + assert.equal(res.body.output[0].object, 'thread.message'); + assert.equal(res.body.output[0].role, 'assistant'); + assert.equal(res.body.output[0].content[0].text.value, 'Processed 1 messages'); + }); + + it('should handle multiple messages', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + input: { + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + { role: 'user', content: 'How are you?' }, + ], + }, + }) + .expect(200); + assert.equal(res.body.status, 'completed'); + assert.equal(res.body.output[0].content[0].text.value, 'Processed 4 messages'); + }); + }); + + describe('GET /api/v1/runs/:id (getRun)', () => { + it('should get a run by id', async () => { + // First create a run + const createRes = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + input: { + messages: [{ role: 'user', content: 'test' }], + }, + }) + .expect(200); + const runId = createRes.body.id; + + // Then get the run + const res = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(res.body.id, runId); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'queued'); + assert(typeof res.body.created_at === 'number'); + }); + + it('should return 500 for non-existent run', async () => { + await app.httpRequest().get('/api/v1/runs/non_existent').expect(500); + }); + }); + + describe('POST /api/v1/runs/:id/cancel (cancelRun)', () => { + it('should cancel a run', async () => { + // First create a run + const createRes = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + input: { + messages: [{ role: 'user', content: 'cancel me' }], + }, + }) + .expect(200); + const runId = createRes.body.id; + + // Cancel it + const res = await app.httpRequest().post(`/api/v1/runs/${runId}/cancel`).expect(200); + assert.equal(res.body.id, runId); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'cancelled'); + + // Verify status changed + const getRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(getRes.body.status, 'cancelled'); + }); + }); + + describe('full workflow', () => { + it('should support create thread → sync run → get run flow', async () => { + // 1. Create thread + const threadRes = await app.httpRequest().post('/api/v1/threads').send({}).expect(200); + const threadId = threadRes.body.id; + + // 2. Run sync with thread + const runRes = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + thread_id: threadId, + input: { + messages: [{ role: 'user', content: 'Hello agent' }], + }, + }) + .expect(200); + assert.equal(runRes.body.status, 'completed'); + const runId = runRes.body.id; + + // 3. Get run details + const getRunRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(getRunRes.body.id, runId); + assert.equal(getRunRes.body.thread_id, threadId); + assert.equal(getRunRes.body.status, 'completed'); + }); + + it('should support async run → get run → cancel flow', async () => { + // 1. Create async run + const asyncRes = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + input: { + messages: [{ role: 'user', content: 'async task' }], + }, + }) + .expect(200); + assert.equal(asyncRes.body.status, 'queued'); + const runId = asyncRes.body.id; + + // 2. Get run - should be queued + const getRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(getRes.body.status, 'queued'); + + // 3. Cancel run + const cancelRes = await app.httpRequest().post(`/api/v1/runs/${runId}/cancel`).expect(200); + assert.equal(cancelRes.body.status, 'cancelled'); + + // 4. Verify cancelled + const verifyRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(verifyRes.body.status, 'cancelled'); + }); + }); +}); diff --git a/tegg/plugin/controller/test/http/base-agent.test.ts b/tegg/plugin/controller/test/http/base-agent.test.ts new file mode 100644 index 0000000000..841ec0a7a0 --- /dev/null +++ b/tegg/plugin/controller/test/http/base-agent.test.ts @@ -0,0 +1,440 @@ +import { strict as assert } from 'node:assert'; +import { rm } from 'node:fs/promises'; +import path from 'node:path'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { describe, it, afterEach, beforeAll, afterAll } from 'vitest'; + +import { getFixtures } from '../utils.ts'; + +describe('plugin/controller/test/http/base-agent.test.ts', () => { + let app: MockApplication; + const agentDataDir = path.join(getFixtures('apps/base-agent-controller-app'), '.agent-data'); + + afterEach(() => { + return mm.restore(); + }); + + beforeAll(async () => { + mm(process.env, 'TEGG_AGENT_DATA_DIR', agentDataDir); + app = mm.app({ + baseDir: getFixtures('apps/base-agent-controller-app'), + }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + await rm(agentDataDir, { recursive: true, force: true }).catch(() => { + /* ignore */ + }); + }); + + describe('POST /api/v1/threads (createThread)', () => { + it('should create a new thread via smart default', async () => { + const res = await app.httpRequest().post('/api/v1/threads').send({}).expect(200); + assert(res.body.id); + assert(res.body.id.startsWith('thread_')); + assert.equal(res.body.object, 'thread'); + assert(typeof res.body.created_at === 'number'); + // Unix seconds + assert(res.body.created_at < Date.now()); + assert(typeof res.body.metadata === 'object'); + }); + }); + + describe('GET /api/v1/threads/:id (getThread)', () => { + it('should get a thread by id', async () => { + const createRes = await app.httpRequest().post('/api/v1/threads').send({}).expect(200); + const threadId = createRes.body.id; + + const res = await app.httpRequest().get(`/api/v1/threads/${threadId}`).expect(200); + assert.equal(res.body.id, threadId); + assert.equal(res.body.object, 'thread'); + assert(Array.isArray(res.body.messages)); + assert(typeof res.body.created_at === 'number'); + }); + + it('should return 500 for non-existent thread', async () => { + await app.httpRequest().get('/api/v1/threads/non_existent').expect(500); + }); + }); + + describe('POST /api/v1/runs/wait (syncRun)', () => { + it('should process via execRun and return completed RunObject', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + input: { + messages: [{ role: 'user', content: 'What is 2+2?' }], + }, + }) + .expect(200); + assert(res.body.id); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'completed'); + assert(res.body.thread_id); + assert(res.body.thread_id.startsWith('thread_')); + assert(Array.isArray(res.body.output)); + assert.equal(res.body.output.length, 1); + assert.equal(res.body.output[0].object, 'thread.message'); + assert.equal(res.body.output[0].role, 'assistant'); + assert.equal(res.body.output[0].status, 'completed'); + assert.equal(res.body.output[0].content[0].type, 'text'); + assert.equal(res.body.output[0].content[0].text.value, 'Processed 1 messages'); + assert(Array.isArray(res.body.output[0].content[0].text.annotations)); + }); + + it('should handle multiple messages', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + input: { + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + { role: 'user', content: 'How are you?' }, + ], + }, + }) + .expect(200); + assert.equal(res.body.status, 'completed'); + assert.equal(res.body.output[0].content[0].text.value, 'Processed 3 messages'); + }); + + it('should pass metadata through syncRun and persist to store', async () => { + const meta = { user_id: 'u_sync', env: 'test' }; + const res = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + input: { + messages: [{ role: 'user', content: 'Hello' }], + }, + metadata: meta, + }) + .expect(200); + assert.deepEqual(res.body.metadata, meta); + + // Verify persisted via getRun + const getRes = await app.httpRequest().get(`/api/v1/runs/${res.body.id}`).expect(200); + assert.deepEqual(getRes.body.metadata, meta); + }); + + it('should auto-create thread and persist messages when thread_id not provided', async () => { + const runRes = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + input: { + messages: [{ role: 'user', content: 'Hello agent' }], + }, + }) + .expect(200); + assert(runRes.body.thread_id); + assert(runRes.body.thread_id.startsWith('thread_')); + + // Verify thread was created and messages were appended + const threadRes = await app.httpRequest().get(`/api/v1/threads/${runRes.body.thread_id}`).expect(200); + assert.equal(threadRes.body.messages.length, 2); // user + assistant + assert.equal(threadRes.body.messages[0].role, 'user'); + assert.equal(threadRes.body.messages[1].role, 'assistant'); + }); + }); + + describe('POST /api/v1/runs (asyncRun)', () => { + it('should create an async run and return queued with auto-created thread_id', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + input: { + messages: [{ role: 'user', content: 'Hello' }], + }, + }) + .expect(200); + assert(res.body.id); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'queued'); + assert(res.body.thread_id); + assert(res.body.thread_id.startsWith('thread_')); + }); + + it('should pass metadata through asyncRun and persist to store', async () => { + const meta = { user_id: 'u_async' }; + const res = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + input: { + messages: [{ role: 'user', content: 'Hello' }], + }, + metadata: meta, + }) + .expect(200); + assert.deepEqual(res.body.metadata, meta); + + // Wait for background task + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify persisted via getRun + const getRes = await app.httpRequest().get(`/api/v1/runs/${res.body.id}`).expect(200); + assert.deepEqual(getRes.body.metadata, meta); + }); + + it('should complete the run in the background', async () => { + const asyncRes = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + input: { + messages: [{ role: 'user', content: 'Hello' }], + }, + }) + .expect(200); + const runId = asyncRes.body.id; + + // Wait a bit for background task + await new Promise((resolve) => setTimeout(resolve, 500)); + + const getRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(getRes.body.status, 'completed'); + assert.equal(getRes.body.output[0].content[0].text.value, 'Processed 1 messages'); + }); + }); + + describe('POST /api/v1/runs/stream (streamRun)', () => { + it('should stream SSE events with OpenAI format', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs/stream') + .send({ + input: { + messages: [{ role: 'user', content: 'Stream me' }], + }, + }) + .buffer(true) + .expect(200) + .expect('Content-Type', /text\/event-stream/); + + // Parse SSE events from response text + const events: { event: string; data: any }[] = []; + const rawEvents = res.text.split('\n\n').filter(Boolean); + for (const raw of rawEvents) { + const lines = raw.split('\n'); + let event = ''; + let data = ''; + for (const line of lines) { + if (line.startsWith('event: ')) event = line.slice(7); + if (line.startsWith('data: ')) data = line.slice(6); + } + if (event && data) { + try { + events.push({ event, data: JSON.parse(data) }); + } catch { + events.push({ event, data }); + } + } + } + + // Expected events: thread.run.created, thread.run.in_progress, + // thread.message.created, thread.message.delta (assistant msg), + // thread.message.completed, thread.run.completed, done + assert(events.length >= 7); + + assert.equal(events[0].event, 'thread.run.created'); + assert(events[0].data.id); + assert.equal(events[0].data.object, 'thread.run'); + assert.equal(events[0].data.status, 'queued'); + assert(events[0].data.thread_id); + assert(events[0].data.thread_id.startsWith('thread_')); + + assert.equal(events[1].event, 'thread.run.in_progress'); + assert.equal(events[1].data.status, 'in_progress'); + + assert.equal(events[2].event, 'thread.message.created'); + assert.equal(events[2].data.object, 'thread.message'); + assert.equal(events[2].data.role, 'assistant'); + assert.equal(events[2].data.status, 'in_progress'); + assert.deepEqual(events[2].data.content, []); + + // thread.message.delta for the assistant message + assert.equal(events[3].event, 'thread.message.delta'); + assert.equal(events[3].data.object, 'thread.message.delta'); + assert.equal(events[3].data.delta.content[0].text.value, 'Processed 1 messages'); + + // No delta for the usage-only yield (type: 'result') + + assert.equal(events[4].event, 'thread.message.completed'); + assert.equal(events[4].data.status, 'completed'); + assert.equal(events[4].data.content[0].text.value, 'Processed 1 messages'); + + assert.equal(events[5].event, 'thread.run.completed'); + assert.equal(events[5].data.status, 'completed'); + assert.equal(events[5].data.output[0].role, 'assistant'); + assert.equal(events[5].data.output[0].content[0].text.value, 'Processed 1 messages'); + assert.equal(events[5].data.usage.prompt_tokens, 10); + assert.equal(events[5].data.usage.completion_tokens, 5); + assert.equal(events[5].data.usage.total_tokens, 15); + + assert.equal(events[6].event, 'done'); + }); + + it('should persist in_progress and started_at to store during stream', async () => { + const res = await app + .httpRequest() + .post('/api/v1/runs/stream') + .send({ + input: { + messages: [{ role: 'user', content: 'Stream me' }], + }, + }) + .buffer(true) + .expect(200); + + // Extract run id from the first SSE event + const firstEvent = res.text.split('\n\n')[0]; + const dataLine = firstEvent.split('\n').find((l: string) => l.startsWith('data: ')); + const runData = JSON.parse(dataLine!.slice(6)); + const runId = runData.id; + + // After stream completes, verify run was persisted with started_at + const getRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(getRes.body.status, 'completed'); + assert(typeof getRes.body.started_at === 'number'); + assert(getRes.body.started_at > 0); + }); + + it('should include metadata in SSE events and persist to store', async () => { + const meta = { user_id: 'u_stream', tag: 'test' }; + const res = await app + .httpRequest() + .post('/api/v1/runs/stream') + .send({ + input: { + messages: [{ role: 'user', content: 'Stream me' }], + }, + metadata: meta, + }) + .buffer(true) + .expect(200); + + // Parse SSE events + const events: { event: string; data: any }[] = []; + const rawEvents = res.text.split('\n\n').filter(Boolean); + for (const raw of rawEvents) { + const lines = raw.split('\n'); + let event = ''; + let data = ''; + for (const line of lines) { + if (line.startsWith('event: ')) event = line.slice(7); + if (line.startsWith('data: ')) data = line.slice(6); + } + if (event && data) { + try { + events.push({ event, data: JSON.parse(data) }); + } catch { + events.push({ event, data }); + } + } + } + + // Verify metadata in SSE events + assert.deepEqual(events[0].data.metadata, meta); // thread.run.created + assert.deepEqual(events[1].data.metadata, meta); // thread.run.in_progress + + // Verify metadata persisted in store via getRun + const runId = events[0].data.id; + const getRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.deepEqual(getRes.body.metadata, meta); + }); + }); + + describe('GET /api/v1/runs/:id (getRun)', () => { + it('should get a run by id', async () => { + const createRes = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + input: { + messages: [{ role: 'user', content: 'test' }], + }, + }) + .expect(200); + const runId = createRes.body.id; + + const res = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(res.body.id, runId); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'completed'); + assert(typeof res.body.created_at === 'number'); + }); + + it('should return 500 for non-existent run', async () => { + await app.httpRequest().get('/api/v1/runs/non_existent').expect(500); + }); + }); + + describe('POST /api/v1/runs/:id/cancel (cancelRun)', () => { + it('should cancel a run', async () => { + const createRes = await app + .httpRequest() + .post('/api/v1/runs') + .send({ + input: { + messages: [{ role: 'user', content: 'cancel me' }], + }, + }) + .expect(200); + const runId = createRes.body.id; + + // Wait for background task to start + await new Promise((resolve) => setTimeout(resolve, 100)); + + const res = await app.httpRequest().post(`/api/v1/runs/${runId}/cancel`).expect(200); + assert.equal(res.body.id, runId); + assert.equal(res.body.object, 'thread.run'); + assert.equal(res.body.status, 'cancelled'); + + // Verify status + const getRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(getRes.body.status, 'cancelled'); + }); + }); + + describe('full workflow', () => { + it('should support create thread → sync run → get thread with messages', async () => { + // 1. Create thread + const threadRes = await app.httpRequest().post('/api/v1/threads').send({}).expect(200); + const threadId = threadRes.body.id; + + // 2. Run sync with thread + const runRes = await app + .httpRequest() + .post('/api/v1/runs/wait') + .send({ + thread_id: threadId, + input: { + messages: [{ role: 'user', content: 'Hello agent' }], + }, + }) + .expect(200); + assert.equal(runRes.body.status, 'completed'); + const runId = runRes.body.id; + + // 3. Get run details + const getRunRes = await app.httpRequest().get(`/api/v1/runs/${runId}`).expect(200); + assert.equal(getRunRes.body.id, runId); + assert.equal(getRunRes.body.thread_id, threadId); + assert.equal(getRunRes.body.status, 'completed'); + + // 4. Thread should have messages appended + const getThreadRes = await app.httpRequest().get(`/api/v1/threads/${threadId}`).expect(200); + assert.equal(getThreadRes.body.messages.length, 2); + assert.equal(getThreadRes.body.messages[0].role, 'user'); + assert.equal(getThreadRes.body.messages[1].role, 'assistant'); + }); + }); +}); From 9951d13d31f8dfd100bf74d641173fd022f94b21 Mon Sep 17 00:00:00 2001 From: jerry Date: Fri, 27 Feb 2026 15:46:24 +0800 Subject: [PATCH 02/12] fix(tegg): address review issues for AgentController PR - Remove stale `packages/skills` entry from pnpm-lock.yaml - Replace hardcoded `Symbol.for('context#eggContext')` with named `EGG_CONTEXT` constant in agentDefaults and test fixture, with comment linking to canonical definition in @eggjs/module-common - Add `AgentNotFoundError` (404) and `AgentConflictError` (409) error classes so Koa returns proper HTTP status codes instead of 500 - Update FileAgentStore, agentDefaults, and test fixtures to use the new error classes - Update integration tests to expect 404 for not-found cases Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 2 - tegg/core/agent-runtime/src/FileAgentStore.ts | 8 ++-- tegg/core/agent-runtime/src/agentDefaults.ts | 39 +++++++++++++------ tegg/core/agent-runtime/src/errors.ts | 26 +++++++++++++ tegg/core/agent-runtime/src/index.ts | 1 + .../agent-runtime/test/FileAgentStore.test.ts | 25 ++++++++++-- .../agent-runtime/test/agentDefaults.test.ts | 12 +++++- tegg/core/tegg/src/agent.ts | 2 +- .../app/controller/AgentTestController.ts | 11 ++++-- .../plugin/controller/test/http/agent.test.ts | 8 ++-- .../controller/test/http/base-agent.test.ts | 8 ++-- 11 files changed, 106 insertions(+), 36 deletions(-) create mode 100644 tegg/core/agent-runtime/src/errors.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b76b693dd..8d694299f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1396,8 +1396,6 @@ importers: specifier: 'catalog:' version: 5.9.3 - packages/skills: {} - packages/supertest: dependencies: '@types/superagent': diff --git a/tegg/core/agent-runtime/src/FileAgentStore.ts b/tegg/core/agent-runtime/src/FileAgentStore.ts index 3d0c238b43..b96b9c6fd3 100644 --- a/tegg/core/agent-runtime/src/FileAgentStore.ts +++ b/tegg/core/agent-runtime/src/FileAgentStore.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import type { InputMessage, MessageObject, AgentRunConfig } from '@eggjs/controller-decorator'; import type { AgentStore, ThreadRecord, RunRecord } from './AgentStore.ts'; +import { AgentNotFoundError } from './errors.ts'; export interface FileAgentStoreOptions { dataDir: string; @@ -54,7 +55,7 @@ export class FileAgentStore implements AgentStore { const filePath = this.safePath(this.threadsDir, threadId); const data = await this.readFile(filePath); if (!data) { - throw new Error(`Thread ${threadId} not found`); + throw new AgentNotFoundError(`Thread ${threadId} not found`); } return data as ThreadRecord; } @@ -92,14 +93,15 @@ export class FileAgentStore implements AgentStore { const filePath = this.safePath(this.runsDir, runId); const data = await this.readFile(filePath); if (!data) { - throw new Error(`Run ${runId} not found`); + throw new AgentNotFoundError(`Run ${runId} not found`); } return data as RunRecord; } async updateRun(runId: string, updates: Partial): Promise { const run = await this.getRun(runId); - Object.assign(run, updates); + const { id: _, object: __, ...safeUpdates } = updates; + Object.assign(run, safeUpdates); await this.writeFile(this.safePath(this.runsDir, runId), run); } diff --git a/tegg/core/agent-runtime/src/agentDefaults.ts b/tegg/core/agent-runtime/src/agentDefaults.ts index a70d964208..a96778a88b 100644 --- a/tegg/core/agent-runtime/src/agentDefaults.ts +++ b/tegg/core/agent-runtime/src/agentDefaults.ts @@ -13,6 +13,11 @@ import type { import { ContextHandler } from '@eggjs/tegg-runtime'; import type { AgentStore } from './AgentStore.ts'; +import { AgentConflictError } from './errors.ts'; + +// Canonical definition in @eggjs/module-common (tegg/plugin/common). +// Re-declared here to avoid a core→plugin dependency. +const EGG_CONTEXT: symbol = Symbol.for('context#eggContext'); interface AgentInstance { __agentStore: AgentStore; @@ -106,13 +111,13 @@ function extractFromStreamMessages( */ function toInputMessageObjects(messages: CreateRunInput['input']['messages'], threadId?: string): MessageObject[] { return messages - .filter((m) => m.role !== 'system') + .filter((m): m is typeof m & { role: 'user' | 'assistant' } => m.role !== 'system') .map((m) => ({ id: newMsgId(), object: 'thread.message' as const, created_at: nowUnix(), thread_id: threadId, - role: m.role as 'user' | 'assistant', + role: m.role, status: 'completed' as const, content: typeof m.content === 'string' @@ -250,9 +255,12 @@ function defaultAsyncRun() { last_error: { code: 'EXEC_ERROR', message: err.message }, failed_at: nowUnix(), }); - } catch { - // Ignore store update failure to avoid swallowing the original error + } catch (storeErr) { + // Log store update failure but don't swallow the original error + console.error('[AgentController] failed to update run status after error:', storeErr); } + } else { + console.error('[AgentController] execRun error during abort:', err); } } finally { this.__runningTasks.delete(run.id); @@ -278,7 +286,7 @@ function defaultStreamRun() { if (!runtimeCtx) { throw new Error('streamRun must be called within a request context'); } - const ctx = runtimeCtx.get(Symbol.for('context#eggContext')); + const ctx = runtimeCtx.get(EGG_CONTEXT); // Bypass Koa response handling — write SSE directly to the raw response ctx.respond = false; @@ -373,7 +381,9 @@ function defaultStreamRun() { } runObj.status = 'cancelled'; runObj.cancelled_at = cancelledAt; - res.write(`event: thread.run.cancelled\ndata: ${JSON.stringify(runObj)}\n\n`); + if (!res.writableEnded) { + res.write(`event: thread.run.cancelled\ndata: ${JSON.stringify(runObj)}\n\n`); + } return; } @@ -420,19 +430,24 @@ function defaultStreamRun() { last_error: { code: 'EXEC_ERROR', message: err.message }, failed_at: failedAt, }); - } catch { - // Ignore store update failure to avoid swallowing the original error + } catch (storeErr) { + // Log store update failure but don't swallow the original error + console.error('[AgentController] failed to update run status after error:', storeErr); } // event: thread.run.failed runObj.status = 'failed'; runObj.failed_at = failedAt; runObj.last_error = { code: 'EXEC_ERROR', message: err.message }; - res.write(`event: thread.run.failed\ndata: ${JSON.stringify(runObj)}\n\n`); + if (!res.writableEnded) { + res.write(`event: thread.run.failed\ndata: ${JSON.stringify(runObj)}\n\n`); + } } finally { // event: done - res.write('event: done\ndata: [DONE]\n\n'); - res.end(); + if (!res.writableEnded) { + res.write('event: done\ndata: [DONE]\n\n'); + res.end(); + } } }; } @@ -476,7 +491,7 @@ function defaultCancelRun() { // Re-read run status after background task has settled const run = await this.__agentStore.getRun(runId); if (TERMINAL_RUN_STATUSES.has(run.status)) { - throw new Error(`Cannot cancel run with status '${run.status}'`); + throw new AgentConflictError(`Cannot cancel run with status '${run.status}'`); } const cancelledAt = nowUnix(); diff --git a/tegg/core/agent-runtime/src/errors.ts b/tegg/core/agent-runtime/src/errors.ts new file mode 100644 index 0000000000..8ad035e0c6 --- /dev/null +++ b/tegg/core/agent-runtime/src/errors.ts @@ -0,0 +1,26 @@ +/** + * Error thrown when a thread or run is not found. + * The `status` property is recognized by Koa/Egg error handling + * to set the corresponding HTTP response status code. + */ +export class AgentNotFoundError extends Error { + status: number = 404; + + constructor(message: string) { + super(message); + this.name = 'AgentNotFoundError'; + } +} + +/** + * Error thrown when an operation conflicts with the current state + * (e.g., cancelling a completed run). + */ +export class AgentConflictError extends Error { + status: number = 409; + + constructor(message: string) { + super(message); + this.name = 'AgentConflictError'; + } +} diff --git a/tegg/core/agent-runtime/src/index.ts b/tegg/core/agent-runtime/src/index.ts index a662eb24a7..a8a76f376f 100644 --- a/tegg/core/agent-runtime/src/index.ts +++ b/tegg/core/agent-runtime/src/index.ts @@ -1,4 +1,5 @@ export * from './AgentStore.ts'; +export * from './errors.ts'; export * from './FileAgentStore.ts'; export { AGENT_DEFAULT_FACTORIES } from './agentDefaults.ts'; export { enhanceAgentController } from './enhanceAgentController.ts'; diff --git a/tegg/core/agent-runtime/test/FileAgentStore.test.ts b/tegg/core/agent-runtime/test/FileAgentStore.test.ts index 3fecee95d2..3c2036ac63 100644 --- a/tegg/core/agent-runtime/test/FileAgentStore.test.ts +++ b/tegg/core/agent-runtime/test/FileAgentStore.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { describe, it, beforeEach, afterEach } from 'vitest'; +import { AgentNotFoundError } from '../src/errors.ts'; import { FileAgentStore } from '../src/FileAgentStore.ts'; describe('core/agent-runtime/test/FileAgentStore.test.ts', () => { @@ -49,8 +50,16 @@ describe('core/agent-runtime/test/FileAgentStore.test.ts', () => { assert.equal(fetched.created_at, created.created_at); }); - it('should throw for non-existent thread', async () => { - await assert.rejects(() => store.getThread('thread_non_existent'), /Thread thread_non_existent not found/); + it('should throw AgentNotFoundError for non-existent thread', async () => { + await assert.rejects( + () => store.getThread('thread_non_existent'), + (err: unknown) => { + assert(err instanceof AgentNotFoundError); + assert.equal(err.status, 404); + assert.match(err.message, /Thread thread_non_existent not found/); + return true; + }, + ); }); it('should append messages to a thread', async () => { @@ -124,8 +133,16 @@ describe('core/agent-runtime/test/FileAgentStore.test.ts', () => { assert.equal(fetched.status, 'queued'); }); - it('should throw for non-existent run', async () => { - await assert.rejects(() => store.getRun('run_non_existent'), /Run run_non_existent not found/); + it('should throw AgentNotFoundError for non-existent run', async () => { + await assert.rejects( + () => store.getRun('run_non_existent'), + (err: unknown) => { + assert(err instanceof AgentNotFoundError); + assert.equal(err.status, 404); + assert.match(err.message, /Run run_non_existent not found/); + return true; + }, + ); }); it('should update a run', async () => { diff --git a/tegg/core/agent-runtime/test/agentDefaults.test.ts b/tegg/core/agent-runtime/test/agentDefaults.test.ts index 9857e2dcb2..aee94c1c58 100644 --- a/tegg/core/agent-runtime/test/agentDefaults.test.ts +++ b/tegg/core/agent-runtime/test/agentDefaults.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { describe, it, beforeEach, afterEach } from 'vitest'; import { AGENT_DEFAULT_FACTORIES } from '../src/agentDefaults.ts'; +import { AgentNotFoundError } from '../src/errors.ts'; import { FileAgentStore } from '../src/FileAgentStore.ts'; describe('core/agent-runtime/test/agentDefaults.test.ts', () => { @@ -68,9 +69,16 @@ describe('core/agent-runtime/test/agentDefaults.test.ts', () => { assert(Array.isArray(result.messages)); }); - it('should throw for non-existent thread', async () => { + it('should throw AgentNotFoundError for non-existent thread', async () => { const getFn = AGENT_DEFAULT_FACTORIES.getThread(); - await assert.rejects(() => getFn.call(mockInstance, 'thread_xxx'), /Thread thread_xxx not found/); + await assert.rejects( + () => getFn.call(mockInstance, 'thread_xxx'), + (err: unknown) => { + assert(err instanceof AgentNotFoundError); + assert.equal(err.status, 404); + return true; + }, + ); }); }); diff --git a/tegg/core/tegg/src/agent.ts b/tegg/core/tegg/src/agent.ts index 41491c5328..58f7a52054 100644 --- a/tegg/core/tegg/src/agent.ts +++ b/tegg/core/tegg/src/agent.ts @@ -3,7 +3,7 @@ export { AgentController } from '@eggjs/controller-decorator'; // Utility types and classes from agent-runtime export type { AgentStore, ThreadRecord, RunRecord } from '@eggjs/agent-runtime'; -export { FileAgentStore } from '@eggjs/agent-runtime'; +export { AgentNotFoundError, AgentConflictError, FileAgentStore } from '@eggjs/agent-runtime'; // Original types and interfaces from controller-decorator export type { AgentHandler } from '@eggjs/controller-decorator'; diff --git a/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/app/controller/AgentTestController.ts b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/app/controller/AgentTestController.ts index 108df4e45e..6ee026b00d 100644 --- a/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/app/controller/AgentTestController.ts +++ b/tegg/plugin/controller/test/fixtures/apps/agent-controller-app/app/controller/AgentTestController.ts @@ -1,4 +1,4 @@ -import { AgentController } from '@eggjs/tegg/agent'; +import { AgentController, AgentNotFoundError } from '@eggjs/tegg/agent'; import type { AgentHandler, CreateRunInput, @@ -11,6 +11,9 @@ import type { } from '@eggjs/tegg/agent'; import { ContextHandler } from '@eggjs/tegg/helper'; +// Canonical definition in @eggjs/module-common +const EGG_CONTEXT: symbol = Symbol.for('context#eggContext'); + // In-memory store for threads and runs const threads = new Map< string, @@ -46,7 +49,7 @@ export class AgentTestController implements AgentHandler { async getThread(threadId: string): Promise { const thread = threads.get(threadId); if (!thread) { - throw new Error(`Thread ${threadId} not found`); + throw new AgentNotFoundError(`Thread ${threadId} not found`); } return { id: thread.id, @@ -72,7 +75,7 @@ export class AgentTestController implements AgentHandler { async streamRun(input: CreateRunInput): Promise { const runtimeCtx = ContextHandler.getContext()!; - const ctx = runtimeCtx.get(Symbol.for('context#eggContext')); + const ctx = runtimeCtx.get(EGG_CONTEXT); // Bypass Koa response handling — write SSE directly to the raw response ctx.respond = false; @@ -174,7 +177,7 @@ export class AgentTestController implements AgentHandler { async getRun(runId: string): Promise { const run = runs.get(runId); if (!run) { - throw new Error(`Run ${runId} not found`); + throw new AgentNotFoundError(`Run ${runId} not found`); } return { id: run.id, diff --git a/tegg/plugin/controller/test/http/agent.test.ts b/tegg/plugin/controller/test/http/agent.test.ts index cb4cb643ff..371c593204 100644 --- a/tegg/plugin/controller/test/http/agent.test.ts +++ b/tegg/plugin/controller/test/http/agent.test.ts @@ -55,8 +55,8 @@ describe('plugin/controller/test/http/agent.test.ts', () => { assert(typeof res.body.created_at === 'number'); }); - it('should return 500 for non-existent thread', async () => { - await app.httpRequest().get('/api/v1/threads/non_existent').expect(500); + it('should return 404 for non-existent thread', async () => { + await app.httpRequest().get('/api/v1/threads/non_existent').expect(404); }); }); @@ -240,8 +240,8 @@ describe('plugin/controller/test/http/agent.test.ts', () => { assert(typeof res.body.created_at === 'number'); }); - it('should return 500 for non-existent run', async () => { - await app.httpRequest().get('/api/v1/runs/non_existent').expect(500); + it('should return 404 for non-existent run', async () => { + await app.httpRequest().get('/api/v1/runs/non_existent').expect(404); }); }); diff --git a/tegg/plugin/controller/test/http/base-agent.test.ts b/tegg/plugin/controller/test/http/base-agent.test.ts index 841ec0a7a0..979ccf7249 100644 --- a/tegg/plugin/controller/test/http/base-agent.test.ts +++ b/tegg/plugin/controller/test/http/base-agent.test.ts @@ -55,8 +55,8 @@ describe('plugin/controller/test/http/base-agent.test.ts', () => { assert(typeof res.body.created_at === 'number'); }); - it('should return 500 for non-existent thread', async () => { - await app.httpRequest().get('/api/v1/threads/non_existent').expect(500); + it('should return 404 for non-existent thread', async () => { + await app.httpRequest().get('/api/v1/threads/non_existent').expect(404); }); }); @@ -372,8 +372,8 @@ describe('plugin/controller/test/http/base-agent.test.ts', () => { assert(typeof res.body.created_at === 'number'); }); - it('should return 500 for non-existent run', async () => { - await app.httpRequest().get('/api/v1/runs/non_existent').expect(500); + it('should return 404 for non-existent run', async () => { + await app.httpRequest().get('/api/v1/runs/non_existent').expect(404); }); }); From 1625d011944dc59556e1b607fe829b69ea8d0306 Mon Sep 17 00:00:00 2001 From: jerry Date: Fri, 27 Feb 2026 17:33:25 +0800 Subject: [PATCH 03/12] refactor(tegg): rewrite agentDefaults factory functions as AgentRuntime class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 7 factory functions in agentDefaults.ts with an AgentRuntime class that holds `store` and `runningTasks` as private fields and delegates execution back to the controller via a `host` reference. This follows the OO style requested in PR review. - Create AgentRuntime class with createThread, getThread, syncRun, asyncRun, streamRun, getRun, cancelRun as instance methods - Define AgentControllerHost interface for host delegation (AOP/mock friendly) - Simplify enhanceAgentController to create AgentRuntime instance on init and delegate stub methods via this[AGENT_RUNTIME] - Delete agentDefaults.ts (all logic moved into AgentRuntime.ts) - Update FileAgentStore concurrency comment to clarify cluster-mode limitation - Rewrite tests to directly instantiate AgentRuntime instead of using factory.call(mockInstance) pattern Closes eggjs/egg#5812 (comment: "需要用 oo 的风格来写") Co-Authored-By: Claude Opus 4.6 --- .../src/{agentDefaults.ts => AgentRuntime.ts} | 168 ++++++++-------- tegg/core/agent-runtime/src/FileAgentStore.ts | 23 +-- .../src/enhanceAgentController.ts | 59 +++--- tegg/core/agent-runtime/src/index.ts | 4 +- ...tDefaults.test.ts => AgentRuntime.test.ts} | 179 +++++++----------- .../test/enhanceAgentController.test.ts | 43 ++--- 6 files changed, 212 insertions(+), 264 deletions(-) rename tegg/core/agent-runtime/src/{agentDefaults.ts => AgentRuntime.ts} (76%) rename tegg/core/agent-runtime/test/{agentDefaults.test.ts => AgentRuntime.test.ts} (57%) diff --git a/tegg/core/agent-runtime/src/agentDefaults.ts b/tegg/core/agent-runtime/src/AgentRuntime.ts similarity index 76% rename from tegg/core/agent-runtime/src/agentDefaults.ts rename to tegg/core/agent-runtime/src/AgentRuntime.ts index a96778a88b..f47ee0daae 100644 --- a/tegg/core/agent-runtime/src/agentDefaults.ts +++ b/tegg/core/agent-runtime/src/AgentRuntime.ts @@ -1,5 +1,3 @@ -import crypto from 'node:crypto'; - import type { CreateRunInput, ThreadObject, @@ -14,24 +12,25 @@ import { ContextHandler } from '@eggjs/tegg-runtime'; import type { AgentStore } from './AgentStore.ts'; import { AgentConflictError } from './errors.ts'; +import { nowUnix, newMsgId } from './utils.ts'; // Canonical definition in @eggjs/module-common (tegg/plugin/common). // Re-declared here to avoid a core→plugin dependency. const EGG_CONTEXT: symbol = Symbol.for('context#eggContext'); -interface AgentInstance { - __agentStore: AgentStore; - __runningTasks: Map; abortController: AbortController }>; +export const AGENT_RUNTIME: unique symbol = Symbol('agentRuntime'); + +/** + * The host interface — only requires execRun so the runtime can delegate + * execution back through the controller's prototype chain (AOP/mock friendly). + */ +export interface AgentControllerHost { execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator; } -function nowUnix(): number { - return Math.floor(Date.now() / 1000); -} +// ─── helper functions ────────────────────────────────────────────── -function newMsgId(): string { - return `msg_${crypto.randomUUID()}`; -} +const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); /** * Convert an AgentStreamMessage's message field into OpenAI MessageContentBlock[]. @@ -126,21 +125,31 @@ function toInputMessageObjects(messages: CreateRunInput['input']['messages'], th })); } -function defaultCreateThread() { - return async function (this: AgentInstance): Promise { - const thread = await this.__agentStore.createThread(); +// ─── AgentRuntime class ──────────────────────────────────────────── + +export class AgentRuntime { + private store: AgentStore; + private runningTasks: Map; abortController: AbortController }>; + private host: AgentControllerHost; + + constructor(host: AgentControllerHost, store: AgentStore) { + this.host = host; + this.store = store; + this.runningTasks = new Map(); + } + + async createThread(): Promise { + const thread = await this.store.createThread(); return { id: thread.id, object: 'thread', created_at: thread.created_at, metadata: thread.metadata ?? {}, }; - }; -} + } -function defaultGetThread() { - return async function (this: AgentInstance, threadId: string): Promise { - const thread = await this.__agentStore.getThread(threadId); + async getThread(threadId: string): Promise { + const thread = await this.store.getThread(threadId); return { id: thread.id, object: 'thread', @@ -148,42 +157,37 @@ function defaultGetThread() { metadata: thread.metadata ?? {}, messages: thread.messages, }; - }; -} + } -function defaultSyncRun() { - return async function (this: AgentInstance, input: CreateRunInput): Promise { + async syncRun(input: CreateRunInput): Promise { let threadId = input.thread_id; if (!threadId) { - const thread = await this.__agentStore.createThread(); + const thread = await this.store.createThread(); threadId = thread.id; input = { ...input, thread_id: threadId }; } - const run = await this.__agentStore.createRun(input.input.messages, threadId, input.config, input.metadata); + const run = await this.store.createRun(input.input.messages, threadId, input.config, input.metadata); try { const startedAt = nowUnix(); - await this.__agentStore.updateRun(run.id, { status: 'in_progress', started_at: startedAt }); + await this.store.updateRun(run.id, { status: 'in_progress', started_at: startedAt }); const streamMessages: AgentStreamMessage[] = []; - for await (const msg of this.execRun(input)) { + for await (const msg of this.host.execRun(input)) { streamMessages.push(msg); } const { output, usage } = extractFromStreamMessages(streamMessages, run.id); const completedAt = nowUnix(); - await this.__agentStore.updateRun(run.id, { + await this.store.updateRun(run.id, { status: 'completed', output, usage, completed_at: completedAt, }); - await this.__agentStore.appendMessages(threadId, [ - ...toInputMessageObjects(input.input.messages, threadId), - ...output, - ]); + await this.store.appendMessages(threadId, [...toInputMessageObjects(input.input.messages, threadId), ...output]); return { id: run.id, @@ -199,35 +203,33 @@ function defaultSyncRun() { }; } catch (err: any) { const failedAt = nowUnix(); - await this.__agentStore.updateRun(run.id, { + await this.store.updateRun(run.id, { status: 'failed', last_error: { code: 'EXEC_ERROR', message: err.message }, failed_at: failedAt, }); throw err; } - }; -} + } -function defaultAsyncRun() { - return async function (this: AgentInstance, input: CreateRunInput): Promise { + async asyncRun(input: CreateRunInput): Promise { let threadId = input.thread_id; if (!threadId) { - const thread = await this.__agentStore.createThread(); + const thread = await this.store.createThread(); threadId = thread.id; input = { ...input, thread_id: threadId }; } - const run = await this.__agentStore.createRun(input.input.messages, threadId, input.config, input.metadata); + const run = await this.store.createRun(input.input.messages, threadId, input.config, input.metadata); const abortController = new AbortController(); const promise = (async () => { try { - await this.__agentStore.updateRun(run.id, { status: 'in_progress', started_at: nowUnix() }); + await this.store.updateRun(run.id, { status: 'in_progress', started_at: nowUnix() }); const streamMessages: AgentStreamMessage[] = []; - for await (const msg of this.execRun(input, abortController.signal)) { + for await (const msg of this.host.execRun(input, abortController.signal)) { if (abortController.signal.aborted) break; streamMessages.push(msg); } @@ -236,38 +238,37 @@ function defaultAsyncRun() { const { output, usage } = extractFromStreamMessages(streamMessages, run.id); - await this.__agentStore.updateRun(run.id, { + await this.store.updateRun(run.id, { status: 'completed', output, usage, completed_at: nowUnix(), }); - await this.__agentStore.appendMessages(threadId!, [ + await this.store.appendMessages(threadId!, [ ...toInputMessageObjects(input.input.messages, threadId), ...output, ]); } catch (err: any) { if (!abortController.signal.aborted) { try { - await this.__agentStore.updateRun(run.id, { + await this.store.updateRun(run.id, { status: 'failed', last_error: { code: 'EXEC_ERROR', message: err.message }, failed_at: nowUnix(), }); } catch (storeErr) { - // Log store update failure but don't swallow the original error console.error('[AgentController] failed to update run status after error:', storeErr); } } else { console.error('[AgentController] execRun error during abort:', err); } } finally { - this.__runningTasks.delete(run.id); + this.runningTasks.delete(run.id); } })(); - this.__runningTasks.set(run.id, { promise, abortController }); + this.runningTasks.set(run.id, { promise, abortController }); return { id: run.id, @@ -277,11 +278,9 @@ function defaultAsyncRun() { status: 'queued', metadata: run.metadata, }; - }; -} + } -function defaultStreamRun() { - return async function (this: AgentInstance, input: CreateRunInput): Promise { + async streamRun(input: CreateRunInput): Promise { const runtimeCtx = ContextHandler.getContext(); if (!runtimeCtx) { throw new Error('streamRun must be called within a request context'); @@ -303,12 +302,12 @@ function defaultStreamRun() { let threadId = input.thread_id; if (!threadId) { - const thread = await this.__agentStore.createThread(); + const thread = await this.store.createThread(); threadId = thread.id; input = { ...input, thread_id: threadId }; } - const run = await this.__agentStore.createRun(input.input.messages, threadId, input.config, input.metadata); + const run = await this.store.createRun(input.input.messages, threadId, input.config, input.metadata); const runObj: RunObject = { id: run.id, @@ -325,7 +324,7 @@ function defaultStreamRun() { // event: thread.run.in_progress runObj.status = 'in_progress'; runObj.started_at = nowUnix(); - await this.__agentStore.updateRun(run.id, { status: 'in_progress', started_at: runObj.started_at }); + await this.store.updateRun(run.id, { status: 'in_progress', started_at: runObj.started_at }); res.write(`event: thread.run.in_progress\ndata: ${JSON.stringify(runObj)}\n\n`); const msgId = newMsgId(); @@ -349,7 +348,7 @@ function defaultStreamRun() { let hasUsage = false; try { - for await (const msg of this.execRun(input, abortController.signal)) { + for await (const msg of this.host.execRun(input, abortController.signal)) { if (abortController.signal.aborted) break; if (msg.message) { const contentBlocks = toContentBlocks(msg.message); @@ -375,7 +374,7 @@ function defaultStreamRun() { if (abortController.signal.aborted) { const cancelledAt = nowUnix(); try { - await this.__agentStore.updateRun(run.id, { status: 'cancelled', cancelled_at: cancelledAt }); + await this.store.updateRun(run.id, { status: 'cancelled', cancelled_at: cancelledAt }); } catch { // Ignore store update failure during abort } @@ -404,17 +403,14 @@ function defaultStreamRun() { } const completedAt = nowUnix(); - await this.__agentStore.updateRun(run.id, { + await this.store.updateRun(run.id, { status: 'completed', output, usage, completed_at: completedAt, }); - await this.__agentStore.appendMessages(threadId!, [ - ...toInputMessageObjects(input.input.messages, threadId), - ...output, - ]); + await this.store.appendMessages(threadId!, [...toInputMessageObjects(input.input.messages, threadId), ...output]); // event: thread.run.completed runObj.status = 'completed'; @@ -425,13 +421,12 @@ function defaultStreamRun() { } catch (err: any) { const failedAt = nowUnix(); try { - await this.__agentStore.updateRun(run.id, { + await this.store.updateRun(run.id, { status: 'failed', last_error: { code: 'EXEC_ERROR', message: err.message }, failed_at: failedAt, }); } catch (storeErr) { - // Log store update failure but don't swallow the original error console.error('[AgentController] failed to update run status after error:', storeErr); } @@ -449,12 +444,10 @@ function defaultStreamRun() { res.end(); } } - }; -} + } -function defaultGetRun() { - return async function (this: AgentInstance, runId: string): Promise { - const run = await this.__agentStore.getRun(runId); + async getRun(runId: string): Promise { + const run = await this.store.getRun(runId); return { id: run.id, object: 'thread.run', @@ -471,15 +464,11 @@ function defaultGetRun() { config: run.config, metadata: run.metadata, }; - }; -} - -const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); + } -function defaultCancelRun() { - return async function (this: AgentInstance, runId: string): Promise { + async cancelRun(runId: string): Promise { // Abort running task first to prevent it from writing completed status - const task = this.__runningTasks.get(runId); + const task = this.runningTasks.get(runId); if (task) { task.abortController.abort(); // Wait for the background task to finish so it won't race with our update @@ -489,13 +478,13 @@ function defaultCancelRun() { } // Re-read run status after background task has settled - const run = await this.__agentStore.getRun(runId); + const run = await this.store.getRun(runId); if (TERMINAL_RUN_STATUSES.has(run.status)) { throw new AgentConflictError(`Cannot cancel run with status '${run.status}'`); } const cancelledAt = nowUnix(); - await this.__agentStore.updateRun(runId, { + await this.store.updateRun(runId, { status: 'cancelled', cancelled_at: cancelledAt, }); @@ -508,15 +497,18 @@ function defaultCancelRun() { status: 'cancelled', cancelled_at: cancelledAt, }; - }; -} + } -export const AGENT_DEFAULT_FACTORIES: Record Function> = { - createThread: defaultCreateThread, - getThread: defaultGetThread, - syncRun: defaultSyncRun, - asyncRun: defaultAsyncRun, - streamRun: defaultStreamRun, - getRun: defaultGetRun, - cancelRun: defaultCancelRun, -}; + async destroy(): Promise { + // Wait for in-flight background tasks + if (this.runningTasks.size) { + const pending = Array.from(this.runningTasks.values()).map((t) => t.promise); + await Promise.allSettled(pending); + } + + // Destroy store + if (this.store.destroy) { + await this.store.destroy(); + } + } +} diff --git a/tegg/core/agent-runtime/src/FileAgentStore.ts b/tegg/core/agent-runtime/src/FileAgentStore.ts index b96b9c6fd3..6737b7d7e2 100644 --- a/tegg/core/agent-runtime/src/FileAgentStore.ts +++ b/tegg/core/agent-runtime/src/FileAgentStore.ts @@ -6,6 +6,7 @@ import type { InputMessage, MessageObject, AgentRunConfig } from '@eggjs/control import type { AgentStore, ThreadRecord, RunRecord } from './AgentStore.ts'; import { AgentNotFoundError } from './errors.ts'; +import { nowUnix } from './utils.ts'; export interface FileAgentStoreOptions { dataDir: string; @@ -45,7 +46,7 @@ export class FileAgentStore implements AgentStore { object: 'thread', messages: [], metadata: metadata ?? {}, - created_at: Math.floor(Date.now() / 1000), + created_at: nowUnix(), }; await this.writeFile(this.safePath(this.threadsDir, threadId), record); return record; @@ -60,8 +61,9 @@ export class FileAgentStore implements AgentStore { return data as ThreadRecord; } - // Note: read-modify-write without locking. Concurrent appends to the same thread may lose messages. - // This is acceptable for a default file-based store; production stores should implement proper locking. + // Note: read-modify-write without locking. In cluster mode with multiple workers + // sharing the same dataDir, concurrent operations on the same thread may lose data. + // For production multi-worker deployments, use a database-backed AgentStore instead. async appendMessages(threadId: string, messages: MessageObject[]): Promise { const thread = await this.getThread(threadId); thread.messages.push(...messages); @@ -83,7 +85,7 @@ export class FileAgentStore implements AgentStore { input, config, metadata, - created_at: Math.floor(Date.now() / 1000), + created_at: nowUnix(), }; await this.writeFile(this.safePath(this.runsDir, runId), record); return record; @@ -106,20 +108,19 @@ export class FileAgentStore implements AgentStore { } private async writeFile(filePath: string, data: unknown): Promise { - const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; - await fs.writeFile(tmpPath, JSON.stringify(data), 'utf-8'); - await fs.rename(tmpPath, filePath); + await fs.writeFile(filePath, JSON.stringify(data), 'utf-8'); } private async readFile(filePath: string): Promise { + let content: string; try { - const content = await fs.readFile(filePath, 'utf-8'); - return JSON.parse(content); - } catch (err: any) { - if (err.code === 'ENOENT') { + content = await fs.readFile(filePath, 'utf-8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return null; } throw err; } + return JSON.parse(content); } } diff --git a/tegg/core/agent-runtime/src/enhanceAgentController.ts b/tegg/core/agent-runtime/src/enhanceAgentController.ts index 616a81e817..4bf5279edb 100644 --- a/tegg/core/agent-runtime/src/enhanceAgentController.ts +++ b/tegg/core/agent-runtime/src/enhanceAgentController.ts @@ -1,35 +1,33 @@ import path from 'node:path'; +import { AgentInfoUtil } from '@eggjs/controller-decorator'; import type { EggProtoImplClass } from '@eggjs/tegg-types'; -import { AGENT_DEFAULT_FACTORIES } from './agentDefaults.ts'; +import { AgentRuntime, AGENT_RUNTIME } from './AgentRuntime.ts'; import type { AgentStore } from './AgentStore.ts'; import { FileAgentStore } from './FileAgentStore.ts'; const AGENT_METHOD_NAMES = ['createThread', 'getThread', 'asyncRun', 'streamRun', 'syncRun', 'getRun', 'cancelRun']; -const NOT_IMPLEMENTED = Symbol.for('AGENT_NOT_IMPLEMENTED'); -const AGENT_ENHANCED = Symbol.for('AGENT_CONTROLLER_ENHANCED'); - // Enhance an AgentController class with smart default implementations. // // Called by the plugin/controller lifecycle hook AFTER the decorator has set // HTTP metadata and injected stub methods. Detects which methods are -// user-defined vs stubs (via Symbol.for('AGENT_NOT_IMPLEMENTED') marker) -// and replaces stubs with store-backed default implementations. -// Also wraps init()/destroy() to manage the AgentStore lifecycle. +// user-defined vs stubs (via AgentInfoUtil.isNotImplemented() marker) +// and replaces stubs with AgentRuntime-delegating methods. +// Also wraps init()/destroy() to manage the AgentRuntime lifecycle. // // Prerequisites: -// - The class must be marked with Symbol.for('AGENT_CONTROLLER') (otherwise this is a no-op). -// - Stub methods must be marked with Symbol.for('AGENT_NOT_IMPLEMENTED'). +// - The class must be marked via AgentInfoUtil.isAgentController() (otherwise this is a no-op). +// - Stub methods must be marked via AgentInfoUtil.isNotImplemented(). export function enhanceAgentController(clazz: EggProtoImplClass): void { // Only enhance classes marked by @AgentController decorator - if (!(clazz as any)[Symbol.for('AGENT_CONTROLLER')]) { + if (!AgentInfoUtil.isAgentController(clazz)) { return; } // Guard against repeated enhancement (e.g., multiple lifecycle hook calls) - if ((clazz as any)[AGENT_ENHANCED]) { + if (AgentInfoUtil.isEnhanced(clazz)) { return; } @@ -37,45 +35,39 @@ export function enhanceAgentController(clazz: EggProtoImplClass): void { const stubMethods = new Set(); for (const name of AGENT_METHOD_NAMES) { const method = clazz.prototype[name]; - if (!method || (method as any)[NOT_IMPLEMENTED]) { + if (!method || AgentInfoUtil.isNotImplemented(method)) { stubMethods.add(name); } } - // Wrap init() lifecycle to create store and task tracking + // Wrap init() lifecycle to create AgentRuntime const originalInit = clazz.prototype.init; clazz.prototype.init = async function () { // Allow user to provide custom store via createStore() + let store: AgentStore; if (typeof this.createStore === 'function') { - this.__agentStore = await this.createStore(); + store = await this.createStore(); } else { const dataDir = process.env.TEGG_AGENT_DATA_DIR || path.join(process.cwd(), '.agent-data'); - this.__agentStore = new FileAgentStore({ dataDir }); + store = new FileAgentStore({ dataDir }); } - if (this.__agentStore.init) { - await (this.__agentStore as AgentStore).init!(); + if (store.init) { + await store.init(); } - this.__runningTasks = new Map(); + this[AGENT_RUNTIME] = new AgentRuntime(this, store); if (originalInit) { await originalInit.call(this); } }; - // Wrap destroy() lifecycle to wait for in-flight tasks and cleanup + // Wrap destroy() lifecycle to cleanup AgentRuntime const originalDestroy = clazz.prototype.destroy; clazz.prototype.destroy = async function () { - // Wait for in-flight background tasks - if (this.__runningTasks?.size) { - const pending = Array.from(this.__runningTasks.values()).map((t: any) => t.promise); - await Promise.allSettled(pending); - } - - // Destroy store - if (this.__agentStore?.destroy) { - await this.__agentStore.destroy(); + if (this[AGENT_RUNTIME]) { + await this[AGENT_RUNTIME].destroy(); } if (originalDestroy) { @@ -83,14 +75,13 @@ export function enhanceAgentController(clazz: EggProtoImplClass): void { } }; - // Inject smart defaults for stub methods + // Replace stub methods with AgentRuntime delegation for (const methodName of AGENT_METHOD_NAMES) { if (!stubMethods.has(methodName)) continue; - const factory = AGENT_DEFAULT_FACTORIES[methodName]; - if (factory) { - clazz.prototype[methodName] = factory(); - } + clazz.prototype[methodName] = function (...args: unknown[]) { + return (this[AGENT_RUNTIME] as AgentRuntime)[methodName as keyof AgentRuntime](...args); + }; } - (clazz as any)[AGENT_ENHANCED] = true; + AgentInfoUtil.setEnhanced(clazz); } diff --git a/tegg/core/agent-runtime/src/index.ts b/tegg/core/agent-runtime/src/index.ts index a8a76f376f..bf7a75984e 100644 --- a/tegg/core/agent-runtime/src/index.ts +++ b/tegg/core/agent-runtime/src/index.ts @@ -1,5 +1,7 @@ export * from './AgentStore.ts'; export * from './errors.ts'; export * from './FileAgentStore.ts'; -export { AGENT_DEFAULT_FACTORIES } from './agentDefaults.ts'; +export * from './utils.ts'; +export { AgentRuntime, AGENT_RUNTIME } from './AgentRuntime.ts'; +export type { AgentControllerHost } from './AgentRuntime.ts'; export { enhanceAgentController } from './enhanceAgentController.ts'; diff --git a/tegg/core/agent-runtime/test/agentDefaults.test.ts b/tegg/core/agent-runtime/test/AgentRuntime.test.ts similarity index 57% rename from tegg/core/agent-runtime/test/agentDefaults.test.ts rename to tegg/core/agent-runtime/test/AgentRuntime.test.ts index aee94c1c58..0356bc6f7e 100644 --- a/tegg/core/agent-runtime/test/agentDefaults.test.ts +++ b/tegg/core/agent-runtime/test/AgentRuntime.test.ts @@ -4,20 +4,21 @@ import path from 'node:path'; import { describe, it, beforeEach, afterEach } from 'vitest'; -import { AGENT_DEFAULT_FACTORIES } from '../src/agentDefaults.ts'; +import { AgentRuntime } from '../src/AgentRuntime.ts'; +import type { AgentControllerHost } from '../src/AgentRuntime.ts'; import { AgentNotFoundError } from '../src/errors.ts'; import { FileAgentStore } from '../src/FileAgentStore.ts'; -describe('core/agent-runtime/test/agentDefaults.test.ts', () => { - const dataDir = path.join(import.meta.dirname, '.agent-defaults-test-data'); - let mockInstance: any; +describe('core/agent-runtime/test/AgentRuntime.test.ts', () => { + const dataDir = path.join(import.meta.dirname, '.agent-runtime-test-data'); + let runtime: AgentRuntime; + let store: FileAgentStore; + let host: AgentControllerHost; beforeEach(async () => { - const store = new FileAgentStore({ dataDir }); + store = new FileAgentStore({ dataDir }); await store.init(); - mockInstance = { - __agentStore: store, - __runningTasks: new Map(), + host = { async *execRun(input: any) { const messages = input.input.messages; yield { @@ -32,22 +33,18 @@ describe('core/agent-runtime/test/agentDefaults.test.ts', () => { usage: { prompt_tokens: 10, completion_tokens: 5 }, }; }, - }; + } as any; + runtime = new AgentRuntime(host, store); }); afterEach(async () => { - // Wait for any in-flight tasks - if (mockInstance.__runningTasks.size) { - const pending = Array.from(mockInstance.__runningTasks.values()).map((t: any) => t.promise); - await Promise.allSettled(pending); - } + await runtime.destroy(); await fs.rm(dataDir, { recursive: true, force: true }); }); describe('createThread', () => { it('should create a thread and return OpenAI ThreadObject', async () => { - const fn = AGENT_DEFAULT_FACTORIES.createThread(); - const result = await fn.call(mockInstance); + const result = await runtime.createThread(); assert(result.id.startsWith('thread_')); assert.equal(result.object, 'thread'); assert(typeof result.created_at === 'number'); @@ -59,20 +56,17 @@ describe('core/agent-runtime/test/agentDefaults.test.ts', () => { describe('getThread', () => { it('should get a thread by id', async () => { - const createFn = AGENT_DEFAULT_FACTORIES.createThread(); - const created = await createFn.call(mockInstance); + const created = await runtime.createThread(); - const getFn = AGENT_DEFAULT_FACTORIES.getThread(); - const result = await getFn.call(mockInstance, created.id); + const result = await runtime.getThread(created.id); assert.equal(result.id, created.id); assert.equal(result.object, 'thread'); assert(Array.isArray(result.messages)); }); it('should throw AgentNotFoundError for non-existent thread', async () => { - const getFn = AGENT_DEFAULT_FACTORIES.getThread(); await assert.rejects( - () => getFn.call(mockInstance, 'thread_xxx'), + () => runtime.getThread('thread_xxx'), (err: unknown) => { assert(err instanceof AgentNotFoundError); assert.equal(err.status, 404); @@ -84,80 +78,72 @@ describe('core/agent-runtime/test/agentDefaults.test.ts', () => { describe('syncRun', () => { it('should collect all chunks and return completed RunObject', async () => { - const fn = AGENT_DEFAULT_FACTORIES.syncRun(); - const result = await fn.call(mockInstance, { + const result = await runtime.syncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); + } as any); assert(result.id.startsWith('run_')); assert.equal(result.object, 'thread.run'); assert.equal(result.status, 'completed'); assert(result.thread_id); assert(result.thread_id.startsWith('thread_')); - assert.equal(result.output.length, 1); - assert.equal(result.output[0].object, 'thread.message'); - assert.equal(result.output[0].role, 'assistant'); - assert.equal(result.output[0].status, 'completed'); - assert.equal(result.output[0].content[0].type, 'text'); - assert.equal(result.output[0].content[0].text.value, 'Hello 1 messages'); - assert(Array.isArray(result.output[0].content[0].text.annotations)); - assert.equal(result.usage.prompt_tokens, 10); - assert.equal(result.usage.completion_tokens, 5); - assert.equal(result.usage.total_tokens, 15); - assert(result.started_at >= result.created_at, 'started_at should be >= created_at'); + assert.equal(result.output!.length, 1); + assert.equal(result.output![0].object, 'thread.message'); + assert.equal(result.output![0].role, 'assistant'); + assert.equal(result.output![0].status, 'completed'); + assert.equal(result.output![0].content[0].type, 'text'); + assert.equal(result.output![0].content[0].text.value, 'Hello 1 messages'); + assert(Array.isArray(result.output![0].content[0].text.annotations)); + assert.equal(result.usage!.prompt_tokens, 10); + assert.equal(result.usage!.completion_tokens, 5); + assert.equal(result.usage!.total_tokens, 15); + assert(result.started_at! >= result.created_at, 'started_at should be >= created_at'); }); it('should pass metadata through to store and return it', async () => { - const fn = AGENT_DEFAULT_FACTORIES.syncRun(); const meta = { user_id: 'u_1', trace: 'xyz' }; - const result = await fn.call(mockInstance, { + const result = await runtime.syncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, metadata: meta, - }); + } as any); assert.deepEqual(result.metadata, meta); // Verify stored in store - const run = await mockInstance.__agentStore.getRun(result.id); + const run = await store.getRun(result.id); assert.deepEqual(run.metadata, meta); }); it('should store the run in the store', async () => { - const fn = AGENT_DEFAULT_FACTORIES.syncRun(); - const result = await fn.call(mockInstance, { + const result = await runtime.syncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); - const run = await mockInstance.__agentStore.getRun(result.id); + } as any); + const run = await store.getRun(result.id); assert.equal(run.status, 'completed'); assert(run.completed_at); }); it('should append messages to thread when thread_id provided', async () => { - const createFn = AGENT_DEFAULT_FACTORIES.createThread(); - const thread = await createFn.call(mockInstance); + const thread = await runtime.createThread(); - const fn = AGENT_DEFAULT_FACTORIES.syncRun(); - await fn.call(mockInstance, { + await runtime.syncRun({ thread_id: thread.id, input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); + } as any); - const getThreadFn = AGENT_DEFAULT_FACTORIES.getThread(); - const updated = await getThreadFn.call(mockInstance, thread.id); + const updated = await runtime.getThread(thread.id); assert.equal(updated.messages.length, 2); // user + assistant assert.equal(updated.messages[0].role, 'user'); assert.equal(updated.messages[1].role, 'assistant'); }); it('should auto-create thread and append messages when thread_id not provided', async () => { - const fn = AGENT_DEFAULT_FACTORIES.syncRun(); - const result = await fn.call(mockInstance, { + const result = await runtime.syncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); + } as any); assert(result.thread_id); assert(result.thread_id.startsWith('thread_')); // Verify thread was created and messages were appended - const getThreadFn = AGENT_DEFAULT_FACTORIES.getThread(); - const thread = await getThreadFn.call(mockInstance, result.thread_id); + const thread = await runtime.getThread(result.thread_id); assert.equal(thread.messages.length, 2); // user + assistant assert.equal(thread.messages[0].role, 'user'); assert.equal(thread.messages[1].role, 'assistant'); @@ -166,10 +152,9 @@ describe('core/agent-runtime/test/agentDefaults.test.ts', () => { describe('asyncRun', () => { it('should return queued status immediately with auto-created thread_id', async () => { - const fn = AGENT_DEFAULT_FACTORIES.asyncRun(); - const result = await fn.call(mockInstance, { + const result = await runtime.asyncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); + } as any); assert(result.id.startsWith('run_')); assert.equal(result.object, 'thread.run'); assert.equal(result.status, 'queued'); @@ -178,70 +163,58 @@ describe('core/agent-runtime/test/agentDefaults.test.ts', () => { }); it('should complete the run in the background', async () => { - const fn = AGENT_DEFAULT_FACTORIES.asyncRun(); - const result = await fn.call(mockInstance, { + const result = await runtime.asyncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); + } as any); - // Wait for background task to complete - const task = mockInstance.__runningTasks.get(result.id); - assert(task); - await task.promise; + // Wait for background task to complete via destroy + await runtime.destroy(); - const run = await mockInstance.__agentStore.getRun(result.id); + const run = await store.getRun(result.id); assert.equal(run.status, 'completed'); assert.equal(run.output![0].content[0].text.value, 'Hello 1 messages'); }); it('should auto-create thread and append messages when thread_id not provided', async () => { - const fn = AGENT_DEFAULT_FACTORIES.asyncRun(); - const result = await fn.call(mockInstance, { + const result = await runtime.asyncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); + } as any); assert(result.thread_id); - // Wait for background task to complete - const task = mockInstance.__runningTasks.get(result.id); - assert(task); - await task.promise; + // Wait for background task to complete via destroy + await runtime.destroy(); // Verify thread was created and messages were appended - const getThreadFn = AGENT_DEFAULT_FACTORIES.getThread(); - const thread = await getThreadFn.call(mockInstance, result.thread_id); + const thread = await store.getThread(result.thread_id); assert.equal(thread.messages.length, 2); // user + assistant assert.equal(thread.messages[0].role, 'user'); assert.equal(thread.messages[1].role, 'assistant'); }); it('should pass metadata through to store and return it', async () => { - const fn = AGENT_DEFAULT_FACTORIES.asyncRun(); const meta = { session: 'sess_1' }; - const result = await fn.call(mockInstance, { + const result = await runtime.asyncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, metadata: meta, - }); + } as any); assert.deepEqual(result.metadata, meta); - // Wait for background task to complete - const task = mockInstance.__runningTasks.get(result.id); - assert(task); - await task.promise; + // Wait for background task to complete via destroy + await runtime.destroy(); // Verify stored in store - const run = await mockInstance.__agentStore.getRun(result.id); + const run = await store.getRun(result.id); assert.deepEqual(run.metadata, meta); }); }); describe('getRun', () => { it('should get a run by id', async () => { - const syncFn = AGENT_DEFAULT_FACTORIES.syncRun(); - const syncResult = await syncFn.call(mockInstance, { + const syncResult = await runtime.syncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); + } as any); - const getRunFn = AGENT_DEFAULT_FACTORIES.getRun(); - const result = await getRunFn.call(mockInstance, syncResult.id); + const result = await runtime.getRun(syncResult.id); assert.equal(result.id, syncResult.id); assert.equal(result.object, 'thread.run'); assert.equal(result.status, 'completed'); @@ -249,24 +222,21 @@ describe('core/agent-runtime/test/agentDefaults.test.ts', () => { }); it('should return metadata from getRun', async () => { - const syncFn = AGENT_DEFAULT_FACTORIES.syncRun(); const meta = { source: 'api' }; - const syncResult = await syncFn.call(mockInstance, { + const syncResult = await runtime.syncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, metadata: meta, - }); + } as any); - const getRunFn = AGENT_DEFAULT_FACTORIES.getRun(); - const result = await getRunFn.call(mockInstance, syncResult.id); + const result = await runtime.getRun(syncResult.id); assert.deepEqual(result.metadata, meta); }); }); describe('cancelRun', () => { it('should cancel a run', async () => { - const asyncFn = AGENT_DEFAULT_FACTORIES.asyncRun(); // Use a signal-aware execRun so abort takes effect - mockInstance.execRun = async function* (_input: any, signal?: AbortSignal) { + host.execRun = async function* (_input: any, signal?: AbortSignal) { yield { type: 'assistant', message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'start' }] }, @@ -289,28 +259,21 @@ describe('core/agent-runtime/test/agentDefaults.test.ts', () => { type: 'assistant', message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'end' }] }, }; - }; + } as any; - const result = await asyncFn.call(mockInstance, { + const result = await runtime.asyncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] }, - }); + } as any); // Let background task start running await new Promise((resolve) => setTimeout(resolve, 50)); - const cancelFn = AGENT_DEFAULT_FACTORIES.cancelRun(); - const cancelResult = await cancelFn.call(mockInstance, result.id); + const cancelResult = await runtime.cancelRun(result.id); assert.equal(cancelResult.id, result.id); assert.equal(cancelResult.object, 'thread.run'); assert.equal(cancelResult.status, 'cancelled'); - // Wait for background task to finish - const task = mockInstance.__runningTasks.get(result.id); - if (task) { - await task.promise; - } - - const run = await mockInstance.__agentStore.getRun(result.id); + const run = await store.getRun(result.id); assert.equal(run.status, 'cancelled'); assert(run.cancelled_at); }); diff --git a/tegg/core/agent-runtime/test/enhanceAgentController.test.ts b/tegg/core/agent-runtime/test/enhanceAgentController.test.ts index 02e7bcf015..6708c9a9fd 100644 --- a/tegg/core/agent-runtime/test/enhanceAgentController.test.ts +++ b/tegg/core/agent-runtime/test/enhanceAgentController.test.ts @@ -2,14 +2,14 @@ import { strict as assert } from 'node:assert'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { AgentInfoUtil } from '@eggjs/controller-decorator'; import { describe, it, beforeEach, afterEach } from 'vitest'; +import { AgentRuntime, AGENT_RUNTIME } from '../src/AgentRuntime.ts'; import { enhanceAgentController } from '../src/enhanceAgentController.ts'; -const NOT_IMPLEMENTED = Symbol.for('AGENT_NOT_IMPLEMENTED'); - // Helper: create a stub function like the @AgentController decorator does -function createStub(hasParam: boolean) { +function createStub(hasParam: boolean): Function { let fn; if (hasParam) { fn = async function (_arg: unknown) { @@ -20,7 +20,7 @@ function createStub(hasParam: boolean) { throw new Error('not implemented'); }; } - (fn as any)[NOT_IMPLEMENTED] = true; + AgentInfoUtil.setNotImplemented(fn); return fn; } @@ -38,7 +38,7 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { }); }); - it('should skip classes without AGENT_CONTROLLER symbol', () => { + it('should skip classes without AGENT_CONTROLLER metadata', () => { class NoMarker { async *execRun() { yield { @@ -48,10 +48,10 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { } } (NoMarker.prototype as any)['syncRun'] = createStub(true); - // Should not throw — class has execRun but no Symbol marker + // Should not throw — class has execRun but no AgentController marker enhanceAgentController(NoMarker as any); // syncRun should remain unchanged (still the stub) - assert((NoMarker.prototype as any).syncRun[NOT_IMPLEMENTED]); + assert(AgentInfoUtil.isNotImplemented((NoMarker.prototype as any).syncRun)); }); it('should replace stub methods with smart defaults', async () => { @@ -63,7 +63,7 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { }; } } - (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + AgentInfoUtil.setIsAgentController(MyAgent as any); // Simulate stubs set by @AgentController (MyAgent.prototype as any)['createThread'] = createStub(false); (MyAgent.prototype as any)['getThread'] = createStub(true); @@ -75,19 +75,18 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { enhanceAgentController(MyAgent as any); - // Stubs should be replaced — no longer marked - assert(!(MyAgent.prototype as any).createThread[NOT_IMPLEMENTED]); - assert(!(MyAgent.prototype as any).syncRun[NOT_IMPLEMENTED]); + // Stubs should be replaced — no longer marked as not implemented + assert(!AgentInfoUtil.isNotImplemented((MyAgent.prototype as any).createThread)); + assert(!AgentInfoUtil.isNotImplemented((MyAgent.prototype as any).syncRun)); // init/destroy should be wrapped assert(typeof (MyAgent.prototype as any).init === 'function'); assert(typeof (MyAgent.prototype as any).destroy === 'function'); - // Actually call init to verify store is created + // Actually call init to verify AgentRuntime is created const instance = new MyAgent() as any; await instance.init(); - assert(instance.__agentStore); - assert(instance.__runningTasks instanceof Map); + assert(instance[AGENT_RUNTIME] instanceof AgentRuntime); // createThread should work and return OpenAI format const thread = await instance.createThread(); @@ -113,7 +112,7 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { return customResult; } } - (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + AgentInfoUtil.setIsAgentController(MyAgent as any); // All other methods are stubs (MyAgent.prototype as any)['createThread'] = createStub(false); (MyAgent.prototype as any)['getThread'] = createStub(true); @@ -131,7 +130,7 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { assert.deepEqual(result, customResult); // Stubs should be replaced - assert(!(instance as any).createThread[NOT_IMPLEMENTED]); + assert(!AgentInfoUtil.isNotImplemented((instance as any).createThread)); await instance.destroy(); }); @@ -151,7 +150,7 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { originalInitCalled = true; } } - (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + AgentInfoUtil.setIsAgentController(MyAgent as any); (MyAgent.prototype as any)['syncRun'] = createStub(true); enhanceAgentController(MyAgent as any); @@ -159,7 +158,7 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { const instance = new MyAgent() as any; await instance.init(); assert(originalInitCalled); - assert(instance.__agentStore); + assert(instance[AGENT_RUNTIME] instanceof AgentRuntime); await instance.destroy(); }); @@ -179,7 +178,7 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { originalDestroyCalled = true; } } - (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + AgentInfoUtil.setIsAgentController(MyAgent as any); (MyAgent.prototype as any)['syncRun'] = createStub(true); enhanceAgentController(MyAgent as any); @@ -234,14 +233,14 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { }; } } - (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + AgentInfoUtil.setIsAgentController(MyAgent as any); (MyAgent.prototype as any)['syncRun'] = createStub(true); enhanceAgentController(MyAgent as any); const instance = new MyAgent() as any; await instance.init(); - assert.strictEqual(instance.__agentStore, customStore); + assert(instance[AGENT_RUNTIME] instanceof AgentRuntime); await instance.destroy(); }); @@ -256,7 +255,7 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { } // No methods defined at all — no stubs either } - (MyAgent as any)[Symbol.for('AGENT_CONTROLLER')] = true; + AgentInfoUtil.setIsAgentController(MyAgent as any); enhanceAgentController(MyAgent as any); From 3b80c03bd6b192ffccf452cf1141c53e3f241249 Mon Sep 17 00:00:00 2001 From: jerry Date: Fri, 27 Feb 2026 17:40:04 +0800 Subject: [PATCH 04/12] fix(tegg): replace raw Symbol.for() with AgentInfoUtil for agent metadata Use dedicated AgentInfoUtil class and MetadataKey constants instead of inline Symbol.for() for agent controller/method markers. Also adds agent-runtime to tsconfig references and moves controller-decorator to dependencies. Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 6 ++-- tegg/core/agent-runtime/package.json | 5 +-- tegg/core/agent-runtime/src/utils.ts | 9 +++++ .../src/decorator/agent/AgentController.ts | 6 ++-- .../src/util/AgentInfoUtil.ts | 33 +++++++++++++++++++ .../controller-decorator/src/util/index.ts | 1 + .../test/AgentController.test.ts | 7 ++-- .../src/controller-decorator/MetadataKey.ts | 4 +++ .../src/lib/EggControllerPrototypeHook.ts | 4 +-- tsconfig.json | 3 ++ 10 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 tegg/core/agent-runtime/src/utils.ts create mode 100644 tegg/core/controller-decorator/src/util/AgentInfoUtil.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d694299f5..0d6bc17d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2052,6 +2052,9 @@ importers: tegg/core/agent-runtime: dependencies: + '@eggjs/controller-decorator': + specifier: workspace:* + version: link:../controller-decorator '@eggjs/tegg-runtime': specifier: workspace:* version: link:../runtime @@ -2059,9 +2062,6 @@ importers: specifier: workspace:* version: link:../types devDependencies: - '@eggjs/controller-decorator': - specifier: workspace:* - version: link:../controller-decorator '@types/node': specifier: 'catalog:' version: 24.10.2 diff --git a/tegg/core/agent-runtime/package.json b/tegg/core/agent-runtime/package.json index 2e3885c811..8aad07ebd5 100644 --- a/tegg/core/agent-runtime/package.json +++ b/tegg/core/agent-runtime/package.json @@ -38,14 +38,15 @@ } }, "scripts": { - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "test": "vitest run" }, "dependencies": { + "@eggjs/controller-decorator": "workspace:*", "@eggjs/tegg-runtime": "workspace:*", "@eggjs/tegg-types": "workspace:*" }, "devDependencies": { - "@eggjs/controller-decorator": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:" }, diff --git a/tegg/core/agent-runtime/src/utils.ts b/tegg/core/agent-runtime/src/utils.ts new file mode 100644 index 0000000000..eb2f168471 --- /dev/null +++ b/tegg/core/agent-runtime/src/utils.ts @@ -0,0 +1,9 @@ +import crypto from 'node:crypto'; + +export function nowUnix(): number { + return Math.floor(Date.now() / 1000); +} + +export function newMsgId(): string { + return `msg_${crypto.randomUUID()}`; +} diff --git a/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts b/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts index ded7206314..b595c58226 100644 --- a/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts +++ b/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts @@ -3,6 +3,7 @@ import { StackUtil } from '@eggjs/tegg-common-util'; import type { EggProtoImplClass } from '@eggjs/tegg-types'; import { AccessLevel, ControllerType, HTTPMethodEnum, HTTPParamType } from '@eggjs/tegg-types'; +import { AgentInfoUtil } from '../../util/AgentInfoUtil.ts'; import { ControllerInfoUtil } from '../../util/ControllerInfoUtil.ts'; import { HTTPInfoUtil } from '../../util/HTTPInfoUtil.ts'; import { MethodInfoUtil } from '../../util/MethodInfoUtil.ts'; @@ -31,7 +32,7 @@ function createNotImplemented(methodName: string, hasParam: boolean) { throw new Error(`${methodName} not implemented`); }; } - (fn as any)[Symbol.for('AGENT_NOT_IMPLEMENTED')] = true; + AgentInfoUtil.setNotImplemented(fn); return fn; } @@ -104,6 +105,7 @@ export function AgentController() { func(constructor); // Set file path for prototype + // Stack depth 5: [0] getCalleeFromStack → [1] decorator fn → [2-4] reflect/oxc runtime → [5] user source PrototypeUtil.setFilePath(constructor, StackUtil.getCalleeFromStack(false, 5)); // Register each agent route @@ -132,6 +134,6 @@ export function AgentController() { } // Mark the class as an AgentController for precise detection - (constructor as any)[Symbol.for('AGENT_CONTROLLER')] = true; + AgentInfoUtil.setIsAgentController(constructor); }; } diff --git a/tegg/core/controller-decorator/src/util/AgentInfoUtil.ts b/tegg/core/controller-decorator/src/util/AgentInfoUtil.ts new file mode 100644 index 0000000000..3ab5b9d926 --- /dev/null +++ b/tegg/core/controller-decorator/src/util/AgentInfoUtil.ts @@ -0,0 +1,33 @@ +import { MetadataUtil } from '@eggjs/core-decorator'; +import { + CONTROLLER_AGENT_CONTROLLER, + CONTROLLER_AGENT_NOT_IMPLEMENTED, + CONTROLLER_AGENT_ENHANCED, +} from '@eggjs/tegg-types'; +import type { EggProtoImplClass } from '@eggjs/tegg-types'; + +export class AgentInfoUtil { + static setIsAgentController(clazz: EggProtoImplClass): void { + MetadataUtil.defineMetaData(CONTROLLER_AGENT_CONTROLLER, true, clazz); + } + + static isAgentController(clazz: EggProtoImplClass): boolean { + return MetadataUtil.getBooleanMetaData(CONTROLLER_AGENT_CONTROLLER, clazz); + } + + static setNotImplemented(fn: Function): void { + Reflect.defineMetadata(CONTROLLER_AGENT_NOT_IMPLEMENTED, true, fn); + } + + static isNotImplemented(fn: Function): boolean { + return !!Reflect.getMetadata(CONTROLLER_AGENT_NOT_IMPLEMENTED, fn); + } + + static setEnhanced(clazz: EggProtoImplClass): void { + MetadataUtil.defineMetaData(CONTROLLER_AGENT_ENHANCED, true, clazz); + } + + static isEnhanced(clazz: EggProtoImplClass): boolean { + return MetadataUtil.getBooleanMetaData(CONTROLLER_AGENT_ENHANCED, clazz); + } +} diff --git a/tegg/core/controller-decorator/src/util/index.ts b/tegg/core/controller-decorator/src/util/index.ts index c97db88411..15305fc730 100644 --- a/tegg/core/controller-decorator/src/util/index.ts +++ b/tegg/core/controller-decorator/src/util/index.ts @@ -1,4 +1,5 @@ export * from './validator/index.ts'; +export * from './AgentInfoUtil.ts'; export * from './ControllerInfoUtil.ts'; export * from './ControllerMetadataUtil.ts'; export * from './HTTPInfoUtil.ts'; diff --git a/tegg/core/controller-decorator/test/AgentController.test.ts b/tegg/core/controller-decorator/test/AgentController.test.ts index 06afb6c501..c3addeb02f 100644 --- a/tegg/core/controller-decorator/test/AgentController.test.ts +++ b/tegg/core/controller-decorator/test/AgentController.test.ts @@ -4,6 +4,7 @@ import { ControllerType, HTTPMethodEnum } from '@eggjs/tegg-types'; import { describe, it } from 'vitest'; import { + AgentInfoUtil, ControllerMetaBuilderFactory, BodyParamMeta, PathParamMeta, @@ -21,8 +22,8 @@ describe('core/controller-decorator/test/AgentController.test.ts', () => { assert.strictEqual(controllerType, ControllerType.HTTP); }); - it('should set AGENT_CONTROLLER symbol on the class', () => { - assert.strictEqual((AgentFooController as any)[Symbol.for('AGENT_CONTROLLER')], true); + it('should set AGENT_CONTROLLER metadata on the class', () => { + assert.strictEqual(AgentInfoUtil.isAgentController(AgentFooController), true); }); it('should set fixed base path /api/v1', () => { @@ -122,7 +123,7 @@ describe('core/controller-decorator/test/AgentController.test.ts', () => { for (const methodName of routeMethods) { assert(typeof proto[methodName] === 'function', `${methodName} should be a function`); assert.strictEqual( - proto[methodName][Symbol.for('AGENT_NOT_IMPLEMENTED')], + AgentInfoUtil.isNotImplemented(proto[methodName]), true, `${methodName} should be marked as AGENT_NOT_IMPLEMENTED`, ); diff --git a/tegg/core/types/src/controller-decorator/MetadataKey.ts b/tegg/core/types/src/controller-decorator/MetadataKey.ts index f0998c364b..d3fedb8077 100644 --- a/tegg/core/types/src/controller-decorator/MetadataKey.ts +++ b/tegg/core/types/src/controller-decorator/MetadataKey.ts @@ -38,3 +38,7 @@ export const CONTROLLER_MCP_PROMPT_PARAMS_MAP: symbol = Symbol.for('EggPrototype export const CONTROLLER_MCP_PROMPT_ARGS_INDEX: symbol = Symbol.for('EggPrototype#controller#mcp#prompt#args'); export const METHOD_TIMEOUT_METADATA: symbol = Symbol.for('EggPrototype#method#timeout'); + +export const CONTROLLER_AGENT_CONTROLLER: symbol = Symbol.for('EggPrototype#controller#agent#isAgent'); +export const CONTROLLER_AGENT_NOT_IMPLEMENTED: symbol = Symbol.for('EggPrototype#controller#agent#notImplemented'); +export const CONTROLLER_AGENT_ENHANCED: symbol = Symbol.for('EggPrototype#controller#agent#enhanced'); diff --git a/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts b/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts index 465c12cdb8..f0d8d104dd 100644 --- a/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts +++ b/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts @@ -1,11 +1,11 @@ -import { ControllerMetaBuilderFactory, ControllerMetadataUtil } from '@eggjs/controller-decorator'; +import { AgentInfoUtil, ControllerMetaBuilderFactory, ControllerMetadataUtil } from '@eggjs/controller-decorator'; import type { LifecycleHook } from '@eggjs/lifecycle'; import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/metadata'; export class EggControllerPrototypeHook implements LifecycleHook { async postCreate(ctx: EggPrototypeLifecycleContext): Promise { // Enhance @AgentController classes with smart defaults before metadata build. - if ((ctx.clazz as any)[Symbol.for('AGENT_CONTROLLER')]) { + if (AgentInfoUtil.isAgentController(ctx.clazz)) { const { enhanceAgentController } = await import('@eggjs/agent-runtime'); enhanceAgentController(ctx.clazz); } diff --git a/tsconfig.json b/tsconfig.json index b67b58ec46..0a167c7226 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -115,6 +115,9 @@ }, { "path": "./tegg/core/vitest" + }, + { + "path": "./tegg/core/agent-runtime" } ] } From c495e7a1e8ef19dc75a943256d9e4ee27465bb7e Mon Sep 17 00:00:00 2001 From: jerry Date: Fri, 27 Feb 2026 18:19:02 +0800 Subject: [PATCH 05/12] refactor(tegg): move helper functions into AgentRuntime as private static methods - Move TERMINAL_RUN_STATUSES, toContentBlocks, toMessageObject, extractFromStreamMessages, toInputMessageObjects from module scope into AgentRuntime class as private static members - Use atomic write (write-tmp-then-rename) in FileAgentStore to prevent data corruption on process crash - Remove misleading comment suggesting FileAgentStore is not for production Co-Authored-By: Claude Opus 4.6 --- tegg/core/agent-runtime/src/AgentRuntime.ts | 199 +++++++++--------- tegg/core/agent-runtime/src/FileAgentStore.ts | 7 +- 2 files changed, 107 insertions(+), 99 deletions(-) diff --git a/tegg/core/agent-runtime/src/AgentRuntime.ts b/tegg/core/agent-runtime/src/AgentRuntime.ts index f47ee0daae..ebea4bf9d4 100644 --- a/tegg/core/agent-runtime/src/AgentRuntime.ts +++ b/tegg/core/agent-runtime/src/AgentRuntime.ts @@ -28,106 +28,105 @@ export interface AgentControllerHost { execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator; } -// ─── helper functions ────────────────────────────────────────────── - -const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); - -/** - * Convert an AgentStreamMessage's message field into OpenAI MessageContentBlock[]. - */ -function toContentBlocks(msg: AgentStreamMessage['message']): MessageContentBlock[] { - if (!msg) return []; - const content = msg.content; - if (typeof content === 'string') { - return [{ type: 'text', text: { value: content, annotations: [] } }]; - } - if (Array.isArray(content)) { - return content - .filter((part) => part.type === 'text') - .map((part) => ({ type: 'text' as const, text: { value: part.text, annotations: [] } })); - } - return []; -} - -/** - * Build a completed MessageObject from an AgentStreamMessage. - */ -function toMessageObject(msg: AgentStreamMessage['message'], runId?: string): MessageObject { - return { - id: newMsgId(), - object: 'thread.message', - created_at: nowUnix(), - run_id: runId, - role: 'assistant', - status: 'completed', - content: toContentBlocks(msg), - }; -} - -/** - * Extract MessageObjects and accumulated usage from AgentStreamMessage objects. - */ -function extractFromStreamMessages( - messages: AgentStreamMessage[], - runId?: string, -): { - output: MessageObject[]; - usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; -} { - const output: MessageObject[] = []; - let promptTokens = 0; - let completionTokens = 0; - let totalTokens = 0; - let hasUsage = false; - - for (const msg of messages) { - if (msg.message) { - output.push(toMessageObject(msg.message, runId)); +export class AgentRuntime { + private static readonly TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); + + /** + * Convert an AgentStreamMessage's message field into OpenAI MessageContentBlock[]. + */ + private static toContentBlocks(msg: AgentStreamMessage['message']): MessageContentBlock[] { + if (!msg) return []; + const content = msg.content; + if (typeof content === 'string') { + return [{ type: 'text', text: { value: content, annotations: [] } }]; } - if (msg.usage) { - hasUsage = true; - promptTokens += msg.usage.prompt_tokens ?? 0; - completionTokens += msg.usage.completion_tokens ?? 0; - totalTokens += msg.usage.total_tokens ?? 0; + if (Array.isArray(content)) { + return content + .filter((part) => part.type === 'text') + .map((part) => ({ type: 'text' as const, text: { value: part.text, annotations: [] } })); } + return []; } - let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined; - if (hasUsage) { - usage = { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: Math.max(promptTokens + completionTokens, totalTokens), + /** + * Build a completed MessageObject from an AgentStreamMessage. + */ + private static toMessageObject(msg: AgentStreamMessage['message'], runId?: string): MessageObject { + return { + id: newMsgId(), + object: 'thread.message', + created_at: nowUnix(), + run_id: runId, + role: 'assistant', + status: 'completed', + content: AgentRuntime.toContentBlocks(msg), }; } - return { output, usage }; -} + /** + * Extract MessageObjects and accumulated usage from AgentStreamMessage objects. + */ + private static extractFromStreamMessages( + messages: AgentStreamMessage[], + runId?: string, + ): { + output: MessageObject[]; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + } { + const output: MessageObject[] = []; + let promptTokens = 0; + let completionTokens = 0; + let totalTokens = 0; + let hasUsage = false; -/** - * Convert input messages to MessageObjects for thread history. - * System messages are filtered out — they are transient instructions, not conversation history. - */ -function toInputMessageObjects(messages: CreateRunInput['input']['messages'], threadId?: string): MessageObject[] { - return messages - .filter((m): m is typeof m & { role: 'user' | 'assistant' } => m.role !== 'system') - .map((m) => ({ - id: newMsgId(), - object: 'thread.message' as const, - created_at: nowUnix(), - thread_id: threadId, - role: m.role, - status: 'completed' as const, - content: - typeof m.content === 'string' - ? [{ type: 'text' as const, text: { value: m.content, annotations: [] } }] - : m.content.map((p) => ({ type: 'text' as const, text: { value: p.text, annotations: [] } })), - })); -} + for (const msg of messages) { + if (msg.message) { + output.push(AgentRuntime.toMessageObject(msg.message, runId)); + } + if (msg.usage) { + hasUsage = true; + promptTokens += msg.usage.prompt_tokens ?? 0; + completionTokens += msg.usage.completion_tokens ?? 0; + totalTokens += msg.usage.total_tokens ?? 0; + } + } -// ─── AgentRuntime class ──────────────────────────────────────────── + let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined; + if (hasUsage) { + usage = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: Math.max(promptTokens + completionTokens, totalTokens), + }; + } + + return { output, usage }; + } + + /** + * Convert input messages to MessageObjects for thread history. + * System messages are filtered out — they are transient instructions, not conversation history. + */ + private static toInputMessageObjects( + messages: CreateRunInput['input']['messages'], + threadId?: string, + ): MessageObject[] { + return messages + .filter((m): m is typeof m & { role: 'user' | 'assistant' } => m.role !== 'system') + .map((m) => ({ + id: newMsgId(), + object: 'thread.message' as const, + created_at: nowUnix(), + thread_id: threadId, + role: m.role, + status: 'completed' as const, + content: + typeof m.content === 'string' + ? [{ type: 'text' as const, text: { value: m.content, annotations: [] } }] + : m.content.map((p) => ({ type: 'text' as const, text: { value: p.text, annotations: [] } })), + })); + } -export class AgentRuntime { private store: AgentStore; private runningTasks: Map; abortController: AbortController }>; private host: AgentControllerHost; @@ -177,7 +176,7 @@ export class AgentRuntime { for await (const msg of this.host.execRun(input)) { streamMessages.push(msg); } - const { output, usage } = extractFromStreamMessages(streamMessages, run.id); + const { output, usage } = AgentRuntime.extractFromStreamMessages(streamMessages, run.id); const completedAt = nowUnix(); await this.store.updateRun(run.id, { @@ -187,7 +186,10 @@ export class AgentRuntime { completed_at: completedAt, }); - await this.store.appendMessages(threadId, [...toInputMessageObjects(input.input.messages, threadId), ...output]); + await this.store.appendMessages(threadId, [ + ...AgentRuntime.toInputMessageObjects(input.input.messages, threadId), + ...output, + ]); return { id: run.id, @@ -236,7 +238,7 @@ export class AgentRuntime { if (abortController.signal.aborted) return; - const { output, usage } = extractFromStreamMessages(streamMessages, run.id); + const { output, usage } = AgentRuntime.extractFromStreamMessages(streamMessages, run.id); await this.store.updateRun(run.id, { status: 'completed', @@ -246,7 +248,7 @@ export class AgentRuntime { }); await this.store.appendMessages(threadId!, [ - ...toInputMessageObjects(input.input.messages, threadId), + ...AgentRuntime.toInputMessageObjects(input.input.messages, threadId), ...output, ]); } catch (err: any) { @@ -351,7 +353,7 @@ export class AgentRuntime { for await (const msg of this.host.execRun(input, abortController.signal)) { if (abortController.signal.aborted) break; if (msg.message) { - const contentBlocks = toContentBlocks(msg.message); + const contentBlocks = AgentRuntime.toContentBlocks(msg.message); accumulatedContent.push(...contentBlocks); // event: thread.message.delta @@ -410,7 +412,10 @@ export class AgentRuntime { completed_at: completedAt, }); - await this.store.appendMessages(threadId!, [...toInputMessageObjects(input.input.messages, threadId), ...output]); + await this.store.appendMessages(threadId!, [ + ...AgentRuntime.toInputMessageObjects(input.input.messages, threadId), + ...output, + ]); // event: thread.run.completed runObj.status = 'completed'; @@ -479,7 +484,7 @@ export class AgentRuntime { // Re-read run status after background task has settled const run = await this.store.getRun(runId); - if (TERMINAL_RUN_STATUSES.has(run.status)) { + if (AgentRuntime.TERMINAL_RUN_STATUSES.has(run.status)) { throw new AgentConflictError(`Cannot cancel run with status '${run.status}'`); } diff --git a/tegg/core/agent-runtime/src/FileAgentStore.ts b/tegg/core/agent-runtime/src/FileAgentStore.ts index 6737b7d7e2..b062ecad6d 100644 --- a/tegg/core/agent-runtime/src/FileAgentStore.ts +++ b/tegg/core/agent-runtime/src/FileAgentStore.ts @@ -63,7 +63,6 @@ export class FileAgentStore implements AgentStore { // Note: read-modify-write without locking. In cluster mode with multiple workers // sharing the same dataDir, concurrent operations on the same thread may lose data. - // For production multi-worker deployments, use a database-backed AgentStore instead. async appendMessages(threadId: string, messages: MessageObject[]): Promise { const thread = await this.getThread(threadId); thread.messages.push(...messages); @@ -108,7 +107,11 @@ export class FileAgentStore implements AgentStore { } private async writeFile(filePath: string, data: unknown): Promise { - await fs.writeFile(filePath, JSON.stringify(data), 'utf-8'); + // Write to a temp file first, then atomically rename to avoid data corruption + // if the process crashes mid-write. + const tmpPath = filePath + '.tmp'; + await fs.writeFile(tmpPath, JSON.stringify(data), 'utf-8'); + await fs.rename(tmpPath, filePath); } private async readFile(filePath: string): Promise { From ed8156f505d456cddebe66c54a8cde68abfac14f Mon Sep 17 00:00:00 2001 From: jerry Date: Fri, 27 Feb 2026 19:15:32 +0800 Subject: [PATCH 06/12] fix(tegg): correct timestamp assertions comparing Unix seconds with Date.now() The test assertions were comparing created_at (Unix seconds) against Date.now() (milliseconds), which always passes and validates nothing. Co-Authored-By: Claude Opus 4.6 --- tegg/core/agent-runtime/test/AgentRuntime.test.ts | 4 ++-- tegg/core/agent-runtime/test/FileAgentStore.test.ts | 6 +++--- tegg/plugin/controller/test/http/base-agent.test.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tegg/core/agent-runtime/test/AgentRuntime.test.ts b/tegg/core/agent-runtime/test/AgentRuntime.test.ts index 0356bc6f7e..05ea9ce4e6 100644 --- a/tegg/core/agent-runtime/test/AgentRuntime.test.ts +++ b/tegg/core/agent-runtime/test/AgentRuntime.test.ts @@ -48,8 +48,8 @@ describe('core/agent-runtime/test/AgentRuntime.test.ts', () => { assert(result.id.startsWith('thread_')); assert.equal(result.object, 'thread'); assert(typeof result.created_at === 'number'); - // Unix seconds — should be much smaller than Date.now() - assert(result.created_at < Date.now()); + // Unix seconds + assert(result.created_at <= Math.floor(Date.now() / 1000)); assert(typeof result.metadata === 'object'); }); }); diff --git a/tegg/core/agent-runtime/test/FileAgentStore.test.ts b/tegg/core/agent-runtime/test/FileAgentStore.test.ts index 3c2036ac63..288ecff3ee 100644 --- a/tegg/core/agent-runtime/test/FileAgentStore.test.ts +++ b/tegg/core/agent-runtime/test/FileAgentStore.test.ts @@ -28,8 +28,8 @@ describe('core/agent-runtime/test/FileAgentStore.test.ts', () => { assert(Array.isArray(thread.messages)); assert.equal(thread.messages.length, 0); assert(typeof thread.created_at === 'number'); - // Unix seconds — should be much smaller than Date.now() - assert(thread.created_at < Date.now()); + // Unix seconds + assert(thread.created_at <= Math.floor(Date.now() / 1000)); }); it('should create a thread with metadata', async () => { @@ -98,7 +98,7 @@ describe('core/agent-runtime/test/FileAgentStore.test.ts', () => { assert.equal(run.input.length, 1); assert(typeof run.created_at === 'number'); // Unix seconds - assert(run.created_at < Date.now()); + assert(run.created_at <= Math.floor(Date.now() / 1000)); }); it('should create a run with thread_id and config', async () => { diff --git a/tegg/plugin/controller/test/http/base-agent.test.ts b/tegg/plugin/controller/test/http/base-agent.test.ts index 979ccf7249..32990fa300 100644 --- a/tegg/plugin/controller/test/http/base-agent.test.ts +++ b/tegg/plugin/controller/test/http/base-agent.test.ts @@ -38,7 +38,7 @@ describe('plugin/controller/test/http/base-agent.test.ts', () => { assert.equal(res.body.object, 'thread'); assert(typeof res.body.created_at === 'number'); // Unix seconds - assert(res.body.created_at < Date.now()); + assert(res.body.created_at <= Math.floor(Date.now() / 1000)); assert(typeof res.body.metadata === 'object'); }); }); From 4806e51ffe1c280f8724b400c8fefd7e57a1f429 Mon Sep 17 00:00:00 2001 From: jerry Date: Sat, 28 Feb 2026 11:01:45 +0800 Subject: [PATCH 07/12] refactor(tegg): extract MessageConverter, define RunStatus/SSEEvent/ErrorCode constants - Replace RunStatus union type with const object + type alias pattern - Add AgentSSEEvent and AgentErrorCode const object definitions - Add AgentStreamMessagePayload type alias - Extract 4 message conversion functions from AgentRuntime into standalone MessageConverter module (toContentBlocks, toMessageObject, extractFromStreamMessages, toInputMessageObjects) - Fix total_tokens calculation: use direct addition instead of Math.max - Replace all string literals with constant references in AgentRuntime and FileAgentStore Co-Authored-By: Claude Opus 4.6 --- tegg/core/agent-runtime/src/AgentRuntime.ts | 192 +++++------------- tegg/core/agent-runtime/src/FileAgentStore.ts | 3 +- .../agent-runtime/src/MessageConverter.ts | 103 ++++++++++ tegg/core/agent-runtime/src/index.ts | 1 + .../src/model/AgentControllerTypes.ts | 35 +++- 5 files changed, 187 insertions(+), 147 deletions(-) create mode 100644 tegg/core/agent-runtime/src/MessageConverter.ts diff --git a/tegg/core/agent-runtime/src/AgentRuntime.ts b/tegg/core/agent-runtime/src/AgentRuntime.ts index ebea4bf9d4..a7f5b35252 100644 --- a/tegg/core/agent-runtime/src/AgentRuntime.ts +++ b/tegg/core/agent-runtime/src/AgentRuntime.ts @@ -4,14 +4,15 @@ import type { ThreadObjectWithMessages, RunObject, MessageObject, - MessageContentBlock, MessageDeltaObject, AgentStreamMessage, } from '@eggjs/controller-decorator'; +import { RunStatus, AgentSSEEvent, AgentErrorCode } from '@eggjs/controller-decorator'; import { ContextHandler } from '@eggjs/tegg-runtime'; import type { AgentStore } from './AgentStore.ts'; import { AgentConflictError } from './errors.ts'; +import { toContentBlocks, extractFromStreamMessages, toInputMessageObjects } from './MessageConverter.ts'; import { nowUnix, newMsgId } from './utils.ts'; // Canonical definition in @eggjs/module-common (tegg/plugin/common). @@ -29,103 +30,12 @@ export interface AgentControllerHost { } export class AgentRuntime { - private static readonly TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); - - /** - * Convert an AgentStreamMessage's message field into OpenAI MessageContentBlock[]. - */ - private static toContentBlocks(msg: AgentStreamMessage['message']): MessageContentBlock[] { - if (!msg) return []; - const content = msg.content; - if (typeof content === 'string') { - return [{ type: 'text', text: { value: content, annotations: [] } }]; - } - if (Array.isArray(content)) { - return content - .filter((part) => part.type === 'text') - .map((part) => ({ type: 'text' as const, text: { value: part.text, annotations: [] } })); - } - return []; - } - - /** - * Build a completed MessageObject from an AgentStreamMessage. - */ - private static toMessageObject(msg: AgentStreamMessage['message'], runId?: string): MessageObject { - return { - id: newMsgId(), - object: 'thread.message', - created_at: nowUnix(), - run_id: runId, - role: 'assistant', - status: 'completed', - content: AgentRuntime.toContentBlocks(msg), - }; - } - - /** - * Extract MessageObjects and accumulated usage from AgentStreamMessage objects. - */ - private static extractFromStreamMessages( - messages: AgentStreamMessage[], - runId?: string, - ): { - output: MessageObject[]; - usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; - } { - const output: MessageObject[] = []; - let promptTokens = 0; - let completionTokens = 0; - let totalTokens = 0; - let hasUsage = false; - - for (const msg of messages) { - if (msg.message) { - output.push(AgentRuntime.toMessageObject(msg.message, runId)); - } - if (msg.usage) { - hasUsage = true; - promptTokens += msg.usage.prompt_tokens ?? 0; - completionTokens += msg.usage.completion_tokens ?? 0; - totalTokens += msg.usage.total_tokens ?? 0; - } - } - - let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined; - if (hasUsage) { - usage = { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: Math.max(promptTokens + completionTokens, totalTokens), - }; - } - - return { output, usage }; - } - - /** - * Convert input messages to MessageObjects for thread history. - * System messages are filtered out — they are transient instructions, not conversation history. - */ - private static toInputMessageObjects( - messages: CreateRunInput['input']['messages'], - threadId?: string, - ): MessageObject[] { - return messages - .filter((m): m is typeof m & { role: 'user' | 'assistant' } => m.role !== 'system') - .map((m) => ({ - id: newMsgId(), - object: 'thread.message' as const, - created_at: nowUnix(), - thread_id: threadId, - role: m.role, - status: 'completed' as const, - content: - typeof m.content === 'string' - ? [{ type: 'text' as const, text: { value: m.content, annotations: [] } }] - : m.content.map((p) => ({ type: 'text' as const, text: { value: p.text, annotations: [] } })), - })); - } + private static readonly TERMINAL_RUN_STATUSES = new Set([ + RunStatus.Completed, + RunStatus.Failed, + RunStatus.Cancelled, + RunStatus.Expired, + ]); private store: AgentStore; private runningTasks: Map; abortController: AbortController }>; @@ -170,33 +80,30 @@ export class AgentRuntime { try { const startedAt = nowUnix(); - await this.store.updateRun(run.id, { status: 'in_progress', started_at: startedAt }); + await this.store.updateRun(run.id, { status: RunStatus.InProgress, started_at: startedAt }); const streamMessages: AgentStreamMessage[] = []; for await (const msg of this.host.execRun(input)) { streamMessages.push(msg); } - const { output, usage } = AgentRuntime.extractFromStreamMessages(streamMessages, run.id); + const { output, usage } = extractFromStreamMessages(streamMessages, run.id); const completedAt = nowUnix(); await this.store.updateRun(run.id, { - status: 'completed', + status: RunStatus.Completed, output, usage, completed_at: completedAt, }); - await this.store.appendMessages(threadId, [ - ...AgentRuntime.toInputMessageObjects(input.input.messages, threadId), - ...output, - ]); + await this.store.appendMessages(threadId, [...toInputMessageObjects(input.input.messages, threadId), ...output]); return { id: run.id, object: 'thread.run', created_at: run.created_at, thread_id: threadId, - status: 'completed', + status: RunStatus.Completed, started_at: startedAt, completed_at: completedAt, output, @@ -206,8 +113,8 @@ export class AgentRuntime { } catch (err: any) { const failedAt = nowUnix(); await this.store.updateRun(run.id, { - status: 'failed', - last_error: { code: 'EXEC_ERROR', message: err.message }, + status: RunStatus.Failed, + last_error: { code: AgentErrorCode.ExecError, message: err.message }, failed_at: failedAt, }); throw err; @@ -228,7 +135,7 @@ export class AgentRuntime { const promise = (async () => { try { - await this.store.updateRun(run.id, { status: 'in_progress', started_at: nowUnix() }); + await this.store.updateRun(run.id, { status: RunStatus.InProgress, started_at: nowUnix() }); const streamMessages: AgentStreamMessage[] = []; for await (const msg of this.host.execRun(input, abortController.signal)) { @@ -238,25 +145,25 @@ export class AgentRuntime { if (abortController.signal.aborted) return; - const { output, usage } = AgentRuntime.extractFromStreamMessages(streamMessages, run.id); + const { output, usage } = extractFromStreamMessages(streamMessages, run.id); await this.store.updateRun(run.id, { - status: 'completed', + status: RunStatus.Completed, output, usage, completed_at: nowUnix(), }); await this.store.appendMessages(threadId!, [ - ...AgentRuntime.toInputMessageObjects(input.input.messages, threadId), + ...toInputMessageObjects(input.input.messages, threadId), ...output, ]); } catch (err: any) { if (!abortController.signal.aborted) { try { await this.store.updateRun(run.id, { - status: 'failed', - last_error: { code: 'EXEC_ERROR', message: err.message }, + status: RunStatus.Failed, + last_error: { code: AgentErrorCode.ExecError, message: err.message }, failed_at: nowUnix(), }); } catch (storeErr) { @@ -277,7 +184,7 @@ export class AgentRuntime { object: 'thread.run', created_at: run.created_at, thread_id: threadId, - status: 'queued', + status: RunStatus.Queued, metadata: run.metadata, }; } @@ -316,18 +223,18 @@ export class AgentRuntime { object: 'thread.run', created_at: run.created_at, thread_id: threadId, - status: 'queued', + status: RunStatus.Queued, metadata: run.metadata, }; // event: thread.run.created - res.write(`event: thread.run.created\ndata: ${JSON.stringify(runObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadRunCreated}\ndata: ${JSON.stringify(runObj)}\n\n`); // event: thread.run.in_progress - runObj.status = 'in_progress'; + runObj.status = RunStatus.InProgress; runObj.started_at = nowUnix(); - await this.store.updateRun(run.id, { status: 'in_progress', started_at: runObj.started_at }); - res.write(`event: thread.run.in_progress\ndata: ${JSON.stringify(runObj)}\n\n`); + await this.store.updateRun(run.id, { status: RunStatus.InProgress, started_at: runObj.started_at }); + res.write(`event: ${AgentSSEEvent.ThreadRunInProgress}\ndata: ${JSON.stringify(runObj)}\n\n`); const msgId = newMsgId(); const accumulatedContent: MessageObject['content'] = []; @@ -342,18 +249,17 @@ export class AgentRuntime { status: 'in_progress', content: [], }; - res.write(`event: thread.message.created\ndata: ${JSON.stringify(msgObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadMessageCreated}\ndata: ${JSON.stringify(msgObj)}\n\n`); let promptTokens = 0; let completionTokens = 0; - let totalTokens = 0; let hasUsage = false; try { for await (const msg of this.host.execRun(input, abortController.signal)) { if (abortController.signal.aborted) break; if (msg.message) { - const contentBlocks = AgentRuntime.toContentBlocks(msg.message); + const contentBlocks = toContentBlocks(msg.message); accumulatedContent.push(...contentBlocks); // event: thread.message.delta @@ -362,13 +268,12 @@ export class AgentRuntime { object: 'thread.message.delta', delta: { content: contentBlocks }, }; - res.write(`event: thread.message.delta\ndata: ${JSON.stringify(delta)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadMessageDelta}\ndata: ${JSON.stringify(delta)}\n\n`); } if (msg.usage) { hasUsage = true; promptTokens += msg.usage.prompt_tokens ?? 0; completionTokens += msg.usage.completion_tokens ?? 0; - totalTokens += msg.usage.total_tokens ?? 0; } } @@ -376,14 +281,14 @@ export class AgentRuntime { if (abortController.signal.aborted) { const cancelledAt = nowUnix(); try { - await this.store.updateRun(run.id, { status: 'cancelled', cancelled_at: cancelledAt }); + await this.store.updateRun(run.id, { status: RunStatus.Cancelled, cancelled_at: cancelledAt }); } catch { // Ignore store update failure during abort } - runObj.status = 'cancelled'; + runObj.status = RunStatus.Cancelled; runObj.cancelled_at = cancelledAt; if (!res.writableEnded) { - res.write(`event: thread.run.cancelled\ndata: ${JSON.stringify(runObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadRunCancelled}\ndata: ${JSON.stringify(runObj)}\n\n`); } return; } @@ -391,7 +296,7 @@ export class AgentRuntime { // event: thread.message.completed msgObj.status = 'completed'; msgObj.content = accumulatedContent; - res.write(`event: thread.message.completed\ndata: ${JSON.stringify(msgObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadMessageCompleted}\ndata: ${JSON.stringify(msgObj)}\n\n`); // Build final output const output: MessageObject[] = accumulatedContent.length > 0 ? [msgObj] : []; @@ -400,35 +305,32 @@ export class AgentRuntime { usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, - total_tokens: Math.max(promptTokens + completionTokens, totalTokens), + total_tokens: promptTokens + completionTokens, }; } const completedAt = nowUnix(); await this.store.updateRun(run.id, { - status: 'completed', + status: RunStatus.Completed, output, usage, completed_at: completedAt, }); - await this.store.appendMessages(threadId!, [ - ...AgentRuntime.toInputMessageObjects(input.input.messages, threadId), - ...output, - ]); + await this.store.appendMessages(threadId!, [...toInputMessageObjects(input.input.messages, threadId), ...output]); // event: thread.run.completed - runObj.status = 'completed'; + runObj.status = RunStatus.Completed; runObj.completed_at = completedAt; runObj.usage = usage; runObj.output = output; - res.write(`event: thread.run.completed\ndata: ${JSON.stringify(runObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadRunCompleted}\ndata: ${JSON.stringify(runObj)}\n\n`); } catch (err: any) { const failedAt = nowUnix(); try { await this.store.updateRun(run.id, { - status: 'failed', - last_error: { code: 'EXEC_ERROR', message: err.message }, + status: RunStatus.Failed, + last_error: { code: AgentErrorCode.ExecError, message: err.message }, failed_at: failedAt, }); } catch (storeErr) { @@ -436,16 +338,16 @@ export class AgentRuntime { } // event: thread.run.failed - runObj.status = 'failed'; + runObj.status = RunStatus.Failed; runObj.failed_at = failedAt; - runObj.last_error = { code: 'EXEC_ERROR', message: err.message }; + runObj.last_error = { code: AgentErrorCode.ExecError, message: err.message }; if (!res.writableEnded) { - res.write(`event: thread.run.failed\ndata: ${JSON.stringify(runObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadRunFailed}\ndata: ${JSON.stringify(runObj)}\n\n`); } } finally { // event: done if (!res.writableEnded) { - res.write('event: done\ndata: [DONE]\n\n'); + res.write(`event: ${AgentSSEEvent.Done}\ndata: [DONE]\n\n`); res.end(); } } @@ -490,7 +392,7 @@ export class AgentRuntime { const cancelledAt = nowUnix(); await this.store.updateRun(runId, { - status: 'cancelled', + status: RunStatus.Cancelled, cancelled_at: cancelledAt, }); @@ -499,7 +401,7 @@ export class AgentRuntime { object: 'thread.run', created_at: run.created_at, thread_id: run.thread_id, - status: 'cancelled', + status: RunStatus.Cancelled, cancelled_at: cancelledAt, }; } diff --git a/tegg/core/agent-runtime/src/FileAgentStore.ts b/tegg/core/agent-runtime/src/FileAgentStore.ts index b062ecad6d..b78659dd04 100644 --- a/tegg/core/agent-runtime/src/FileAgentStore.ts +++ b/tegg/core/agent-runtime/src/FileAgentStore.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { InputMessage, MessageObject, AgentRunConfig } from '@eggjs/controller-decorator'; +import { RunStatus } from '@eggjs/controller-decorator'; import type { AgentStore, ThreadRecord, RunRecord } from './AgentStore.ts'; import { AgentNotFoundError } from './errors.ts'; @@ -80,7 +81,7 @@ export class FileAgentStore implements AgentStore { id: runId, object: 'thread.run', thread_id: threadId, - status: 'queued', + status: RunStatus.Queued, input, config, metadata, diff --git a/tegg/core/agent-runtime/src/MessageConverter.ts b/tegg/core/agent-runtime/src/MessageConverter.ts new file mode 100644 index 0000000000..40871984a2 --- /dev/null +++ b/tegg/core/agent-runtime/src/MessageConverter.ts @@ -0,0 +1,103 @@ +import type { + CreateRunInput, + MessageObject, + MessageContentBlock, + AgentStreamMessage, + AgentStreamMessagePayload, +} from '@eggjs/controller-decorator'; + +import { nowUnix, newMsgId } from './utils.ts'; + +/** + * Convert an AgentStreamMessage's message payload into OpenAI MessageContentBlock[]. + */ +export function toContentBlocks(msg: AgentStreamMessagePayload): MessageContentBlock[] { + if (!msg) return []; + const content = msg.content; + if (typeof content === 'string') { + return [{ type: 'text', text: { value: content, annotations: [] } }]; + } + if (Array.isArray(content)) { + return content + .filter((part) => part.type === 'text') + .map((part) => ({ type: 'text' as const, text: { value: part.text, annotations: [] } })); + } + return []; +} + +/** + * Build a completed MessageObject from an AgentStreamMessage payload. + */ +export function toMessageObject(msg: AgentStreamMessagePayload, runId?: string): MessageObject { + return { + id: newMsgId(), + object: 'thread.message', + created_at: nowUnix(), + run_id: runId, + role: 'assistant', + status: 'completed', + content: toContentBlocks(msg), + }; +} + +/** + * Extract MessageObjects and accumulated usage from AgentStreamMessage objects. + */ +export function extractFromStreamMessages( + messages: AgentStreamMessage[], + runId?: string, +): { + output: MessageObject[]; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; +} { + const output: MessageObject[] = []; + let promptTokens = 0; + let completionTokens = 0; + let hasUsage = false; + + for (const msg of messages) { + if (msg.message) { + output.push(toMessageObject(msg.message, runId)); + } + if (msg.usage) { + hasUsage = true; + promptTokens += msg.usage.prompt_tokens ?? 0; + completionTokens += msg.usage.completion_tokens ?? 0; + } + } + + let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined; + if (hasUsage) { + usage = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }; + } + + return { output, usage }; +} + +/** + * Convert input messages to MessageObjects for thread history. + * System messages are filtered out — they are transient instructions, not conversation history. + */ +export function toInputMessageObjects( + messages: CreateRunInput['input']['messages'], + threadId?: string, +): MessageObject[] { + return messages + .filter((m): m is typeof m & { role: 'user' | 'assistant' } => m.role !== 'system') + .map((m) => ({ + id: newMsgId(), + object: 'thread.message' as const, + created_at: nowUnix(), + thread_id: threadId, + role: m.role, + status: 'completed' as const, + content: + typeof m.content === 'string' + ? [{ type: 'text' as const, text: { value: m.content, annotations: [] } }] + : m.content.map((p) => ({ type: 'text' as const, text: { value: p.text, annotations: [] } })), + })); +} diff --git a/tegg/core/agent-runtime/src/index.ts b/tegg/core/agent-runtime/src/index.ts index bf7a75984e..8fb9bf8caa 100644 --- a/tegg/core/agent-runtime/src/index.ts +++ b/tegg/core/agent-runtime/src/index.ts @@ -1,6 +1,7 @@ export * from './AgentStore.ts'; export * from './errors.ts'; export * from './FileAgentStore.ts'; +export * from './MessageConverter.ts'; export * from './utils.ts'; export { AgentRuntime, AGENT_RUNTIME } from './AgentRuntime.ts'; export type { AgentControllerHost } from './AgentRuntime.ts'; diff --git a/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts b/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts index 1dca91a363..c0660efe6e 100644 --- a/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts +++ b/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts @@ -47,7 +47,16 @@ export interface ThreadObjectWithMessages extends ThreadObject { // ===== Run types ===== -export type RunStatus = 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'cancelling' | 'expired'; +export const RunStatus = { + Queued: 'queued', + InProgress: 'in_progress', + Completed: 'completed', + Failed: 'failed', + Cancelled: 'cancelled', + Cancelling: 'cancelling', + Expired: 'expired', +} as const; +export type RunStatus = (typeof RunStatus)[keyof typeof RunStatus]; export interface RunObject { id: string; // "run_xxx" @@ -85,8 +94,32 @@ export interface MessageDeltaObject { delta: { content: MessageContentBlock[] }; } +// ===== SSE Event names ===== + +export const AgentSSEEvent = { + ThreadRunCreated: 'thread.run.created', + ThreadRunInProgress: 'thread.run.in_progress', + ThreadRunCompleted: 'thread.run.completed', + ThreadRunFailed: 'thread.run.failed', + ThreadRunCancelled: 'thread.run.cancelled', + ThreadMessageCreated: 'thread.message.created', + ThreadMessageDelta: 'thread.message.delta', + ThreadMessageCompleted: 'thread.message.completed', + Done: 'done', +} as const; +export type AgentSSEEvent = (typeof AgentSSEEvent)[keyof typeof AgentSSEEvent]; + +// ===== Error codes ===== + +export const AgentErrorCode = { + ExecError: 'EXEC_ERROR', +} as const; +export type AgentErrorCode = (typeof AgentErrorCode)[keyof typeof AgentErrorCode]; + // ===== Internal types ===== +export type AgentStreamMessagePayload = AgentStreamMessage['message']; + export interface AgentRunUsage { total_tokens?: number; prompt_tokens?: number; From 0daba4c4cdb3cc43a0b50806ffa9888da6173c98 Mon Sep 17 00:00:00 2001 From: jerry Date: Sat, 28 Feb 2026 11:16:11 +0800 Subject: [PATCH 08/12] refactor(tegg): introduce RunBuilder to encapsulate RunObject state transitions Extract RunObject construction and state mutation logic into a dedicated RunBuilder class. This replaces scattered inline object literals and field assignments with explicit state transition methods (start, complete, fail, cancel, snapshot), making the run lifecycle clearer and less error-prone. Co-Authored-By: Claude Opus 4.6 --- tegg/core/agent-runtime/src/AgentRuntime.ts | 131 ++++++++------------ tegg/core/agent-runtime/src/RunBuilder.ts | 65 ++++++++++ tegg/core/agent-runtime/src/index.ts | 1 + 3 files changed, 115 insertions(+), 82 deletions(-) create mode 100644 tegg/core/agent-runtime/src/RunBuilder.ts diff --git a/tegg/core/agent-runtime/src/AgentRuntime.ts b/tegg/core/agent-runtime/src/AgentRuntime.ts index a7f5b35252..7d3f082570 100644 --- a/tegg/core/agent-runtime/src/AgentRuntime.ts +++ b/tegg/core/agent-runtime/src/AgentRuntime.ts @@ -7,12 +7,13 @@ import type { MessageDeltaObject, AgentStreamMessage, } from '@eggjs/controller-decorator'; -import { RunStatus, AgentSSEEvent, AgentErrorCode } from '@eggjs/controller-decorator'; +import { RunStatus, AgentSSEEvent } from '@eggjs/controller-decorator'; import { ContextHandler } from '@eggjs/tegg-runtime'; import type { AgentStore } from './AgentStore.ts'; import { AgentConflictError } from './errors.ts'; import { toContentBlocks, extractFromStreamMessages, toInputMessageObjects } from './MessageConverter.ts'; +import { RunBuilder } from './RunBuilder.ts'; import { nowUnix, newMsgId } from './utils.ts'; // Canonical definition in @eggjs/module-common (tegg/plugin/common). @@ -77,10 +78,11 @@ export class AgentRuntime { } const run = await this.store.createRun(input.input.messages, threadId, input.config, input.metadata); + const rb = RunBuilder.create(run, threadId); try { - const startedAt = nowUnix(); - await this.store.updateRun(run.id, { status: RunStatus.InProgress, started_at: startedAt }); + const started = rb.start(); + await this.store.updateRun(run.id, { status: started.status, started_at: started.started_at }); const streamMessages: AgentStreamMessage[] = []; for await (const msg of this.host.execRun(input)) { @@ -88,34 +90,23 @@ export class AgentRuntime { } const { output, usage } = extractFromStreamMessages(streamMessages, run.id); - const completedAt = nowUnix(); + const completed = rb.complete(output, usage); await this.store.updateRun(run.id, { - status: RunStatus.Completed, + status: completed.status, output, usage, - completed_at: completedAt, + completed_at: completed.completed_at, }); await this.store.appendMessages(threadId, [...toInputMessageObjects(input.input.messages, threadId), ...output]); - return { - id: run.id, - object: 'thread.run', - created_at: run.created_at, - thread_id: threadId, - status: RunStatus.Completed, - started_at: startedAt, - completed_at: completedAt, - output, - usage, - metadata: run.metadata, - }; + return completed; } catch (err: any) { - const failedAt = nowUnix(); + const failed = rb.fail(err); await this.store.updateRun(run.id, { - status: RunStatus.Failed, - last_error: { code: AgentErrorCode.ExecError, message: err.message }, - failed_at: failedAt, + status: failed.status, + last_error: failed.last_error, + failed_at: failed.failed_at, }); throw err; } @@ -130,12 +121,17 @@ export class AgentRuntime { } const run = await this.store.createRun(input.input.messages, threadId, input.config, input.metadata); + const rb = RunBuilder.create(run, threadId); const abortController = new AbortController(); + // Capture queued snapshot before background task mutates state + const queuedSnapshot = rb.snapshot(); + const promise = (async () => { try { - await this.store.updateRun(run.id, { status: RunStatus.InProgress, started_at: nowUnix() }); + const started = rb.start(); + await this.store.updateRun(run.id, { status: started.status, started_at: started.started_at }); const streamMessages: AgentStreamMessage[] = []; for await (const msg of this.host.execRun(input, abortController.signal)) { @@ -147,11 +143,12 @@ export class AgentRuntime { const { output, usage } = extractFromStreamMessages(streamMessages, run.id); + const completed = rb.complete(output, usage); await this.store.updateRun(run.id, { - status: RunStatus.Completed, + status: completed.status, output, usage, - completed_at: nowUnix(), + completed_at: completed.completed_at, }); await this.store.appendMessages(threadId!, [ @@ -161,10 +158,11 @@ export class AgentRuntime { } catch (err: any) { if (!abortController.signal.aborted) { try { + const failed = rb.fail(err); await this.store.updateRun(run.id, { - status: RunStatus.Failed, - last_error: { code: AgentErrorCode.ExecError, message: err.message }, - failed_at: nowUnix(), + status: failed.status, + last_error: failed.last_error, + failed_at: failed.failed_at, }); } catch (storeErr) { console.error('[AgentController] failed to update run status after error:', storeErr); @@ -179,14 +177,7 @@ export class AgentRuntime { this.runningTasks.set(run.id, { promise, abortController }); - return { - id: run.id, - object: 'thread.run', - created_at: run.created_at, - thread_id: threadId, - status: RunStatus.Queued, - metadata: run.metadata, - }; + return queuedSnapshot; } async streamRun(input: CreateRunInput): Promise { @@ -217,24 +208,15 @@ export class AgentRuntime { } const run = await this.store.createRun(input.input.messages, threadId, input.config, input.metadata); - - const runObj: RunObject = { - id: run.id, - object: 'thread.run', - created_at: run.created_at, - thread_id: threadId, - status: RunStatus.Queued, - metadata: run.metadata, - }; + const rb = RunBuilder.create(run, threadId); // event: thread.run.created - res.write(`event: ${AgentSSEEvent.ThreadRunCreated}\ndata: ${JSON.stringify(runObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadRunCreated}\ndata: ${JSON.stringify(rb.snapshot())}\n\n`); // event: thread.run.in_progress - runObj.status = RunStatus.InProgress; - runObj.started_at = nowUnix(); - await this.store.updateRun(run.id, { status: RunStatus.InProgress, started_at: runObj.started_at }); - res.write(`event: ${AgentSSEEvent.ThreadRunInProgress}\ndata: ${JSON.stringify(runObj)}\n\n`); + const started = rb.start(); + await this.store.updateRun(run.id, { status: started.status, started_at: started.started_at }); + res.write(`event: ${AgentSSEEvent.ThreadRunInProgress}\ndata: ${JSON.stringify(started)}\n\n`); const msgId = newMsgId(); const accumulatedContent: MessageObject['content'] = []; @@ -279,16 +261,14 @@ export class AgentRuntime { // If client disconnected / abort signaled, emit cancelled and return if (abortController.signal.aborted) { - const cancelledAt = nowUnix(); + const cancelled = rb.cancel(); try { - await this.store.updateRun(run.id, { status: RunStatus.Cancelled, cancelled_at: cancelledAt }); + await this.store.updateRun(run.id, { status: cancelled.status, cancelled_at: cancelled.cancelled_at }); } catch { // Ignore store update failure during abort } - runObj.status = RunStatus.Cancelled; - runObj.cancelled_at = cancelledAt; if (!res.writableEnded) { - res.write(`event: ${AgentSSEEvent.ThreadRunCancelled}\ndata: ${JSON.stringify(runObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadRunCancelled}\ndata: ${JSON.stringify(cancelled)}\n\n`); } return; } @@ -309,40 +289,33 @@ export class AgentRuntime { }; } - const completedAt = nowUnix(); + const completed = rb.complete(output, usage); await this.store.updateRun(run.id, { - status: RunStatus.Completed, + status: completed.status, output, usage, - completed_at: completedAt, + completed_at: completed.completed_at, }); await this.store.appendMessages(threadId!, [...toInputMessageObjects(input.input.messages, threadId), ...output]); // event: thread.run.completed - runObj.status = RunStatus.Completed; - runObj.completed_at = completedAt; - runObj.usage = usage; - runObj.output = output; - res.write(`event: ${AgentSSEEvent.ThreadRunCompleted}\ndata: ${JSON.stringify(runObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadRunCompleted}\ndata: ${JSON.stringify(completed)}\n\n`); } catch (err: any) { - const failedAt = nowUnix(); + const failed = rb.fail(err); try { await this.store.updateRun(run.id, { - status: RunStatus.Failed, - last_error: { code: AgentErrorCode.ExecError, message: err.message }, - failed_at: failedAt, + status: failed.status, + last_error: failed.last_error, + failed_at: failed.failed_at, }); } catch (storeErr) { console.error('[AgentController] failed to update run status after error:', storeErr); } // event: thread.run.failed - runObj.status = RunStatus.Failed; - runObj.failed_at = failedAt; - runObj.last_error = { code: AgentErrorCode.ExecError, message: err.message }; if (!res.writableEnded) { - res.write(`event: ${AgentSSEEvent.ThreadRunFailed}\ndata: ${JSON.stringify(runObj)}\n\n`); + res.write(`event: ${AgentSSEEvent.ThreadRunFailed}\ndata: ${JSON.stringify(failed)}\n\n`); } } finally { // event: done @@ -390,20 +363,14 @@ export class AgentRuntime { throw new AgentConflictError(`Cannot cancel run with status '${run.status}'`); } - const cancelledAt = nowUnix(); + const rb = RunBuilder.create(run, run.thread_id ?? ''); + const cancelled = rb.cancel(); await this.store.updateRun(runId, { - status: RunStatus.Cancelled, - cancelled_at: cancelledAt, + status: cancelled.status, + cancelled_at: cancelled.cancelled_at, }); - return { - id: run.id, - object: 'thread.run', - created_at: run.created_at, - thread_id: run.thread_id, - status: RunStatus.Cancelled, - cancelled_at: cancelledAt, - }; + return cancelled; } async destroy(): Promise { diff --git a/tegg/core/agent-runtime/src/RunBuilder.ts b/tegg/core/agent-runtime/src/RunBuilder.ts new file mode 100644 index 0000000000..f20b88236c --- /dev/null +++ b/tegg/core/agent-runtime/src/RunBuilder.ts @@ -0,0 +1,65 @@ +import type { MessageObject, RunObject } from '@eggjs/controller-decorator'; +import { RunStatus, AgentErrorCode } from '@eggjs/controller-decorator'; + +import type { RunRecord } from './AgentStore.ts'; +import { nowUnix } from './utils.ts'; + +/** + * Encapsulates RunObject state transitions. + * Each mutation method returns a snapshot of the current state. + */ +export class RunBuilder { + private readonly runObj: RunObject; + + private constructor(id: string, threadId: string, createdAt: number, metadata?: Record) { + this.runObj = { + id, + object: 'thread.run', + created_at: createdAt, + thread_id: threadId, + status: RunStatus.Queued, + metadata, + }; + } + + /** Create a RunBuilder from a store RunRecord. */ + static create(run: RunRecord, threadId: string): RunBuilder { + return new RunBuilder(run.id, threadId, run.created_at, run.metadata); + } + + /** queued → in_progress */ + start(): RunObject { + this.runObj.status = RunStatus.InProgress; + this.runObj.started_at = nowUnix(); + return this.snapshot(); + } + + /** in_progress → completed */ + complete(output: MessageObject[], usage?: RunObject['usage']): RunObject { + this.runObj.status = RunStatus.Completed; + this.runObj.completed_at = nowUnix(); + this.runObj.output = output; + this.runObj.usage = usage; + return this.snapshot(); + } + + /** in_progress → failed */ + fail(error: Error): RunObject { + this.runObj.status = RunStatus.Failed; + this.runObj.failed_at = nowUnix(); + this.runObj.last_error = { code: AgentErrorCode.ExecError, message: error.message }; + return this.snapshot(); + } + + /** in_progress/queued → cancelled */ + cancel(): RunObject { + this.runObj.status = RunStatus.Cancelled; + this.runObj.cancelled_at = nowUnix(); + return this.snapshot(); + } + + /** Return the current state snapshot. */ + snapshot(): RunObject { + return { ...this.runObj }; + } +} diff --git a/tegg/core/agent-runtime/src/index.ts b/tegg/core/agent-runtime/src/index.ts index 8fb9bf8caa..12bed2a376 100644 --- a/tegg/core/agent-runtime/src/index.ts +++ b/tegg/core/agent-runtime/src/index.ts @@ -2,6 +2,7 @@ export * from './AgentStore.ts'; export * from './errors.ts'; export * from './FileAgentStore.ts'; export * from './MessageConverter.ts'; +export * from './RunBuilder.ts'; export * from './utils.ts'; export { AgentRuntime, AGENT_RUNTIME } from './AgentRuntime.ts'; export type { AgentControllerHost } from './AgentRuntime.ts'; From 310a49343ec5856dcabdc17bb79ca70ba9af100b Mon Sep 17 00:00:00 2001 From: jerry Date: Sat, 28 Feb 2026 11:18:12 +0800 Subject: [PATCH 09/12] refactor(tegg): introduce SSEWriter interface to decouple streamRun from HTTP - Define SSEWriter interface (writeEvent, closed, end, onClose) as an abstract SSE transport layer - Implement NodeSSEWriter backed by node http.ServerResponse - Refactor AgentRuntime.streamRun to accept SSEWriter parameter instead of accessing ctx.res directly, removing ContextHandler/EGG_CONTEXT dependency from AgentRuntime - Move HTTP context handling (ctx.respond, NodeSSEWriter creation) to enhanceAgentController's streamRun stub - Use res.once('close') instead of res.on('close') for client disconnect Co-Authored-By: Claude Opus 4.6 --- tegg/core/agent-runtime/src/AgentRuntime.ts | 51 ++++++------------- tegg/core/agent-runtime/src/SSEWriter.ts | 51 +++++++++++++++++++ .../src/enhanceAgentController.ts | 23 ++++++++- tegg/core/agent-runtime/src/index.ts | 1 + 4 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 tegg/core/agent-runtime/src/SSEWriter.ts diff --git a/tegg/core/agent-runtime/src/AgentRuntime.ts b/tegg/core/agent-runtime/src/AgentRuntime.ts index 7d3f082570..dba156302d 100644 --- a/tegg/core/agent-runtime/src/AgentRuntime.ts +++ b/tegg/core/agent-runtime/src/AgentRuntime.ts @@ -8,18 +8,14 @@ import type { AgentStreamMessage, } from '@eggjs/controller-decorator'; import { RunStatus, AgentSSEEvent } from '@eggjs/controller-decorator'; -import { ContextHandler } from '@eggjs/tegg-runtime'; import type { AgentStore } from './AgentStore.ts'; import { AgentConflictError } from './errors.ts'; import { toContentBlocks, extractFromStreamMessages, toInputMessageObjects } from './MessageConverter.ts'; import { RunBuilder } from './RunBuilder.ts'; +import type { SSEWriter } from './SSEWriter.ts'; import { nowUnix, newMsgId } from './utils.ts'; -// Canonical definition in @eggjs/module-common (tegg/plugin/common). -// Re-declared here to avoid a core→plugin dependency. -const EGG_CONTEXT: symbol = Symbol.for('context#eggContext'); - export const AGENT_RUNTIME: unique symbol = Symbol('agentRuntime'); /** @@ -180,25 +176,10 @@ export class AgentRuntime { return queuedSnapshot; } - async streamRun(input: CreateRunInput): Promise { - const runtimeCtx = ContextHandler.getContext(); - if (!runtimeCtx) { - throw new Error('streamRun must be called within a request context'); - } - const ctx = runtimeCtx.get(EGG_CONTEXT); - - // Bypass Koa response handling — write SSE directly to the raw response - ctx.respond = false; - const res = ctx.res; - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - + async streamRun(input: CreateRunInput, writer: SSEWriter): Promise { // Abort execRun generator when client disconnects const abortController = new AbortController(); - res.on('close', () => abortController.abort()); + writer.onClose(() => abortController.abort()); let threadId = input.thread_id; if (!threadId) { @@ -211,12 +192,12 @@ export class AgentRuntime { const rb = RunBuilder.create(run, threadId); // event: thread.run.created - res.write(`event: ${AgentSSEEvent.ThreadRunCreated}\ndata: ${JSON.stringify(rb.snapshot())}\n\n`); + writer.writeEvent(AgentSSEEvent.ThreadRunCreated, rb.snapshot()); // event: thread.run.in_progress const started = rb.start(); await this.store.updateRun(run.id, { status: started.status, started_at: started.started_at }); - res.write(`event: ${AgentSSEEvent.ThreadRunInProgress}\ndata: ${JSON.stringify(started)}\n\n`); + writer.writeEvent(AgentSSEEvent.ThreadRunInProgress, started); const msgId = newMsgId(); const accumulatedContent: MessageObject['content'] = []; @@ -231,7 +212,7 @@ export class AgentRuntime { status: 'in_progress', content: [], }; - res.write(`event: ${AgentSSEEvent.ThreadMessageCreated}\ndata: ${JSON.stringify(msgObj)}\n\n`); + writer.writeEvent(AgentSSEEvent.ThreadMessageCreated, msgObj); let promptTokens = 0; let completionTokens = 0; @@ -250,7 +231,7 @@ export class AgentRuntime { object: 'thread.message.delta', delta: { content: contentBlocks }, }; - res.write(`event: ${AgentSSEEvent.ThreadMessageDelta}\ndata: ${JSON.stringify(delta)}\n\n`); + writer.writeEvent(AgentSSEEvent.ThreadMessageDelta, delta); } if (msg.usage) { hasUsage = true; @@ -267,8 +248,8 @@ export class AgentRuntime { } catch { // Ignore store update failure during abort } - if (!res.writableEnded) { - res.write(`event: ${AgentSSEEvent.ThreadRunCancelled}\ndata: ${JSON.stringify(cancelled)}\n\n`); + if (!writer.closed) { + writer.writeEvent(AgentSSEEvent.ThreadRunCancelled, cancelled); } return; } @@ -276,7 +257,7 @@ export class AgentRuntime { // event: thread.message.completed msgObj.status = 'completed'; msgObj.content = accumulatedContent; - res.write(`event: ${AgentSSEEvent.ThreadMessageCompleted}\ndata: ${JSON.stringify(msgObj)}\n\n`); + writer.writeEvent(AgentSSEEvent.ThreadMessageCompleted, msgObj); // Build final output const output: MessageObject[] = accumulatedContent.length > 0 ? [msgObj] : []; @@ -300,7 +281,7 @@ export class AgentRuntime { await this.store.appendMessages(threadId!, [...toInputMessageObjects(input.input.messages, threadId), ...output]); // event: thread.run.completed - res.write(`event: ${AgentSSEEvent.ThreadRunCompleted}\ndata: ${JSON.stringify(completed)}\n\n`); + writer.writeEvent(AgentSSEEvent.ThreadRunCompleted, completed); } catch (err: any) { const failed = rb.fail(err); try { @@ -314,14 +295,14 @@ export class AgentRuntime { } // event: thread.run.failed - if (!res.writableEnded) { - res.write(`event: ${AgentSSEEvent.ThreadRunFailed}\ndata: ${JSON.stringify(failed)}\n\n`); + if (!writer.closed) { + writer.writeEvent(AgentSSEEvent.ThreadRunFailed, failed); } } finally { // event: done - if (!res.writableEnded) { - res.write(`event: ${AgentSSEEvent.Done}\ndata: [DONE]\n\n`); - res.end(); + if (!writer.closed) { + writer.writeEvent(AgentSSEEvent.Done, '[DONE]'); + writer.end(); } } } diff --git a/tegg/core/agent-runtime/src/SSEWriter.ts b/tegg/core/agent-runtime/src/SSEWriter.ts new file mode 100644 index 0000000000..2c3d43ba04 --- /dev/null +++ b/tegg/core/agent-runtime/src/SSEWriter.ts @@ -0,0 +1,51 @@ +import type { ServerResponse } from 'node:http'; + +/** + * Abstract interface for writing SSE events. + * Decouples AgentRuntime from HTTP transport details. + */ +export interface SSEWriter { + /** Write an SSE event with the given name and JSON-serializable data. */ + writeEvent(event: string, data: unknown): void; + /** Whether the underlying connection has been closed. */ + readonly closed: boolean; + /** End the SSE stream. */ + end(): void; + /** Register a callback for when the client disconnects. */ + onClose(callback: () => void): void; +} + +/** + * SSEWriter implementation backed by a Node.js http.ServerResponse. + */ +export class NodeSSEWriter implements SSEWriter { + private readonly res: ServerResponse; + + constructor(res: ServerResponse) { + this.res = res; + this.res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + } + + writeEvent(event: string, data: unknown): void { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + this.res.write(`event: ${event}\ndata: ${payload}\n\n`); + } + + get closed(): boolean { + return this.res.writableEnded; + } + + end(): void { + if (!this.res.writableEnded) { + this.res.end(); + } + } + + onClose(callback: () => void): void { + this.res.once('close', callback); + } +} diff --git a/tegg/core/agent-runtime/src/enhanceAgentController.ts b/tegg/core/agent-runtime/src/enhanceAgentController.ts index 4bf5279edb..5f27988f32 100644 --- a/tegg/core/agent-runtime/src/enhanceAgentController.ts +++ b/tegg/core/agent-runtime/src/enhanceAgentController.ts @@ -1,13 +1,20 @@ import path from 'node:path'; import { AgentInfoUtil } from '@eggjs/controller-decorator'; +import type { CreateRunInput } from '@eggjs/controller-decorator'; +import { ContextHandler } from '@eggjs/tegg-runtime'; import type { EggProtoImplClass } from '@eggjs/tegg-types'; import { AgentRuntime, AGENT_RUNTIME } from './AgentRuntime.ts'; import type { AgentStore } from './AgentStore.ts'; import { FileAgentStore } from './FileAgentStore.ts'; +import { NodeSSEWriter } from './SSEWriter.ts'; -const AGENT_METHOD_NAMES = ['createThread', 'getThread', 'asyncRun', 'streamRun', 'syncRun', 'getRun', 'cancelRun']; +// Canonical definition in @eggjs/module-common (tegg/plugin/common). +// Re-declared here to avoid a core→plugin dependency. +const EGG_CONTEXT: symbol = Symbol.for('context#eggContext'); + +const AGENT_METHOD_NAMES = ['createThread', 'getThread', 'asyncRun', 'syncRun', 'getRun', 'cancelRun']; // Enhance an AgentController class with smart default implementations. // @@ -83,5 +90,19 @@ export function enhanceAgentController(clazz: EggProtoImplClass): void { }; } + // streamRun needs special handling: create SSEWriter from request context + if (stubMethods.has('streamRun')) { + clazz.prototype.streamRun = async function (input: CreateRunInput) { + const runtimeCtx = ContextHandler.getContext(); + if (!runtimeCtx) { + throw new Error('streamRun must be called within a request context'); + } + const ctx = runtimeCtx.get(EGG_CONTEXT); + ctx.respond = false; + const writer = new NodeSSEWriter(ctx.res); + return (this[AGENT_RUNTIME] as AgentRuntime).streamRun(input, writer); + }; + } + AgentInfoUtil.setEnhanced(clazz); } diff --git a/tegg/core/agent-runtime/src/index.ts b/tegg/core/agent-runtime/src/index.ts index 12bed2a376..7a8d21d8f0 100644 --- a/tegg/core/agent-runtime/src/index.ts +++ b/tegg/core/agent-runtime/src/index.ts @@ -3,6 +3,7 @@ export * from './errors.ts'; export * from './FileAgentStore.ts'; export * from './MessageConverter.ts'; export * from './RunBuilder.ts'; +export * from './SSEWriter.ts'; export * from './utils.ts'; export { AgentRuntime, AGENT_RUNTIME } from './AgentRuntime.ts'; export type { AgentControllerHost } from './AgentRuntime.ts'; From e369850f94078736dd5e4f5d76c3f94336ca0b3a Mon Sep 17 00:00:00 2001 From: jerry Date: Sat, 28 Feb 2026 11:19:32 +0800 Subject: [PATCH 10/12] refactor(tegg): inject logger into AgentRuntime via options object - Change AgentRuntime constructor to accept AgentRuntimeOptions object with host, store, and optional logger fields - Define AgentRuntimeLogger interface for logger abstraction - Replace all console.error calls with this.logger.error - Default to console when no logger is provided - Update enhanceAgentController and tests for new constructor signature Co-Authored-By: Claude Opus 4.6 --- tegg/core/agent-runtime/src/AgentRuntime.ts | 24 ++++++++++++++----- .../src/enhanceAgentController.ts | 2 +- .../agent-runtime/test/AgentRuntime.test.ts | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/tegg/core/agent-runtime/src/AgentRuntime.ts b/tegg/core/agent-runtime/src/AgentRuntime.ts index dba156302d..5fa0caccb8 100644 --- a/tegg/core/agent-runtime/src/AgentRuntime.ts +++ b/tegg/core/agent-runtime/src/AgentRuntime.ts @@ -26,6 +26,16 @@ export interface AgentControllerHost { execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator; } +export interface AgentRuntimeLogger { + error(...args: unknown[]): void; +} + +export interface AgentRuntimeOptions { + host: AgentControllerHost; + store: AgentStore; + logger?: AgentRuntimeLogger; +} + export class AgentRuntime { private static readonly TERMINAL_RUN_STATUSES = new Set([ RunStatus.Completed, @@ -37,10 +47,12 @@ export class AgentRuntime { private store: AgentStore; private runningTasks: Map; abortController: AbortController }>; private host: AgentControllerHost; + private logger: AgentRuntimeLogger; - constructor(host: AgentControllerHost, store: AgentStore) { - this.host = host; - this.store = store; + constructor(options: AgentRuntimeOptions) { + this.host = options.host; + this.store = options.store; + this.logger = options.logger ?? console; this.runningTasks = new Map(); } @@ -161,10 +173,10 @@ export class AgentRuntime { failed_at: failed.failed_at, }); } catch (storeErr) { - console.error('[AgentController] failed to update run status after error:', storeErr); + this.logger.error('[AgentController] failed to update run status after error:', storeErr); } } else { - console.error('[AgentController] execRun error during abort:', err); + this.logger.error('[AgentController] execRun error during abort:', err); } } finally { this.runningTasks.delete(run.id); @@ -291,7 +303,7 @@ export class AgentRuntime { failed_at: failed.failed_at, }); } catch (storeErr) { - console.error('[AgentController] failed to update run status after error:', storeErr); + this.logger.error('[AgentController] failed to update run status after error:', storeErr); } // event: thread.run.failed diff --git a/tegg/core/agent-runtime/src/enhanceAgentController.ts b/tegg/core/agent-runtime/src/enhanceAgentController.ts index 5f27988f32..7e43d5543a 100644 --- a/tegg/core/agent-runtime/src/enhanceAgentController.ts +++ b/tegg/core/agent-runtime/src/enhanceAgentController.ts @@ -63,7 +63,7 @@ export function enhanceAgentController(clazz: EggProtoImplClass): void { await store.init(); } - this[AGENT_RUNTIME] = new AgentRuntime(this, store); + this[AGENT_RUNTIME] = new AgentRuntime({ host: this, store }); if (originalInit) { await originalInit.call(this); diff --git a/tegg/core/agent-runtime/test/AgentRuntime.test.ts b/tegg/core/agent-runtime/test/AgentRuntime.test.ts index 05ea9ce4e6..7d80c6a1c6 100644 --- a/tegg/core/agent-runtime/test/AgentRuntime.test.ts +++ b/tegg/core/agent-runtime/test/AgentRuntime.test.ts @@ -34,7 +34,7 @@ describe('core/agent-runtime/test/AgentRuntime.test.ts', () => { }; }, } as any; - runtime = new AgentRuntime(host, store); + runtime = new AgentRuntime({ host, store }); }); afterEach(async () => { From 0b5d37063c76ec5df11c72684fe917f4ca676c10 Mon Sep 17 00:00:00 2001 From: jerry Date: Sat, 28 Feb 2026 11:24:23 +0800 Subject: [PATCH 11/12] refactor(tegg): install delegate methods per-instance instead of on prototype Move stub method replacement from prototype-level to instance-level. Previously, enhanceAgentController replaced stub methods directly on clazz.prototype, which affected all instances. Now, delegate methods are installed on each instance during init(), leaving the original prototype untouched. Also adds createAgentRuntime factory function which resolves the pre-existing TS2556 spread argument type error. Co-Authored-By: Claude Opus 4.6 --- tegg/core/agent-runtime/src/AgentRuntime.ts | 5 ++ .../src/enhanceAgentController.ts | 59 ++++++++++--------- tegg/core/agent-runtime/src/index.ts | 4 +- .../test/enhanceAgentController.test.ts | 17 ++++-- 4 files changed, 49 insertions(+), 36 deletions(-) diff --git a/tegg/core/agent-runtime/src/AgentRuntime.ts b/tegg/core/agent-runtime/src/AgentRuntime.ts index 5fa0caccb8..9fcb1047c7 100644 --- a/tegg/core/agent-runtime/src/AgentRuntime.ts +++ b/tegg/core/agent-runtime/src/AgentRuntime.ts @@ -379,3 +379,8 @@ export class AgentRuntime { } } } + +/** Factory function — avoids the spread-arg type issue with dynamic delegation. */ +export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { + return new AgentRuntime(options); +} diff --git a/tegg/core/agent-runtime/src/enhanceAgentController.ts b/tegg/core/agent-runtime/src/enhanceAgentController.ts index 7e43d5543a..490448d5c0 100644 --- a/tegg/core/agent-runtime/src/enhanceAgentController.ts +++ b/tegg/core/agent-runtime/src/enhanceAgentController.ts @@ -5,7 +5,8 @@ import type { CreateRunInput } from '@eggjs/controller-decorator'; import { ContextHandler } from '@eggjs/tegg-runtime'; import type { EggProtoImplClass } from '@eggjs/tegg-types'; -import { AgentRuntime, AGENT_RUNTIME } from './AgentRuntime.ts'; +import type { AgentRuntime } from './AgentRuntime.ts'; +import { AGENT_RUNTIME, createAgentRuntime } from './AgentRuntime.ts'; import type { AgentStore } from './AgentStore.ts'; import { FileAgentStore } from './FileAgentStore.ts'; import { NodeSSEWriter } from './SSEWriter.ts'; @@ -21,8 +22,8 @@ const AGENT_METHOD_NAMES = ['createThread', 'getThread', 'asyncRun', 'syncRun', // Called by the plugin/controller lifecycle hook AFTER the decorator has set // HTTP metadata and injected stub methods. Detects which methods are // user-defined vs stubs (via AgentInfoUtil.isNotImplemented() marker) -// and replaces stubs with AgentRuntime-delegating methods. -// Also wraps init()/destroy() to manage the AgentRuntime lifecycle. +// and installs AgentRuntime-delegating methods on each instance (not on +// the prototype) during init(). // // Prerequisites: // - The class must be marked via AgentInfoUtil.isAgentController() (otherwise this is a no-op). @@ -46,8 +47,10 @@ export function enhanceAgentController(clazz: EggProtoImplClass): void { stubMethods.add(name); } } + // Check streamRun separately (handled specially below) + const streamRunIsStub = !clazz.prototype.streamRun || AgentInfoUtil.isNotImplemented(clazz.prototype.streamRun); - // Wrap init() lifecycle to create AgentRuntime + // Wrap init() lifecycle to create AgentRuntime and install per-instance delegates const originalInit = clazz.prototype.init; clazz.prototype.init = async function () { // Allow user to provide custom store via createStore() @@ -63,7 +66,29 @@ export function enhanceAgentController(clazz: EggProtoImplClass): void { await store.init(); } - this[AGENT_RUNTIME] = new AgentRuntime({ host: this, store }); + const runtime = createAgentRuntime({ host: this, store }); + this[AGENT_RUNTIME] = runtime; + + // Install delegate methods on this instance (not on prototype) + for (const methodName of stubMethods) { + this[methodName] = (...args: unknown[]) => { + return (runtime as any)[methodName](...args); + }; + } + + // streamRun needs special handling: create SSEWriter from request context + if (streamRunIsStub) { + this.streamRun = async (input: CreateRunInput) => { + const runtimeCtx = ContextHandler.getContext(); + if (!runtimeCtx) { + throw new Error('streamRun must be called within a request context'); + } + const ctx = runtimeCtx.get(EGG_CONTEXT); + ctx.respond = false; + const writer = new NodeSSEWriter(ctx.res); + return runtime.streamRun(input, writer); + }; + } if (originalInit) { await originalInit.call(this); @@ -74,7 +99,7 @@ export function enhanceAgentController(clazz: EggProtoImplClass): void { const originalDestroy = clazz.prototype.destroy; clazz.prototype.destroy = async function () { if (this[AGENT_RUNTIME]) { - await this[AGENT_RUNTIME].destroy(); + await (this[AGENT_RUNTIME] as AgentRuntime).destroy(); } if (originalDestroy) { @@ -82,27 +107,5 @@ export function enhanceAgentController(clazz: EggProtoImplClass): void { } }; - // Replace stub methods with AgentRuntime delegation - for (const methodName of AGENT_METHOD_NAMES) { - if (!stubMethods.has(methodName)) continue; - clazz.prototype[methodName] = function (...args: unknown[]) { - return (this[AGENT_RUNTIME] as AgentRuntime)[methodName as keyof AgentRuntime](...args); - }; - } - - // streamRun needs special handling: create SSEWriter from request context - if (stubMethods.has('streamRun')) { - clazz.prototype.streamRun = async function (input: CreateRunInput) { - const runtimeCtx = ContextHandler.getContext(); - if (!runtimeCtx) { - throw new Error('streamRun must be called within a request context'); - } - const ctx = runtimeCtx.get(EGG_CONTEXT); - ctx.respond = false; - const writer = new NodeSSEWriter(ctx.res); - return (this[AGENT_RUNTIME] as AgentRuntime).streamRun(input, writer); - }; - } - AgentInfoUtil.setEnhanced(clazz); } diff --git a/tegg/core/agent-runtime/src/index.ts b/tegg/core/agent-runtime/src/index.ts index 7a8d21d8f0..6cc11ebf4d 100644 --- a/tegg/core/agent-runtime/src/index.ts +++ b/tegg/core/agent-runtime/src/index.ts @@ -5,6 +5,6 @@ export * from './MessageConverter.ts'; export * from './RunBuilder.ts'; export * from './SSEWriter.ts'; export * from './utils.ts'; -export { AgentRuntime, AGENT_RUNTIME } from './AgentRuntime.ts'; -export type { AgentControllerHost } from './AgentRuntime.ts'; +export { AgentRuntime, AGENT_RUNTIME, createAgentRuntime } from './AgentRuntime.ts'; +export type { AgentControllerHost, AgentRuntimeOptions, AgentRuntimeLogger } from './AgentRuntime.ts'; export { enhanceAgentController } from './enhanceAgentController.ts'; diff --git a/tegg/core/agent-runtime/test/enhanceAgentController.test.ts b/tegg/core/agent-runtime/test/enhanceAgentController.test.ts index 6708c9a9fd..f3bbd86131 100644 --- a/tegg/core/agent-runtime/test/enhanceAgentController.test.ts +++ b/tegg/core/agent-runtime/test/enhanceAgentController.test.ts @@ -75,19 +75,24 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { enhanceAgentController(MyAgent as any); - // Stubs should be replaced — no longer marked as not implemented - assert(!AgentInfoUtil.isNotImplemented((MyAgent.prototype as any).createThread)); - assert(!AgentInfoUtil.isNotImplemented((MyAgent.prototype as any).syncRun)); + // Prototype stubs remain untouched — delegates are set per-instance in init() + assert(AgentInfoUtil.isNotImplemented((MyAgent.prototype as any).createThread)); + assert(AgentInfoUtil.isNotImplemented((MyAgent.prototype as any).syncRun)); // init/destroy should be wrapped assert(typeof (MyAgent.prototype as any).init === 'function'); assert(typeof (MyAgent.prototype as any).destroy === 'function'); - // Actually call init to verify AgentRuntime is created + // Actually call init to verify AgentRuntime is created and delegates installed const instance = new MyAgent() as any; await instance.init(); assert(instance[AGENT_RUNTIME] instanceof AgentRuntime); + // Instance methods should be own properties (not on prototype) + assert(Object.hasOwn(instance, 'createThread')); + assert(Object.hasOwn(instance, 'syncRun')); + assert(Object.hasOwn(instance, 'streamRun')); + // createThread should work and return OpenAI format const thread = await instance.createThread(); assert(thread.id.startsWith('thread_')); @@ -129,8 +134,8 @@ describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { const result = await instance.syncRun(); assert.deepEqual(result, customResult); - // Stubs should be replaced - assert(!AgentInfoUtil.isNotImplemented((instance as any).createThread)); + // Stubs should be replaced on the instance + assert(Object.hasOwn(instance, 'createThread')); await instance.destroy(); }); From 15721d24d64947174e8acb1eae1ff724f8dd2932 Mon Sep 17 00:00:00 2001 From: jerry Date: Sun, 1 Mar 2026 17:51:35 +0800 Subject: [PATCH 12/12] refactor(tegg): address killagu PR #5812 review feedback - Replace enhanceAgentController with AgentControllerProto/AgentControllerObject pattern following ORM's SingletonModelProto/SingletonModelObject design - Eliminate all `any` usage; use Record and Reflect.apply - Inject egg logger via AgentRuntimeLogger interface instead of console - Add camelCase conversion layer: RunBuilder uses camelCase internally, converts to snake_case only at store/API boundaries - Extract consumeStreamMessages from streamRun for better readability - Use abort-first strategy in destroy() for faster graceful shutdown - Add AGENT_CONTROLLER_PROTO_IMPL_TYPE for custom proto/object creation - Delete enhanceAgentController.ts and its test (prototype patching removed) Co-Authored-By: Claude Opus 4.6 --- tegg/core/agent-runtime/src/AgentRuntime.ts | 213 +++++++------- tegg/core/agent-runtime/src/AgentStore.ts | 12 +- tegg/core/agent-runtime/src/FileAgentStore.ts | 6 +- .../agent-runtime/src/MessageConverter.ts | 37 ++- tegg/core/agent-runtime/src/RunBuilder.ts | 135 ++++++--- .../src/enhanceAgentController.ts | 111 ------- tegg/core/agent-runtime/src/index.ts | 1 - .../agent-runtime/test/AgentRuntime.test.ts | 22 +- .../test/enhanceAgentController.test.ts | 277 ------------------ .../src/decorator/agent/AgentController.ts | 11 +- .../src/model/AgentControllerTypes.ts | 53 +++- .../src/controller-decorator/MetadataKey.ts | 2 + tegg/plugin/controller/src/app.ts | 9 + .../src/lib/AgentControllerObject.ts | 258 ++++++++++++++++ .../src/lib/AgentControllerProto.ts | 105 +++++++ .../src/lib/EggControllerPrototypeHook.ts | 7 +- 16 files changed, 673 insertions(+), 586 deletions(-) delete mode 100644 tegg/core/agent-runtime/src/enhanceAgentController.ts delete mode 100644 tegg/core/agent-runtime/test/enhanceAgentController.test.ts create mode 100644 tegg/plugin/controller/src/lib/AgentControllerObject.ts create mode 100644 tegg/plugin/controller/src/lib/AgentControllerProto.ts diff --git a/tegg/core/agent-runtime/src/AgentRuntime.ts b/tegg/core/agent-runtime/src/AgentRuntime.ts index 9fcb1047c7..8d37b36542 100644 --- a/tegg/core/agent-runtime/src/AgentRuntime.ts +++ b/tegg/core/agent-runtime/src/AgentRuntime.ts @@ -5,14 +5,16 @@ import type { RunObject, MessageObject, MessageDeltaObject, + MessageContentBlock, AgentStreamMessage, } from '@eggjs/controller-decorator'; -import { RunStatus, AgentSSEEvent } from '@eggjs/controller-decorator'; +import { RunStatus, AgentSSEEvent, AgentObjectType, MessageRole, MessageStatus } from '@eggjs/controller-decorator'; import type { AgentStore } from './AgentStore.ts'; import { AgentConflictError } from './errors.ts'; import { toContentBlocks, extractFromStreamMessages, toInputMessageObjects } from './MessageConverter.ts'; import { RunBuilder } from './RunBuilder.ts'; +import type { RunUsage } from './RunBuilder.ts'; import type { SSEWriter } from './SSEWriter.ts'; import { nowUnix, newMsgId } from './utils.ts'; @@ -33,7 +35,7 @@ export interface AgentRuntimeLogger { export interface AgentRuntimeOptions { host: AgentControllerHost; store: AgentStore; - logger?: AgentRuntimeLogger; + logger: AgentRuntimeLogger; } export class AgentRuntime { @@ -52,7 +54,10 @@ export class AgentRuntime { constructor(options: AgentRuntimeOptions) { this.host = options.host; this.store = options.store; - this.logger = options.logger ?? console; + if (!options.logger) { + throw new Error('AgentRuntimeOptions.logger is required'); + } + this.logger = options.logger; this.runningTasks = new Map(); } @@ -60,7 +65,7 @@ export class AgentRuntime { const thread = await this.store.createThread(); return { id: thread.id, - object: 'thread', + object: AgentObjectType.Thread, created_at: thread.created_at, metadata: thread.metadata ?? {}, }; @@ -70,7 +75,7 @@ export class AgentRuntime { const thread = await this.store.getThread(threadId); return { id: thread.id, - object: 'thread', + object: AgentObjectType.Thread, created_at: thread.created_at, metadata: thread.metadata ?? {}, messages: thread.messages, @@ -89,8 +94,7 @@ export class AgentRuntime { const rb = RunBuilder.create(run, threadId); try { - const started = rb.start(); - await this.store.updateRun(run.id, { status: started.status, started_at: started.started_at }); + await this.store.updateRun(run.id, rb.start()); const streamMessages: AgentStreamMessage[] = []; for await (const msg of this.host.execRun(input)) { @@ -98,24 +102,13 @@ export class AgentRuntime { } const { output, usage } = extractFromStreamMessages(streamMessages, run.id); - const completed = rb.complete(output, usage); - await this.store.updateRun(run.id, { - status: completed.status, - output, - usage, - completed_at: completed.completed_at, - }); + await this.store.updateRun(run.id, rb.complete(output, usage)); await this.store.appendMessages(threadId, [...toInputMessageObjects(input.input.messages, threadId), ...output]); - return completed; - } catch (err: any) { - const failed = rb.fail(err); - await this.store.updateRun(run.id, { - status: failed.status, - last_error: failed.last_error, - failed_at: failed.failed_at, - }); + return rb.snapshot(); + } catch (err: unknown) { + await this.store.updateRun(run.id, rb.fail(err as Error)); throw err; } } @@ -138,8 +131,7 @@ export class AgentRuntime { const promise = (async () => { try { - const started = rb.start(); - await this.store.updateRun(run.id, { status: started.status, started_at: started.started_at }); + await this.store.updateRun(run.id, rb.start()); const streamMessages: AgentStreamMessage[] = []; for await (const msg of this.host.execRun(input, abortController.signal)) { @@ -151,27 +143,16 @@ export class AgentRuntime { const { output, usage } = extractFromStreamMessages(streamMessages, run.id); - const completed = rb.complete(output, usage); - await this.store.updateRun(run.id, { - status: completed.status, - output, - usage, - completed_at: completed.completed_at, - }); + await this.store.updateRun(run.id, rb.complete(output, usage)); await this.store.appendMessages(threadId!, [ ...toInputMessageObjects(input.input.messages, threadId), ...output, ]); - } catch (err: any) { + } catch (err: unknown) { if (!abortController.signal.aborted) { try { - const failed = rb.fail(err); - await this.store.updateRun(run.id, { - status: failed.status, - last_error: failed.last_error, - failed_at: failed.failed_at, - }); + await this.store.updateRun(run.id, rb.fail(err as Error)); } catch (storeErr) { this.logger.error('[AgentController] failed to update run status after error:', storeErr); } @@ -207,108 +188,65 @@ export class AgentRuntime { writer.writeEvent(AgentSSEEvent.ThreadRunCreated, rb.snapshot()); // event: thread.run.in_progress - const started = rb.start(); - await this.store.updateRun(run.id, { status: started.status, started_at: started.started_at }); - writer.writeEvent(AgentSSEEvent.ThreadRunInProgress, started); + await this.store.updateRun(run.id, rb.start()); + writer.writeEvent(AgentSSEEvent.ThreadRunInProgress, rb.snapshot()); const msgId = newMsgId(); - const accumulatedContent: MessageObject['content'] = []; // event: thread.message.created const msgObj: MessageObject = { id: msgId, - object: 'thread.message', + object: AgentObjectType.ThreadMessage, created_at: nowUnix(), run_id: run.id, - role: 'assistant', - status: 'in_progress', + role: MessageRole.Assistant, + status: MessageStatus.InProgress, content: [], }; writer.writeEvent(AgentSSEEvent.ThreadMessageCreated, msgObj); - let promptTokens = 0; - let completionTokens = 0; - let hasUsage = false; - try { - for await (const msg of this.host.execRun(input, abortController.signal)) { - if (abortController.signal.aborted) break; - if (msg.message) { - const contentBlocks = toContentBlocks(msg.message); - accumulatedContent.push(...contentBlocks); - - // event: thread.message.delta - const delta: MessageDeltaObject = { - id: msgId, - object: 'thread.message.delta', - delta: { content: contentBlocks }, - }; - writer.writeEvent(AgentSSEEvent.ThreadMessageDelta, delta); - } - if (msg.usage) { - hasUsage = true; - promptTokens += msg.usage.prompt_tokens ?? 0; - completionTokens += msg.usage.completion_tokens ?? 0; - } - } - - // If client disconnected / abort signaled, emit cancelled and return - if (abortController.signal.aborted) { - const cancelled = rb.cancel(); + const { content, usage, aborted } = await this.consumeStreamMessages( + input, + abortController.signal, + writer, + msgId, + ); + + if (aborted) { try { - await this.store.updateRun(run.id, { status: cancelled.status, cancelled_at: cancelled.cancelled_at }); + await this.store.updateRun(run.id, rb.cancel()); } catch { // Ignore store update failure during abort } if (!writer.closed) { - writer.writeEvent(AgentSSEEvent.ThreadRunCancelled, cancelled); + writer.writeEvent(AgentSSEEvent.ThreadRunCancelled, rb.snapshot()); } return; } // event: thread.message.completed - msgObj.status = 'completed'; - msgObj.content = accumulatedContent; + msgObj.status = MessageStatus.Completed; + msgObj.content = content; writer.writeEvent(AgentSSEEvent.ThreadMessageCompleted, msgObj); - // Build final output - const output: MessageObject[] = accumulatedContent.length > 0 ? [msgObj] : []; - let usage: RunObject['usage']; - if (hasUsage) { - usage = { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, - }; - } - - const completed = rb.complete(output, usage); - await this.store.updateRun(run.id, { - status: completed.status, - output, - usage, - completed_at: completed.completed_at, - }); - + // Persist and emit completion + const output: MessageObject[] = content.length > 0 ? [msgObj] : []; + await this.store.updateRun(run.id, rb.complete(output, usage)); await this.store.appendMessages(threadId!, [...toInputMessageObjects(input.input.messages, threadId), ...output]); // event: thread.run.completed - writer.writeEvent(AgentSSEEvent.ThreadRunCompleted, completed); - } catch (err: any) { - const failed = rb.fail(err); + writer.writeEvent(AgentSSEEvent.ThreadRunCompleted, rb.snapshot()); + } catch (err: unknown) { try { - await this.store.updateRun(run.id, { - status: failed.status, - last_error: failed.last_error, - failed_at: failed.failed_at, - }); + await this.store.updateRun(run.id, rb.fail(err as Error)); } catch (storeErr) { this.logger.error('[AgentController] failed to update run status after error:', storeErr); } // event: thread.run.failed if (!writer.closed) { - writer.writeEvent(AgentSSEEvent.ThreadRunFailed, failed); + writer.writeEvent(AgentSSEEvent.ThreadRunFailed, rb.snapshot()); } } finally { // event: done @@ -319,11 +257,54 @@ export class AgentRuntime { } } + /** + * Consume the execRun async generator, emitting SSE message.delta events + * for each chunk and accumulating content blocks and token usage. + */ + private async consumeStreamMessages( + input: CreateRunInput, + signal: AbortSignal, + writer: SSEWriter, + msgId: string, + ): Promise<{ content: MessageContentBlock[]; usage?: RunUsage; aborted: boolean }> { + const content: MessageContentBlock[] = []; + let promptTokens = 0; + let completionTokens = 0; + let hasUsage = false; + + for await (const msg of this.host.execRun(input, signal)) { + if (signal.aborted) break; + if (msg.message) { + const contentBlocks = toContentBlocks(msg.message); + content.push(...contentBlocks); + + // event: thread.message.delta + const delta: MessageDeltaObject = { + id: msgId, + object: AgentObjectType.ThreadMessageDelta, + delta: { content: contentBlocks }, + }; + writer.writeEvent(AgentSSEEvent.ThreadMessageDelta, delta); + } + if (msg.usage) { + hasUsage = true; + promptTokens += msg.usage.prompt_tokens ?? 0; + completionTokens += msg.usage.completion_tokens ?? 0; + } + } + + return { + content, + usage: hasUsage ? { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens } : undefined, + aborted: signal.aborted, + }; + } + async getRun(runId: string): Promise { const run = await this.store.getRun(runId); return { id: run.id, - object: 'thread.run', + object: AgentObjectType.ThreadRun, created_at: run.created_at, thread_id: run.thread_id, status: run.status, @@ -357,21 +338,25 @@ export class AgentRuntime { } const rb = RunBuilder.create(run, run.thread_id ?? ''); - const cancelled = rb.cancel(); - await this.store.updateRun(runId, { - status: cancelled.status, - cancelled_at: cancelled.cancelled_at, - }); + await this.store.updateRun(runId, rb.cancel()); - return cancelled; + return rb.snapshot(); } - async destroy(): Promise { - // Wait for in-flight background tasks + /** Wait for all in-flight background tasks to complete naturally (without aborting). */ + async waitForPendingTasks(): Promise { if (this.runningTasks.size) { const pending = Array.from(this.runningTasks.values()).map((t) => t.promise); await Promise.allSettled(pending); } + } + + async destroy(): Promise { + // Abort all in-flight background tasks, then wait for them to settle + for (const task of this.runningTasks.values()) { + task.abortController.abort(); + } + await this.waitForPendingTasks(); // Destroy store if (this.store.destroy) { diff --git a/tegg/core/agent-runtime/src/AgentStore.ts b/tegg/core/agent-runtime/src/AgentStore.ts index 69670bdd10..d30c28023b 100644 --- a/tegg/core/agent-runtime/src/AgentStore.ts +++ b/tegg/core/agent-runtime/src/AgentStore.ts @@ -1,8 +1,14 @@ -import type { InputMessage, MessageObject, AgentRunConfig, RunStatus } from '@eggjs/controller-decorator'; +import type { + InputMessage, + MessageObject, + AgentRunConfig, + RunStatus, + AgentObjectType, +} from '@eggjs/controller-decorator'; export interface ThreadRecord { id: string; - object: 'thread'; + object: typeof AgentObjectType.Thread; messages: MessageObject[]; metadata: Record; created_at: number; // Unix seconds @@ -10,7 +16,7 @@ export interface ThreadRecord { export interface RunRecord { id: string; - object: 'thread.run'; + object: typeof AgentObjectType.ThreadRun; thread_id?: string; status: RunStatus; input: InputMessage[]; diff --git a/tegg/core/agent-runtime/src/FileAgentStore.ts b/tegg/core/agent-runtime/src/FileAgentStore.ts index b78659dd04..0e6bb5b522 100644 --- a/tegg/core/agent-runtime/src/FileAgentStore.ts +++ b/tegg/core/agent-runtime/src/FileAgentStore.ts @@ -3,7 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { InputMessage, MessageObject, AgentRunConfig } from '@eggjs/controller-decorator'; -import { RunStatus } from '@eggjs/controller-decorator'; +import { RunStatus, AgentObjectType } from '@eggjs/controller-decorator'; import type { AgentStore, ThreadRecord, RunRecord } from './AgentStore.ts'; import { AgentNotFoundError } from './errors.ts'; @@ -44,7 +44,7 @@ export class FileAgentStore implements AgentStore { const threadId = `thread_${crypto.randomUUID()}`; const record: ThreadRecord = { id: threadId, - object: 'thread', + object: AgentObjectType.Thread, messages: [], metadata: metadata ?? {}, created_at: nowUnix(), @@ -79,7 +79,7 @@ export class FileAgentStore implements AgentStore { const runId = `run_${crypto.randomUUID()}`; const record: RunRecord = { id: runId, - object: 'thread.run', + object: AgentObjectType.ThreadRun, thread_id: threadId, status: RunStatus.Queued, input, diff --git a/tegg/core/agent-runtime/src/MessageConverter.ts b/tegg/core/agent-runtime/src/MessageConverter.ts index 40871984a2..f9804c8d2c 100644 --- a/tegg/core/agent-runtime/src/MessageConverter.ts +++ b/tegg/core/agent-runtime/src/MessageConverter.ts @@ -5,7 +5,9 @@ import type { AgentStreamMessage, AgentStreamMessagePayload, } from '@eggjs/controller-decorator'; +import { AgentObjectType, MessageRole, MessageStatus, ContentBlockType } from '@eggjs/controller-decorator'; +import type { RunUsage } from './RunBuilder.ts'; import { nowUnix, newMsgId } from './utils.ts'; /** @@ -15,12 +17,12 @@ export function toContentBlocks(msg: AgentStreamMessagePayload): MessageContentB if (!msg) return []; const content = msg.content; if (typeof content === 'string') { - return [{ type: 'text', text: { value: content, annotations: [] } }]; + return [{ type: ContentBlockType.Text, text: { value: content, annotations: [] } }]; } if (Array.isArray(content)) { return content - .filter((part) => part.type === 'text') - .map((part) => ({ type: 'text' as const, text: { value: part.text, annotations: [] } })); + .filter((part) => part.type === ContentBlockType.Text) + .map((part) => ({ type: ContentBlockType.Text, text: { value: part.text, annotations: [] } })); } return []; } @@ -31,24 +33,25 @@ export function toContentBlocks(msg: AgentStreamMessagePayload): MessageContentB export function toMessageObject(msg: AgentStreamMessagePayload, runId?: string): MessageObject { return { id: newMsgId(), - object: 'thread.message', + object: AgentObjectType.ThreadMessage, created_at: nowUnix(), run_id: runId, - role: 'assistant', - status: 'completed', + role: MessageRole.Assistant, + status: MessageStatus.Completed, content: toContentBlocks(msg), }; } /** * Extract MessageObjects and accumulated usage from AgentStreamMessage objects. + * Returns camelCase `RunUsage` for internal use; callers convert to snake_case at boundaries. */ export function extractFromStreamMessages( messages: AgentStreamMessage[], runId?: string, ): { output: MessageObject[]; - usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + usage?: RunUsage; } { const output: MessageObject[] = []; let promptTokens = 0; @@ -66,12 +69,12 @@ export function extractFromStreamMessages( } } - let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined; + let usage: RunUsage | undefined; if (hasUsage) { usage = { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, }; } @@ -87,17 +90,19 @@ export function toInputMessageObjects( threadId?: string, ): MessageObject[] { return messages - .filter((m): m is typeof m & { role: 'user' | 'assistant' } => m.role !== 'system') + .filter( + (m): m is typeof m & { role: Exclude } => m.role !== MessageRole.System, + ) .map((m) => ({ id: newMsgId(), - object: 'thread.message' as const, + object: AgentObjectType.ThreadMessage, created_at: nowUnix(), thread_id: threadId, role: m.role, - status: 'completed' as const, + status: MessageStatus.Completed, content: typeof m.content === 'string' - ? [{ type: 'text' as const, text: { value: m.content, annotations: [] } }] - : m.content.map((p) => ({ type: 'text' as const, text: { value: p.text, annotations: [] } })), + ? [{ type: ContentBlockType.Text, text: { value: m.content, annotations: [] } }] + : m.content.map((p) => ({ type: ContentBlockType.Text, text: { value: p.text, annotations: [] } })), })); } diff --git a/tegg/core/agent-runtime/src/RunBuilder.ts b/tegg/core/agent-runtime/src/RunBuilder.ts index f20b88236c..944f7e76d9 100644 --- a/tegg/core/agent-runtime/src/RunBuilder.ts +++ b/tegg/core/agent-runtime/src/RunBuilder.ts @@ -1,25 +1,48 @@ import type { MessageObject, RunObject } from '@eggjs/controller-decorator'; -import { RunStatus, AgentErrorCode } from '@eggjs/controller-decorator'; +import { RunStatus, AgentErrorCode, AgentObjectType } from '@eggjs/controller-decorator'; import type { RunRecord } from './AgentStore.ts'; import { nowUnix } from './utils.ts'; /** - * Encapsulates RunObject state transitions. - * Each mutation method returns a snapshot of the current state. + * Accumulated token usage in camelCase for internal use. + * Converted to snake_case at output boundaries (store / API / SSE). + */ +export interface RunUsage { + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +/** + * Encapsulates run state transitions using camelCase internally. + * + * Mutation methods (`start`, `complete`, `fail`, `cancel`) update internal + * state and return `Partial` (snake_case) for the store. + * + * `snapshot()` converts the full internal state to a snake_case `RunObject` + * suitable for API responses and SSE events. */ export class RunBuilder { - private readonly runObj: RunObject; + private readonly id: string; + private readonly threadId: string; + private readonly createdAt: number; + private readonly metadata?: Record; + + private status: RunStatus = RunStatus.Queued; + private startedAt?: number; + private completedAt?: number; + private cancelledAt?: number; + private failedAt?: number; + private lastError?: { code: string; message: string } | null; + private usage?: RunUsage; + private output?: MessageObject[]; private constructor(id: string, threadId: string, createdAt: number, metadata?: Record) { - this.runObj = { - id, - object: 'thread.run', - created_at: createdAt, - thread_id: threadId, - status: RunStatus.Queued, - metadata, - }; + this.id = id; + this.threadId = threadId; + this.createdAt = createdAt; + this.metadata = metadata; } /** Create a RunBuilder from a store RunRecord. */ @@ -27,39 +50,77 @@ export class RunBuilder { return new RunBuilder(run.id, threadId, run.created_at, run.metadata); } - /** queued → in_progress */ - start(): RunObject { - this.runObj.status = RunStatus.InProgress; - this.runObj.started_at = nowUnix(); - return this.snapshot(); + /** queued → in_progress. Returns store update (snake_case). */ + start(): Partial { + this.status = RunStatus.InProgress; + this.startedAt = nowUnix(); + return { status: this.status, started_at: this.startedAt }; } - /** in_progress → completed */ - complete(output: MessageObject[], usage?: RunObject['usage']): RunObject { - this.runObj.status = RunStatus.Completed; - this.runObj.completed_at = nowUnix(); - this.runObj.output = output; - this.runObj.usage = usage; - return this.snapshot(); + /** in_progress → completed. Returns store update (snake_case). */ + complete(output: MessageObject[], usage?: RunUsage): Partial { + this.status = RunStatus.Completed; + this.completedAt = nowUnix(); + this.output = output; + this.usage = usage; + return { + status: this.status, + output, + usage: usage + ? { + prompt_tokens: usage.promptTokens, + completion_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + } + : undefined, + completed_at: this.completedAt, + }; } - /** in_progress → failed */ - fail(error: Error): RunObject { - this.runObj.status = RunStatus.Failed; - this.runObj.failed_at = nowUnix(); - this.runObj.last_error = { code: AgentErrorCode.ExecError, message: error.message }; - return this.snapshot(); + /** in_progress → failed. Returns store update (snake_case). */ + fail(error: Error): Partial { + this.status = RunStatus.Failed; + this.failedAt = nowUnix(); + this.lastError = { code: AgentErrorCode.ExecError, message: error.message }; + return { + status: this.status, + last_error: this.lastError, + failed_at: this.failedAt, + }; } - /** in_progress/queued → cancelled */ - cancel(): RunObject { - this.runObj.status = RunStatus.Cancelled; - this.runObj.cancelled_at = nowUnix(); - return this.snapshot(); + /** in_progress/queued → cancelled. Returns store update (snake_case). */ + cancel(): Partial { + this.status = RunStatus.Cancelled; + this.cancelledAt = nowUnix(); + return { + status: this.status, + cancelled_at: this.cancelledAt, + }; } - /** Return the current state snapshot. */ + /** Convert internal camelCase state to snake_case RunObject for API / SSE. */ snapshot(): RunObject { - return { ...this.runObj }; + return { + id: this.id, + object: AgentObjectType.ThreadRun, + created_at: this.createdAt, + thread_id: this.threadId, + status: this.status, + last_error: this.lastError, + started_at: this.startedAt ?? null, + completed_at: this.completedAt ?? null, + cancelled_at: this.cancelledAt ?? null, + failed_at: this.failedAt ?? null, + usage: this.usage + ? { + prompt_tokens: this.usage.promptTokens, + completion_tokens: this.usage.completionTokens, + total_tokens: this.usage.totalTokens, + } + : null, + metadata: this.metadata, + output: this.output, + }; } } diff --git a/tegg/core/agent-runtime/src/enhanceAgentController.ts b/tegg/core/agent-runtime/src/enhanceAgentController.ts deleted file mode 100644 index 490448d5c0..0000000000 --- a/tegg/core/agent-runtime/src/enhanceAgentController.ts +++ /dev/null @@ -1,111 +0,0 @@ -import path from 'node:path'; - -import { AgentInfoUtil } from '@eggjs/controller-decorator'; -import type { CreateRunInput } from '@eggjs/controller-decorator'; -import { ContextHandler } from '@eggjs/tegg-runtime'; -import type { EggProtoImplClass } from '@eggjs/tegg-types'; - -import type { AgentRuntime } from './AgentRuntime.ts'; -import { AGENT_RUNTIME, createAgentRuntime } from './AgentRuntime.ts'; -import type { AgentStore } from './AgentStore.ts'; -import { FileAgentStore } from './FileAgentStore.ts'; -import { NodeSSEWriter } from './SSEWriter.ts'; - -// Canonical definition in @eggjs/module-common (tegg/plugin/common). -// Re-declared here to avoid a core→plugin dependency. -const EGG_CONTEXT: symbol = Symbol.for('context#eggContext'); - -const AGENT_METHOD_NAMES = ['createThread', 'getThread', 'asyncRun', 'syncRun', 'getRun', 'cancelRun']; - -// Enhance an AgentController class with smart default implementations. -// -// Called by the plugin/controller lifecycle hook AFTER the decorator has set -// HTTP metadata and injected stub methods. Detects which methods are -// user-defined vs stubs (via AgentInfoUtil.isNotImplemented() marker) -// and installs AgentRuntime-delegating methods on each instance (not on -// the prototype) during init(). -// -// Prerequisites: -// - The class must be marked via AgentInfoUtil.isAgentController() (otherwise this is a no-op). -// - Stub methods must be marked via AgentInfoUtil.isNotImplemented(). -export function enhanceAgentController(clazz: EggProtoImplClass): void { - // Only enhance classes marked by @AgentController decorator - if (!AgentInfoUtil.isAgentController(clazz)) { - return; - } - - // Guard against repeated enhancement (e.g., multiple lifecycle hook calls) - if (AgentInfoUtil.isEnhanced(clazz)) { - return; - } - - // Identify which methods are stubs vs user-defined - const stubMethods = new Set(); - for (const name of AGENT_METHOD_NAMES) { - const method = clazz.prototype[name]; - if (!method || AgentInfoUtil.isNotImplemented(method)) { - stubMethods.add(name); - } - } - // Check streamRun separately (handled specially below) - const streamRunIsStub = !clazz.prototype.streamRun || AgentInfoUtil.isNotImplemented(clazz.prototype.streamRun); - - // Wrap init() lifecycle to create AgentRuntime and install per-instance delegates - const originalInit = clazz.prototype.init; - clazz.prototype.init = async function () { - // Allow user to provide custom store via createStore() - let store: AgentStore; - if (typeof this.createStore === 'function') { - store = await this.createStore(); - } else { - const dataDir = process.env.TEGG_AGENT_DATA_DIR || path.join(process.cwd(), '.agent-data'); - store = new FileAgentStore({ dataDir }); - } - - if (store.init) { - await store.init(); - } - - const runtime = createAgentRuntime({ host: this, store }); - this[AGENT_RUNTIME] = runtime; - - // Install delegate methods on this instance (not on prototype) - for (const methodName of stubMethods) { - this[methodName] = (...args: unknown[]) => { - return (runtime as any)[methodName](...args); - }; - } - - // streamRun needs special handling: create SSEWriter from request context - if (streamRunIsStub) { - this.streamRun = async (input: CreateRunInput) => { - const runtimeCtx = ContextHandler.getContext(); - if (!runtimeCtx) { - throw new Error('streamRun must be called within a request context'); - } - const ctx = runtimeCtx.get(EGG_CONTEXT); - ctx.respond = false; - const writer = new NodeSSEWriter(ctx.res); - return runtime.streamRun(input, writer); - }; - } - - if (originalInit) { - await originalInit.call(this); - } - }; - - // Wrap destroy() lifecycle to cleanup AgentRuntime - const originalDestroy = clazz.prototype.destroy; - clazz.prototype.destroy = async function () { - if (this[AGENT_RUNTIME]) { - await (this[AGENT_RUNTIME] as AgentRuntime).destroy(); - } - - if (originalDestroy) { - await originalDestroy.call(this); - } - }; - - AgentInfoUtil.setEnhanced(clazz); -} diff --git a/tegg/core/agent-runtime/src/index.ts b/tegg/core/agent-runtime/src/index.ts index 6cc11ebf4d..5cba75b523 100644 --- a/tegg/core/agent-runtime/src/index.ts +++ b/tegg/core/agent-runtime/src/index.ts @@ -7,4 +7,3 @@ export * from './SSEWriter.ts'; export * from './utils.ts'; export { AgentRuntime, AGENT_RUNTIME, createAgentRuntime } from './AgentRuntime.ts'; export type { AgentControllerHost, AgentRuntimeOptions, AgentRuntimeLogger } from './AgentRuntime.ts'; -export { enhanceAgentController } from './enhanceAgentController.ts'; diff --git a/tegg/core/agent-runtime/test/AgentRuntime.test.ts b/tegg/core/agent-runtime/test/AgentRuntime.test.ts index 7d80c6a1c6..4d7e32aff2 100644 --- a/tegg/core/agent-runtime/test/AgentRuntime.test.ts +++ b/tegg/core/agent-runtime/test/AgentRuntime.test.ts @@ -34,7 +34,15 @@ describe('core/agent-runtime/test/AgentRuntime.test.ts', () => { }; }, } as any; - runtime = new AgentRuntime({ host, store }); + runtime = new AgentRuntime({ + host, + store, + logger: { + error() { + /* noop */ + }, + }, + }); }); afterEach(async () => { @@ -167,8 +175,8 @@ describe('core/agent-runtime/test/AgentRuntime.test.ts', () => { input: { messages: [{ role: 'user', content: 'Hi' }] }, } as any); - // Wait for background task to complete via destroy - await runtime.destroy(); + // Wait for background task to complete naturally + await runtime.waitForPendingTasks(); const run = await store.getRun(result.id); assert.equal(run.status, 'completed'); @@ -181,8 +189,8 @@ describe('core/agent-runtime/test/AgentRuntime.test.ts', () => { } as any); assert(result.thread_id); - // Wait for background task to complete via destroy - await runtime.destroy(); + // Wait for background task to complete naturally + await runtime.waitForPendingTasks(); // Verify thread was created and messages were appended const thread = await store.getThread(result.thread_id); @@ -199,8 +207,8 @@ describe('core/agent-runtime/test/AgentRuntime.test.ts', () => { } as any); assert.deepEqual(result.metadata, meta); - // Wait for background task to complete via destroy - await runtime.destroy(); + // Wait for background task to complete naturally + await runtime.waitForPendingTasks(); // Verify stored in store const run = await store.getRun(result.id); diff --git a/tegg/core/agent-runtime/test/enhanceAgentController.test.ts b/tegg/core/agent-runtime/test/enhanceAgentController.test.ts deleted file mode 100644 index f3bbd86131..0000000000 --- a/tegg/core/agent-runtime/test/enhanceAgentController.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { strict as assert } from 'node:assert'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { AgentInfoUtil } from '@eggjs/controller-decorator'; -import { describe, it, beforeEach, afterEach } from 'vitest'; - -import { AgentRuntime, AGENT_RUNTIME } from '../src/AgentRuntime.ts'; -import { enhanceAgentController } from '../src/enhanceAgentController.ts'; - -// Helper: create a stub function like the @AgentController decorator does -function createStub(hasParam: boolean): Function { - let fn; - if (hasParam) { - fn = async function (_arg: unknown) { - throw new Error('not implemented'); - }; - } else { - fn = async function () { - throw new Error('not implemented'); - }; - } - AgentInfoUtil.setNotImplemented(fn); - return fn; -} - -describe('core/agent-runtime/test/enhanceAgentController.test.ts', () => { - const dataDir = path.join(import.meta.dirname, '.enhance-test-data'); - - beforeEach(() => { - process.env.TEGG_AGENT_DATA_DIR = dataDir; - }); - - afterEach(async () => { - delete process.env.TEGG_AGENT_DATA_DIR; - await fs.rm(dataDir, { recursive: true, force: true }).catch(() => { - /* ignore */ - }); - }); - - it('should skip classes without AGENT_CONTROLLER metadata', () => { - class NoMarker { - async *execRun() { - yield { - type: 'assistant', - message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, - }; - } - } - (NoMarker.prototype as any)['syncRun'] = createStub(true); - // Should not throw — class has execRun but no AgentController marker - enhanceAgentController(NoMarker as any); - // syncRun should remain unchanged (still the stub) - assert(AgentInfoUtil.isNotImplemented((NoMarker.prototype as any).syncRun)); - }); - - it('should replace stub methods with smart defaults', async () => { - class MyAgent { - async *execRun() { - yield { - type: 'assistant', - message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, - }; - } - } - AgentInfoUtil.setIsAgentController(MyAgent as any); - // Simulate stubs set by @AgentController - (MyAgent.prototype as any)['createThread'] = createStub(false); - (MyAgent.prototype as any)['getThread'] = createStub(true); - (MyAgent.prototype as any)['syncRun'] = createStub(true); - (MyAgent.prototype as any)['asyncRun'] = createStub(true); - (MyAgent.prototype as any)['streamRun'] = createStub(true); - (MyAgent.prototype as any)['getRun'] = createStub(true); - (MyAgent.prototype as any)['cancelRun'] = createStub(true); - - enhanceAgentController(MyAgent as any); - - // Prototype stubs remain untouched — delegates are set per-instance in init() - assert(AgentInfoUtil.isNotImplemented((MyAgent.prototype as any).createThread)); - assert(AgentInfoUtil.isNotImplemented((MyAgent.prototype as any).syncRun)); - - // init/destroy should be wrapped - assert(typeof (MyAgent.prototype as any).init === 'function'); - assert(typeof (MyAgent.prototype as any).destroy === 'function'); - - // Actually call init to verify AgentRuntime is created and delegates installed - const instance = new MyAgent() as any; - await instance.init(); - assert(instance[AGENT_RUNTIME] instanceof AgentRuntime); - - // Instance methods should be own properties (not on prototype) - assert(Object.hasOwn(instance, 'createThread')); - assert(Object.hasOwn(instance, 'syncRun')); - assert(Object.hasOwn(instance, 'streamRun')); - - // createThread should work and return OpenAI format - const thread = await instance.createThread(); - assert(thread.id.startsWith('thread_')); - assert.equal(thread.object, 'thread'); - - await instance.destroy(); - }); - - it('should preserve user-defined methods (not stubs)', async () => { - const customResult = { id: 'custom', object: 'thread.run', created_at: 1, status: 'completed', output: [] }; - - class MyAgent { - async *execRun() { - yield { - type: 'assistant', - message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, - }; - } - - // User-defined syncRun — no NOT_IMPLEMENTED marker - async syncRun() { - return customResult; - } - } - AgentInfoUtil.setIsAgentController(MyAgent as any); - // All other methods are stubs - (MyAgent.prototype as any)['createThread'] = createStub(false); - (MyAgent.prototype as any)['getThread'] = createStub(true); - (MyAgent.prototype as any)['asyncRun'] = createStub(true); - (MyAgent.prototype as any)['streamRun'] = createStub(true); - (MyAgent.prototype as any)['getRun'] = createStub(true); - (MyAgent.prototype as any)['cancelRun'] = createStub(true); - - enhanceAgentController(MyAgent as any); - - // User syncRun should be preserved - const instance = new MyAgent() as any; - await instance.init(); - const result = await instance.syncRun(); - assert.deepEqual(result, customResult); - - // Stubs should be replaced on the instance - assert(Object.hasOwn(instance, 'createThread')); - - await instance.destroy(); - }); - - it('should wrap init() and call original init', async () => { - let originalInitCalled = false; - - class MyAgent { - async *execRun() { - yield { - type: 'assistant', - message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, - }; - } - - async init() { - originalInitCalled = true; - } - } - AgentInfoUtil.setIsAgentController(MyAgent as any); - (MyAgent.prototype as any)['syncRun'] = createStub(true); - - enhanceAgentController(MyAgent as any); - - const instance = new MyAgent() as any; - await instance.init(); - assert(originalInitCalled); - assert(instance[AGENT_RUNTIME] instanceof AgentRuntime); - - await instance.destroy(); - }); - - it('should wrap destroy() and call original destroy', async () => { - let originalDestroyCalled = false; - - class MyAgent { - async *execRun() { - yield { - type: 'assistant', - message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, - }; - } - - async destroy() { - originalDestroyCalled = true; - } - } - AgentInfoUtil.setIsAgentController(MyAgent as any); - (MyAgent.prototype as any)['syncRun'] = createStub(true); - - enhanceAgentController(MyAgent as any); - - const instance = new MyAgent() as any; - await instance.init(); - await instance.destroy(); - assert(originalDestroyCalled); - }); - - it('should support custom store via createStore()', async () => { - const customStore = { - createThread: async () => ({ - id: 'custom_t', - object: 'thread' as const, - messages: [], - metadata: {}, - created_at: 1, - }), - getThread: async () => ({ id: 'custom_t', object: 'thread' as const, messages: [], metadata: {}, created_at: 1 }), - appendMessages: async () => { - /* noop */ - }, - createRun: async () => ({ - id: 'custom_r', - object: 'thread.run' as const, - status: 'queued' as const, - input: [], - created_at: 1, - }), - getRun: async () => ({ - id: 'custom_r', - object: 'thread.run' as const, - status: 'queued' as const, - input: [], - created_at: 1, - }), - updateRun: async () => { - /* noop */ - }, - }; - - class MyAgent { - async createStore() { - return customStore; - } - - async *execRun() { - yield { - type: 'assistant', - message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, - }; - } - } - AgentInfoUtil.setIsAgentController(MyAgent as any); - (MyAgent.prototype as any)['syncRun'] = createStub(true); - - enhanceAgentController(MyAgent as any); - - const instance = new MyAgent() as any; - await instance.init(); - assert(instance[AGENT_RUNTIME] instanceof AgentRuntime); - - await instance.destroy(); - }); - - it('should treat missing methods the same as stubs', async () => { - class MyAgent { - async *execRun() { - yield { - type: 'assistant', - message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'hello' }] }, - }; - } - // No methods defined at all — no stubs either - } - AgentInfoUtil.setIsAgentController(MyAgent as any); - - enhanceAgentController(MyAgent as any); - - const instance = new MyAgent() as any; - await instance.init(); - - // Default createThread should be injected and return OpenAI format - const thread = await instance.createThread(); - assert(thread.id.startsWith('thread_')); - assert.equal(thread.object, 'thread'); - - await instance.destroy(); - }); -}); diff --git a/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts b/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts index b595c58226..d98f56d3ab 100644 --- a/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts +++ b/tegg/core/controller-decorator/src/decorator/agent/AgentController.ts @@ -1,7 +1,13 @@ import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator'; import { StackUtil } from '@eggjs/tegg-common-util'; import type { EggProtoImplClass } from '@eggjs/tegg-types'; -import { AccessLevel, ControllerType, HTTPMethodEnum, HTTPParamType } from '@eggjs/tegg-types'; +import { + AccessLevel, + AGENT_CONTROLLER_PROTO_IMPL_TYPE, + ControllerType, + HTTPMethodEnum, + HTTPParamType, +} from '@eggjs/tegg-types'; import { AgentInfoUtil } from '../../util/AgentInfoUtil.ts'; import { ControllerInfoUtil } from '../../util/ControllerInfoUtil.ts'; @@ -98,9 +104,10 @@ export function AgentController() { // Set the fixed base HTTP path HTTPInfoUtil.setHTTPPath('/api/v1', constructor); - // Apply SingletonProto + // Apply SingletonProto with custom proto impl type const func = SingletonProto({ accessLevel: AccessLevel.PUBLIC, + protoImplType: AGENT_CONTROLLER_PROTO_IMPL_TYPE, }); func(constructor); diff --git a/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts b/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts index c0660efe6e..7d23410e99 100644 --- a/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts +++ b/tegg/core/controller-decorator/src/model/AgentControllerTypes.ts @@ -1,13 +1,48 @@ +// ===== Object types ===== + +export const AgentObjectType = { + Thread: 'thread', + ThreadRun: 'thread.run', + ThreadMessage: 'thread.message', + ThreadMessageDelta: 'thread.message.delta', +} as const; +export type AgentObjectType = (typeof AgentObjectType)[keyof typeof AgentObjectType]; + +// ===== Message roles ===== + +export const MessageRole = { + User: 'user', + Assistant: 'assistant', + System: 'system', +} as const; +export type MessageRole = (typeof MessageRole)[keyof typeof MessageRole]; + +// ===== Message statuses ===== + +export const MessageStatus = { + InProgress: 'in_progress', + Incomplete: 'incomplete', + Completed: 'completed', +} as const; +export type MessageStatus = (typeof MessageStatus)[keyof typeof MessageStatus]; + +// ===== Content block types ===== + +export const ContentBlockType = { + Text: 'text', +} as const; +export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType]; + // ===== Input Message (what clients send in request body) ===== export interface InputMessage { - role: 'user' | 'assistant' | 'system'; + role: MessageRole; content: string | InputContentPart[]; metadata?: Record; } export interface InputContentPart { - type: 'text'; + type: ContentBlockType; text: string; } @@ -15,18 +50,18 @@ export interface InputContentPart { export interface MessageObject { id: string; // "msg_xxx" - object: 'thread.message'; + object: typeof AgentObjectType.ThreadMessage; created_at: number; // Unix seconds thread_id?: string; run_id?: string; - role: 'user' | 'assistant'; - status: 'in_progress' | 'incomplete' | 'completed'; + role: Exclude; + status: MessageStatus; content: MessageContentBlock[]; metadata?: Record; } export interface TextContentBlock { - type: 'text'; + type: ContentBlockType; text: { value: string; annotations: unknown[] }; } @@ -36,7 +71,7 @@ export type MessageContentBlock = TextContentBlock; export interface ThreadObject { id: string; // "thread_xxx" - object: 'thread'; + object: typeof AgentObjectType.Thread; created_at: number; // Unix seconds metadata: Record; } @@ -60,7 +95,7 @@ export type RunStatus = (typeof RunStatus)[keyof typeof RunStatus]; export interface RunObject { id: string; // "run_xxx" - object: 'thread.run'; + object: typeof AgentObjectType.ThreadRun; created_at: number; // Unix seconds thread_id?: string; status: RunStatus; @@ -90,7 +125,7 @@ export interface CreateRunInput { export interface MessageDeltaObject { id: string; - object: 'thread.message.delta'; + object: typeof AgentObjectType.ThreadMessageDelta; delta: { content: MessageContentBlock[] }; } diff --git a/tegg/core/types/src/controller-decorator/MetadataKey.ts b/tegg/core/types/src/controller-decorator/MetadataKey.ts index d3fedb8077..11348fa84a 100644 --- a/tegg/core/types/src/controller-decorator/MetadataKey.ts +++ b/tegg/core/types/src/controller-decorator/MetadataKey.ts @@ -42,3 +42,5 @@ export const METHOD_TIMEOUT_METADATA: symbol = Symbol.for('EggPrototype#method#t export const CONTROLLER_AGENT_CONTROLLER: symbol = Symbol.for('EggPrototype#controller#agent#isAgent'); export const CONTROLLER_AGENT_NOT_IMPLEMENTED: symbol = Symbol.for('EggPrototype#controller#agent#notImplemented'); export const CONTROLLER_AGENT_ENHANCED: symbol = Symbol.for('EggPrototype#controller#agent#enhanced'); + +export const AGENT_CONTROLLER_PROTO_IMPL_TYPE = 'AGENT_CONTROLLER_PROTO'; diff --git a/tegg/plugin/controller/src/app.ts b/tegg/plugin/controller/src/app.ts index 2990cf8142..837c46fdef 100644 --- a/tegg/plugin/controller/src/app.ts +++ b/tegg/plugin/controller/src/app.ts @@ -3,8 +3,11 @@ import assert from 'node:assert'; import { ControllerMetaBuilderFactory, ControllerType } from '@eggjs/controller-decorator'; import { GlobalGraph, type LoadUnitLifecycleContext } from '@eggjs/metadata'; import { type LoadUnitInstanceLifecycleContext, ModuleLoadUnitInstance } from '@eggjs/tegg-runtime'; +import { AGENT_CONTROLLER_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; +import { AgentControllerObject } from './lib/AgentControllerObject.ts'; +import { AgentControllerProto } from './lib/AgentControllerProto.ts'; import { AppLoadUnitControllerHook } from './lib/AppLoadUnitControllerHook.ts'; import { CONTROLLER_LOAD_UNIT, ControllerLoadUnit } from './lib/ControllerLoadUnit.ts'; import { ControllerLoadUnitHandler } from './lib/ControllerLoadUnitHandler.ts'; @@ -37,11 +40,17 @@ export default class ControllerAppBootHook implements ILifecycleBoot { this.app.controllerMetaBuilderFactory = ControllerMetaBuilderFactory; this.loadUnitHook = new AppLoadUnitControllerHook(this.controllerRegisterFactory, this.app.rootProtoManager); this.controllerPrototypeHook = new EggControllerPrototypeHook(); + this.app.eggPrototypeCreatorFactory.registerPrototypeCreator( + AGENT_CONTROLLER_PROTO_IMPL_TYPE, + AgentControllerProto.createProto, + ); + AgentControllerObject.setLogger(this.app.logger); } configWillLoad(): void { this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitHook); this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.controllerPrototypeHook); + this.app.eggObjectFactory.registerEggObjectCreateMethod(AgentControllerProto, AgentControllerObject.createObject); this.app.loaderFactory.registerLoader(CONTROLLER_LOAD_UNIT, (unitPath) => { return new EggControllerLoader(unitPath); }); diff --git a/tegg/plugin/controller/src/lib/AgentControllerObject.ts b/tegg/plugin/controller/src/lib/AgentControllerObject.ts new file mode 100644 index 0000000000..431ca5207e --- /dev/null +++ b/tegg/plugin/controller/src/lib/AgentControllerObject.ts @@ -0,0 +1,258 @@ +import path from 'node:path'; + +import { + type AgentRuntime, + type AgentControllerHost, + type AgentRuntimeLogger, + AGENT_RUNTIME, + createAgentRuntime, + FileAgentStore, + NodeSSEWriter, +} from '@eggjs/agent-runtime'; +import type { AgentStore } from '@eggjs/agent-runtime'; +import { AgentInfoUtil } from '@eggjs/controller-decorator'; +import type { CreateRunInput } from '@eggjs/controller-decorator'; +import { IdenticalUtil } from '@eggjs/lifecycle'; +import { LoadUnitFactory } from '@eggjs/metadata'; +import { EGG_CONTEXT } from '@eggjs/module-common'; +import { ContextHandler, EggContainerFactory, EggObjectLifecycleUtil, EggObjectUtil } from '@eggjs/tegg-runtime'; +import type { + EggObject, + EggObjectLifeCycleContext, + EggObjectLifecycle, + EggObjectName, + EggPrototype, + EggPrototypeName, +} from '@eggjs/tegg-types'; +import { EggObjectStatus, ObjectInitType } from '@eggjs/tegg-types'; + +import { AgentControllerProto } from './AgentControllerProto.ts'; + +/** Method names that can be delegated to AgentRuntime. */ +type AgentMethodName = 'createThread' | 'getThread' | 'asyncRun' | 'syncRun' | 'getRun' | 'cancelRun'; + +const AGENT_METHOD_NAMES: AgentMethodName[] = [ + 'createThread', + 'getThread', + 'asyncRun', + 'syncRun', + 'getRun', + 'cancelRun', +]; + +/** + * Custom EggObject for @AgentController classes. + * + * Replicates the full EggObjectImpl.initWithInjectProperty lifecycle and + * inserts AgentRuntime delegate installation between postInject and init + * hooks — exactly where the user's `init()` expects runtime to be ready. + */ +export class AgentControllerObject implements EggObject { + private static logger: AgentRuntimeLogger; + + private _obj!: object; + private status: EggObjectStatus = EggObjectStatus.PENDING; + private runtime: AgentRuntime | undefined; + + readonly id: string; + readonly name: EggPrototypeName; + readonly proto: AgentControllerProto; + + /** Inject a logger to be used by all AgentRuntime instances. */ + static setLogger(logger: AgentRuntimeLogger): void { + AgentControllerObject.logger = logger; + } + + constructor(name: EggObjectName, proto: AgentControllerProto) { + this.name = name; + this.proto = proto; + const ctx = ContextHandler.getContext(); + this.id = IdenticalUtil.createObjectId(this.proto.id, ctx?.id); + } + + get obj(): object { + return this._obj; + } + + get isReady(): boolean { + return this.status === EggObjectStatus.READY; + } + + injectProperty(name: EggObjectName, descriptor: PropertyDescriptor): void { + Reflect.defineProperty(this._obj, name, descriptor); + } + + /** + * Full lifecycle sequence mirroring EggObjectImpl.initWithInjectProperty, + * with AgentRuntime installation inserted between postInject and init. + */ + async init(ctx: EggObjectLifeCycleContext): Promise { + try { + // 1. Construct object + this._obj = this.proto.constructEggObject(); + const objLifecycleHook = this._obj as EggObjectLifecycle; + + // 2. Global preCreate hook + await EggObjectLifecycleUtil.objectPreCreate(ctx, this); + + // 3. Self postConstruct hook + const postConstructMethod = + EggObjectLifecycleUtil.getLifecycleHook('postConstruct', this.proto) ?? 'postConstruct'; + if (objLifecycleHook[postConstructMethod]) { + await objLifecycleHook[postConstructMethod](ctx, this); + } + + // 4. Self preInject hook + const preInjectMethod = EggObjectLifecycleUtil.getLifecycleHook('preInject', this.proto) ?? 'preInject'; + if (objLifecycleHook[preInjectMethod]) { + await objLifecycleHook[preInjectMethod](ctx, this); + } + + // 5. Inject dependencies + await Promise.all( + this.proto.injectObjects.map(async (injectObject) => { + const proto = injectObject.proto; + const loadUnit = LoadUnitFactory.getLoadUnitById(proto.loadUnitId); + if (!loadUnit) { + throw new Error(`can not find load unit: ${proto.loadUnitId}`); + } + if ( + this.proto.initType !== ObjectInitType.CONTEXT && + injectObject.proto.initType === ObjectInitType.CONTEXT + ) { + this.injectProperty( + injectObject.refName, + EggObjectUtil.contextEggObjectGetProperty(proto, injectObject.objName), + ); + } else { + const injectObj = await EggContainerFactory.getOrCreateEggObject(proto, injectObject.objName); + this.injectProperty(injectObject.refName, EggObjectUtil.eggObjectGetProperty(injectObj)); + } + }), + ); + + // 6. Global postCreate hook + await EggObjectLifecycleUtil.objectPostCreate(ctx, this); + + // 7. Self postInject hook + const postInjectMethod = EggObjectLifecycleUtil.getLifecycleHook('postInject', this.proto) ?? 'postInject'; + if (objLifecycleHook[postInjectMethod]) { + await objLifecycleHook[postInjectMethod](ctx, this); + } + + // === AgentRuntime installation (before user init) === + await this.installAgentRuntime(); + + // 8. Self init hook (user's init()) + const initMethod = EggObjectLifecycleUtil.getLifecycleHook('init', this.proto) ?? 'init'; + if (objLifecycleHook[initMethod]) { + await objLifecycleHook[initMethod](ctx, this); + } + + // 9. Ready + this.status = EggObjectStatus.READY; + } catch (e) { + this.status = EggObjectStatus.ERROR; + throw e; + } + } + + async destroy(ctx: EggObjectLifeCycleContext): Promise { + if (this.status === EggObjectStatus.READY) { + this.status = EggObjectStatus.DESTROYING; + + // Destroy AgentRuntime first (waits for in-flight tasks) + if (this.runtime) { + await this.runtime.destroy(); + } + + // Global preDestroy hook + await EggObjectLifecycleUtil.objectPreDestroy(ctx, this); + + // Self lifecycle hooks + const objLifecycleHook = this._obj as EggObjectLifecycle; + const preDestroyMethod = EggObjectLifecycleUtil.getLifecycleHook('preDestroy', this.proto) ?? 'preDestroy'; + if (objLifecycleHook[preDestroyMethod]) { + await objLifecycleHook[preDestroyMethod](ctx, this); + } + + const destroyMethod = EggObjectLifecycleUtil.getLifecycleHook('destroy', this.proto) ?? 'destroy'; + if (objLifecycleHook[destroyMethod]) { + await objLifecycleHook[destroyMethod](ctx, this); + } + + this.status = EggObjectStatus.DESTROYED; + } + } + + /** + * Create AgentRuntime and install delegate methods on the instance. + * Logic ported from the removed enhanceAgentController.ts. + */ + private async installAgentRuntime(): Promise { + const instance = this._obj as Record; + + // Determine which methods are stubs vs user-defined + const stubMethods = new Set(); + for (const name of AGENT_METHOD_NAMES) { + const method = instance[name]; + if (typeof method !== 'function' || AgentInfoUtil.isNotImplemented(method)) { + stubMethods.add(name); + } + } + const streamRunFn = instance['streamRun']; + const streamRunIsStub = typeof streamRunFn !== 'function' || AgentInfoUtil.isNotImplemented(streamRunFn); + + // Create store (support user-defined createStore()) + let store: AgentStore; + const createStoreFn = instance['createStore']; + if (typeof createStoreFn === 'function') { + store = (await Reflect.apply(createStoreFn, this._obj, [])) as AgentStore; + } else { + const dataDir = process.env.TEGG_AGENT_DATA_DIR || path.join(process.cwd(), '.agent-data'); + store = new FileAgentStore({ dataDir }); + } + if (store.init) { + await store.init(); + } + + // Create runtime with injected logger + const runtime = createAgentRuntime({ + host: this._obj as AgentControllerHost, + store, + logger: AgentControllerObject.logger, + }); + this.runtime = runtime; + instance[AGENT_RUNTIME] = runtime; + + // Install delegate methods for stubs (type-safe: all names are keys of AgentRuntime) + for (const methodName of stubMethods) { + const runtimeMethod = runtime[methodName].bind(runtime); + instance[methodName] = runtimeMethod; + } + + // streamRun needs special handling: create NodeSSEWriter from request context + if (streamRunIsStub) { + instance['streamRun'] = async (input: CreateRunInput): Promise => { + const runtimeCtx = ContextHandler.getContext(); + if (!runtimeCtx) { + throw new Error('streamRun must be called within a request context'); + } + const eggCtx = runtimeCtx.get(EGG_CONTEXT); + eggCtx.respond = false; + const writer = new NodeSSEWriter(eggCtx.res); + return runtime.streamRun(input, writer); + }; + } + } + + static async createObject( + name: EggObjectName, + proto: EggPrototype, + lifecycleContext: EggObjectLifeCycleContext, + ): Promise { + const obj = new AgentControllerObject(name, proto as AgentControllerProto); + await obj.init(lifecycleContext); + return obj; + } +} diff --git a/tegg/plugin/controller/src/lib/AgentControllerProto.ts b/tegg/plugin/controller/src/lib/AgentControllerProto.ts new file mode 100644 index 0000000000..1a92d26451 --- /dev/null +++ b/tegg/plugin/controller/src/lib/AgentControllerProto.ts @@ -0,0 +1,105 @@ +import { EggPrototypeCreatorFactory } from '@eggjs/metadata'; +import type { + AccessLevel, + EggPrototype, + EggPrototypeCreator, + EggPrototypeLifecycleContext, + EggPrototypeName, + InjectConstructorProto, + InjectObjectProto, + InjectType, + MetaDataKey, + ObjectInitTypeLike, + QualifierAttribute, + QualifierInfo, + QualifierValue, +} from '@eggjs/tegg-types'; +import { DEFAULT_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; + +/** + * Wraps a standard EggPrototypeImpl (created by the DEFAULT creator) to + * provide a distinct class identity so that EggObjectFactory can dispatch + * to AgentControllerObject.createObject. + * + * All EggPrototype interface members are delegated to the inner proto. + * Symbol-keyed properties (qualifier descriptors set by the runtime) are + * forwarded via a Proxy on `this`. + */ +export class AgentControllerProto implements EggPrototype { + [key: symbol]: PropertyDescriptor; + + private readonly delegate: EggPrototype; + + constructor(delegate: EggPrototype) { + this.delegate = delegate; + + // Copy symbol-keyed properties from delegate (qualifier descriptors, etc.) + for (const sym of Object.getOwnPropertySymbols(delegate)) { + const desc = Object.getOwnPropertyDescriptor(delegate, sym); + if (desc) { + Object.defineProperty(this, sym, desc); + } + } + } + + get id(): string { + return this.delegate.id; + } + get name(): EggPrototypeName { + return this.delegate.name; + } + get initType(): ObjectInitTypeLike { + return this.delegate.initType; + } + get accessLevel(): AccessLevel { + return this.delegate.accessLevel; + } + get loadUnitId(): string { + return this.delegate.loadUnitId; + } + get injectObjects(): Array { + return this.delegate.injectObjects; + } + get injectType(): InjectType | undefined { + return this.delegate.injectType; + } + get className(): string | undefined { + return this.delegate.className; + } + get multiInstanceConstructorIndex(): number | undefined { + return this.delegate.multiInstanceConstructorIndex; + } + get multiInstanceConstructorAttributes(): QualifierAttribute[] | undefined { + return this.delegate.multiInstanceConstructorAttributes; + } + + getMetaData(metadataKey: MetaDataKey): T | undefined { + return this.delegate.getMetaData(metadataKey); + } + + verifyQualifier(qualifier: QualifierInfo): boolean { + return this.delegate.verifyQualifier(qualifier); + } + + verifyQualifiers(qualifiers: QualifierInfo[]): boolean { + return this.delegate.verifyQualifiers(qualifiers); + } + + getQualifier(attribute: QualifierAttribute): QualifierValue | undefined { + return this.delegate.getQualifier(attribute); + } + + constructEggObject(...args: any): object { + return this.delegate.constructEggObject(...args); + } + + static createProto(ctx: EggPrototypeLifecycleContext): AgentControllerProto { + const defaultCreator: EggPrototypeCreator | undefined = + EggPrototypeCreatorFactory.getPrototypeCreator(DEFAULT_PROTO_IMPL_TYPE); + if (!defaultCreator) { + throw new Error(`Default prototype creator (${DEFAULT_PROTO_IMPL_TYPE}) not registered`); + } + const delegate = defaultCreator(ctx); + return new AgentControllerProto(delegate); + } +} diff --git a/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts b/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts index f0d8d104dd..e6f5743c4a 100644 --- a/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts +++ b/tegg/plugin/controller/src/lib/EggControllerPrototypeHook.ts @@ -1,14 +1,9 @@ -import { AgentInfoUtil, ControllerMetaBuilderFactory, ControllerMetadataUtil } from '@eggjs/controller-decorator'; +import { ControllerMetaBuilderFactory, ControllerMetadataUtil } from '@eggjs/controller-decorator'; import type { LifecycleHook } from '@eggjs/lifecycle'; import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/metadata'; export class EggControllerPrototypeHook implements LifecycleHook { async postCreate(ctx: EggPrototypeLifecycleContext): Promise { - // Enhance @AgentController classes with smart defaults before metadata build. - if (AgentInfoUtil.isAgentController(ctx.clazz)) { - const { enhanceAgentController } = await import('@eggjs/agent-runtime'); - enhanceAgentController(ctx.clazz); - } const metadata = ControllerMetaBuilderFactory.build(ctx.clazz); if (metadata) { ControllerMetadataUtil.setControllerMetadata(ctx.clazz, metadata);