-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(tegg): add AgentController for building AI agent HTTP APIs #5812
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from 2 commits
9d7cc15
9951d13
1625d01
3b80c03
c495e7a
ed8156f
4806e51
0daba4c
310a493
e369850
0b5d370
15721d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| { | ||
| "name": "@eggjs/agent-runtime", | ||
| "version": "4.0.2-beta.1", | ||
| "description": "Smart default runtime for @AgentController in tegg", | ||
| "keywords": [ | ||
| "agent", | ||
| "egg", | ||
| "tegg", | ||
| "typescript" | ||
| ], | ||
| "homepage": "https://github.com/eggjs/egg/tree/next/tegg/core/agent-runtime", | ||
| "bugs": { | ||
| "url": "https://github.com/eggjs/egg/issues" | ||
| }, | ||
| "license": "MIT", | ||
| "author": "killagu <killa123@126.com>", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "git+https://github.com/eggjs/egg.git", | ||
| "directory": "tegg/core/agent-runtime" | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "type": "module", | ||
| "main": "./dist/index.js", | ||
| "module": "./dist/index.js", | ||
| "types": "./dist/index.d.ts", | ||
| "exports": { | ||
| ".": "./src/index.ts", | ||
| "./package.json": "./package.json" | ||
| }, | ||
| "publishConfig": { | ||
| "access": "public", | ||
| "exports": { | ||
| ".": "./dist/index.js", | ||
| "./package.json": "./package.json" | ||
| } | ||
| }, | ||
| "scripts": { | ||
| "typecheck": "tsgo --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "@eggjs/tegg-runtime": "workspace:*", | ||
| "@eggjs/tegg-types": "workspace:*" | ||
| }, | ||
| "devDependencies": { | ||
| "@eggjs/controller-decorator": "workspace:*", | ||
| "@types/node": "catalog:", | ||
| "typescript": "catalog:" | ||
| }, | ||
| "engines": { | ||
| "node": ">=22.18.0" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import type { InputMessage, MessageObject, AgentRunConfig, RunStatus } from '@eggjs/controller-decorator'; | ||
|
|
||
| export interface ThreadRecord { | ||
| id: string; | ||
| object: 'thread'; | ||
| messages: MessageObject[]; | ||
| metadata: Record<string, unknown>; | ||
| created_at: number; // Unix seconds | ||
| } | ||
|
|
||
| export interface RunRecord { | ||
| id: string; | ||
| object: 'thread.run'; | ||
| thread_id?: string; | ||
| status: RunStatus; | ||
| input: InputMessage[]; | ||
| output?: MessageObject[]; | ||
| last_error?: { code: string; message: string } | null; | ||
| usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null; | ||
| config?: AgentRunConfig; | ||
| metadata?: Record<string, unknown>; | ||
| created_at: number; | ||
| started_at?: number | null; | ||
| completed_at?: number | null; | ||
| cancelled_at?: number | null; | ||
| failed_at?: number | null; | ||
| } | ||
|
|
||
| export interface AgentStore { | ||
| init?(): Promise<void>; | ||
| destroy?(): Promise<void>; | ||
| createThread(metadata?: Record<string, unknown>): Promise<ThreadRecord>; | ||
| getThread(threadId: string): Promise<ThreadRecord>; | ||
| appendMessages(threadId: string, messages: MessageObject[]): Promise<void>; | ||
| createRun( | ||
| input: InputMessage[], | ||
| threadId?: string, | ||
| config?: AgentRunConfig, | ||
| metadata?: Record<string, unknown>, | ||
| ): Promise<RunRecord>; | ||
| getRun(runId: string): Promise<RunRecord>; | ||
| updateRun(runId: string, updates: Partial<RunRecord>): Promise<void>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import crypto from 'node:crypto'; | ||
| import fs from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
|
|
||
| import type { InputMessage, MessageObject, AgentRunConfig } from '@eggjs/controller-decorator'; | ||
|
|
||
| import type { AgentStore, ThreadRecord, RunRecord } from './AgentStore.ts'; | ||
| import { AgentNotFoundError } from './errors.ts'; | ||
|
|
||
| export interface FileAgentStoreOptions { | ||
| dataDir: string; | ||
| } | ||
|
|
||
| export class FileAgentStore implements AgentStore { | ||
| private readonly dataDir: string; | ||
| private readonly threadsDir: string; | ||
| private readonly runsDir: string; | ||
|
|
||
| constructor(options: FileAgentStoreOptions) { | ||
| this.dataDir = options.dataDir; | ||
| this.threadsDir = path.join(this.dataDir, 'threads'); | ||
| this.runsDir = path.join(this.dataDir, 'runs'); | ||
| } | ||
|
|
||
| private safePath(baseDir: string, id: string): string { | ||
| if (!id) { | ||
| throw new Error('Invalid id: id must not be empty'); | ||
| } | ||
| const filePath = path.join(baseDir, `${id}.json`); | ||
| if (!filePath.startsWith(baseDir + path.sep)) { | ||
| throw new Error(`Invalid id: ${id}`); | ||
| } | ||
| return filePath; | ||
| } | ||
|
Comment on lines
+27
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Path traversal check may be insufficient for edge cases. The 🛡️ Proposed enhanced validation private safePath(baseDir: string, id: string): string {
if (!id) {
throw new Error('Invalid id: id must not be empty');
}
+ // Reject IDs containing path separators or traversal sequences
+ if (/[/\\]|\.\./.test(id)) {
+ throw new Error(`Invalid id: ${id}`);
+ }
const filePath = path.join(baseDir, `${id}.json`);
- if (!filePath.startsWith(baseDir + path.sep)) {
+ // Resolve to absolute paths for reliable comparison
+ const resolvedPath = path.resolve(filePath);
+ const resolvedBase = path.resolve(baseDir);
+ if (!resolvedPath.startsWith(resolvedBase + path.sep)) {
throw new Error(`Invalid id: ${id}`);
}
return filePath;
}🤖 Prompt for AI Agents |
||
|
|
||
| async init(): Promise<void> { | ||
| await fs.mkdir(this.threadsDir, { recursive: true }); | ||
| await fs.mkdir(this.runsDir, { recursive: true }); | ||
| } | ||
|
|
||
| async createThread(metadata?: Record<string, unknown>): Promise<ThreadRecord> { | ||
| const threadId = `thread_${crypto.randomUUID()}`; | ||
| const record: ThreadRecord = { | ||
| id: threadId, | ||
| object: 'thread', | ||
| messages: [], | ||
| metadata: metadata ?? {}, | ||
| created_at: Math.floor(Date.now() / 1000), | ||
| }; | ||
| await this.writeFile(this.safePath(this.threadsDir, threadId), record); | ||
| return record; | ||
| } | ||
|
|
||
| async getThread(threadId: string): Promise<ThreadRecord> { | ||
| const filePath = this.safePath(this.threadsDir, threadId); | ||
| const data = await this.readFile(filePath); | ||
| if (!data) { | ||
| throw new AgentNotFoundError(`Thread ${threadId} not found`); | ||
| } | ||
| return data as ThreadRecord; | ||
| } | ||
|
|
||
| // Note: read-modify-write without locking. Concurrent appends to the same thread may lose messages. | ||
| // This is acceptable for a default file-based store; production stores should implement proper locking. | ||
| async appendMessages(threadId: string, messages: MessageObject[]): Promise<void> { | ||
| const thread = await this.getThread(threadId); | ||
| thread.messages.push(...messages); | ||
| await this.writeFile(this.safePath(this.threadsDir, threadId), thread); | ||
| } | ||
|
|
||
| async createRun( | ||
| input: InputMessage[], | ||
| threadId?: string, | ||
| config?: AgentRunConfig, | ||
| metadata?: Record<string, unknown>, | ||
| ): Promise<RunRecord> { | ||
| const runId = `run_${crypto.randomUUID()}`; | ||
| const record: RunRecord = { | ||
| id: runId, | ||
| object: 'thread.run', | ||
| thread_id: threadId, | ||
| status: 'queued', | ||
| input, | ||
| config, | ||
| metadata, | ||
| created_at: Math.floor(Date.now() / 1000), | ||
|
||
| }; | ||
| await this.writeFile(this.safePath(this.runsDir, runId), record); | ||
| return record; | ||
| } | ||
|
|
||
| async getRun(runId: string): Promise<RunRecord> { | ||
| const filePath = this.safePath(this.runsDir, runId); | ||
| const data = await this.readFile(filePath); | ||
| if (!data) { | ||
| throw new AgentNotFoundError(`Run ${runId} not found`); | ||
| } | ||
| return data as RunRecord; | ||
| } | ||
|
|
||
| async updateRun(runId: string, updates: Partial<RunRecord>): Promise<void> { | ||
| const run = await this.getRun(runId); | ||
| const { id: _, object: __, ...safeUpdates } = updates; | ||
| Object.assign(run, safeUpdates); | ||
| await this.writeFile(this.safePath(this.runsDir, runId), run); | ||
| } | ||
|
|
||
| private async writeFile(filePath: string, data: unknown): Promise<void> { | ||
| const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; | ||
| await fs.writeFile(tmpPath, JSON.stringify(data), 'utf-8'); | ||
| await fs.rename(tmpPath, filePath); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这个是考虑原子操作?会有并发写吗,没有的话 fs open 然后用 fd 去写就可以了。现在这样会反复 open
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 如果 thread_id 一样,会有并发,这里先简化么?有没有并发,其实主要看client侧怎么用的,目前初期估计不太会碰到
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已在 关于并发写:分析后发现同一进程内同一 run 的 |
||
| } | ||
|
|
||
| private async readFile(filePath: string): Promise<unknown | null> { | ||
| try { | ||
| const content = await fs.readFile(filePath, 'utf-8'); | ||
| return JSON.parse(content); | ||
|
||
| } catch (err: any) { | ||
| if (err.code === 'ENOENT') { | ||
| return null; | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The specified Node.js engine version
>=22.18.0appears to be a typo, as this version has not been released. This will prevent users from installing the package. Please use a valid version range, such as>=22.0.0.