From 119d07539116d0e3346c0ba161433f63b56460ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=97=B0=E6=98=8E?= Date: Fri, 13 Mar 2026 19:09:13 +0800 Subject: [PATCH 1/2] feat: add agent-runtime package with OSS store, AgentRuntime engine and @AgentController decorator Migrated from eggjs/egg next branch (commits ee3c309a, 1a4fb20c, 3e4afccb). - core/types/agent-runtime: type definitions for AgentStore, AgentRuntime, messages, errors - core/agent-runtime: AgentRuntime with sync/async/stream execution, OSSAgentStore, HttpSSEWriter - core/controller-decorator: @AgentController decorator with smart defaults pattern, AgentInfoUtil - core/runtime: export EggObjectUtil for plugin use - plugin/controller: AgentControllerProto + AgentControllerObject lifecycle integration - core/tegg: agent.ts facade re-exporting decorator, runtime classes and types Co-Authored-By: Claude Opus 4.6 --- core/agent-runtime/index.ts | 12 + core/agent-runtime/package.json | 49 ++ core/agent-runtime/src/AgentRuntime.ts | 456 ++++++++++++++ core/agent-runtime/src/AgentStoreUtils.ts | 17 + core/agent-runtime/src/HttpSSEWriter.ts | 55 ++ core/agent-runtime/src/MessageConverter.ts | 129 ++++ core/agent-runtime/src/OSSAgentStore.ts | 202 +++++++ .../src/OSSObjectStorageClient.ts | 92 +++ core/agent-runtime/src/RunBuilder.ts | 156 +++++ core/agent-runtime/src/SSEWriter.ts | 14 + core/agent-runtime/test/AgentRuntime.test.ts | 565 ++++++++++++++++++ core/agent-runtime/test/HttpSSEWriter.test.ts | 129 ++++ .../test/MessageConverter.test.ts | 182 ++++++ core/agent-runtime/test/OSSAgentStore.test.ts | 325 ++++++++++ .../test/OSSObjectStorageClient.test.ts | 179 ++++++ core/agent-runtime/test/RunBuilder.test.ts | 261 ++++++++ core/agent-runtime/test/helpers.ts | 36 ++ core/agent-runtime/tsconfig.json | 12 + core/agent-runtime/tsconfig.pub.json | 12 + core/controller-decorator/index.ts | 3 + .../src/decorator/agent/AgentController.ts | 146 +++++ .../src/decorator/agent/AgentHandler.ts | 23 + .../src/decorator/agent/index.ts | 2 + .../src/util/AgentInfoUtil.ts | 33 + .../test/AgentController.test.ts | 218 +++++++ .../test/fixtures/AgentFooController.ts | 23 + core/runtime/index.ts | 1 + core/tegg/agent.ts | 27 + core/tegg/package.json | 1 + core/types/agent-runtime/AgentMessage.ts | 39 ++ core/types/agent-runtime/AgentRuntime.ts | 116 ++++ core/types/agent-runtime/AgentStore.ts | 84 +++ .../agent-runtime/ObjectStorageClient.ts | 27 + core/types/agent-runtime/errors.ts | 39 ++ core/types/agent-runtime/index.ts | 5 + .../types/controller-decorator/MetadataKey.ts | 6 + core/types/index.ts | 1 + plugin/controller/app.ts | 9 + .../controller/lib/AgentControllerObject.ts | 259 ++++++++ plugin/controller/lib/AgentControllerProto.ts | 105 ++++ plugin/controller/package.json | 1 + .../test/lib/AgentControllerProto.test.ts | 189 ++++++ 42 files changed, 4240 insertions(+) create mode 100644 core/agent-runtime/index.ts create mode 100644 core/agent-runtime/package.json create mode 100644 core/agent-runtime/src/AgentRuntime.ts create mode 100644 core/agent-runtime/src/AgentStoreUtils.ts create mode 100644 core/agent-runtime/src/HttpSSEWriter.ts create mode 100644 core/agent-runtime/src/MessageConverter.ts create mode 100644 core/agent-runtime/src/OSSAgentStore.ts create mode 100644 core/agent-runtime/src/OSSObjectStorageClient.ts create mode 100644 core/agent-runtime/src/RunBuilder.ts create mode 100644 core/agent-runtime/src/SSEWriter.ts create mode 100644 core/agent-runtime/test/AgentRuntime.test.ts create mode 100644 core/agent-runtime/test/HttpSSEWriter.test.ts create mode 100644 core/agent-runtime/test/MessageConverter.test.ts create mode 100644 core/agent-runtime/test/OSSAgentStore.test.ts create mode 100644 core/agent-runtime/test/OSSObjectStorageClient.test.ts create mode 100644 core/agent-runtime/test/RunBuilder.test.ts create mode 100644 core/agent-runtime/test/helpers.ts create mode 100644 core/agent-runtime/tsconfig.json create mode 100644 core/agent-runtime/tsconfig.pub.json create mode 100644 core/controller-decorator/src/decorator/agent/AgentController.ts create mode 100644 core/controller-decorator/src/decorator/agent/AgentHandler.ts create mode 100644 core/controller-decorator/src/decorator/agent/index.ts create mode 100644 core/controller-decorator/src/util/AgentInfoUtil.ts create mode 100644 core/controller-decorator/test/AgentController.test.ts create mode 100644 core/controller-decorator/test/fixtures/AgentFooController.ts create mode 100644 core/tegg/agent.ts create mode 100644 core/types/agent-runtime/AgentMessage.ts create mode 100644 core/types/agent-runtime/AgentRuntime.ts create mode 100644 core/types/agent-runtime/AgentStore.ts create mode 100644 core/types/agent-runtime/ObjectStorageClient.ts create mode 100644 core/types/agent-runtime/errors.ts create mode 100644 core/types/agent-runtime/index.ts create mode 100644 plugin/controller/lib/AgentControllerObject.ts create mode 100644 plugin/controller/lib/AgentControllerProto.ts create mode 100644 plugin/controller/test/lib/AgentControllerProto.test.ts diff --git a/core/agent-runtime/index.ts b/core/agent-runtime/index.ts new file mode 100644 index 000000000..2a3016a40 --- /dev/null +++ b/core/agent-runtime/index.ts @@ -0,0 +1,12 @@ +// Re-export types from @eggjs/tegg-types (backward compatible) +export * from '@eggjs/tegg-types/agent-runtime'; +// Implementation code +export * from './src/OSSObjectStorageClient'; +export * from './src/OSSAgentStore'; +export * from './src/AgentStoreUtils'; +export * from './src/MessageConverter'; +export * from './src/RunBuilder'; +export * from './src/SSEWriter'; +export * from './src/HttpSSEWriter'; +export { AgentRuntime, AGENT_RUNTIME } from './src/AgentRuntime'; +export type { AgentExecutor, AgentRuntimeOptions } from './src/AgentRuntime'; diff --git a/core/agent-runtime/package.json b/core/agent-runtime/package.json new file mode 100644 index 000000000..9e6302fad --- /dev/null +++ b/core/agent-runtime/package.json @@ -0,0 +1,49 @@ +{ + "name": "@eggjs/agent-runtime", + "version": "3.72.0", + "description": "Agent runtime with store abstraction for Egg.js tegg", + "keywords": [ + "agent", + "egg", + "runtime", + "store", + "tegg" + ], + "homepage": "https://github.com/eggjs/tegg/tree/master/core/agent-runtime", + "bugs": { + "url": "https://github.com/eggjs/tegg/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/tegg.git", + "directory": "core/agent-runtime" + }, + "files": [ + "dist", + "index.js", + "index.d.ts" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "tsc": "tsc -p tsconfig.json", + "tsc:pub": "tsc -p tsconfig.pub.json", + "prepublishOnly": "npm run tsc:pub", + "test": "mocha" + }, + "dependencies": { + "@eggjs/tegg-types": "^3.72.0", + "egg-logger": "^3.0.1", + "oss-client": "^2.5.1" + }, + "devDependencies": { + "@types/mocha": "^10.0.0", + "@types/node": "^20.0.0", + "mocha": "^10.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">= 16.0.0" + } +} diff --git a/core/agent-runtime/src/AgentRuntime.ts b/core/agent-runtime/src/AgentRuntime.ts new file mode 100644 index 000000000..48d4dc90e --- /dev/null +++ b/core/agent-runtime/src/AgentRuntime.ts @@ -0,0 +1,456 @@ +import type { + CreateRunInput, + ThreadObject, + ThreadObjectWithMessages, + RunObject, + MessageObject, + MessageDeltaObject, + MessageContentBlock, + AgentStreamMessage, + AgentStore, +} from '@eggjs/tegg-types/agent-runtime'; +import { RunStatus, AgentSSEEvent, AgentObjectType } from '@eggjs/tegg-types/agent-runtime'; +import { AgentConflictError } from '@eggjs/tegg-types/agent-runtime'; +import type { EggLogger } from 'egg-logger'; + +import { newMsgId } from './AgentStoreUtils'; +import { MessageConverter } from './MessageConverter'; +import { RunBuilder } from './RunBuilder'; +import type { RunUsage } from './RunBuilder'; +import type { SSEWriter } from './SSEWriter'; + +export const AGENT_RUNTIME: unique symbol = Symbol('agentRuntime'); + +/** + * The executor interface — only requires execRun so the runtime can delegate + * execution back through the controller's prototype chain (AOP/mock friendly). + */ +export interface AgentExecutor { + execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator; +} + +export interface AgentRuntimeOptions { + executor: AgentExecutor; + store: AgentStore; + logger: EggLogger; +} + +export class AgentRuntime { + private static readonly TERMINAL_RUN_STATUSES = new Set([ + RunStatus.Completed, + RunStatus.Failed, + RunStatus.Cancelled, + RunStatus.Expired, + ]); + + private store: AgentStore; + private runningTasks: Map; abortController: AbortController }>; + private executor: AgentExecutor; + private logger: EggLogger; + + constructor(options: AgentRuntimeOptions) { + this.executor = options.executor; + this.store = options.store; + if (!options.logger) { + throw new Error('AgentRuntimeOptions.logger is required'); + } + this.logger = options.logger; + this.runningTasks = new Map(); + } + + async createThread(): Promise { + const thread = await this.store.createThread(); + return { + id: thread.id, + object: AgentObjectType.Thread, + createdAt: thread.createdAt, + metadata: thread.metadata ?? {}, + }; + } + + async getThread(threadId: string): Promise { + const thread = await this.store.getThread(threadId); + return { + id: thread.id, + object: AgentObjectType.Thread, + createdAt: thread.createdAt, + metadata: thread.metadata ?? {}, + messages: thread.messages, + }; + } + + private async ensureThread(input: CreateRunInput): Promise<{ threadId: string; input: CreateRunInput }> { + if (input.threadId) { + return { threadId: input.threadId, input }; + } + const thread = await this.store.createThread(); + return { threadId: thread.id, input: { ...input, threadId: thread.id } }; + } + + async syncRun(input: CreateRunInput, signal?: AbortSignal): Promise { + const { threadId, input: resolvedInput } = await this.ensureThread(input); + input = resolvedInput; + + const run = await this.store.createRun(input.input.messages, threadId, input.config, input.metadata); + const rb = RunBuilder.create(run, threadId); + + // Bridge external signal to an internal AbortController so cancelRun can abort syncRun + const abortController = new AbortController(); + if (signal) { + if (signal.aborted) { + abortController.abort(); + } else { + signal.addEventListener('abort', () => abortController.abort(), { once: true }); + } + } + + // Register in runningTasks so cancelRun can find and await this run. + // Use a real pending promise (not Promise.resolve()) so cancelRun's + // `await task.promise` blocks until syncRun's try/finally completes. + let resolveTask!: () => void; + const taskPromise = new Promise((r) => { + resolveTask = r; + }); + this.runningTasks.set(run.id, { promise: taskPromise, abortController }); + + try { + await this.store.updateRun(run.id, rb.start()); + + const streamMessages: AgentStreamMessage[] = []; + for await (const msg of this.executor.execRun(input, abortController.signal)) { + if (abortController.signal.aborted) { + // Run was cancelled externally — re-read store for the latest state + const latest = await this.store.getRun(run.id); + return RunBuilder.fromRecord(latest).snapshot(); + } + streamMessages.push(msg); + } + + const { output, usage } = MessageConverter.extractFromStreamMessages(streamMessages, run.id); + + // Append messages first so that if updateRun fails the run stays in_progress + // and can be retried, rather than showing completed with missing thread history. + // TODO(atomicity): for full consistency, add an aggregate store method + // (e.g. completeRunWithMessages) that wraps both writes in a single transaction. + await this.store.appendMessages(threadId, [ + ...MessageConverter.toInputMessageObjects(input.input.messages, threadId), + ...output, + ]); + + await this.store.updateRun(run.id, rb.complete(output, usage)); + + return rb.snapshot(); + } catch (err: unknown) { + if (abortController.signal.aborted) { + // Cancelled — re-read store for the latest state + const latest = await this.store.getRun(run.id); + return RunBuilder.fromRecord(latest).snapshot(); + } + try { + await this.store.updateRun(run.id, rb.fail(err as Error)); + } catch (storeErr) { + this.logger.error('[AgentRuntime] failed to update run status after syncRun error:', storeErr); + } + throw err; + } finally { + resolveTask(); + this.runningTasks.delete(run.id); + } + } + + async asyncRun(input: CreateRunInput): Promise { + const { threadId, input: resolvedInput } = await this.ensureThread(input); + input = resolvedInput; + + 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(); + + // Register in runningTasks before the IIFE starts executing to avoid a race + // where the IIFE's finally block deletes the entry before it is set. + let resolveTask!: () => void; + const taskPromise = new Promise((r) => { + resolveTask = r; + }); + this.runningTasks.set(run.id, { promise: taskPromise, abortController }); + + (async () => { + try { + await this.store.updateRun(run.id, rb.start()); + + const streamMessages: AgentStreamMessage[] = []; + for await (const msg of this.executor.execRun(input, abortController.signal)) { + if (abortController.signal.aborted) return; + streamMessages.push(msg); + } + + // Check if another worker has cancelled this run before writing final state + const currentRun = await this.store.getRun(run.id); + if (currentRun.status === RunStatus.Cancelling || currentRun.status === RunStatus.Cancelled) { + return; + } + + const { output, usage } = MessageConverter.extractFromStreamMessages(streamMessages, run.id); + + // Append messages before marking run as completed — see syncRun comment. + // TODO(atomicity): add aggregate store method for full transactional guarantee. + await this.store.appendMessages(threadId, [ + ...MessageConverter.toInputMessageObjects(input.input.messages, threadId), + ...output, + ]); + + await this.store.updateRun(run.id, rb.complete(output, usage)); + } catch (err: unknown) { + if (!abortController.signal.aborted) { + // Check store before writing failed state — another worker may have cancelled + try { + const currentRun = await this.store.getRun(run.id); + if (currentRun.status !== RunStatus.Cancelling && currentRun.status !== RunStatus.Cancelled) { + await this.store.updateRun(run.id, rb.fail(err as Error)); + } + } catch (storeErr) { + // TODO: need a background expiry mechanism to clean up runs stuck in non-terminal states + // (e.g. in_progress or cancelling) when store writes fail persistently. + this.logger.error('[AgentRuntime] failed to update run status after error:', storeErr); + } + } else { + this.logger.error('[AgentRuntime] execRun error during abort:', err); + } + } finally { + resolveTask(); + this.runningTasks.delete(run.id); + } + })(); + + return queuedSnapshot; + } + + async streamRun(input: CreateRunInput, writer: SSEWriter): Promise { + // Abort execRun generator when client disconnects + const abortController = new AbortController(); + writer.onClose(() => abortController.abort()); + + const { threadId, input: resolvedInput } = await this.ensureThread(input); + input = resolvedInput; + + const run = await this.store.createRun(input.input.messages, threadId, input.config, input.metadata); + const rb = RunBuilder.create(run, threadId); + + // Register in runningTasks so cancelRun/destroy can manage streaming runs. + let resolveTask!: () => void; + const taskPromise = new Promise((r) => { + resolveTask = r; + }); + this.runningTasks.set(run.id, { promise: taskPromise, abortController }); + + // event: thread.run.created + writer.writeEvent(AgentSSEEvent.ThreadRunCreated, rb.snapshot()); + + // event: thread.run.in_progress + await this.store.updateRun(run.id, rb.start()); + writer.writeEvent(AgentSSEEvent.ThreadRunInProgress, rb.snapshot()); + + const msgId = newMsgId(); + + // event: thread.message.created + const msgObj = MessageConverter.createStreamMessage(msgId, run.id); + writer.writeEvent(AgentSSEEvent.ThreadMessageCreated, msgObj); + + try { + const { content, usage, aborted } = await this.consumeStreamMessages( + input, + abortController.signal, + writer, + msgId, + ); + + if (aborted) { + // Skip intermediate cancelling store write — no external observer between the + // two states since the SSE client has already disconnected. + rb.cancelling(); + try { + await this.store.updateRun(run.id, rb.cancel()); + } catch (storeErr) { + this.logger.error('[AgentRuntime] failed to write cancelled status during stream abort:', storeErr); + } + if (!writer.closed) { + writer.writeEvent(AgentSSEEvent.ThreadRunCancelled, rb.snapshot()); + } + return; + } + + // event: thread.message.completed + const completedMsg = MessageConverter.completeMessage(msgObj, content); + writer.writeEvent(AgentSSEEvent.ThreadMessageCompleted, completedMsg); + + // Persist and emit completion — append messages before marking run as completed + // so a failure leaves the run in_progress (retryable) instead of completed-but-incomplete. + // TODO(atomicity): add aggregate store method for full transactional guarantee. + const output: MessageObject[] = content.length > 0 ? [completedMsg] : []; + await this.store.appendMessages(threadId, [ + ...MessageConverter.toInputMessageObjects(input.input.messages, threadId), + ...output, + ]); + await this.store.updateRun(run.id, rb.complete(output, usage)); + + // event: thread.run.completed + writer.writeEvent(AgentSSEEvent.ThreadRunCompleted, rb.snapshot()); + } catch (err: unknown) { + if (abortController.signal.aborted) { + // Client disconnected or cancelRun fired — mark as cancelled, not failed + rb.cancelling(); + try { + await this.store.updateRun(run.id, rb.cancel()); + } catch (storeErr) { + this.logger.error('[AgentRuntime] failed to write cancelled status during stream error:', storeErr); + } + if (!writer.closed) { + writer.writeEvent(AgentSSEEvent.ThreadRunCancelled, rb.snapshot()); + } + } else { + try { + await this.store.updateRun(run.id, rb.fail(err as Error)); + } catch (storeErr) { + this.logger.error('[AgentRuntime] failed to update run status after error:', storeErr); + } + + // event: thread.run.failed + if (!writer.closed) { + writer.writeEvent(AgentSSEEvent.ThreadRunFailed, rb.snapshot()); + } + } + } finally { + resolveTask(); + this.runningTasks.delete(run.id); + + // event: done + if (!writer.closed) { + writer.writeEvent(AgentSSEEvent.Done, '[DONE]'); + writer.end(); + } + } + } + + /** + * 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.executor.execRun(input, signal)) { + if (signal.aborted) { + return { content, usage: undefined, aborted: true as const }; + } + if (msg.message) { + const contentBlocks = MessageConverter.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.promptTokens ?? 0; + completionTokens += msg.usage.completionTokens ?? 0; + } + } + + return { + content, + usage: hasUsage ? { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens } : undefined, + aborted: false as const, + }; + } + + async getRun(runId: string): Promise { + const run = await this.store.getRun(runId); + return RunBuilder.fromRecord(run).snapshot(); + } + + async cancelRun(runId: string): Promise { + // 1. Check current status — reject if already terminal + const run = await this.store.getRun(runId); + if (AgentRuntime.TERMINAL_RUN_STATUSES.has(run.status)) { + throw new AgentConflictError(`Cannot cancel run with status '${run.status}'`); + } + + const rb = RunBuilder.fromRecord(run); + + // 2. Write "cancelling" to store first — visible to all workers + await this.store.updateRun(runId, rb.cancelling()); + + // 3. If the task is running locally, abort it for immediate effect + const task = this.runningTasks.get(runId); + if (task) { + task.abortController.abort(); + await task.promise.catch(() => { + /* ignore */ + }); + } + + // 4. Re-read store to mitigate TOCTOU: if the run completed/failed between + // steps 2 and 4, do not overwrite the terminal state. + // TODO: For full atomicity, use CAS / ETag-based conditional writes. + const freshRun = await this.store.getRun(runId); + if (AgentRuntime.TERMINAL_RUN_STATUSES.has(freshRun.status)) { + // Run reached a terminal state while we were cancelling — return as-is + return RunBuilder.fromRecord(freshRun).snapshot(); + } + + // 5. Transition to final "cancelled" state + try { + await this.store.updateRun(runId, rb.cancel()); + } catch (err) { + this.logger.error('[AgentRuntime] failed to write cancelled state after cancelling:', err); + // Return best-effort snapshot from store + const fallback = await this.store.getRun(runId); + return RunBuilder.fromRecord(fallback).snapshot(); + } + + return rb.snapshot(); + } + + /** 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) { + await this.store.destroy(); + } + } + + /** Factory method — avoids the spread-arg type issue with dynamic delegation. */ + static create(options: AgentRuntimeOptions): AgentRuntime { + return new AgentRuntime(options); + } +} diff --git a/core/agent-runtime/src/AgentStoreUtils.ts b/core/agent-runtime/src/AgentStoreUtils.ts new file mode 100644 index 000000000..775219b7f --- /dev/null +++ b/core/agent-runtime/src/AgentStoreUtils.ts @@ -0,0 +1,17 @@ +import crypto from 'node:crypto'; + +export function nowUnix(): number { + return Math.floor(Date.now() / 1000); +} + +export function newMsgId(): string { + return `msg_${crypto.randomUUID()}`; +} + +export function newThreadId(): string { + return `thread_${crypto.randomUUID()}`; +} + +export function newRunId(): string { + return `run_${crypto.randomUUID()}`; +} diff --git a/core/agent-runtime/src/HttpSSEWriter.ts b/core/agent-runtime/src/HttpSSEWriter.ts new file mode 100644 index 000000000..8becd9d72 --- /dev/null +++ b/core/agent-runtime/src/HttpSSEWriter.ts @@ -0,0 +1,55 @@ +import type { ServerResponse } from 'node:http'; + +import type { SSEWriter } from './SSEWriter'; + +export class HttpSSEWriter implements SSEWriter { + private res: ServerResponse; + private _closed = false; + private closeCallbacks: Array<() => void> = []; + private headersSent = false; + private readonly onResClose: () => void; + + constructor(res: ServerResponse) { + this.res = res; + this.onResClose = () => { + this._closed = true; + for (const cb of this.closeCallbacks) cb(); + this.closeCallbacks.length = 0; + }; + res.on('close', this.onResClose); + } + + /** Lazily write headers on first event — avoids sending corrupt headers if constructor throws. */ + private ensureHeaders(): void { + if (this.headersSent) return; + this.headersSent = true; + this.res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }); + } + + writeEvent(event: string, data: unknown): void { + if (this._closed) return; + this.ensureHeaders(); + this.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } + + get closed(): boolean { + return this._closed; + } + + end(): void { + if (!this._closed) { + this._closed = true; + this.res.off('close', this.onResClose); + this.closeCallbacks.length = 0; + this.res.end(); + } + } + + onClose(callback: () => void): void { + this.closeCallbacks.push(callback); + } +} diff --git a/core/agent-runtime/src/MessageConverter.ts b/core/agent-runtime/src/MessageConverter.ts new file mode 100644 index 000000000..c74c5a801 --- /dev/null +++ b/core/agent-runtime/src/MessageConverter.ts @@ -0,0 +1,129 @@ +import type { + CreateRunInput, + MessageObject, + MessageContentBlock, + AgentStreamMessage, + AgentStreamMessagePayload, +} from '@eggjs/tegg-types/agent-runtime'; +import { AgentObjectType, MessageRole, MessageStatus, ContentBlockType } from '@eggjs/tegg-types/agent-runtime'; + +import { nowUnix, newMsgId } from './AgentStoreUtils'; +import type { RunUsage } from './RunBuilder'; + +export class MessageConverter { + /** + * Convert an AgentStreamMessage's message payload into OpenAI MessageContentBlock[]. + */ + static toContentBlocks(msg: AgentStreamMessagePayload): MessageContentBlock[] { + if (!msg) return []; + const content = msg.content; + if (typeof content === 'string') { + return [{ type: ContentBlockType.Text, text: { value: content, annotations: [] } }]; + } + if (Array.isArray(content)) { + return content + .filter((part) => part.type === ContentBlockType.Text) + .map((part) => ({ type: ContentBlockType.Text, text: { value: part.text, annotations: [] } })); + } + return []; + } + + /** + * Build a completed MessageObject from an AgentStreamMessage payload. + */ + static toMessageObject(msg: AgentStreamMessagePayload, runId?: string): MessageObject { + return { + id: newMsgId(), + object: AgentObjectType.ThreadMessage, + createdAt: nowUnix(), + runId, + role: MessageRole.Assistant, + status: MessageStatus.Completed, + content: MessageConverter.toContentBlocks(msg), + }; + } + + /** + * Extract MessageObjects and accumulated usage from AgentStreamMessage objects. + */ + static extractFromStreamMessages( + messages: AgentStreamMessage[], + runId?: string, + ): { + output: MessageObject[]; + usage?: RunUsage; + } { + const output: MessageObject[] = []; + let promptTokens = 0; + let completionTokens = 0; + let hasUsage = false; + + for (const msg of messages) { + if (msg.message) { + output.push(MessageConverter.toMessageObject(msg.message, runId)); + } + if (msg.usage) { + hasUsage = true; + promptTokens += msg.usage.promptTokens ?? 0; + completionTokens += msg.usage.completionTokens ?? 0; + } + } + + let usage: RunUsage | undefined; + if (hasUsage) { + usage = { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + }; + } + + return { output, usage }; + } + + /** + * Produce a completed copy of a streaming MessageObject with final content. + */ + static completeMessage(msg: MessageObject, content: MessageContentBlock[]): MessageObject { + return { ...msg, status: MessageStatus.Completed, content }; + } + + /** + * Create an in-progress MessageObject for streaming (before content is known). + */ + static createStreamMessage(msgId: string, runId: string): MessageObject { + return { + id: msgId, + object: AgentObjectType.ThreadMessage, + createdAt: nowUnix(), + runId, + role: MessageRole.Assistant, + status: MessageStatus.InProgress, + content: [], + }; + } + + /** + * Convert input messages to MessageObjects for thread history. + * System messages are filtered out — they are transient instructions, not conversation history. + */ + static toInputMessageObjects(messages: CreateRunInput['input']['messages'], threadId?: string): MessageObject[] { + return messages + .filter( + (m): m is typeof m & { role: Exclude } => + m.role !== MessageRole.System, + ) + .map((m) => ({ + id: newMsgId(), + object: AgentObjectType.ThreadMessage, + createdAt: nowUnix(), + threadId, + role: m.role, + status: MessageStatus.Completed, + content: + typeof m.content === 'string' + ? [{ type: ContentBlockType.Text, text: { value: m.content, annotations: [] } }] + : m.content.map((p) => ({ type: ContentBlockType.Text, text: { value: p.text, annotations: [] } })), + })); + } +} diff --git a/core/agent-runtime/src/OSSAgentStore.ts b/core/agent-runtime/src/OSSAgentStore.ts new file mode 100644 index 000000000..a3d6768cf --- /dev/null +++ b/core/agent-runtime/src/OSSAgentStore.ts @@ -0,0 +1,202 @@ +import type { + AgentRunConfig, + AgentStore, + InputMessage, + MessageObject, + RunRecord, + ThreadRecord, +} from '@eggjs/tegg-types/agent-runtime'; +import { AgentObjectType, RunStatus } from '@eggjs/tegg-types/agent-runtime'; +import { AgentNotFoundError } from '@eggjs/tegg-types/agent-runtime'; +import type { ObjectStorageClient } from '@eggjs/tegg-types/agent-runtime'; + +import { nowUnix, newThreadId, newRunId } from './AgentStoreUtils'; + +export interface OSSAgentStoreOptions { + client: ObjectStorageClient; + prefix?: string; +} + +/** + * Thread metadata stored as a JSON object (excludes messages). + * Messages are stored separately in a JSONL file for append-friendly writes. + */ +type ThreadMetadata = Omit; + +/** + * AgentStore implementation backed by an ObjectStorageClient (OSS, S3, etc.). + * + * ## Storage layout + * + * ``` + * {prefix}threads/{id}/meta.json — Thread metadata (JSON) + * {prefix}threads/{id}/messages.jsonl — Messages (JSONL, one JSON object per line) + * {prefix}runs/{id}.json — Run record (JSON) + * ``` + * + * ### Why split threads into two keys? + * + * Thread messages are append-only: new messages are added at the end but never + * modified or deleted. Storing them as a JSONL file allows us to leverage the + * OSS AppendObject API (or similar) to write new messages without reading the + * entire thread first. This is much more efficient than read-modify-write for + * long conversations. + * + * If the underlying ObjectStorageClient provides an `append()` method, it will + * be used for O(1) message writes. Otherwise, the store falls back to + * get-concat-put (which is NOT atomic and may lose data under concurrent + * writers — acceptable for single-writer scenarios). + * + * ### Atomicity note + * + * Run updates still use read-modify-write because run fields are mutated + * (status, timestamps, output, etc.) — they cannot be modelled as append-only. + * For multi-writer safety, consider a database-backed AgentStore or ETag-based + * conditional writes with retry. + */ +export class OSSAgentStore implements AgentStore { + private readonly client: ObjectStorageClient; + private readonly prefix: string; + + constructor(options: OSSAgentStoreOptions) { + this.client = options.client; + // Normalize: ensure non-empty prefix ends with '/' + const raw = options.prefix ?? ''; + this.prefix = raw && !raw.endsWith('/') ? raw + '/' : raw; + } + + // ── Key helpers ────────────────────────────────────────────────────── + + /** Key for thread metadata (JSON). */ + private threadMetaKey(threadId: string): string { + return `${this.prefix}threads/${threadId}/meta.json`; + } + + /** Key for thread messages (JSONL, one message per line). */ + private threadMessagesKey(threadId: string): string { + return `${this.prefix}threads/${threadId}/messages.jsonl`; + } + + /** Key for run record (JSON). */ + private runKey(runId: string): string { + return `${this.prefix}runs/${runId}.json`; + } + + // ── Lifecycle ──────────────────────────────────────────────────────── + + async init(): Promise { + await this.client.init?.(); + } + + async destroy(): Promise { + await this.client.destroy?.(); + } + + // ── Thread operations ──────────────────────────────────────────────── + + async createThread(metadata?: Record): Promise { + const threadId = newThreadId(); + const meta: ThreadMetadata = { + id: threadId, + object: AgentObjectType.Thread, + metadata: metadata ?? {}, + createdAt: nowUnix(), + }; + await this.client.put(this.threadMetaKey(threadId), JSON.stringify(meta)); + // Messages file is created lazily on first appendMessages call. + return { ...meta, messages: [] }; + } + + async getThread(threadId: string): Promise { + const [metaData, messagesData] = await Promise.all([ + this.client.get(this.threadMetaKey(threadId)), + this.client.get(this.threadMessagesKey(threadId)), + ]); + if (!metaData) { + throw new AgentNotFoundError(`Thread ${threadId} not found`); + } + const meta = JSON.parse(metaData) as ThreadMetadata; + + // Parse messages JSONL — may not exist yet if no messages were appended. + const messages: MessageObject[] = messagesData + ? messagesData + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as MessageObject) + : []; + + return { ...meta, messages }; + } + + /** + * Append messages to a thread. + * + * Each message is serialized as a single JSON line (JSONL format). + * When the underlying client supports `append()`, this is a single + * O(1) write — no need to read the existing messages first. + */ + async appendMessages(threadId: string, messages: MessageObject[]): Promise { + // Verify the thread exists before writing messages (or returning early), + // so callers always get AgentNotFoundError for invalid threadIds. + const metaData = await this.client.get(this.threadMetaKey(threadId)); + if (!metaData) { + throw new AgentNotFoundError(`Thread ${threadId} not found`); + } + if (messages.length === 0) return; + + const lines = messages.map((m) => JSON.stringify(m)).join('\n') + '\n'; + const messagesKey = this.threadMessagesKey(threadId); + + if (this.client.append) { + // Fast path: use the native append API (e.g., OSS AppendObject). + await this.client.append(messagesKey, lines); + } else { + // Slow path: read-modify-write fallback. + // NOTE: Not atomic — concurrent appends may lose data. + const existing = (await this.client.get(messagesKey)) ?? ''; + await this.client.put(messagesKey, existing + lines); + } + } + + // ── Run operations ─────────────────────────────────────────────────── + + async createRun( + input: InputMessage[], + threadId?: string, + config?: AgentRunConfig, + metadata?: Record, + ): Promise { + const runId = newRunId(); + const record: RunRecord = { + id: runId, + object: AgentObjectType.ThreadRun, + threadId, + status: RunStatus.Queued, + input, + config, + metadata, + createdAt: nowUnix(), + }; + await this.client.put(this.runKey(runId), JSON.stringify(record)); + return record; + } + + async getRun(runId: string): Promise { + const data = await this.client.get(this.runKey(runId)); + if (!data) { + throw new AgentNotFoundError(`Run ${runId} not found`); + } + return JSON.parse(data) as RunRecord; + } + + // TODO: read-modify-write is NOT atomic. Concurrent updates may lose data. + // Acceptable for single-writer scenarios; for multi-writer, consider ETag-based + // conditional writes with retry, or use a database-backed AgentStore instead. + async updateRun(runId: string, updates: Partial): Promise { + const run = await this.getRun(runId); + const { id: _, object: __, ...safeUpdates } = updates; + Object.assign(run, safeUpdates); + await this.client.put(this.runKey(runId), JSON.stringify(run)); + } +} diff --git a/core/agent-runtime/src/OSSObjectStorageClient.ts b/core/agent-runtime/src/OSSObjectStorageClient.ts new file mode 100644 index 000000000..4dfbf5082 --- /dev/null +++ b/core/agent-runtime/src/OSSObjectStorageClient.ts @@ -0,0 +1,92 @@ +import type { ObjectStorageClient } from '@eggjs/tegg-types/agent-runtime'; +import type { OSSObject } from 'oss-client'; + +function isOSSError(err: unknown, code: string): boolean { + return err != null && typeof err === 'object' && 'code' in err && (err as { code: unknown }).code === code; +} + +/** + * ObjectStorageClient backed by Alibaba Cloud OSS (via oss-client). + * + * Supports both `put`/`get` for normal objects and `append` for + * OSS Appendable Objects. The append path uses a local position cache + * to avoid extra HEAD requests; on position mismatch it falls back to + * HEAD + retry automatically. + * + * The OSSObject instance should be constructed and injected by the caller, + * following the IoC/DI principle. + */ +export class OSSObjectStorageClient implements ObjectStorageClient { + private readonly client: OSSObject; + + /** + * In-memory cache of next-append positions. + * + * After each successful `append()`, OSS returns `nextAppendPosition`. + * We cache it here so the next append can skip a HEAD round-trip. + * If the cached position is stale (e.g., process restarted or another + * writer appended), the append will fail with PositionNotEqualToLength + * and we fall back to HEAD + retry. + */ + private readonly appendPositions = new Map(); + + constructor(client: OSSObject) { + this.client = client; + } + + async put(key: string, value: string): Promise { + await this.client.put(key, Buffer.from(value, 'utf-8')); + } + + async get(key: string): Promise { + try { + const result = await this.client.get(key); + if (result.content) { + return Buffer.isBuffer(result.content) ? result.content.toString('utf-8') : String(result.content); + } + return null; + } catch (err: unknown) { + if (isOSSError(err, 'NoSuchKey')) { + return null; + } + throw err; + } + } + + /** + * Append data to an OSS Appendable Object. + * + * OSS AppendObject requires a `position` parameter that must equal the + * current object size. We use a three-step strategy: + * + * 1. Use the cached position (0 for new objects, or the value from the + * last successful append). + * 2. If OSS returns PositionNotEqualToLength (cache is stale), issue a + * HEAD request to learn the current object size, then retry once. + * 3. Update the cache with `nextAppendPosition` from the response. + * + * This gives us single-round-trip performance in the common case (single + * writer, no restarts) while still being self-healing when the cache is + * stale. + */ + async append(key: string, value: string): Promise { + const buf = Buffer.from(value, 'utf-8'); + const position = this.appendPositions.get(key) ?? 0; + + try { + const result = await this.client.append(key, buf, { position }); + this.appendPositions.set(key, Number(result.nextAppendPosition)); + } catch (err: unknown) { + // Position mismatch — the object grew since our last cached position. + // Fall back to HEAD to learn the actual size, then retry. + if (isOSSError(err, 'PositionNotEqualToLength')) { + const head = await this.client.head(key); + const currentPos = Number(head.res.headers['content-length'] ?? 0); + const result = await this.client.append(key, buf, { position: currentPos }); + this.appendPositions.set(key, Number(result.nextAppendPosition)); + } else { + throw err; + } + } + } +} diff --git a/core/agent-runtime/src/RunBuilder.ts b/core/agent-runtime/src/RunBuilder.ts new file mode 100644 index 000000000..4185f295a --- /dev/null +++ b/core/agent-runtime/src/RunBuilder.ts @@ -0,0 +1,156 @@ +import type { MessageObject, RunObject, RunRecord, AgentRunConfig } from '@eggjs/tegg-types/agent-runtime'; +import { RunStatus, AgentErrorCode, AgentObjectType } from '@eggjs/tegg-types/agent-runtime'; +import { InvalidRunStateTransitionError } from '@eggjs/tegg-types/agent-runtime'; + +import { nowUnix } from './AgentStoreUtils'; + +/** Accumulated token usage — same shape as non-null RunRecord['usage']. */ +export type RunUsage = NonNullable; + +/** + * Encapsulates run state transitions. + * + * Mutation methods (`start`, `complete`, `fail`, `cancel`) update internal + * state and return `Partial` for the store. + * + * `snapshot()` produces a `RunObject` suitable for API responses and SSE events. + */ +export class RunBuilder { + private readonly id: string; + private readonly threadId: string; + private readonly createdAt: number; + private readonly metadata?: Record; + private readonly config?: AgentRunConfig; + + private status: RunStatus; + 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, + status: RunStatus, + metadata?: Record, + config?: AgentRunConfig, + ) { + this.id = id; + this.threadId = threadId; + this.createdAt = createdAt; + this.status = status; + this.metadata = metadata; + this.config = config; + } + + /** Create a RunBuilder from a store RunRecord, using its own threadId. */ + static fromRecord(run: RunRecord): RunBuilder { + return RunBuilder.create(run, run.threadId ?? ''); + } + + /** Create a RunBuilder from a store RunRecord, restoring all mutable state. */ + static create(run: RunRecord, threadId: string): RunBuilder { + const rb = new RunBuilder(run.id, threadId, run.createdAt, run.status, run.metadata, run.config); + rb.startedAt = run.startedAt ?? undefined; + rb.completedAt = run.completedAt ?? undefined; + rb.cancelledAt = run.cancelledAt ?? undefined; + rb.failedAt = run.failedAt ?? undefined; + rb.lastError = run.lastError ?? undefined; + rb.output = run.output; + if (run.usage) { + rb.usage = { ...run.usage }; + } + return rb; + } + + /** queued -> in_progress. Returns store update. */ + start(): Partial { + if (this.status !== RunStatus.Queued) { + throw new InvalidRunStateTransitionError(this.status, RunStatus.InProgress); + } + this.status = RunStatus.InProgress; + this.startedAt = nowUnix(); + return { status: this.status, startedAt: this.startedAt }; + } + + /** in_progress -> completed. Returns store update. */ + complete(output: MessageObject[], usage?: RunUsage): Partial { + if (this.status !== RunStatus.InProgress) { + throw new InvalidRunStateTransitionError(this.status, RunStatus.Completed); + } + this.status = RunStatus.Completed; + this.completedAt = nowUnix(); + this.output = output; + this.usage = usage; + return { + status: this.status, + output, + usage, + completedAt: this.completedAt, + }; + } + + /** queued/in_progress -> failed. Returns store update. */ + fail(error: Error): Partial { + if (this.status !== RunStatus.InProgress && this.status !== RunStatus.Queued) { + throw new InvalidRunStateTransitionError(this.status, RunStatus.Failed); + } + this.status = RunStatus.Failed; + this.failedAt = nowUnix(); + this.lastError = { code: AgentErrorCode.ExecError, message: error.message }; + return { + status: this.status, + lastError: this.lastError, + failedAt: this.failedAt, + }; + } + + /** in_progress/queued -> cancelling (idempotent if already cancelling). Returns store update. */ + cancelling(): Partial { + if (this.status === RunStatus.Cancelling) { + return { status: this.status }; + } + if (this.status !== RunStatus.InProgress && this.status !== RunStatus.Queued) { + throw new InvalidRunStateTransitionError(this.status, RunStatus.Cancelling); + } + this.status = RunStatus.Cancelling; + return { status: this.status }; + } + + /** cancelling -> cancelled. Returns store update. */ + cancel(): Partial { + if (this.status !== RunStatus.Cancelling) { + throw new InvalidRunStateTransitionError(this.status, RunStatus.Cancelled); + } + this.status = RunStatus.Cancelled; + this.cancelledAt = nowUnix(); + return { + status: this.status, + cancelledAt: this.cancelledAt, + }; + } + + /** Produce a RunObject snapshot for API / SSE. */ + snapshot(): RunObject { + return { + id: this.id, + object: AgentObjectType.ThreadRun, + createdAt: this.createdAt, + threadId: this.threadId, + status: this.status, + lastError: this.lastError, + startedAt: this.startedAt ?? null, + completedAt: this.completedAt ?? null, + cancelledAt: this.cancelledAt ?? null, + failedAt: this.failedAt ?? null, + usage: this.usage ?? null, + metadata: this.metadata, + output: this.output, + config: this.config, + }; + } +} diff --git a/core/agent-runtime/src/SSEWriter.ts b/core/agent-runtime/src/SSEWriter.ts new file mode 100644 index 000000000..bfd29b484 --- /dev/null +++ b/core/agent-runtime/src/SSEWriter.ts @@ -0,0 +1,14 @@ +/** + * 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; +} diff --git a/core/agent-runtime/test/AgentRuntime.test.ts b/core/agent-runtime/test/AgentRuntime.test.ts new file mode 100644 index 000000000..b645abf89 --- /dev/null +++ b/core/agent-runtime/test/AgentRuntime.test.ts @@ -0,0 +1,565 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; + +import { + RunStatus, + AgentSSEEvent, + AgentObjectType, + MessageRole, + MessageStatus, + ContentBlockType, +} from '@eggjs/tegg-types/agent-runtime'; +import type { RunRecord, RunObject, CreateRunInput, AgentStreamMessage } from '@eggjs/tegg-types/agent-runtime'; +import { AgentNotFoundError, AgentConflictError } from '@eggjs/tegg-types/agent-runtime'; + +import { AgentRuntime } from '../src/AgentRuntime'; +import type { AgentExecutor, AgentRuntimeOptions } from '../src/AgentRuntime'; +import { OSSAgentStore } from '../src/OSSAgentStore'; +import type { SSEWriter } from '../src/SSEWriter'; +import { MapStorageClient } from './helpers'; + +class MockSSEWriter implements SSEWriter { + events: Array<{ event: string; data: unknown }> = []; + closed = false; + private closeCallbacks: Array<() => void> = []; + + writeEvent(event: string, data: unknown): void { + this.events.push({ event, data }); + } + + end(): void { + this.closed = true; + } + + onClose(callback: () => void): void { + this.closeCallbacks.push(callback); + } + + simulateClose(): void { + this.closed = true; + for (const cb of this.closeCallbacks) cb(); + } +} + +async function waitForRunStatus( + agentStore: OSSAgentStore, + runId: string, + expectedStatus: RunStatus, + timeoutMs = 2000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const run = await agentStore.getRun(runId); + if (run.status === expectedStatus) return; + await setTimeout(10); + } + throw new Error(`Run ${runId} did not reach status '${expectedStatus}' within ${timeoutMs}ms`); +} + +function createSlowExecRun(chunks: AgentStreamMessage[], onYielded?: () => void): AgentExecutor['execRun'] { + return async function* (_input: CreateRunInput, signal?: AbortSignal): AsyncGenerator { + for (const chunk of chunks) { + yield chunk; + } + onYielded?.(); + await new Promise((resolve, reject) => { + const timer = globalThis.setTimeout(resolve, 5000); + if (signal) { + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new Error('aborted')); + }, + { once: true }, + ); + } + }); + }; +} + +function createBlockingExecRun( + resolveRef: { resolve?: () => void }, + chunks: AgentStreamMessage[], +): AgentExecutor['execRun'] { + return async function* (_input: CreateRunInput, signal?: AbortSignal): AsyncGenerator { + await new Promise((resolve, reject) => { + resolveRef.resolve = resolve; + if (signal) { + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + } + }); + for (const chunk of chunks) { + yield chunk; + } + }; +} + +describe('test/AgentRuntime.test.ts', () => { + let runtime: AgentRuntime; + let store: OSSAgentStore; + let executor: AgentExecutor; + + beforeEach(() => { + store = new OSSAgentStore({ client: new MapStorageClient() }); + executor = { + async *execRun(input: CreateRunInput): AsyncGenerator { + const messages = input.input.messages; + yield { + message: { + role: MessageRole.Assistant, + content: [{ type: 'text', text: `Hello ${messages.length} messages` }], + }, + }; + yield { + usage: { promptTokens: 10, completionTokens: 5 }, + }; + }, + }; + runtime = new AgentRuntime({ + executor, + store, + logger: { + error() { + /* noop */ + }, + } as unknown as AgentRuntimeOptions['logger'], + }); + }); + + afterEach(async () => { + await runtime.destroy(); + }); + + describe('createThread', () => { + it('should create a thread and return ThreadObject', async () => { + const result = await runtime.createThread(); + assert(result.id.startsWith('thread_')); + assert.equal(result.object, AgentObjectType.Thread); + assert(typeof result.createdAt === 'number'); + // Unix seconds + assert(result.createdAt <= Math.floor(Date.now() / 1000)); + assert(typeof result.metadata === 'object'); + }); + }); + + describe('getThread', () => { + it('should get a thread by id', async () => { + const created = await runtime.createThread(); + + const result = await runtime.getThread(created.id); + assert.equal(result.id, created.id); + assert.equal(result.object, AgentObjectType.Thread); + assert(Array.isArray(result.messages)); + }); + + it('should throw AgentNotFoundError for non-existent thread', async () => { + await assert.rejects( + () => runtime.getThread('thread_xxx'), + (err: unknown) => { + assert(err instanceof AgentNotFoundError); + assert.equal(err.status, 404); + return true; + }, + ); + }); + }); + + describe('syncRun', () => { + it('should collect all chunks and return completed RunObject', async () => { + const result = await runtime.syncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert(result.id.startsWith('run_')); + assert.equal(result.object, AgentObjectType.ThreadRun); + assert.equal(result.status, RunStatus.Completed); + assert(result.threadId); + assert(result.threadId.startsWith('thread_')); + assert.equal(result.output!.length, 1); + assert.equal(result.output![0].object, AgentObjectType.ThreadMessage); + assert.equal(result.output![0].role, MessageRole.Assistant); + assert.equal(result.output![0].status, MessageStatus.Completed); + const content = result.output![0].content; + assert.equal(content[0].type, ContentBlockType.Text); + assert.equal(content[0].text.value, 'Hello 1 messages'); + assert(Array.isArray(content[0].text.annotations)); + assert.equal(result.usage!.promptTokens, 10); + assert.equal(result.usage!.completionTokens, 5); + assert.equal(result.usage!.totalTokens, 15); + assert(result.startedAt! >= result.createdAt, 'startedAt should be >= createdAt'); + }); + + it('should pass metadata through to store and return it', async () => { + const meta = { user_id: 'u_1', trace: 'xyz' }; + const result = await runtime.syncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + metadata: meta, + }); + assert.deepStrictEqual(result.metadata, meta); + + const run = await store.getRun(result.id); + assert.deepStrictEqual(run.metadata, meta); + }); + + it('should store the run in the store', async () => { + const result = await runtime.syncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + const run = await store.getRun(result.id); + assert.equal(run.status, RunStatus.Completed); + assert(run.completedAt); + }); + + it('should append messages to thread when threadId provided', async () => { + const thread = await runtime.createThread(); + + await runtime.syncRun({ + threadId: thread.id, + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + const updated = await runtime.getThread(thread.id); + assert.equal(updated.messages.length, 2); + assert.equal(updated.messages[0].role, MessageRole.User); + assert.equal(updated.messages[1].role, MessageRole.Assistant); + }); + + it('should auto-create thread and append messages when threadId not provided', async () => { + const result = await runtime.syncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert(result.threadId); + assert(result.threadId.startsWith('thread_')); + + const thread = await runtime.getThread(result.threadId); + assert.equal(thread.messages.length, 2); + assert.equal(thread.messages[0].role, MessageRole.User); + assert.equal(thread.messages[1].role, MessageRole.Assistant); + }); + + it('should not throw when store.updateRun fails in catch block', async () => { + executor.execRun = async function* (): AsyncGenerator { + throw new Error('exec failed'); + }; + + let callCount = 0; + const origUpdateRun = store.updateRun.bind(store); + store.updateRun = async (runId: string, updates: Partial) => { + callCount++; + if (callCount === 2) { + throw new Error('store down'); + } + return origUpdateRun(runId, updates); + }; + + await assert.rejects( + () => runtime.syncRun({ input: { messages: [{ role: 'user', content: 'Hi' }] } }), + (err: unknown) => { + assert(err instanceof Error); + assert.equal(err.message, 'exec failed'); + return true; + }, + ); + }); + }); + + describe('asyncRun', () => { + it('should return queued status immediately with auto-created threadId', async () => { + const result = await runtime.asyncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert(result.id.startsWith('run_')); + assert.equal(result.object, AgentObjectType.ThreadRun); + assert.equal(result.status, RunStatus.Queued); + assert(result.threadId); + assert(result.threadId.startsWith('thread_')); + }); + + it('should complete the run in the background', async () => { + const result = await runtime.asyncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + await runtime.waitForPendingTasks(); + + const run = await store.getRun(result.id); + assert.equal(run.status, RunStatus.Completed); + const outputContent = run.output![0].content; + assert.equal(outputContent[0].text.value, 'Hello 1 messages'); + }); + + it('should auto-create thread and append messages when threadId not provided', async () => { + const result = await runtime.asyncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert(result.threadId); + + await runtime.waitForPendingTasks(); + + const thread = await store.getThread(result.threadId); + assert.equal(thread.messages.length, 2); + assert.equal(thread.messages[0].role, MessageRole.User); + assert.equal(thread.messages[1].role, MessageRole.Assistant); + }); + + it('should pass metadata through to store and return it', async () => { + const meta = { session: 'sess_1' }; + const result = await runtime.asyncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + metadata: meta, + }); + assert.deepStrictEqual(result.metadata, meta); + + await runtime.waitForPendingTasks(); + + const run = await store.getRun(result.id); + assert.deepStrictEqual(run.metadata, meta); + }); + }); + + describe('streamRun', () => { + it('should emit correct SSE event sequence for normal flow', async () => { + const writer = new MockSSEWriter(); + await runtime.streamRun({ input: { messages: [{ role: 'user', content: 'Hi' }] } }, writer); + + const eventNames = writer.events.map((e) => e.event); + assert(eventNames.includes(AgentSSEEvent.ThreadRunCreated)); + assert(eventNames.includes(AgentSSEEvent.ThreadRunInProgress)); + assert(eventNames.includes(AgentSSEEvent.ThreadMessageCreated)); + assert(eventNames.includes(AgentSSEEvent.ThreadMessageDelta)); + assert(eventNames.includes(AgentSSEEvent.ThreadMessageCompleted)); + assert(eventNames.includes(AgentSSEEvent.ThreadRunCompleted)); + assert(eventNames.includes(AgentSSEEvent.Done)); + assert(writer.closed); + + // Verify order: created < in_progress < message.created < delta < message.completed < run.completed < done + const createdIdx = eventNames.indexOf(AgentSSEEvent.ThreadRunCreated); + const progressIdx = eventNames.indexOf(AgentSSEEvent.ThreadRunInProgress); + const msgCreatedIdx = eventNames.indexOf(AgentSSEEvent.ThreadMessageCreated); + const deltaIdx = eventNames.indexOf(AgentSSEEvent.ThreadMessageDelta); + const msgCompletedIdx = eventNames.indexOf(AgentSSEEvent.ThreadMessageCompleted); + const runCompletedIdx = eventNames.indexOf(AgentSSEEvent.ThreadRunCompleted); + const doneIdx = eventNames.indexOf(AgentSSEEvent.Done); + assert(createdIdx < progressIdx); + assert(progressIdx < msgCreatedIdx); + assert(msgCreatedIdx < deltaIdx); + assert(deltaIdx < msgCompletedIdx); + assert(msgCompletedIdx < runCompletedIdx); + assert(runCompletedIdx < doneIdx); + + // Verify messages persisted to thread (consistent with syncRun/asyncRun tests) + const runCreatedEvent = writer.events.find((e) => e.event === AgentSSEEvent.ThreadRunCreated); + const threadId = (runCreatedEvent!.data as RunObject).threadId; + const thread = await runtime.getThread(threadId); + assert.equal(thread.messages.length, 2); + assert.equal(thread.messages[0]['role'], MessageRole.User); + assert.equal(thread.messages[1]['role'], MessageRole.Assistant); + }); + + it('should emit cancelled event on client disconnect', async () => { + let resolveYielded!: () => void; + const yieldedPromise = new Promise((r) => { + resolveYielded = r; + }); + + executor.execRun = async function* ( + _input: CreateRunInput, + signal?: AbortSignal, + ): AsyncGenerator { + yield { message: { role: MessageRole.Assistant, content: [{ type: 'text', text: 'start' }] } }; + resolveYielded(); + await new Promise((resolve) => { + const timer = globalThis.setTimeout(resolve, 5000); + if (signal) { + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + } + }); + }; + + const writer = new MockSSEWriter(); + + const streamPromise = runtime.streamRun({ input: { messages: [{ role: 'user', content: 'Hi' }] } }, writer); + + await yieldedPromise; + writer.simulateClose(); + + await streamPromise; + + const eventNames = writer.events.map((e) => e.event); + assert(eventNames.includes(AgentSSEEvent.ThreadRunCreated)); + assert(eventNames.includes(AgentSSEEvent.ThreadRunInProgress)); + }); + + it('should emit failed event when execRun throws', async () => { + executor.execRun = async function* (): AsyncGenerator { + throw new Error('model unavailable'); + }; + + const writer = new MockSSEWriter(); + await runtime.streamRun({ input: { messages: [{ role: 'user', content: 'Hi' }] } }, writer); + + const eventNames = writer.events.map((e) => e.event); + assert(eventNames.includes(AgentSSEEvent.ThreadRunFailed)); + assert(eventNames.includes(AgentSSEEvent.Done)); + assert(writer.closed); + }); + }); + + describe('getRun', () => { + it('should get a run by id', async () => { + const syncResult = await runtime.syncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + const result = await runtime.getRun(syncResult.id); + assert.equal(result.id, syncResult.id); + assert.equal(result.object, AgentObjectType.ThreadRun); + assert.equal(result.status, RunStatus.Completed); + assert(typeof result.createdAt === 'number'); + }); + + it('should return metadata from getRun', async () => { + const meta = { source: 'api' }; + const syncResult = await runtime.syncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + metadata: meta, + }); + + const result = await runtime.getRun(syncResult.id); + assert.deepStrictEqual(result.metadata, meta); + }); + }); + + describe('cancelRun', () => { + it('should cancel a run', async () => { + executor.execRun = createSlowExecRun([ + { + message: { role: MessageRole.Assistant, content: [{ type: 'text', text: 'start' }] }, + }, + ]); + + const result = await runtime.asyncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + await waitForRunStatus(store, result.id, RunStatus.InProgress); + + const cancelResult = await runtime.cancelRun(result.id); + assert.equal(cancelResult.id, result.id); + assert.equal(cancelResult.object, AgentObjectType.ThreadRun); + assert.equal(cancelResult.status, RunStatus.Cancelled); + + const run = await store.getRun(result.id); + assert.equal(run.status, RunStatus.Cancelled); + assert(run.cancelledAt); + }); + + it('should write cancelling then cancelled to store', async () => { + executor.execRun = createSlowExecRun([ + { + message: { role: MessageRole.Assistant, content: [{ type: 'text', text: 'start' }] }, + }, + ]); + + const statusHistory: string[] = []; + const origUpdateRun = store.updateRun.bind(store); + store.updateRun = async (runId: string, updates: Partial) => { + if (updates.status) { + statusHistory.push(updates.status); + } + return origUpdateRun(runId, updates); + }; + + const asyncResult = await runtime.asyncRun({ + input: { messages: [{ role: 'user', content: 'Hello' }] }, + }); + + await waitForRunStatus(store, asyncResult.id, RunStatus.InProgress); + statusHistory.length = 0; + + await runtime.cancelRun(asyncResult.id); + + const cancellingIdx = statusHistory.indexOf(RunStatus.Cancelling); + const cancelledIdx = statusHistory.indexOf(RunStatus.Cancelled); + assert(cancellingIdx >= 0, 'cancelling should have been written'); + assert(cancelledIdx > cancellingIdx, 'cancelled should come after cancelling'); + }); + + it('should throw AgentConflictError when cancelling a completed run', async () => { + const result = await runtime.syncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert.equal(result.status, RunStatus.Completed); + + await assert.rejects( + () => runtime.cancelRun(result.id), + (err: unknown) => { + assert(err instanceof AgentConflictError); + return true; + }, + ); + }); + + it('should not overwrite cancelling status with completed (cross-worker scenario)', async () => { + const resolveRef: { resolve?: () => void } = {}; + executor.execRun = createBlockingExecRun(resolveRef, [ + { + message: { role: MessageRole.Assistant, content: [{ type: 'text', text: 'done' }] }, + }, + { + usage: { promptTokens: 1, completionTokens: 1 }, + }, + ]); + + const result = await runtime.asyncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + + await waitForRunStatus(store, result.id, RunStatus.InProgress); + + await store.updateRun(result.id, { status: RunStatus.Cancelling }); + + resolveRef.resolve!(); + await runtime.waitForPendingTasks(); + + const run = await store.getRun(result.id); + assert.equal(run.status, RunStatus.Cancelling); + }); + + it('should not overwrite terminal state when run completes during cancellation (TOCTOU)', async () => { + const resolveRef: { resolve?: () => void } = {}; + executor.execRun = createBlockingExecRun(resolveRef, [ + { + message: { role: MessageRole.Assistant, content: [{ type: 'text', text: 'done' }] }, + }, + { usage: { promptTokens: 1, completionTokens: 1 } }, + ]); + + const result = await runtime.asyncRun({ + input: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + await waitForRunStatus(store, result.id, RunStatus.InProgress); + + const origUpdateRun = store.updateRun.bind(store); + store.updateRun = async (runId: string, updates: Partial) => { + await origUpdateRun(runId, updates); + if (updates.status === RunStatus.Cancelling) { + await origUpdateRun(runId, { status: RunStatus.Completed, completedAt: Math.floor(Date.now() / 1000) }); + store.updateRun = origUpdateRun; + } + }; + + resolveRef.resolve!(); + + const cancelResult = await runtime.cancelRun(result.id); + assert.equal(cancelResult.status, RunStatus.Completed); + }); + }); +}); diff --git a/core/agent-runtime/test/HttpSSEWriter.test.ts b/core/agent-runtime/test/HttpSSEWriter.test.ts new file mode 100644 index 000000000..2bef41ba0 --- /dev/null +++ b/core/agent-runtime/test/HttpSSEWriter.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert'; +import { EventEmitter } from 'node:events'; + +import { HttpSSEWriter } from '../src/HttpSSEWriter'; + +/** + * Minimal mock of Node.js ServerResponse for testing HttpSSEWriter. + * Captures writeHead/write/end calls and emits 'close' on demand. + */ +class MockServerResponse extends EventEmitter { + writtenHead: { statusCode: number; headers: Record } | null = null; + chunks: string[] = []; + ended = false; + + writeHead(statusCode: number, headers: Record): void { + this.writtenHead = { statusCode, headers }; + } + + write(chunk: string): boolean { + this.chunks.push(chunk); + return true; + } + + end(): void { + this.ended = true; + } +} + +describe('test/HttpSSEWriter.test.ts', () => { + let res: MockServerResponse; + + beforeEach(() => { + res = new MockServerResponse(); + }); + + it('should delay headers until first writeEvent', () => { + const writer = new HttpSSEWriter(res as any); + + // Headers not sent yet after construction + assert.equal(res.writtenHead, null); + assert.equal(res.chunks.length, 0); + + writer.writeEvent('test', { foo: 'bar' }); + + // Now headers should be sent + assert.ok(res.writtenHead); + assert.equal(res.writtenHead.statusCode, 200); + }); + + it('should use lowercase header keys', () => { + const writer = new HttpSSEWriter(res as any); + writer.writeEvent('ping', {}); + + assert.ok(res.writtenHead); + assert.equal(res.writtenHead.headers['content-type'], 'text/event-stream'); + assert.equal(res.writtenHead.headers['cache-control'], 'no-cache'); + assert.equal(res.writtenHead.headers['connection'], 'keep-alive'); + }); + + it('should format SSE events correctly', () => { + const writer = new HttpSSEWriter(res as any); + writer.writeEvent('message', { text: 'hello' }); + + assert.equal(res.chunks.length, 1); + assert.equal(res.chunks[0], 'event: message\ndata: {"text":"hello"}\n\n'); + }); + + it('should not write after connection closes', () => { + const writer = new HttpSSEWriter(res as any); + + // Simulate client disconnect + res.emit('close'); + + assert.equal(writer.closed, true); + writer.writeEvent('late', { data: 'ignored' }); + + // No headers sent, no chunks written + assert.equal(res.writtenHead, null); + assert.equal(res.chunks.length, 0); + }); + + it('should trigger onClose callbacks when connection closes', () => { + const writer = new HttpSSEWriter(res as any); + const calls: number[] = []; + + writer.onClose(() => calls.push(1)); + writer.onClose(() => calls.push(2)); + + res.emit('close'); + + assert.deepStrictEqual(calls, [1, 2]); + }); + + it('should handle end() idempotently', () => { + const writer = new HttpSSEWriter(res as any); + + assert.equal(writer.closed, false); + + writer.end(); + assert.equal(writer.closed, true); + assert.equal(res.ended, true); + + // Reset flag to verify second end() doesn't call res.end() again + res.ended = false; + writer.end(); + assert.equal(res.ended, false); // Not called again + }); + + it('should write multiple events sequentially', () => { + const writer = new HttpSSEWriter(res as any); + + writer.writeEvent('event1', { n: 1 }); + writer.writeEvent('event2', { n: 2 }); + writer.writeEvent('event3', { n: 3 }); + + assert.equal(res.chunks.length, 3); + assert.equal(res.chunks[0], 'event: event1\ndata: {"n":1}\n\n'); + assert.equal(res.chunks[1], 'event: event2\ndata: {"n":2}\n\n'); + assert.equal(res.chunks[2], 'event: event3\ndata: {"n":3}\n\n'); + + // Headers sent only once + assert.ok(res.writtenHead); + }); + + it('should start with closed=false', () => { + const writer = new HttpSSEWriter(res as any); + assert.equal(writer.closed, false); + }); +}); diff --git a/core/agent-runtime/test/MessageConverter.test.ts b/core/agent-runtime/test/MessageConverter.test.ts new file mode 100644 index 000000000..1d27901cb --- /dev/null +++ b/core/agent-runtime/test/MessageConverter.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert'; + +import type { AgentStreamMessage, AgentStreamMessagePayload } from '@eggjs/tegg-types/agent-runtime'; +import { MessageRole, MessageStatus, AgentObjectType, ContentBlockType } from '@eggjs/tegg-types/agent-runtime'; + +import { MessageConverter } from '../src/MessageConverter'; + +describe('test/MessageConverter.test.ts', () => { + describe('toContentBlocks', () => { + it('should return empty array for falsy payload', () => { + const result = MessageConverter.toContentBlocks(undefined as unknown as AgentStreamMessagePayload); + assert.deepStrictEqual(result, []); + }); + + it('should convert string content to a single text block', () => { + const payload: AgentStreamMessagePayload = { content: 'hello world' }; + const result = MessageConverter.toContentBlocks(payload); + assert.equal(result.length, 1); + assert.equal(result[0].type, ContentBlockType.Text); + assert.equal(result[0].text.value, 'hello world'); + assert.deepStrictEqual(result[0].text.annotations, []); + }); + + it('should convert array content parts to text blocks', () => { + const payload: AgentStreamMessagePayload = { + content: [ + { type: 'text', text: 'part1' }, + { type: 'text', text: 'part2' }, + ], + }; + const result = MessageConverter.toContentBlocks(payload); + assert.equal(result.length, 2); + assert.equal(result[0].text.value, 'part1'); + assert.equal(result[1].text.value, 'part2'); + }); + + it('should filter out non-text content parts', () => { + const payload: AgentStreamMessagePayload = { + content: [ + { type: 'text', text: 'keep' }, + { type: 'image' as 'text', text: 'discard' }, + ], + }; + const result = MessageConverter.toContentBlocks(payload); + assert.equal(result.length, 1); + assert.equal(result[0].text.value, 'keep'); + }); + + it('should return empty array for non-string non-array content', () => { + const payload = { content: 123 } as unknown as AgentStreamMessagePayload; + const result = MessageConverter.toContentBlocks(payload); + assert.deepStrictEqual(result, []); + }); + }); + + describe('toMessageObject', () => { + it('should create a completed assistant message', () => { + const payload: AgentStreamMessagePayload = { content: 'reply' }; + const msg = MessageConverter.toMessageObject(payload, 'run_1'); + + assert.ok(msg.id.startsWith('msg_')); + assert.equal(msg.object, AgentObjectType.ThreadMessage); + assert.equal(msg.runId, 'run_1'); + assert.equal(msg.role, MessageRole.Assistant); + assert.equal(msg.status, MessageStatus.Completed); + assert.equal(typeof msg.createdAt, 'number'); + const content = msg.content; + assert.equal(content.length, 1); + assert.equal(content[0].text.value, 'reply'); + }); + + it('should work without runId', () => { + const payload: AgentStreamMessagePayload = { content: 'test' }; + const msg = MessageConverter.toMessageObject(payload); + assert.equal(msg.runId, undefined); + }); + }); + + describe('createStreamMessage', () => { + it('should create an in-progress message with empty content', () => { + const msg = MessageConverter.createStreamMessage('msg_abc', 'run_1'); + + assert.equal(msg.id, 'msg_abc'); + assert.equal(msg.object, AgentObjectType.ThreadMessage); + assert.equal(msg.runId, 'run_1'); + assert.equal(msg.role, MessageRole.Assistant); + assert.equal(msg.status, MessageStatus.InProgress); + assert.deepStrictEqual(msg.content, []); + assert.equal(typeof msg.createdAt, 'number'); + }); + }); + + describe('extractFromStreamMessages', () => { + it('should extract messages and accumulate usage', () => { + const messages: AgentStreamMessage[] = [ + { message: { content: 'chunk1' }, usage: { promptTokens: 10, completionTokens: 5 } }, + { message: { content: 'chunk2' }, usage: { promptTokens: 0, completionTokens: 8 } }, + ]; + const { output, usage } = MessageConverter.extractFromStreamMessages(messages, 'run_1'); + + assert.equal(output.length, 2); + assert.equal(output[0].content[0].text.value, 'chunk1'); + assert.equal(output[1].content[0].text.value, 'chunk2'); + assert.ok(usage); + assert.equal(usage.promptTokens, 10); + assert.equal(usage.completionTokens, 13); + assert.equal(usage.totalTokens, 23); + }); + + it('should return undefined usage when no usage info', () => { + const messages: AgentStreamMessage[] = [{ message: { content: 'data' } }]; + const { output, usage } = MessageConverter.extractFromStreamMessages(messages); + assert.equal(output.length, 1); + assert.equal(usage, undefined); + }); + + it('should handle messages without message payload (usage only)', () => { + const messages: AgentStreamMessage[] = [{ usage: { promptTokens: 5, completionTokens: 3 } }]; + const { output, usage } = MessageConverter.extractFromStreamMessages(messages); + assert.equal(output.length, 0); + assert.ok(usage); + assert.equal(usage.totalTokens, 8); + }); + + it('should handle empty message array', () => { + const { output, usage } = MessageConverter.extractFromStreamMessages([]); + assert.equal(output.length, 0); + assert.equal(usage, undefined); + }); + }); + + describe('toInputMessageObjects', () => { + it('should convert user and assistant messages', () => { + const messages = [ + { role: MessageRole.User as MessageRole, content: 'hi' }, + { role: MessageRole.Assistant as MessageRole, content: 'hello' }, + ]; + const result = MessageConverter.toInputMessageObjects(messages, 'thread_1'); + + assert.equal(result.length, 2); + assert.equal(result[0].role, MessageRole.User); + assert.equal(result[0].threadId, 'thread_1'); + assert.equal(result[1].role, MessageRole.Assistant); + + const content0 = result[0].content; + assert.equal(content0[0].text.value, 'hi'); + }); + + it('should filter out system messages', () => { + const messages = [ + { role: MessageRole.System as MessageRole, content: 'you are a bot' }, + { role: MessageRole.User as MessageRole, content: 'hi' }, + ]; + const result = MessageConverter.toInputMessageObjects(messages); + assert.equal(result.length, 1); + assert.equal(result[0].role, MessageRole.User); + }); + + it('should handle array content parts', () => { + const messages = [ + { + role: MessageRole.User as MessageRole, + content: [ + { type: 'text' as const, text: 'part1' }, + { type: 'text' as const, text: 'part2' }, + ], + }, + ]; + const result = MessageConverter.toInputMessageObjects(messages); + const content = result[0].content; + assert.equal(content.length, 2); + assert.equal(content[0].text.value, 'part1'); + assert.equal(content[1].text.value, 'part2'); + }); + + it('should work without threadId', () => { + const messages = [{ role: MessageRole.User as MessageRole, content: 'hi' }]; + const result = MessageConverter.toInputMessageObjects(messages); + assert.equal(result[0].threadId, undefined); + }); + }); +}); diff --git a/core/agent-runtime/test/OSSAgentStore.test.ts b/core/agent-runtime/test/OSSAgentStore.test.ts new file mode 100644 index 000000000..9f2e73bee --- /dev/null +++ b/core/agent-runtime/test/OSSAgentStore.test.ts @@ -0,0 +1,325 @@ +import assert from 'node:assert'; + +import { AgentNotFoundError } from '../index'; +import { OSSAgentStore } from '../index'; +import { MapStorageClient, MapStorageClientWithoutAppend } from './helpers'; + +describe('test/OSSAgentStore.test.ts', () => { + let store: OSSAgentStore; + + beforeEach(() => { + store = new OSSAgentStore({ client: new MapStorageClient() }); + }); + + 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.createdAt === 'number'); + assert(thread.createdAt <= Math.floor(Date.now() / 1000)); + }); + + 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.createdAt, created.createdAt); + }); + + it('should return empty messages for a new thread', async () => { + const thread = await store.createThread(); + const fetched = await store.getThread(thread.id); + assert.deepEqual(fetched.messages, []); + }); + + 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 () => { + const thread = await store.createThread(); + await store.appendMessages(thread.id, [ + { + id: 'msg_1', + object: 'thread.message', + createdAt: Math.floor(Date.now() / 1000), + role: 'user', + status: 'completed', + content: [{ type: 'text', text: { value: 'Hello', annotations: [] } }], + }, + { + id: 'msg_2', + object: 'thread.message', + createdAt: 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].id, 'msg_1'); + assert.equal(fetched.messages[1].id, 'msg_2'); + }); + + it('should append messages incrementally', async () => { + const thread = await store.createThread(); + await store.appendMessages(thread.id, [ + { + id: 'msg_1', + object: 'thread.message', + createdAt: Math.floor(Date.now() / 1000), + role: 'user', + status: 'completed', + content: [{ type: 'text', text: { value: 'First', annotations: [] } }], + }, + ]); + await store.appendMessages(thread.id, [ + { + id: 'msg_2', + object: 'thread.message', + createdAt: Math.floor(Date.now() / 1000), + role: 'assistant', + status: 'completed', + content: [{ type: 'text', text: { value: 'Second', annotations: [] } }], + }, + ]); + const fetched = await store.getThread(thread.id); + assert.equal(fetched.messages.length, 2); + assert.equal(fetched.messages[0].id, 'msg_1'); + assert.equal(fetched.messages[1].id, 'msg_2'); + }); + + it('should throw AgentNotFoundError when appending to non-existent thread', async () => { + await assert.rejects( + () => + store.appendMessages('thread_non_existent', [ + { + id: 'msg_1', + object: 'thread.message', + createdAt: Math.floor(Date.now() / 1000), + role: 'user', + status: 'completed', + content: [{ type: 'text', text: { value: 'Hello', annotations: [] } }], + }, + ]), + (err: unknown) => { + assert(err instanceof AgentNotFoundError); + return true; + }, + ); + }); + }); + + describe('threads (without append)', () => { + it('should fall back to get-concat-put when client has no append', async () => { + const fallbackStore = new OSSAgentStore({ client: new MapStorageClientWithoutAppend() }); + const thread = await fallbackStore.createThread(); + await fallbackStore.appendMessages(thread.id, [ + { + id: 'msg_1', + object: 'thread.message', + createdAt: Math.floor(Date.now() / 1000), + role: 'user', + status: 'completed', + content: [{ type: 'text', text: { value: 'Hello', annotations: [] } }], + }, + ]); + await fallbackStore.appendMessages(thread.id, [ + { + id: 'msg_2', + object: 'thread.message', + createdAt: Math.floor(Date.now() / 1000), + role: 'assistant', + status: 'completed', + content: [{ type: 'text', text: { value: 'Hi!', annotations: [] } }], + }, + ]); + const fetched = await fallbackStore.getThread(thread.id); + assert.equal(fetched.messages.length, 2); + assert.equal(fetched.messages[0].id, 'msg_1'); + assert.equal(fetched.messages[1].id, 'msg_2'); + }); + }); + + 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.createdAt === 'number'); + assert(run.createdAt <= Math.floor(Date.now() / 1000)); + }); + + it('should create a run with threadId and config', async () => { + const run = await store.createRun([{ role: 'user', content: 'Hello' }], 'thread_123', { timeoutMs: 5000 }); + assert.equal(run.threadId, 'thread_123'); + assert.deepEqual(run.config, { timeoutMs: 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); + + 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', startedAt: 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 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 () => { + const run = await store.createRun([{ role: 'user', content: 'Hello' }]); + await store.updateRun(run.id, { + status: 'completed', + output: [ + { + id: 'msg_1', + object: 'thread.message', + createdAt: Math.floor(Date.now() / 1000), + role: 'assistant', + status: 'completed', + content: [{ type: 'text', text: { value: 'World', annotations: [] } }], + }, + ], + completedAt: Math.floor(Date.now() / 1000), + }); + const fetched = await store.getRun(run.id); + assert.equal(fetched.status, 'completed'); + assert(fetched.output); + assert.equal(fetched.output.length, 1); + assert(typeof fetched.completedAt === 'number'); + }); + + it('should not allow overwriting id or object via updateRun', async () => { + const run = await store.createRun([{ role: 'user', content: 'Hello' }]); + await store.updateRun(run.id, { + id: 'run_hacked', + object: 'thread' as never, + status: 'completed', + }); + const fetched = await store.getRun(run.id); + assert.equal(fetched.id, run.id); + assert.equal(fetched.object, 'thread.run'); + assert.equal(fetched.status, 'completed'); + }); + }); + + describe('init / destroy', () => { + it('should call client init when present', async () => { + let initCalled = false; + const client = new MapStorageClient(); + client.init = async () => { initCalled = true; }; + const s = new OSSAgentStore({ client }); + await s.init(); + assert.equal(initCalled, true); + }); + + it('should call client destroy when present', async () => { + let destroyCalled = false; + const client = new MapStorageClient(); + client.destroy = async () => { destroyCalled = true; }; + const s = new OSSAgentStore({ client }); + await s.destroy(); + assert.equal(destroyCalled, true); + }); + + it('should not throw when client has no init/destroy', async () => { + const s = new OSSAgentStore({ client: new MapStorageClient() }); + await s.init(); + await s.destroy(); + }); + }); + + describe('prefix', () => { + it('should use prefix in storage keys', async () => { + const client = new MapStorageClient(); + const prefixedStore = new OSSAgentStore({ client, prefix: 'myapp/' }); + + const thread = await prefixedStore.createThread(); + // Verify we can get it back (proves the prefix is used consistently) + const fetched = await prefixedStore.getThread(thread.id); + assert.equal(fetched.id, thread.id); + + const run = await prefixedStore.createRun([{ role: 'user', content: 'Hello' }]); + const fetchedRun = await prefixedStore.getRun(run.id); + assert.equal(fetchedRun.id, run.id); + }); + + it('should normalize prefix without trailing slash', async () => { + const client = new MapStorageClient(); + const withSlash = new OSSAgentStore({ client, prefix: 'myapp/' }); + const withoutSlash = new OSSAgentStore({ client, prefix: 'myapp' }); + + // Both stores should write to the same keys + const thread = await withSlash.createThread(); + const fetched = await withoutSlash.getThread(thread.id); + assert.equal(fetched.id, thread.id); + }); + + it('should isolate data between different prefixes', async () => { + const client = new MapStorageClient(); + const store1 = new OSSAgentStore({ client, prefix: 'app1/' }); + const store2 = new OSSAgentStore({ client, prefix: 'app2/' }); + + const thread = await store1.createThread(); + await assert.rejects( + () => store2.getThread(thread.id), + (err: unknown) => { + assert(err instanceof AgentNotFoundError); + return true; + }, + ); + }); + }); +}); diff --git a/core/agent-runtime/test/OSSObjectStorageClient.test.ts b/core/agent-runtime/test/OSSObjectStorageClient.test.ts new file mode 100644 index 000000000..fd0f298d3 --- /dev/null +++ b/core/agent-runtime/test/OSSObjectStorageClient.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert'; + +import type { OSSObject } from 'oss-client'; + +import { OSSObjectStorageClient } from '../src/OSSObjectStorageClient'; + +/** Simple mock function helper for mocha tests. */ +function mockFn() { + const calls: any[][] = []; + let nextResults: Array<{ type: 'resolve' | 'reject'; value: any }> = []; + const fn = (...args: any[]) => { + calls.push(args); + const result = nextResults.shift(); + if (result) { + return result.type === 'resolve' ? Promise.resolve(result.value) : Promise.reject(result.value); + } + return Promise.resolve({}); + }; + fn.mock = { calls }; + fn.mockResolvedValue = (val: any) => { nextResults = []; fn.mockResolvedValueOnce(val); nextResults = nextResults.map(() => ({ type: 'resolve' as const, value: val })); (fn as any)._defaultResult = { type: 'resolve', value: val }; return fn; }; + fn.mockResolvedValueOnce = (val: any) => { nextResults.push({ type: 'resolve', value: val }); return fn; }; + fn.mockRejectedValue = (val: any) => { (fn as any)._defaultResult = { type: 'reject', value: val }; return fn; }; + fn.mockRejectedValueOnce = (val: any) => { nextResults.push({ type: 'reject', value: val }); return fn; }; + + // Override fn to use default result when nextResults is empty + const wrappedFn: any = (...args: any[]) => { + calls.push(args); + const result = nextResults.shift(); + if (result) { + return result.type === 'resolve' ? Promise.resolve(result.value) : Promise.reject(result.value); + } + const def = (wrappedFn as any)._defaultResult; + if (def) { + return def.type === 'resolve' ? Promise.resolve(def.value) : Promise.reject(def.value); + } + return Promise.resolve({}); + }; + wrappedFn.mock = { calls }; + wrappedFn.mockResolvedValue = (val: any) => { (wrappedFn as any)._defaultResult = { type: 'resolve', value: val }; return wrappedFn; }; + wrappedFn.mockResolvedValueOnce = (val: any) => { nextResults.push({ type: 'resolve', value: val }); return wrappedFn; }; + wrappedFn.mockRejectedValue = (val: any) => { (wrappedFn as any)._defaultResult = { type: 'reject', value: val }; return wrappedFn; }; + wrappedFn.mockRejectedValueOnce = (val: any) => { nextResults.push({ type: 'reject', value: val }); return wrappedFn; }; + + return wrappedFn; +} + +describe('test/OSSObjectStorageClient.test.ts', () => { + let client: OSSObjectStorageClient; + let mockOSS: { + put: ReturnType; + get: ReturnType; + append: ReturnType; + head: ReturnType; + }; + + beforeEach(() => { + mockOSS = { + put: mockFn(), + get: mockFn(), + append: mockFn(), + head: mockFn(), + }; + client = new OSSObjectStorageClient(mockOSS as unknown as OSSObject); + }); + + describe('put', () => { + it('should pass Buffer to SDK put', async () => { + mockOSS.put.mockResolvedValue({}); + await client.put('threads/t1.json', '{"id":"t1"}'); + + assert.equal(mockOSS.put.mock.calls.length, 1); + const [key, body] = mockOSS.put.mock.calls[0]; + assert.equal(key, 'threads/t1.json'); + assert(Buffer.isBuffer(body)); + assert.equal(body.toString('utf-8'), '{"id":"t1"}'); + }); + }); + + describe('get', () => { + it('should return string when content is Buffer', async () => { + mockOSS.get.mockResolvedValue({ + content: Buffer.from('{"id":"t1"}', 'utf-8'), + }); + const result = await client.get('threads/t1.json'); + assert.equal(result, '{"id":"t1"}'); + }); + + it('should return string when content is non-Buffer', async () => { + mockOSS.get.mockResolvedValue({ + content: '{"id":"t1"}', + }); + const result = await client.get('threads/t1.json'); + assert.equal(result, '{"id":"t1"}'); + }); + + it('should return null when content is empty', async () => { + mockOSS.get.mockResolvedValue({ content: null }); + const result = await client.get('threads/t1.json'); + assert.equal(result, null); + }); + + it('should return null for NoSuchKey error', async () => { + const err = new Error('Object not exists'); + (err as Error & { code: string }).code = 'NoSuchKey'; + mockOSS.get.mockRejectedValue(err); + + const result = await client.get('threads/nonexistent.json'); + assert.equal(result, null); + }); + + it('should re-throw non-NoSuchKey errors', async () => { + const err = new Error('Network failure'); + mockOSS.get.mockRejectedValue(err); + + await assert.rejects( + () => client.get('threads/t1.json'), + (thrown: unknown) => { + assert(thrown instanceof Error); + assert.equal(thrown.message, 'Network failure'); + return true; + }, + ); + }); + }); + + describe('append', () => { + it('should create new object with position 0 on first append', async () => { + mockOSS.append.mockResolvedValue({ nextAppendPosition: '13' }); + await client.append('msgs.jsonl', '{"id":"m1"}\n'); + + assert.equal(mockOSS.append.mock.calls.length, 1); + const [key, buf, opts] = mockOSS.append.mock.calls[0]; + assert.equal(key, 'msgs.jsonl'); + assert(Buffer.isBuffer(buf)); + assert.equal(buf.toString('utf-8'), '{"id":"m1"}\n'); + assert.equal(opts.position, 0); + }); + + it('should use cached nextAppendPosition on subsequent appends', async () => { + mockOSS.append.mockResolvedValueOnce({ nextAppendPosition: '13' }); + await client.append('msgs.jsonl', '{"id":"m1"}\n'); + + mockOSS.append.mockResolvedValueOnce({ nextAppendPosition: '26' }); + await client.append('msgs.jsonl', '{"id":"m2"}\n'); + + assert.equal(mockOSS.append.mock.calls.length, 2); + assert.equal(mockOSS.append.mock.calls[1][2].position, 13); + }); + + it('should fall back to HEAD + retry on PositionNotEqualToLength', async () => { + const posErr = new Error('Position mismatch'); + (posErr as Error & { code: string }).code = 'PositionNotEqualToLength'; + mockOSS.append.mockRejectedValueOnce(posErr); + mockOSS.head.mockResolvedValue({ res: { headers: { 'content-length': '50' } } }); + mockOSS.append.mockResolvedValueOnce({ nextAppendPosition: '63' }); + + await client.append('msgs.jsonl', '{"id":"m1"}\n'); + + // First attempt failed, then HEAD, then retry + assert.equal(mockOSS.append.mock.calls.length, 2); + assert.equal(mockOSS.head.mock.calls.length, 1); + assert.equal(mockOSS.append.mock.calls[1][2].position, 50); + }); + + it('should re-throw non-position errors', async () => { + const err = new Error('Network failure'); + mockOSS.append.mockRejectedValue(err); + + await assert.rejects( + () => client.append('msgs.jsonl', '{"id":"m1"}\n'), + (thrown: unknown) => { + assert(thrown instanceof Error); + assert.equal(thrown.message, 'Network failure'); + return true; + }, + ); + }); + }); +}); diff --git a/core/agent-runtime/test/RunBuilder.test.ts b/core/agent-runtime/test/RunBuilder.test.ts new file mode 100644 index 000000000..802e82974 --- /dev/null +++ b/core/agent-runtime/test/RunBuilder.test.ts @@ -0,0 +1,261 @@ +import assert from 'node:assert'; + +import type { RunRecord, MessageObject } from '@eggjs/tegg-types/agent-runtime'; +import { RunStatus, AgentObjectType, AgentErrorCode } from '@eggjs/tegg-types/agent-runtime'; +import { InvalidRunStateTransitionError } from '@eggjs/tegg-types/agent-runtime'; + +import { RunBuilder } from '../src/RunBuilder'; +import type { RunUsage } from '../src/RunBuilder'; + +function makeRunRecord(overrides?: Partial): RunRecord { + return { + id: 'run_1', + object: AgentObjectType.ThreadRun, + threadId: 'thread_1', + status: RunStatus.Queued, + input: [{ role: 'user', content: 'hello' }], + createdAt: 1000, + ...overrides, + }; +} + +describe('test/RunBuilder.test.ts', () => { + describe('create and snapshot', () => { + it('should create from a queued RunRecord and produce a valid snapshot', () => { + const record = makeRunRecord(); + const rb = RunBuilder.create(record, 'thread_1'); + const snap = rb.snapshot(); + + assert.equal(snap.id, 'run_1'); + assert.equal(snap.object, AgentObjectType.ThreadRun); + assert.equal(snap.createdAt, 1000); + assert.equal(snap.threadId, 'thread_1'); + assert.equal(snap.status, RunStatus.Queued); + assert.equal(snap.startedAt, null); + assert.equal(snap.completedAt, null); + assert.equal(snap.cancelledAt, null); + assert.equal(snap.failedAt, null); + assert.equal(snap.usage, null); + assert.equal(snap.lastError, undefined); + }); + + it('should restore all mutable fields from a completed RunRecord', () => { + const record = makeRunRecord({ + status: RunStatus.Completed, + startedAt: 1001, + completedAt: 1002, + output: [ + { + id: 'msg_1', + object: 'thread.message', + createdAt: 1001, + role: 'assistant', + status: 'completed', + content: [], + }, + ], + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + metadata: { key: 'value' }, + config: { maxIterations: 10 }, + }); + const snap = RunBuilder.create(record, 'thread_1').snapshot(); + + assert.equal(snap.status, RunStatus.Completed); + assert.equal(snap.startedAt, 1001); + assert.equal(snap.completedAt, 1002); + assert.equal(snap.output?.length, 1); + assert.deepStrictEqual(snap.usage, { promptTokens: 10, completionTokens: 5, totalTokens: 15 }); + assert.deepStrictEqual(snap.metadata, { key: 'value' }); + assert.deepStrictEqual(snap.config, { maxIterations: 10 }); + }); + + it('should restore failed state with lastError', () => { + const record = makeRunRecord({ + status: RunStatus.Failed, + startedAt: 1001, + failedAt: 1003, + lastError: { code: 'EXEC_ERROR', message: 'boom' }, + }); + const snap = RunBuilder.create(record, 'thread_1').snapshot(); + + assert.equal(snap.status, RunStatus.Failed); + assert.equal(snap.failedAt, 1003); + assert.deepStrictEqual(snap.lastError, { code: 'EXEC_ERROR', message: 'boom' }); + }); + }); + + describe('start', () => { + it('should transition queued → in_progress', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + const update = rb.start(); + + assert.equal(update.status, RunStatus.InProgress); + assert.equal(typeof update.startedAt, 'number'); + assert.equal(rb.snapshot().status, RunStatus.InProgress); + }); + + it('should throw for non-queued status', () => { + const rb = RunBuilder.create(makeRunRecord({ status: RunStatus.InProgress }), 'thread_1'); + assert.throws(() => rb.start(), InvalidRunStateTransitionError); + }); + }); + + describe('complete', () => { + it('should transition in_progress → completed with output and usage', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + + const output: MessageObject[] = [ + { id: 'msg_1', object: 'thread.message', createdAt: 1001, role: 'assistant', status: 'completed', content: [] }, + ]; + const usage: RunUsage = { promptTokens: 10, completionTokens: 5, totalTokens: 15 }; + const update = rb.complete(output, usage); + + assert.equal(update.status, RunStatus.Completed); + assert.equal(typeof update.completedAt, 'number'); + assert.deepStrictEqual(update.usage, { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }); + assert.equal(update.output, output); + + const snap = rb.snapshot(); + assert.equal(snap.status, RunStatus.Completed); + assert.deepStrictEqual(snap.usage, { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }); + }); + + it('should complete without usage', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + + const update = rb.complete([]); + assert.equal(update.status, RunStatus.Completed); + assert.equal(update.usage, undefined); + + const snap = rb.snapshot(); + assert.equal(snap.usage, null); + }); + + it('should throw for non-in_progress status', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + assert.throws(() => rb.complete([]), InvalidRunStateTransitionError); + }); + }); + + describe('fail', () => { + it('should transition in_progress → failed with error', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + + const update = rb.fail(new Error('something broke')); + assert.equal(update.status, RunStatus.Failed); + assert.equal(typeof update.failedAt, 'number'); + assert.deepStrictEqual(update.lastError, { + code: AgentErrorCode.ExecError, + message: 'something broke', + }); + + const snap = rb.snapshot(); + assert.equal(snap.status, RunStatus.Failed); + }); + + it('should allow failing from queued status', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + const update = rb.fail(new Error('early failure')); + assert.equal(update.status, RunStatus.Failed); + }); + + it('should throw for terminal status', () => { + const rb = RunBuilder.create(makeRunRecord({ status: RunStatus.Completed }), 'thread_1'); + assert.throws(() => rb.fail(new Error('nope')), InvalidRunStateTransitionError); + }); + }); + + describe('cancelling', () => { + it('should transition in_progress → cancelling', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + + const update = rb.cancelling(); + assert.equal(update.status, RunStatus.Cancelling); + assert.equal(rb.snapshot().status, RunStatus.Cancelling); + }); + + it('should transition queued → cancelling', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + const update = rb.cancelling(); + assert.equal(update.status, RunStatus.Cancelling); + }); + + it('should be idempotent when already cancelling', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + rb.cancelling(); + const update = rb.cancelling(); + assert.equal(update.status, RunStatus.Cancelling); + }); + + it('should throw for terminal status', () => { + const rb = RunBuilder.create(makeRunRecord({ status: RunStatus.Completed }), 'thread_1'); + assert.throws(() => rb.cancelling(), InvalidRunStateTransitionError); + }); + }); + + describe('cancel', () => { + it('should transition cancelling → cancelled', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + rb.cancelling(); + + const update = rb.cancel(); + assert.equal(update.status, RunStatus.Cancelled); + assert.equal(typeof update.cancelledAt, 'number'); + + const snap = rb.snapshot(); + assert.equal(snap.status, RunStatus.Cancelled); + assert.equal(typeof snap.cancelledAt, 'number'); + }); + + it('should throw when not in cancelling status', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + assert.throws(() => rb.cancel(), InvalidRunStateTransitionError); + }); + }); + + describe('full lifecycle', () => { + it('should support queued → in_progress → completed', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + assert.equal(rb.snapshot().status, RunStatus.Queued); + + rb.start(); + assert.equal(rb.snapshot().status, RunStatus.InProgress); + + rb.complete([], { promptTokens: 1, completionTokens: 2, totalTokens: 3 }); + const snap = rb.snapshot(); + assert.equal(snap.status, RunStatus.Completed); + assert.ok(snap.startedAt); + assert.ok(snap.completedAt); + }); + + it('should support queued → in_progress → cancelling → cancelled', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + rb.cancelling(); + rb.cancel(); + assert.equal(rb.snapshot().status, RunStatus.Cancelled); + }); + + it('should support queued → in_progress → failed', () => { + const rb = RunBuilder.create(makeRunRecord(), 'thread_1'); + rb.start(); + rb.fail(new Error('err')); + assert.equal(rb.snapshot().status, RunStatus.Failed); + }); + }); +}); diff --git a/core/agent-runtime/test/helpers.ts b/core/agent-runtime/test/helpers.ts new file mode 100644 index 000000000..7f3b98d5b --- /dev/null +++ b/core/agent-runtime/test/helpers.ts @@ -0,0 +1,36 @@ +import type { ObjectStorageClient } from '@eggjs/tegg-types/agent-runtime'; + +/** In-memory ObjectStorageClient backed by a Map — for testing only. */ +export class MapStorageClient implements ObjectStorageClient { + private readonly store = new Map(); + init?(): Promise; + destroy?(): Promise; + + async put(key: string, value: string): Promise { + this.store.set(key, value); + } + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async append(key: string, value: string): Promise { + const existing = this.store.get(key) ?? ''; + this.store.set(key, existing + value); + } +} + +/** MapStorageClient variant without append — tests the get-concat-put fallback path. */ +export class MapStorageClientWithoutAppend implements ObjectStorageClient { + private readonly store = new Map(); + init?(): Promise; + destroy?(): Promise; + + async put(key: string, value: string): Promise { + this.store.set(key, value); + } + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } +} diff --git a/core/agent-runtime/tsconfig.json b/core/agent-runtime/tsconfig.json new file mode 100644 index 000000000..64b224050 --- /dev/null +++ b/core/agent-runtime/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "./" + }, + "exclude": [ + "dist", + "node_modules", + "test" + ] +} diff --git a/core/agent-runtime/tsconfig.pub.json b/core/agent-runtime/tsconfig.pub.json new file mode 100644 index 000000000..64b224050 --- /dev/null +++ b/core/agent-runtime/tsconfig.pub.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "./" + }, + "exclude": [ + "dist", + "node_modules", + "test" + ] +} diff --git a/core/controller-decorator/index.ts b/core/controller-decorator/index.ts index 2ad9ea417..03cd745ac 100644 --- a/core/controller-decorator/index.ts +++ b/core/controller-decorator/index.ts @@ -22,3 +22,6 @@ export * from './src/util/HTTPPriorityUtil'; export { default as ControllerInfoUtil } from './src/util/ControllerInfoUtil'; export { default as MethodInfoUtil } from './src/util/MethodInfoUtil'; + +export * from './src/decorator/agent'; +export { AgentInfoUtil } from './src/util/AgentInfoUtil'; diff --git a/core/controller-decorator/src/decorator/agent/AgentController.ts b/core/controller-decorator/src/decorator/agent/AgentController.ts new file mode 100644 index 000000000..1b193a22e --- /dev/null +++ b/core/controller-decorator/src/decorator/agent/AgentController.ts @@ -0,0 +1,146 @@ +import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator'; +import { StackUtil } from '@eggjs/tegg-common-util'; +import type { EggProtoImplClass } from '@eggjs/tegg-types'; +import { + AccessLevel, + AGENT_CONTROLLER_PROTO_IMPL_TYPE, + ControllerType, + HTTPMethodEnum, + HTTPParamType, +} from '@eggjs/tegg-types'; + +import { AgentInfoUtil } from '../../util/AgentInfoUtil'; +import ControllerInfoUtil from '../../util/ControllerInfoUtil'; +import HTTPInfoUtil from '../../util/HTTPInfoUtil'; +import MethodInfoUtil from '../../util/MethodInfoUtil'; + +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`); + }; + } + AgentInfoUtil.setNotImplemented(fn); + 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(): (constructor: EggProtoImplClass) => void { + return function (constructor: EggProtoImplClass): void { + // 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 with custom proto impl type + const func = SingletonProto({ + accessLevel: AccessLevel.PUBLIC, + protoImplType: AGENT_CONTROLLER_PROTO_IMPL_TYPE, + }); + 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 + 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 + AgentInfoUtil.setAgentController(constructor); + }; +} diff --git a/core/controller-decorator/src/decorator/agent/AgentHandler.ts b/core/controller-decorator/src/decorator/agent/AgentHandler.ts new file mode 100644 index 000000000..634950674 --- /dev/null +++ b/core/controller-decorator/src/decorator/agent/AgentHandler.ts @@ -0,0 +1,23 @@ +import type { + ThreadObject, + ThreadObjectWithMessages, + CreateRunInput, + RunObject, + AgentStreamMessage, +} from '@eggjs/tegg-types/agent-runtime'; + +// 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; + /** Create the AgentStore used to persist threads and runs. */ + 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/core/controller-decorator/src/decorator/agent/index.ts b/core/controller-decorator/src/decorator/agent/index.ts new file mode 100644 index 000000000..2d78e29a9 --- /dev/null +++ b/core/controller-decorator/src/decorator/agent/index.ts @@ -0,0 +1,2 @@ +export * from './AgentController'; +export * from './AgentHandler'; diff --git a/core/controller-decorator/src/util/AgentInfoUtil.ts b/core/controller-decorator/src/util/AgentInfoUtil.ts new file mode 100644 index 000000000..5d727a815 --- /dev/null +++ b/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 setAgentController(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/core/controller-decorator/test/AgentController.test.ts b/core/controller-decorator/test/AgentController.test.ts new file mode 100644 index 000000000..f754b6ef9 --- /dev/null +++ b/core/controller-decorator/test/AgentController.test.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; + +import { ControllerType, HTTPMethodEnum } from '@eggjs/tegg-types'; + +import { + AgentInfoUtil, + ControllerMetaBuilderFactory, + BodyParamMeta, + PathParamMeta, + ControllerInfoUtil, + MethodInfoUtil, +} from '../index'; +import HTTPInfoUtil from '../src/util/HTTPInfoUtil'; +import { HTTPControllerMeta } from '../src/model/index'; +import { AgentFooController } from './fixtures/AgentFooController'; + +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 metadata on the class', () => { + assert.strictEqual(AgentInfoUtil.isAgentController(AgentFooController), 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('AgentInfoUtil.setEnhanced / isEnhanced', () => { + it('should return false before setEnhanced is called', () => { + class NotEnhanced {} + assert.strictEqual(AgentInfoUtil.isEnhanced(NotEnhanced), false); + }); + + it('should return true after setEnhanced is called', () => { + class ToBeEnhanced {} + AgentInfoUtil.setEnhanced(ToBeEnhanced); + assert.strictEqual(AgentInfoUtil.isEnhanced(ToBeEnhanced), true); + }); + }); + + 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( + AgentInfoUtil.isNotImplemented(proto[methodName]), + 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/core/controller-decorator/test/fixtures/AgentFooController.ts b/core/controller-decorator/test/fixtures/AgentFooController.ts new file mode 100644 index 000000000..769828335 --- /dev/null +++ b/core/controller-decorator/test/fixtures/AgentFooController.ts @@ -0,0 +1,23 @@ +import type { CreateRunInput, AgentStreamMessage } from '@eggjs/tegg-types/agent-runtime'; + +import { AgentController } from '../../src/decorator/agent/AgentController'; +import type { AgentHandler } from '../../src/decorator/agent/AgentHandler'; + +// AgentController that only implements execRun (smart defaults pattern) +@AgentController() +export class AgentFooController implements AgentHandler { + async createStore(): Promise { + return new Map(); + } + + 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/core/runtime/index.ts b/core/runtime/index.ts index 4c941f919..456ef5d92 100644 --- a/core/runtime/index.ts +++ b/core/runtime/index.ts @@ -9,6 +9,7 @@ export * from './src/factory/EggContainerFactory'; export * from './src/factory/EggObjectFactory'; export * from './src/factory/LoadUnitInstanceFactory'; export * from './src/impl/ModuleLoadUnitInstance'; +export * from './src/impl/EggObjectUtil'; export * from './src/model/ContextHandler'; import './src/impl/EggAlwaysNewObjectContainer'; diff --git a/core/tegg/agent.ts b/core/tegg/agent.ts new file mode 100644 index 000000000..38552c0fd --- /dev/null +++ b/core/tegg/agent.ts @@ -0,0 +1,27 @@ +// AgentController decorator from controller-decorator +export { AgentController } from '@eggjs/controller-decorator'; +export type { AgentHandler } from '@eggjs/controller-decorator'; + +// Implementation classes from agent-runtime +export { AgentNotFoundError, AgentConflictError, HttpSSEWriter } from '@eggjs/agent-runtime'; + +// Types (re-exported from agent-runtime, which re-exports @eggjs/tegg-types) +export type { + AgentStore, + ThreadRecord, + RunRecord, + CreateRunInput, + AgentStreamMessage, + RunObject, + ThreadObject, + ThreadObjectWithMessages, + MessageObject, + InputMessage, + InputContentPart, + MessageContentBlock, + TextContentBlock, + MessageDeltaObject, + AgentRunConfig, + AgentRunUsage, + RunStatus, +} from '@eggjs/agent-runtime'; diff --git a/core/tegg/package.json b/core/tegg/package.json index 42b3a68ca..1a3f8cd0e 100644 --- a/core/tegg/package.json +++ b/core/tegg/package.json @@ -35,6 +35,7 @@ "node": ">=14.0.0" }, "dependencies": { + "@eggjs/agent-runtime": "^3.72.0", "@eggjs/ajv-decorator": "^3.72.0", "@eggjs/aop-decorator": "^3.72.0", "@eggjs/controller-decorator": "^3.72.0", diff --git a/core/types/agent-runtime/AgentMessage.ts b/core/types/agent-runtime/AgentMessage.ts new file mode 100644 index 000000000..5a0c6c4dc --- /dev/null +++ b/core/types/agent-runtime/AgentMessage.ts @@ -0,0 +1,39 @@ +// ===== Content block types ===== + +export const ContentBlockType = { + Text: 'text', +} as const; +export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType]; + +// ===== Content types ===== + +export interface InputContentPart { + type: typeof ContentBlockType.Text; + text: string; +} + +export interface TextContentBlock { + type: typeof ContentBlockType.Text; + text: { value: string; annotations: unknown[] }; +} + +export type MessageContentBlock = TextContentBlock; + +// ===== Input / Output message types ===== + +export interface InputMessage { + role: string; + content: string | { type: string; text: string }[]; + metadata?: Record; +} + +export interface MessageObject { + id: string; + object: string; + createdAt: number; + role: string; + status: string; + content: MessageContentBlock[]; + runId?: string; + threadId?: string; +} diff --git a/core/types/agent-runtime/AgentRuntime.ts b/core/types/agent-runtime/AgentRuntime.ts new file mode 100644 index 000000000..09c1de650 --- /dev/null +++ b/core/types/agent-runtime/AgentRuntime.ts @@ -0,0 +1,116 @@ +import type { InputContentPart, MessageContentBlock } from './AgentMessage'; +import type { AgentRunConfig, InputMessage, MessageObject, RunStatus } from './AgentStore'; + +export { ContentBlockType } from './AgentMessage'; +export type { InputContentPart, MessageContentBlock, TextContentBlock } from './AgentMessage'; + +// ===== 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]; + +// ===== SSE events ===== + +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]; + +// ===== Thread objects ===== + +export interface ThreadObject { + id: string; + object: 'thread'; + createdAt: number; + metadata: Record; +} + +export interface ThreadObjectWithMessages extends ThreadObject { + messages: MessageObject[]; +} + +// ===== Run objects ===== + +export interface RunObject { + id: string; + object: 'thread.run'; + createdAt: number; + threadId: string; + status: RunStatus; + lastError?: { code: string; message: string } | null; + startedAt?: number | null; + completedAt?: number | null; + cancelledAt?: number | null; + failedAt?: number | null; + usage?: { promptTokens: number; completionTokens: number; totalTokens: number } | null; + metadata?: Record; + output?: MessageObject[]; + config?: AgentRunConfig; +} + +// ===== Run input ===== + +export interface CreateRunInput { + threadId?: string; + input: { + messages: InputMessage[]; + }; + config?: AgentRunConfig; + metadata?: Record; +} + +// ===== Message delta ===== + +export interface MessageDeltaObject { + id: string; + object: 'thread.message.delta'; + delta: { + content: MessageContentBlock[]; + }; +} + +// ===== Stream message types ===== + +export interface AgentStreamMessagePayload { + role?: string; + content: string | InputContentPart[]; +} + +export interface AgentRunUsage { + promptTokens?: number; + completionTokens?: number; +} + +export interface AgentStreamMessage { + type?: string; + message?: AgentStreamMessagePayload; + usage?: AgentRunUsage; +} diff --git a/core/types/agent-runtime/AgentStore.ts b/core/types/agent-runtime/AgentStore.ts new file mode 100644 index 000000000..6237901bf --- /dev/null +++ b/core/types/agent-runtime/AgentStore.ts @@ -0,0 +1,84 @@ +import type { InputMessage, MessageObject } from './AgentMessage'; + +export type { InputMessage, MessageObject } from './AgentMessage'; + +// ===== 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]; + +// ===== Run statuses ===== + +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]; + +// ===== Run configuration ===== + +export interface AgentRunConfig { + maxIterations?: number; + timeoutMs?: number; +} + +// ===== Store records ===== + +export interface ThreadRecord { + id: string; + object: typeof AgentObjectType.Thread; + /** + * Logically belongs to the thread. In OSSAgentStore the messages are stored + * separately as a JSONL file and assembled on read — callers should treat + * this as a unified view regardless of the underlying storage layout. + */ + messages: MessageObject[]; + metadata: Record; + createdAt: number; // Unix seconds +} + +export interface RunRecord { + id: string; + object: typeof AgentObjectType.ThreadRun; + threadId?: string; + status: RunStatus; + input: InputMessage[]; + output?: MessageObject[]; + lastError?: { code: string; message: string } | null; + usage?: { promptTokens: number; completionTokens: number; totalTokens: number } | null; + config?: AgentRunConfig; + metadata?: Record; + createdAt: number; + startedAt?: number | null; + completedAt?: number | null; + cancelledAt?: number | null; + failedAt?: number | null; +} + +// ===== Store interface ===== + +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/core/types/agent-runtime/ObjectStorageClient.ts b/core/types/agent-runtime/ObjectStorageClient.ts new file mode 100644 index 000000000..3d766eb35 --- /dev/null +++ b/core/types/agent-runtime/ObjectStorageClient.ts @@ -0,0 +1,27 @@ +/** + * Abstract interface for object storage operations (e.g., OSS, S3, local fs). + * Implementations must handle serialization/deserialization of values. + */ +export interface ObjectStorageClient { + init?(): Promise; + destroy?(): Promise; + + /** Overwrite (or create) the object at `key` with `value`. */ + put(key: string, value: string): Promise; + + /** Read the full object at `key`. Returns `null` if the object does not exist. */ + get(key: string): Promise; + + /** + * Append `value` to an existing Appendable Object. + * If the object does not exist yet, create it. + * + * Used by OSSAgentStore to incrementally write JSONL message lines without + * reading the entire thread — much more efficient than read-modify-write for + * append-only workloads. + * + * This method is optional: when absent, OSSAgentStore falls back to + * get-concat-put (which is NOT atomic under concurrent writers). + */ + append?(key: string, value: string): Promise; +} diff --git a/core/types/agent-runtime/errors.ts b/core/types/agent-runtime/errors.ts new file mode 100644 index 000000000..76fbf3265 --- /dev/null +++ b/core/types/agent-runtime/errors.ts @@ -0,0 +1,39 @@ +/** + * 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'; + } +} + +/** + * Error thrown when a RunBuilder state transition is invalid + * (e.g., calling `complete()` on a queued run). + */ +export class InvalidRunStateTransitionError extends Error { + status: number = 409; + + constructor(from: string, to: string) { + super(`Invalid run state transition: '${from}' -> '${to}'`); + this.name = 'InvalidRunStateTransitionError'; + } +} diff --git a/core/types/agent-runtime/index.ts b/core/types/agent-runtime/index.ts new file mode 100644 index 000000000..c790a87e1 --- /dev/null +++ b/core/types/agent-runtime/index.ts @@ -0,0 +1,5 @@ +export * from './AgentMessage'; +export * from './AgentStore'; +export * from './AgentRuntime'; +export * from './ObjectStorageClient'; +export * from './errors'; diff --git a/core/types/controller-decorator/MetadataKey.ts b/core/types/controller-decorator/MetadataKey.ts index 0b48a4947..5eb77550e 100644 --- a/core/types/controller-decorator/MetadataKey.ts +++ b/core/types/controller-decorator/MetadataKey.ts @@ -36,3 +36,9 @@ export const CONTROLLER_MCP_EXTRA_INDEX = Symbol.for('EggPrototype#controller#mc export const CONTROLLER_MCP_PROMPT_MAP = Symbol.for('EggPrototype#controller#mcp#prompt'); export const CONTROLLER_MCP_PROMPT_PARAMS_MAP = Symbol.for('EggPrototype#controller#mcp#prompt#params'); export const CONTROLLER_MCP_PROMPT_ARGS_INDEX = Symbol.for('EggPrototype#controller#mcp#prompt#args'); + +export const CONTROLLER_AGENT_CONTROLLER = Symbol.for('EggPrototype#controller#agent#isAgent'); +export const CONTROLLER_AGENT_NOT_IMPLEMENTED = Symbol.for('EggPrototype#controller#agent#notImplemented'); +export const CONTROLLER_AGENT_ENHANCED = Symbol.for('EggPrototype#controller#agent#enhanced'); + +export const AGENT_CONTROLLER_PROTO_IMPL_TYPE = 'AGENT_CONTROLLER_PROTO'; diff --git a/core/types/index.ts b/core/types/index.ts index bc2a0a738..6fa75b637 100644 --- a/core/types/index.ts +++ b/core/types/index.ts @@ -10,3 +10,4 @@ export * from './orm'; export * from './runtime'; export * from './schedule'; export * from './transaction'; +export * from './agent-runtime'; diff --git a/plugin/controller/app.ts b/plugin/controller/app.ts index 663601a41..5093705da 100644 --- a/plugin/controller/app.ts +++ b/plugin/controller/app.ts @@ -12,6 +12,9 @@ import { EggControllerPrototypeHook } from './lib/EggControllerPrototypeHook'; import { RootProtoManager } from './lib/RootProtoManager'; import { EggControllerLoader } from './lib/EggControllerLoader'; import { middlewareGraphHook } from './lib/MiddlewareGraphHook'; +import { AGENT_CONTROLLER_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; +import { AgentControllerProto } from './lib/AgentControllerProto'; +import { AgentControllerObject } from './lib/AgentControllerObject'; import assert from 'node:assert'; // Load Controller process @@ -37,6 +40,11 @@ export default class ControllerAppBootHook { 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); if (parseInt(process.versions.node.split('.')[0], 10) >= 18) { // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -47,6 +55,7 @@ export default class ControllerAppBootHook { configWillLoad() { 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/plugin/controller/lib/AgentControllerObject.ts b/plugin/controller/lib/AgentControllerObject.ts new file mode 100644 index 000000000..95300e984 --- /dev/null +++ b/plugin/controller/lib/AgentControllerObject.ts @@ -0,0 +1,259 @@ +import { AgentRuntime, type AgentExecutor, AGENT_RUNTIME, HttpSSEWriter } from '@eggjs/agent-runtime'; +import { AgentInfoUtil } from '@eggjs/tegg'; +import { IdenticalUtil } from '@eggjs/tegg'; +import { LoadUnitFactory } from '@eggjs/tegg-metadata'; +import { EGG_CONTEXT } from '@eggjs/egg-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 type { AgentStore, CreateRunInput } from '@eggjs/tegg-types/agent-runtime'; +import type { EggLogger } from 'egg'; + +import { AgentControllerProto } from './AgentControllerProto'; + +/** 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: EggLogger; + + 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: EggLogger): 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) { + // Clean up runtime if it was created but init failed + if (this.runtime) { + try { + await this.runtime.destroy(); + } catch { + // Swallow cleanup errors to preserve the original exception + } + this.runtime = undefined; + } + 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 — user must implement createStore() + let store: AgentStore; + const createStoreFn = instance['createStore']; + if (typeof createStoreFn === 'function') { + store = (await Reflect.apply(createStoreFn, this._obj, [])) as AgentStore; + } else { + throw new Error( + '@AgentController requires a createStore() method. ' + + 'Implement createStore() in your controller to provide an AgentStore instance.', + ); + } + if (store.init) { + await store.init(); + } + + // Create runtime with AgentRuntime.create factory + const runtime = AgentRuntime.create({ + executor: this._obj as AgentExecutor, + 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 HttpSSEWriter 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 HttpSSEWriter(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/plugin/controller/lib/AgentControllerProto.ts b/plugin/controller/lib/AgentControllerProto.ts new file mode 100644 index 000000000..a62aa74ef --- /dev/null +++ b/plugin/controller/lib/AgentControllerProto.ts @@ -0,0 +1,105 @@ +import { EggPrototypeCreatorFactory } from '@eggjs/tegg-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 + * copied from the delegate in the constructor via Object.defineProperty. + */ +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/plugin/controller/package.json b/plugin/controller/package.json index 9c6fcb664..f762479ed 100644 --- a/plugin/controller/package.json +++ b/plugin/controller/package.json @@ -48,6 +48,7 @@ "node": ">=14.0.0" }, "dependencies": { + "@eggjs/agent-runtime": "^3.72.0", "@eggjs/egg-module-common": "^3.72.0", "@eggjs/router": "^2.0.1", "@eggjs/tegg": "^3.72.0", diff --git a/plugin/controller/test/lib/AgentControllerProto.test.ts b/plugin/controller/test/lib/AgentControllerProto.test.ts new file mode 100644 index 000000000..817707ef7 --- /dev/null +++ b/plugin/controller/test/lib/AgentControllerProto.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; + +import { EggPrototypeCreatorFactory } from '@eggjs/tegg-metadata'; +import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/tegg-types'; +import { DEFAULT_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; + +import { AgentControllerProto } from '../../lib/AgentControllerProto'; + +function createMockDelegate(): EggPrototype { + const sym1 = Symbol('qualifier1'); + const sym2 = Symbol('qualifier2'); + + const delegate = { + id: 'mock-id', + name: 'mockProto', + initType: 'SINGLETON', + accessLevel: 'PUBLIC', + loadUnitId: 'load-unit-1', + injectObjects: [{ refName: 'dep1' }], + injectType: 'PROPERTY', + className: 'MockClass', + multiInstanceConstructorIndex: undefined, + multiInstanceConstructorAttributes: undefined, + + getMetaData(key: string) { + if (key === 'testKey') return 'testValue'; + return undefined; + }, + verifyQualifier(q: { attribute: string }) { + return q.attribute === 'valid'; + }, + verifyQualifiers(qs: Array<{ attribute: string }>) { + return qs.every((q) => q.attribute === 'valid'); + }, + getQualifier(attr: string) { + if (attr === 'env') return 'prod'; + return undefined; + }, + constructEggObject(...args: any[]) { + return { constructed: true, args }; + }, + } as unknown as EggPrototype; + + // Add symbol-keyed properties to test symbol copying + Object.defineProperty(delegate, sym1, { value: 'value1', enumerable: false }); + Object.defineProperty(delegate, sym2, { value: 'value2', enumerable: false }); + + return delegate; +} + +describe('plugin/controller/test/lib/AgentControllerProto.test.ts', () => { + describe('constructor delegation', () => { + it('should be an instance of AgentControllerProto', () => { + const delegate = createMockDelegate(); + const proto = new AgentControllerProto(delegate); + assert(proto instanceof AgentControllerProto); + }); + + it('should copy symbol-keyed properties from delegate', () => { + const delegate = createMockDelegate(); + const symbols = Object.getOwnPropertySymbols(delegate); + assert(symbols.length >= 2, 'delegate should have symbol properties'); + + const proto = new AgentControllerProto(delegate); + for (const sym of symbols) { + assert.strictEqual((proto as any)[sym], (delegate as any)[sym]); + } + }); + }); + + describe('getter delegation', () => { + const delegate = createMockDelegate(); + const proto = new AgentControllerProto(delegate); + + it('should delegate id', () => { + assert.strictEqual(proto.id, 'mock-id'); + }); + + it('should delegate name', () => { + assert.strictEqual(proto.name, 'mockProto'); + }); + + it('should delegate initType', () => { + assert.strictEqual(proto.initType, 'SINGLETON'); + }); + + it('should delegate accessLevel', () => { + assert.strictEqual(proto.accessLevel, 'PUBLIC'); + }); + + it('should delegate loadUnitId', () => { + assert.strictEqual(proto.loadUnitId, 'load-unit-1'); + }); + + it('should delegate injectObjects', () => { + assert.deepStrictEqual(proto.injectObjects, [{ refName: 'dep1' }]); + }); + + it('should delegate injectType', () => { + assert.strictEqual(proto.injectType, 'PROPERTY'); + }); + + it('should delegate className', () => { + assert.strictEqual(proto.className, 'MockClass'); + }); + + it('should delegate multiInstanceConstructorIndex', () => { + assert.strictEqual(proto.multiInstanceConstructorIndex, undefined); + }); + + it('should delegate multiInstanceConstructorAttributes', () => { + assert.strictEqual(proto.multiInstanceConstructorAttributes, undefined); + }); + }); + + describe('method delegation', () => { + const delegate = createMockDelegate(); + const proto = new AgentControllerProto(delegate); + + it('should delegate getMetaData', () => { + assert.strictEqual(proto.getMetaData('testKey'), 'testValue'); + assert.strictEqual(proto.getMetaData('unknown'), undefined); + }); + + it('should delegate verifyQualifier', () => { + assert.strictEqual(proto.verifyQualifier({ attribute: 'valid' } as any), true); + assert.strictEqual(proto.verifyQualifier({ attribute: 'invalid' } as any), false); + }); + + it('should delegate verifyQualifiers', () => { + assert.strictEqual(proto.verifyQualifiers([{ attribute: 'valid' }] as any), true); + assert.strictEqual(proto.verifyQualifiers([{ attribute: 'invalid' }] as any), false); + }); + + it('should delegate getQualifier', () => { + assert.strictEqual(proto.getQualifier('env'), 'prod'); + assert.strictEqual(proto.getQualifier('missing'), undefined); + }); + + it('should delegate constructEggObject', () => { + const result = proto.constructEggObject('a', 'b'); + assert.deepStrictEqual(result, { constructed: true, args: ['a', 'b'] }); + }); + }); + + describe('static createProto', () => { + const mockDelegate = createMockDelegate(); + let originalCreator: ReturnType; + + before(() => { + originalCreator = EggPrototypeCreatorFactory.getPrototypeCreator(DEFAULT_PROTO_IMPL_TYPE); + EggPrototypeCreatorFactory.registerPrototypeCreator(DEFAULT_PROTO_IMPL_TYPE, () => mockDelegate); + }); + + after(() => { + if (originalCreator) { + EggPrototypeCreatorFactory.registerPrototypeCreator(DEFAULT_PROTO_IMPL_TYPE, originalCreator); + } + }); + + it('should create an AgentControllerProto wrapping the default creator result', () => { + const ctx = {} as EggPrototypeLifecycleContext; + const proto = AgentControllerProto.createProto(ctx); + assert(proto instanceof AgentControllerProto); + assert.strictEqual(proto.id, mockDelegate.id); + assert.strictEqual(proto.name, mockDelegate.name); + }); + + it('should throw when default creator is not registered', () => { + // Temporarily remove the creator + const saved = EggPrototypeCreatorFactory.getPrototypeCreator(DEFAULT_PROTO_IMPL_TYPE); + EggPrototypeCreatorFactory.registerPrototypeCreator(DEFAULT_PROTO_IMPL_TYPE, undefined as any); + // Force the map entry to be deleted so getPrototypeCreator returns undefined + (EggPrototypeCreatorFactory as any).creatorMap.delete(DEFAULT_PROTO_IMPL_TYPE); + + try { + assert.throws( + () => AgentControllerProto.createProto({} as EggPrototypeLifecycleContext), + /Default prototype creator.*not registered/, + ); + } finally { + // Restore + if (saved) { + EggPrototypeCreatorFactory.registerPrototypeCreator(DEFAULT_PROTO_IMPL_TYPE, saved); + } + } + }); + }); +}); From 8187cb341ea7ad4de163b23e52d5d4787d4c2c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=97=B0=E6=98=8E?= Date: Sun, 15 Mar 2026 20:24:30 +0800 Subject: [PATCH 2/2] fix: resolve ESLint errors for CI compliance Fix formatting and code style issues flagged by eslint-config-egg/typescript: - arrow-parens, array-bracket-spacing, indent, dot-notation, generator-star-spacing - Remove unused destructured variables in OSSAgentStore - Remove duplicate type re-exports in agent-runtime types - Add eslint-disable for intentional unused parameter in AgentController stub Co-Authored-By: Claude Opus 4.6 --- core/agent-runtime/src/AgentRuntime.ts | 13 ++++--- core/agent-runtime/src/MessageConverter.ts | 14 ++++---- core/agent-runtime/src/OSSAgentStore.ts | 22 ++++++------ core/agent-runtime/src/RunBuilder.ts | 3 +- core/agent-runtime/test/AgentRuntime.test.ts | 21 ++++++----- core/agent-runtime/test/HttpSSEWriter.test.ts | 4 +-- core/agent-runtime/test/OSSAgentStore.test.ts | 3 +- .../test/OSSObjectStorageClient.test.ts | 4 +-- core/agent-runtime/test/RunBuilder.test.ts | 3 +- .../src/decorator/agent/AgentController.ts | 7 ++-- .../test/AgentController.test.ts | 36 +++++++++---------- core/types/agent-runtime/AgentRuntime.ts | 7 ++-- core/types/agent-runtime/AgentStore.ts | 2 -- core/types/agent-runtime/errors.ts | 6 ++-- .../controller/lib/AgentControllerObject.ts | 11 +++--- .../test/lib/AgentControllerProto.test.ts | 4 +-- 16 files changed, 75 insertions(+), 85 deletions(-) diff --git a/core/agent-runtime/src/AgentRuntime.ts b/core/agent-runtime/src/AgentRuntime.ts index 48d4dc90e..9b08f91bf 100644 --- a/core/agent-runtime/src/AgentRuntime.ts +++ b/core/agent-runtime/src/AgentRuntime.ts @@ -9,8 +9,7 @@ import type { AgentStreamMessage, AgentStore, } from '@eggjs/tegg-types/agent-runtime'; -import { RunStatus, AgentSSEEvent, AgentObjectType } from '@eggjs/tegg-types/agent-runtime'; -import { AgentConflictError } from '@eggjs/tegg-types/agent-runtime'; +import { RunStatus, AgentSSEEvent, AgentObjectType, AgentConflictError } from '@eggjs/tegg-types/agent-runtime'; import type { EggLogger } from 'egg-logger'; import { newMsgId } from './AgentStoreUtils'; @@ -108,7 +107,7 @@ export class AgentRuntime { // Use a real pending promise (not Promise.resolve()) so cancelRun's // `await task.promise` blocks until syncRun's try/finally completes. let resolveTask!: () => void; - const taskPromise = new Promise((r) => { + const taskPromise = new Promise(r => { resolveTask = r; }); this.runningTasks.set(run.id, { promise: taskPromise, abortController }); @@ -173,7 +172,7 @@ export class AgentRuntime { // Register in runningTasks before the IIFE starts executing to avoid a race // where the IIFE's finally block deletes the entry before it is set. let resolveTask!: () => void; - const taskPromise = new Promise((r) => { + const taskPromise = new Promise(r => { resolveTask = r; }); this.runningTasks.set(run.id, { promise: taskPromise, abortController }); @@ -242,7 +241,7 @@ export class AgentRuntime { // Register in runningTasks so cancelRun/destroy can manage streaming runs. let resolveTask!: () => void; - const taskPromise = new Promise((r) => { + const taskPromise = new Promise(r => { resolveTask = r; }); this.runningTasks.set(run.id, { promise: taskPromise, abortController }); @@ -290,7 +289,7 @@ export class AgentRuntime { // Persist and emit completion — append messages before marking run as completed // so a failure leaves the run in_progress (retryable) instead of completed-but-incomplete. // TODO(atomicity): add aggregate store method for full transactional guarantee. - const output: MessageObject[] = content.length > 0 ? [completedMsg] : []; + const output: MessageObject[] = content.length > 0 ? [ completedMsg ] : []; await this.store.appendMessages(threadId, [ ...MessageConverter.toInputMessageObjects(input.input.messages, threadId), ...output, @@ -431,7 +430,7 @@ export class AgentRuntime { /** 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); + const pending = Array.from(this.runningTasks.values()).map(t => t.promise); await Promise.allSettled(pending); } } diff --git a/core/agent-runtime/src/MessageConverter.ts b/core/agent-runtime/src/MessageConverter.ts index c74c5a801..31c930b59 100644 --- a/core/agent-runtime/src/MessageConverter.ts +++ b/core/agent-runtime/src/MessageConverter.ts @@ -22,8 +22,8 @@ export class MessageConverter { } if (Array.isArray(content)) { return content - .filter((part) => part.type === ContentBlockType.Text) - .map((part) => ({ type: ContentBlockType.Text, text: { value: part.text, annotations: [] } })); + .filter(part => part.type === ContentBlockType.Text) + .map(part => ({ type: ContentBlockType.Text, text: { value: part.text, annotations: [] } })); } return []; } @@ -50,9 +50,9 @@ export class MessageConverter { messages: AgentStreamMessage[], runId?: string, ): { - output: MessageObject[]; - usage?: RunUsage; - } { + output: MessageObject[]; + usage?: RunUsage; + } { const output: MessageObject[] = []; let promptTokens = 0; let completionTokens = 0; @@ -113,7 +113,7 @@ export class MessageConverter { (m): m is typeof m & { role: Exclude } => m.role !== MessageRole.System, ) - .map((m) => ({ + .map(m => ({ id: newMsgId(), object: AgentObjectType.ThreadMessage, createdAt: nowUnix(), @@ -123,7 +123,7 @@ export class MessageConverter { content: typeof m.content === 'string' ? [{ type: ContentBlockType.Text, text: { value: m.content, annotations: [] } }] - : m.content.map((p) => ({ type: ContentBlockType.Text, text: { value: p.text, annotations: [] } })), + : m.content.map(p => ({ type: ContentBlockType.Text, text: { value: p.text, annotations: [] } })), })); } } diff --git a/core/agent-runtime/src/OSSAgentStore.ts b/core/agent-runtime/src/OSSAgentStore.ts index a3d6768cf..37497de06 100644 --- a/core/agent-runtime/src/OSSAgentStore.ts +++ b/core/agent-runtime/src/OSSAgentStore.ts @@ -5,10 +5,8 @@ import type { MessageObject, RunRecord, ThreadRecord, -} from '@eggjs/tegg-types/agent-runtime'; -import { AgentObjectType, RunStatus } from '@eggjs/tegg-types/agent-runtime'; -import { AgentNotFoundError } from '@eggjs/tegg-types/agent-runtime'; -import type { ObjectStorageClient } from '@eggjs/tegg-types/agent-runtime'; + ObjectStorageClient } from '@eggjs/tegg-types/agent-runtime'; +import { AgentObjectType, RunStatus, AgentNotFoundError } from '@eggjs/tegg-types/agent-runtime'; import { nowUnix, newThreadId, newRunId } from './AgentStoreUtils'; @@ -108,7 +106,7 @@ export class OSSAgentStore implements AgentStore { } async getThread(threadId: string): Promise { - const [metaData, messagesData] = await Promise.all([ + const [ metaData, messagesData ] = await Promise.all([ this.client.get(this.threadMetaKey(threadId)), this.client.get(this.threadMessagesKey(threadId)), ]); @@ -120,10 +118,10 @@ export class OSSAgentStore implements AgentStore { // Parse messages JSONL — may not exist yet if no messages were appended. const messages: MessageObject[] = messagesData ? messagesData - .trim() - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as MessageObject) + .trim() + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as MessageObject) : []; return { ...meta, messages }; @@ -145,7 +143,7 @@ export class OSSAgentStore implements AgentStore { } if (messages.length === 0) return; - const lines = messages.map((m) => JSON.stringify(m)).join('\n') + '\n'; + const lines = messages.map(m => JSON.stringify(m)).join('\n') + '\n'; const messagesKey = this.threadMessagesKey(threadId); if (this.client.append) { @@ -195,7 +193,9 @@ export class OSSAgentStore implements AgentStore { // conditional writes with retry, or use a database-backed AgentStore instead. async updateRun(runId: string, updates: Partial): Promise { const run = await this.getRun(runId); - const { id: _, object: __, ...safeUpdates } = updates; + const safeUpdates = { ...updates }; + delete safeUpdates.id; + delete (safeUpdates as any).object; Object.assign(run, safeUpdates); await this.client.put(this.runKey(runId), JSON.stringify(run)); } diff --git a/core/agent-runtime/src/RunBuilder.ts b/core/agent-runtime/src/RunBuilder.ts index 4185f295a..ee6508565 100644 --- a/core/agent-runtime/src/RunBuilder.ts +++ b/core/agent-runtime/src/RunBuilder.ts @@ -1,6 +1,5 @@ import type { MessageObject, RunObject, RunRecord, AgentRunConfig } from '@eggjs/tegg-types/agent-runtime'; -import { RunStatus, AgentErrorCode, AgentObjectType } from '@eggjs/tegg-types/agent-runtime'; -import { InvalidRunStateTransitionError } from '@eggjs/tegg-types/agent-runtime'; +import { RunStatus, AgentErrorCode, AgentObjectType, InvalidRunStateTransitionError } from '@eggjs/tegg-types/agent-runtime'; import { nowUnix } from './AgentStoreUtils'; diff --git a/core/agent-runtime/test/AgentRuntime.test.ts b/core/agent-runtime/test/AgentRuntime.test.ts index b645abf89..c8335cb68 100644 --- a/core/agent-runtime/test/AgentRuntime.test.ts +++ b/core/agent-runtime/test/AgentRuntime.test.ts @@ -8,9 +8,8 @@ import { MessageRole, MessageStatus, ContentBlockType, -} from '@eggjs/tegg-types/agent-runtime'; + AgentNotFoundError, AgentConflictError } from '@eggjs/tegg-types/agent-runtime'; import type { RunRecord, RunObject, CreateRunInput, AgentStreamMessage } from '@eggjs/tegg-types/agent-runtime'; -import { AgentNotFoundError, AgentConflictError } from '@eggjs/tegg-types/agent-runtime'; import { AgentRuntime } from '../src/AgentRuntime'; import type { AgentExecutor, AgentRuntimeOptions } from '../src/AgentRuntime'; @@ -103,7 +102,7 @@ describe('test/AgentRuntime.test.ts', () => { beforeEach(() => { store = new OSSAgentStore({ client: new MapStorageClient() }); executor = { - async *execRun(input: CreateRunInput): AsyncGenerator { + async* execRun(input: CreateRunInput): AsyncGenerator { const messages = input.input.messages; yield { message: { @@ -322,7 +321,7 @@ describe('test/AgentRuntime.test.ts', () => { const writer = new MockSSEWriter(); await runtime.streamRun({ input: { messages: [{ role: 'user', content: 'Hi' }] } }, writer); - const eventNames = writer.events.map((e) => e.event); + const eventNames = writer.events.map(e => e.event); assert(eventNames.includes(AgentSSEEvent.ThreadRunCreated)); assert(eventNames.includes(AgentSSEEvent.ThreadRunInProgress)); assert(eventNames.includes(AgentSSEEvent.ThreadMessageCreated)); @@ -348,17 +347,17 @@ describe('test/AgentRuntime.test.ts', () => { assert(runCompletedIdx < doneIdx); // Verify messages persisted to thread (consistent with syncRun/asyncRun tests) - const runCreatedEvent = writer.events.find((e) => e.event === AgentSSEEvent.ThreadRunCreated); + const runCreatedEvent = writer.events.find(e => e.event === AgentSSEEvent.ThreadRunCreated); const threadId = (runCreatedEvent!.data as RunObject).threadId; const thread = await runtime.getThread(threadId); assert.equal(thread.messages.length, 2); - assert.equal(thread.messages[0]['role'], MessageRole.User); - assert.equal(thread.messages[1]['role'], MessageRole.Assistant); + assert.equal(thread.messages[0].role, MessageRole.User); + assert.equal(thread.messages[1].role, MessageRole.Assistant); }); it('should emit cancelled event on client disconnect', async () => { let resolveYielded!: () => void; - const yieldedPromise = new Promise((r) => { + const yieldedPromise = new Promise(r => { resolveYielded = r; }); @@ -368,7 +367,7 @@ describe('test/AgentRuntime.test.ts', () => { ): AsyncGenerator { yield { message: { role: MessageRole.Assistant, content: [{ type: 'text', text: 'start' }] } }; resolveYielded(); - await new Promise((resolve) => { + await new Promise(resolve => { const timer = globalThis.setTimeout(resolve, 5000); if (signal) { signal.addEventListener( @@ -392,7 +391,7 @@ describe('test/AgentRuntime.test.ts', () => { await streamPromise; - const eventNames = writer.events.map((e) => e.event); + const eventNames = writer.events.map(e => e.event); assert(eventNames.includes(AgentSSEEvent.ThreadRunCreated)); assert(eventNames.includes(AgentSSEEvent.ThreadRunInProgress)); }); @@ -405,7 +404,7 @@ describe('test/AgentRuntime.test.ts', () => { const writer = new MockSSEWriter(); await runtime.streamRun({ input: { messages: [{ role: 'user', content: 'Hi' }] } }, writer); - const eventNames = writer.events.map((e) => e.event); + const eventNames = writer.events.map(e => e.event); assert(eventNames.includes(AgentSSEEvent.ThreadRunFailed)); assert(eventNames.includes(AgentSSEEvent.Done)); assert(writer.closed); diff --git a/core/agent-runtime/test/HttpSSEWriter.test.ts b/core/agent-runtime/test/HttpSSEWriter.test.ts index 2bef41ba0..eb568bcdd 100644 --- a/core/agent-runtime/test/HttpSSEWriter.test.ts +++ b/core/agent-runtime/test/HttpSSEWriter.test.ts @@ -54,7 +54,7 @@ describe('test/HttpSSEWriter.test.ts', () => { assert.ok(res.writtenHead); assert.equal(res.writtenHead.headers['content-type'], 'text/event-stream'); assert.equal(res.writtenHead.headers['cache-control'], 'no-cache'); - assert.equal(res.writtenHead.headers['connection'], 'keep-alive'); + assert.equal(res.writtenHead.headers.connection, 'keep-alive'); }); it('should format SSE events correctly', () => { @@ -88,7 +88,7 @@ describe('test/HttpSSEWriter.test.ts', () => { res.emit('close'); - assert.deepStrictEqual(calls, [1, 2]); + assert.deepStrictEqual(calls, [ 1, 2 ]); }); it('should handle end() idempotently', () => { diff --git a/core/agent-runtime/test/OSSAgentStore.test.ts b/core/agent-runtime/test/OSSAgentStore.test.ts index 9f2e73bee..b0da0b5a8 100644 --- a/core/agent-runtime/test/OSSAgentStore.test.ts +++ b/core/agent-runtime/test/OSSAgentStore.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert'; -import { AgentNotFoundError } from '../index'; -import { OSSAgentStore } from '../index'; +import { AgentNotFoundError, OSSAgentStore } from '../index'; import { MapStorageClient, MapStorageClientWithoutAppend } from './helpers'; describe('test/OSSAgentStore.test.ts', () => { diff --git a/core/agent-runtime/test/OSSObjectStorageClient.test.ts b/core/agent-runtime/test/OSSObjectStorageClient.test.ts index fd0f298d3..0f138e045 100644 --- a/core/agent-runtime/test/OSSObjectStorageClient.test.ts +++ b/core/agent-runtime/test/OSSObjectStorageClient.test.ts @@ -69,7 +69,7 @@ describe('test/OSSObjectStorageClient.test.ts', () => { await client.put('threads/t1.json', '{"id":"t1"}'); assert.equal(mockOSS.put.mock.calls.length, 1); - const [key, body] = mockOSS.put.mock.calls[0]; + const [ key, body ] = mockOSS.put.mock.calls[0]; assert.equal(key, 'threads/t1.json'); assert(Buffer.isBuffer(body)); assert.equal(body.toString('utf-8'), '{"id":"t1"}'); @@ -129,7 +129,7 @@ describe('test/OSSObjectStorageClient.test.ts', () => { await client.append('msgs.jsonl', '{"id":"m1"}\n'); assert.equal(mockOSS.append.mock.calls.length, 1); - const [key, buf, opts] = mockOSS.append.mock.calls[0]; + const [ key, buf, opts ] = mockOSS.append.mock.calls[0]; assert.equal(key, 'msgs.jsonl'); assert(Buffer.isBuffer(buf)); assert.equal(buf.toString('utf-8'), '{"id":"m1"}\n'); diff --git a/core/agent-runtime/test/RunBuilder.test.ts b/core/agent-runtime/test/RunBuilder.test.ts index 802e82974..87db4bf76 100644 --- a/core/agent-runtime/test/RunBuilder.test.ts +++ b/core/agent-runtime/test/RunBuilder.test.ts @@ -1,8 +1,7 @@ import assert from 'node:assert'; import type { RunRecord, MessageObject } from '@eggjs/tegg-types/agent-runtime'; -import { RunStatus, AgentObjectType, AgentErrorCode } from '@eggjs/tegg-types/agent-runtime'; -import { InvalidRunStateTransitionError } from '@eggjs/tegg-types/agent-runtime'; +import { RunStatus, AgentObjectType, AgentErrorCode, InvalidRunStateTransitionError } from '@eggjs/tegg-types/agent-runtime'; import { RunBuilder } from '../src/RunBuilder'; import type { RunUsage } from '../src/RunBuilder'; diff --git a/core/controller-decorator/src/decorator/agent/AgentController.ts b/core/controller-decorator/src/decorator/agent/AgentController.ts index 1b193a22e..21e806c68 100644 --- a/core/controller-decorator/src/decorator/agent/AgentController.ts +++ b/core/controller-decorator/src/decorator/agent/AgentController.ts @@ -30,11 +30,12 @@ interface AgentRouteDefinition { function createNotImplemented(methodName: string, hasParam: boolean) { let fn; if (hasParam) { - fn = async function (_arg: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fn = async function(_arg: unknown) { throw new Error(`${methodName} not implemented`); }; } else { - fn = async function () { + fn = async function() { throw new Error(`${methodName} not implemented`); }; } @@ -97,7 +98,7 @@ const AGENT_ROUTES: AgentRouteDefinition[] = [ ]; export function AgentController(): (constructor: EggProtoImplClass) => void { - return function (constructor: EggProtoImplClass): void { + return function(constructor: EggProtoImplClass): void { // Set controller type as HTTP so existing infrastructure handles it ControllerInfoUtil.setControllerType(constructor, ControllerType.HTTP); diff --git a/core/controller-decorator/test/AgentController.test.ts b/core/controller-decorator/test/AgentController.test.ts index f754b6ef9..979dfb0c5 100644 --- a/core/controller-decorator/test/AgentController.test.ts +++ b/core/controller-decorator/test/AgentController.test.ts @@ -105,7 +105,7 @@ describe('core/controller-decorator/test/AgentController.test.ts', () => { describe('context index', () => { it('should not set contextIndex on any method', () => { - const methods = ['createThread', 'getThread', 'asyncRun', 'streamRun', 'syncRun', 'getRun', 'cancelRun']; + 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`); @@ -131,7 +131,7 @@ describe('core/controller-decorator/test/AgentController.test.ts', () => { // 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']; + 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( @@ -144,12 +144,12 @@ describe('core/controller-decorator/test/AgentController.test.ts', () => { const stubMethods = [ { name: 'createThread', args: [] }, - { name: 'getThread', args: ['thread_1'] }, + { 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'] }, + { name: 'getRun', args: [ 'run_1' ] }, + { name: 'cancelRun', args: [ 'run_1' ] }, ]; for (const { name, args } of stubMethods) { @@ -171,40 +171,40 @@ describe('core/controller-decorator/test/AgentController.test.ts', () => { 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')!; + 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')!; + 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')]])); + assert.deepStrictEqual(getThread.paramMap, new Map([[ 0, new PathParamMeta('id') ]])); - const asyncRun = meta.methods.find((m) => m.name === 'asyncRun')!; + 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()]])); + assert.deepStrictEqual(asyncRun.paramMap, new Map([[ 0, new BodyParamMeta() ]])); - const streamRun = meta.methods.find((m) => m.name === 'streamRun')!; + 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()]])); + assert.deepStrictEqual(streamRun.paramMap, new Map([[ 0, new BodyParamMeta() ]])); - const syncRun = meta.methods.find((m) => m.name === 'syncRun')!; + 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()]])); + assert.deepStrictEqual(syncRun.paramMap, new Map([[ 0, new BodyParamMeta() ]])); - const getRun = meta.methods.find((m) => m.name === 'getRun')!; + 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')]])); + assert.deepStrictEqual(getRun.paramMap, new Map([[ 0, new PathParamMeta('id') ]])); - const cancelRun = meta.methods.find((m) => m.name === 'cancelRun')!; + 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')]])); + assert.deepStrictEqual(cancelRun.paramMap, new Map([[ 0, new PathParamMeta('id') ]])); }); it('should have all real paths start with /', () => { diff --git a/core/types/agent-runtime/AgentRuntime.ts b/core/types/agent-runtime/AgentRuntime.ts index 09c1de650..1fed45bda 100644 --- a/core/types/agent-runtime/AgentRuntime.ts +++ b/core/types/agent-runtime/AgentRuntime.ts @@ -1,8 +1,5 @@ -import type { InputContentPart, MessageContentBlock } from './AgentMessage'; -import type { AgentRunConfig, InputMessage, MessageObject, RunStatus } from './AgentStore'; - -export { ContentBlockType } from './AgentMessage'; -export type { InputContentPart, MessageContentBlock, TextContentBlock } from './AgentMessage'; +import type { InputContentPart, InputMessage, MessageContentBlock, MessageObject } from './AgentMessage'; +import type { AgentRunConfig, RunStatus } from './AgentStore'; // ===== Message roles ===== diff --git a/core/types/agent-runtime/AgentStore.ts b/core/types/agent-runtime/AgentStore.ts index 6237901bf..e5880b3d3 100644 --- a/core/types/agent-runtime/AgentStore.ts +++ b/core/types/agent-runtime/AgentStore.ts @@ -1,7 +1,5 @@ import type { InputMessage, MessageObject } from './AgentMessage'; -export type { InputMessage, MessageObject } from './AgentMessage'; - // ===== Object types ===== export const AgentObjectType = { diff --git a/core/types/agent-runtime/errors.ts b/core/types/agent-runtime/errors.ts index 76fbf3265..6ee680d12 100644 --- a/core/types/agent-runtime/errors.ts +++ b/core/types/agent-runtime/errors.ts @@ -4,7 +4,7 @@ * to set the corresponding HTTP response status code. */ export class AgentNotFoundError extends Error { - status: number = 404; + status = 404; constructor(message: string) { super(message); @@ -17,7 +17,7 @@ export class AgentNotFoundError extends Error { * (e.g., cancelling a completed run). */ export class AgentConflictError extends Error { - status: number = 409; + status = 409; constructor(message: string) { super(message); @@ -30,7 +30,7 @@ export class AgentConflictError extends Error { * (e.g., calling `complete()` on a queued run). */ export class InvalidRunStateTransitionError extends Error { - status: number = 409; + status = 409; constructor(from: string, to: string) { super(`Invalid run state transition: '${from}' -> '${to}'`); diff --git a/plugin/controller/lib/AgentControllerObject.ts b/plugin/controller/lib/AgentControllerObject.ts index 95300e984..d61abadd7 100644 --- a/plugin/controller/lib/AgentControllerObject.ts +++ b/plugin/controller/lib/AgentControllerObject.ts @@ -1,6 +1,5 @@ import { AgentRuntime, type AgentExecutor, AGENT_RUNTIME, HttpSSEWriter } from '@eggjs/agent-runtime'; -import { AgentInfoUtil } from '@eggjs/tegg'; -import { IdenticalUtil } from '@eggjs/tegg'; +import { AgentInfoUtil, IdenticalUtil } from '@eggjs/tegg'; import { LoadUnitFactory } from '@eggjs/tegg-metadata'; import { EGG_CONTEXT } from '@eggjs/egg-module-common'; import { ContextHandler, EggContainerFactory, EggObjectLifecycleUtil, EggObjectUtil } from '@eggjs/tegg-runtime'; @@ -100,7 +99,7 @@ export class AgentControllerObject implements EggObject { // 5. Inject dependencies await Promise.all( - this.proto.injectObjects.map(async (injectObject) => { + this.proto.injectObjects.map(async injectObject => { const proto = injectObject.proto; const loadUnit = LoadUnitFactory.getLoadUnitById(proto.loadUnitId); if (!loadUnit) { @@ -199,12 +198,12 @@ export class AgentControllerObject implements EggObject { stubMethods.add(name); } } - const streamRunFn = instance['streamRun']; + const streamRunFn = instance.streamRun; const streamRunIsStub = typeof streamRunFn !== 'function' || AgentInfoUtil.isNotImplemented(streamRunFn); // Create store — user must implement createStore() let store: AgentStore; - const createStoreFn = instance['createStore']; + const createStoreFn = instance.createStore; if (typeof createStoreFn === 'function') { store = (await Reflect.apply(createStoreFn, this._obj, [])) as AgentStore; } else { @@ -234,7 +233,7 @@ export class AgentControllerObject implements EggObject { // streamRun needs special handling: create HttpSSEWriter from request context if (streamRunIsStub) { - instance['streamRun'] = async (input: CreateRunInput): Promise => { + instance.streamRun = async (input: CreateRunInput): Promise => { const runtimeCtx = ContextHandler.getContext(); if (!runtimeCtx) { throw new Error('streamRun must be called within a request context'); diff --git a/plugin/controller/test/lib/AgentControllerProto.test.ts b/plugin/controller/test/lib/AgentControllerProto.test.ts index 817707ef7..f6f1be942 100644 --- a/plugin/controller/test/lib/AgentControllerProto.test.ts +++ b/plugin/controller/test/lib/AgentControllerProto.test.ts @@ -30,7 +30,7 @@ function createMockDelegate(): EggPrototype { return q.attribute === 'valid'; }, verifyQualifiers(qs: Array<{ attribute: string }>) { - return qs.every((q) => q.attribute === 'valid'); + return qs.every(q => q.attribute === 'valid'); }, getQualifier(attr: string) { if (attr === 'env') return 'prod'; @@ -139,7 +139,7 @@ describe('plugin/controller/test/lib/AgentControllerProto.test.ts', () => { it('should delegate constructEggObject', () => { const result = proto.constructEggObject('a', 'b'); - assert.deepStrictEqual(result, { constructed: true, args: ['a', 'b'] }); + assert.deepStrictEqual(result, { constructed: true, args: [ 'a', 'b' ] }); }); });