diff --git a/.DS_Store b/.DS_Store index d9a4863..56b5029 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d5840f..d6f0f8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,34 +13,28 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'pnpm' + bun-version: 1.3.11 - name: Install dependencies - run: pnpm install --frozen-lockfile + run: bun install --frozen-lockfile - name: Typecheck - run: pnpm typecheck + run: bun run typecheck - name: Typecheck session store - run: pnpm exec tsc -p packages/session-sqlite/tsconfig.json --noEmit + run: bun x tsc -p packages/session-sqlite/tsconfig.json --noEmit - name: Typecheck client - run: pnpm typecheck:client + run: bun run typecheck:client - name: Run unit tests - run: pnpm test + run: bun run test - name: Build agent-core - run: pnpm build:agent-core + run: bun run build:agent-core - name: Build client - run: pnpm build:client + run: bun run build:client diff --git a/.github/workflows/deploy-gh-pages.yml b/.github/workflows/deploy-gh-pages.yml index cf93ff8..c470683 100644 --- a/.github/workflows/deploy-gh-pages.yml +++ b/.github/workflows/deploy-gh-pages.yml @@ -14,22 +14,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'pnpm' + bun-version: 1.3.11 - name: Install dependencies - run: pnpm install --frozen-lockfile + run: bun install --frozen-lockfile - name: Build client - run: pnpm build:client + run: bun run build:client - name: Deploy to gh-pages uses: peaceiris/actions-gh-pages@v3 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 94814fc..8b298d0 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -19,22 +19,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'pnpm' + bun-version: 1.3.11 - name: Install dependencies - run: pnpm install --frozen-lockfile + run: bun install --frozen-lockfile - name: Build client - run: pnpm build:client + run: bun run build:client - name: Deploy to gh-pages uses: peaceiris/actions-gh-pages@v3 diff --git a/AGENTS.md b/AGENTS.md index 3013a1d..435edc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## 项目概览 -- 技术栈:TypeScript + React/Ink 终端 CLI,包管理器是 pnpm。 +- 技术栈:TypeScript + React/Ink 终端 CLI,包管理器是 bun。 - 入口:`source/app.tsx` 是 CLI 应用;核心逻辑在 `packages/agent-core`。 - 会话历史存储:`packages/session-sqlite`,基于 Node 内置 `node:sqlite`。 - 测试:统一放在根目录 `tests/`,使用 Vitest,命名 `*.spec.ts` 或 `*.test.ts`。 @@ -34,12 +34,12 @@ 5. 修改会话存储相关代码时,至少覆盖会话创建、条目追加、泳道/分支、记录、统计和租约。 6. 修改完成后必须运行完整测试: ```bash - pnpm test + bun run test ``` 7. 提交前必须通过类型检查: ```bash - pnpm typecheck - pnpm exec tsc -p packages/session-sqlite/tsconfig.json --noEmit + bun run typecheck + bun x tsc -p packages/session-sqlite/tsconfig.json --noEmit ``` 8. 如测试失败,先修复问题再继续,不允许跳过或注释测试。 diff --git a/README.md b/README.md index c31fb72..a0ec2dc 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ call-code 是一个本地运行的终端编程 Agent(CLI coding agent),基 - 终端交互界面:基于 Ink 的命令行界面,支持首页、对话、历史选择和相关页面预览。 - 双执行模式:`PLAN` 模式只允许生成计划和读取环境,`BUILD` 模式可以写入文件、执行命令并推进任务。 -- 本地工具集:内置 `get_environment`、`read_file`、`write_file`、`list_files`、`run_command` 五个工具。 +- 本地工具集:内置 `get_environment`、`read_file`、`write_file`、`bash`、`git_diff`、`ocr_image` 六个工具。 - 结构化响应协议:模型输出统一为 `tool_call` 或 `final` 的 JSON action,循环解析并继续执行。 - 本地记忆:短期记忆按任务保存,长期记忆按主题沉淀,仅在进程内使用,不写入本地 JSON。 - 上下文预算:运行时基于 token 估算对历史消息做裁剪,减少超出模型上下文的风险。 @@ -35,7 +35,7 @@ agent-core 核心层 ├─ protocol/ 解析 tool_call / final JSON action ├─ policy/ PLAN / BUILD 模式下的工具权限 ├─ tools/ get_environment / read_file / write_file -│ list_files / run_command +│ bash / git_diff / ocr_image ├─ memory/ short / long 记忆(仅存内存,不落盘 JSON) └─ prompt/ 系统提示词、工具说明与模式提示词 │ OpenAI chat.completions 请求(支持流式) @@ -100,14 +100,14 @@ pnpm dev ## 环境变量 -| 变量 | 说明 | -| --- | --- | -| `OPENAI_API_KEY` | 必填,OpenAI 兼容 API 的 Key。 | -| `OPENAI_API_BASE_URL` | 可选,自定义 OpenAI 兼容 base URL。 | -| `OPENAI_MODEL` | 必填,模型名称,无默认值;未配置时 CLI 会提示。 | -| `AGENT_DESKTOP_DIR` | 可选,覆盖桌面目录路径,便于测试或自定义工作环境。 | -| `SESSION_DB_PATH` | 可选,SQLite 会话库文件路径,默认 `.agent-sessions/sessions.db`。 | -| `CALL_CODE_WEB_DATA` | 可选,CLI 内 `/export` 的输出路径,默认 `packages/client/public/data.json`。 | +| 变量 | 说明 | +| --------------------- | ---------------------------------------------------------------------------- | +| `OPENAI_API_KEY` | 必填,OpenAI 兼容 API 的 Key。 | +| `OPENAI_API_BASE_URL` | 可选,自定义 OpenAI 兼容 base URL。 | +| `OPENAI_MODEL` | 必填,模型名称,无默认值;未配置时 CLI 会提示。 | +| `AGENT_DESKTOP_DIR` | 可选,覆盖桌面目录路径,便于测试或自定义工作环境。 | +| `SESSION_DB_PATH` | 可选,SQLite 会话库文件路径,默认 `.agent-sessions/sessions.db`。 | +| `CALL_CODE_WEB_DATA` | 可选,CLI 内 `/export` 的输出路径,默认 `packages/client/public/data.json`。 | ## CLI 命令与快捷键 diff --git a/bun.lock b/bun.lock index 3c4a6e8..21444af 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,9 @@ "packages/session-sqlite": { "name": "@call-code/session-sqlite", "version": "0.1.0", + "dependencies": { + "sqlite-vec": "^0.1.9", + }, }, }, "trustedDependencies": [ @@ -442,6 +445,18 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "sqlite-vec": ["sqlite-vec@0.1.9", "", { "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.9", "sqlite-vec-darwin-x64": "0.1.9", "sqlite-vec-linux-arm64": "0.1.9", "sqlite-vec-linux-x64": "0.1.9", "sqlite-vec-windows-x64": "0.1.9" } }, "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA=="], + + "sqlite-vec-darwin-arm64": ["sqlite-vec-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg=="], + + "sqlite-vec-darwin-x64": ["sqlite-vec-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA=="], + + "sqlite-vec-linux-arm64": ["sqlite-vec-linux-arm64@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw=="], + + "sqlite-vec-linux-x64": ["sqlite-vec-linux-x64@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA=="], + + "sqlite-vec-windows-x64": ["sqlite-vec-windows-x64@0.1.9", "", { "os": "win32", "cpu": "x64" }, "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], diff --git a/package.json b/package.json index ae6a573..896b1ec 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "typecheck:client": "tsc -p packages/client/tsconfig.json --noEmit", "test": "vitest run --reporter verbose", "build:agent-core": "tsc -p packages/agent-core/tsconfig.json", - "export:web": "tsx source/export-web.ts", + "export:web": "tsx tests/export-web.ts", "dev:client": "bun run --cwd packages/client dev", "build:client": "bun run --cwd packages/client build", "preview:client": "bun run --cwd packages/client preview" diff --git a/packages/agent-core/src/harness/compaction/branch-summarization.ts b/packages/agent-core/src/harness/compaction/branch-summarization.ts new file mode 100644 index 0000000..6022093 --- /dev/null +++ b/packages/agent-core/src/harness/compaction/branch-summarization.ts @@ -0,0 +1,282 @@ +import type { ContextMessage } from '../session/context/context-types'; +import type { EntryLike, SessionStoreLike } from '../session/sessionInfo/session-repository'; +import type { SummarizeFn } from './compaction'; +import { + computeFileLists, + createFileOps, + estimateTokens, + extractFileOpsFromEntry, + formatFileOperations, + serializeConversation, + type FileOperations, +} from './utils'; + +export interface BranchSummaryEntryPayload { + kind: 'branch_summary'; + summary: string; + readFiles: string[]; + modifiedFiles: string[]; +} + +export interface BranchSummaryResult { + summary: string; + readFiles: string[]; + modifiedFiles: string[]; +} + +export interface BranchPreparation { + messages: ContextMessage[]; + totalTokens: number; + fileOps: FileOperations; +} + +export interface CollectEntriesResult { + entries: EntryLike[]; + commonAncestorId: string | null; +} + +export interface GenerateBranchSummaryOptions { + summarize: SummarizeFn; + customInstructions?: string; + reserveTokens?: number; + contextWindow?: number; +} + +const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here. + +`; + +const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later. + +Use this format: + +## Goal + +## Constraints & Preferences + +## Progress +### Done +### In Progress +### Blocked + +## Key Decisions + +## Next Steps + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const entryToMessage = (entry: EntryLike): ContextMessage | null => { + if (!entry.payload || typeof entry.payload !== 'object') { + return null; + } + const payload = entry.payload as Record; + + if (entry.type === 'compaction' || entry.type === 'branch_summary') { + if (typeof payload.summary !== 'string') { + return null; + } + const prefix = entry.type === 'compaction' ? '[历史摘要]' : '[分支摘要]'; + return { role: 'user', content: `${prefix}\n${payload.summary}` }; + } + + if (typeof payload.content !== 'string') { + return null; + } + if (entry.type === 'assistant') { + return { role: 'assistant', content: payload.content }; + } + if (entry.type === 'user' || entry.type === 'tool') { + return { role: 'user', content: payload.content }; + } + return null; +}; + +const readFilesFromPayload = ( + fileOps: FileOperations, + payload: Record, +): void => { + if (Array.isArray(payload.readFiles)) { + for (const file of payload.readFiles) { + if (typeof file === 'string') { + fileOps.read.add(file); + } + } + } + if (Array.isArray(payload.modifiedFiles)) { + for (const file of payload.modifiedFiles) { + if (typeof file === 'string') { + fileOps.edited.add(file); + } + } + } +}; + +export const collectEntriesForBranchSummary = ( + store: SessionStoreLike, + sessionId: string, + oldLeafId: string | null, + targetId: string, +): CollectEntriesResult => { + if (!oldLeafId) { + return { entries: [], commonAncestorId: null }; + } + + const entries = store.getEntries(sessionId, { limit: 100000 }); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const oldLeaf = byId.get(oldLeafId); + if (!oldLeaf) { + return { entries: [], commonAncestorId: null }; + } + + const oldPath = new Set(); + let current: EntryLike | undefined = oldLeaf; + while (current) { + oldPath.add(current.id); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + + const targetPath: EntryLike[] = []; + current = byId.get(targetId); + while (current) { + targetPath.push(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + + const commonAncestor = + targetPath.find((entry) => oldPath.has(entry.id)) ?? null; + const branchEntries: EntryLike[] = []; + current = oldLeaf; + while (current && current.id !== commonAncestor?.id) { + branchEntries.unshift(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + + return { + entries: branchEntries, + commonAncestorId: commonAncestor?.id ?? null, + }; +}; + +export const prepareBranchEntries = ( + entries: EntryLike[], + tokenBudget = 0, +): BranchPreparation => { + const messages: ContextMessage[] = []; + const fileOps = createFileOps(); + + for (const entry of entries) { + if ( + entry.type === 'compaction' || + entry.type === 'branch_summary' + ) { + if (entry.payload && typeof entry.payload === 'object') { + readFilesFromPayload(fileOps, entry.payload as Record); + } + } + extractFileOpsFromEntry(entry, fileOps); + } + + let totalTokens = 0; + for (let index = entries.length - 1; index >= 0; index--) { + const message = entryToMessage(entries[index]); + if (!message) { + continue; + } + + const tokens = estimateTokens(message); + if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { + if ( + entries[index].type === 'compaction' || + entries[index].type === 'branch_summary' + ) { + if (totalTokens < tokenBudget * 0.9) { + messages.unshift(message); + totalTokens += tokens; + } + } + break; + } + + messages.unshift(message); + totalTokens += tokens; + } + + return { messages, totalTokens, fileOps }; +}; + +export const generateBranchSummary = async ( + entries: EntryLike[], + options: GenerateBranchSummaryOptions, +): Promise => { + const contextWindow = options.contextWindow ?? 8000; + const reserveTokens = options.reserveTokens ?? Math.floor(0.2 * contextWindow); + const tokenBudget = Math.max(0, contextWindow - reserveTokens); + const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); + + if (messages.length === 0) { + return { summary: 'No content to summarize', readFiles: [], modifiedFiles: [] }; + } + + const conversationText = serializeConversation(messages); + const prompt = options.customInstructions + ? `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${options.customInstructions}` + : BRANCH_SUMMARY_PROMPT; + const text = await options.summarize([ + { + role: 'system', + content: `You are a context summarization assistant. Do not continue the conversation; only output the branch summary.`, + }, + { + role: 'user', + content: `\n${conversationText}\n\n\n${prompt}`, + }, + ]); + + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + return { + summary: + `${BRANCH_SUMMARY_PREAMBLE}${text?.trim() || 'No summary generated'}` + + formatFileOperations(readFiles, modifiedFiles), + readFiles, + modifiedFiles, + }; +}; + +export const persistBranchSummaryEntry = ( + sessionId: string, + result: BranchSummaryResult, + store: SessionStoreLike, + options: { + parentId?: string; + branchId?: string; + lane?: string; + fromId?: string; + } = {}, +): EntryLike => { + const payload: BranchSummaryEntryPayload = { + kind: 'branch_summary', + summary: result.summary, + readFiles: result.readFiles, + modifiedFiles: result.modifiedFiles, + }; + + return store.appendEntry(sessionId, { + parentId: options.parentId, + type: 'branch_summary', + payload, + branchId: options.branchId, + lane: options.lane, + }); +}; + +export const branchSummaryEntryToMessage = (entry: EntryLike): ContextMessage | null => { + if (entry.type !== 'branch_summary') { + return null; + } + const payload = entry.payload as Partial | null; + if (!payload || typeof payload.summary !== 'string') { + return null; + } + return { role: 'user', content: `[分支摘要]\n${payload.summary}` }; +}; + diff --git a/packages/agent-core/src/harness/compaction/compaction.ts b/packages/agent-core/src/harness/compaction/compaction.ts new file mode 100644 index 0000000..bc53dbf --- /dev/null +++ b/packages/agent-core/src/harness/compaction/compaction.ts @@ -0,0 +1,395 @@ +import type { ContextMessage } from '../session/context/context-types'; +import type { EntryLike, SessionStoreLike } from '../session/sessionInfo/session-repository'; +import { + computeFileLists, + createFileOps, + estimateContextTokens, + estimateTokens, + extractFileOpsFromMessage, + formatFileOperations, + serializeConversation, + type FileOperations, +} from './utils'; + +export interface CompactionSettings { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; + contextWindow?: number; +} + +const envContextWindow = Number(process.env.OPENAI_CONTEXT_WINDOW ?? 8000); + +export const DEFAULT_CONTEXT_WINDOW = + Number.isFinite(envContextWindow) && envContextWindow > 0 ? envContextWindow : 8000; + +export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { + enabled: true, + reserveTokens: 1600, + keepRecentTokens: 2000, + contextWindow: DEFAULT_CONTEXT_WINDOW, +}; + +export interface MessageLike { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export type SummarizeFn = (messages: MessageLike[]) => Promise; + +export interface CompactionEntryPayload { + kind: 'compaction'; + summary: string; + tokensBefore: number; + retainedTail: ContextMessage[]; + readFiles: string[]; + modifiedFiles: string[]; +} + +export interface CompactResult { + summary: string; + tokensBefore: number; + retainedTail: ContextMessage[]; + readFiles: string[]; + modifiedFiles: string[]; +} + +export interface CutPointResult { + firstKeptIndex: number; + turnStartIndex: number; + isSplitTurn: boolean; +} + +export interface CompactionPreparation { + messagesToSummarize: ContextMessage[]; + turnPrefixMessages: ContextMessage[]; + retainedTail: ContextMessage[]; + isSplitTurn: boolean; + tokensBefore: number; + previousSummary?: string; + fileOps: FileOperations; + settings: CompactionSettings; +} + +export interface GenerateSummaryOptions { + summarize: SummarizeFn; + previousSummary?: string; + customInstructions?: string; +} + +const SUMMARY_PREFIX = '[历史摘要]'; + +export const isSummaryMessage = (message: ContextMessage): boolean => + message.role === 'user' && message.content.startsWith(SUMMARY_PREFIX); + +export const createSummaryMessage = (summary: string): ContextMessage => ({ + role: 'user', + content: `${SUMMARY_PREFIX}\n${summary}`, +}); + +export const readPreviousSummary = (message: ContextMessage): string | undefined => { + if (!isSummaryMessage(message)) { + return undefined; + } + return message.content.slice(SUMMARY_PREFIX.length + 1); +}; + +export const shouldCompact = ( + contextTokens: number, + contextWindow: number, + settings: CompactionSettings, +): boolean => { + if (!settings.enabled) { + return false; + } + return contextTokens > contextWindow - settings.reserveTokens; +}; + +export const findCutPoint = ( + messages: ContextMessage[], + keepRecentTokens: number, +): CutPointResult => { + if (messages.length === 0) { + return { firstKeptIndex: 0, turnStartIndex: -1, isSplitTurn: false }; + } + + let cutIndex = 0; + let accumulated = 0; + for (let index = messages.length - 1; index >= 0; index--) { + accumulated += estimateTokens(messages[index]); + if (accumulated >= keepRecentTokens) { + cutIndex = index; + break; + } + } + + if (cutIndex >= messages.length) { + cutIndex = messages.length - 1; + } + + const cutMessage = messages[cutIndex]; + if (cutMessage.role === 'user') { + return { firstKeptIndex: cutIndex, turnStartIndex: -1, isSplitTurn: false }; + } + + const turnStartIndex = findTurnStartIndex(messages, cutIndex); + return { + firstKeptIndex: cutIndex, + turnStartIndex, + isSplitTurn: turnStartIndex !== -1, + }; +}; + +const findTurnStartIndex = (messages: ContextMessage[], cutIndex: number): number => { + for (let index = cutIndex - 1; index >= 0; index--) { + if (messages[index].role === 'user') { + return index; + } + } + return -1; +}; + +export const prepareMessagesToCompact = ( + messages: ContextMessage[], + settings: CompactionSettings, +): CompactionPreparation | undefined => { + const previousSummary = + messages.length > 0 ? readPreviousSummary(messages[0]) : undefined; + const contentMessages = previousSummary ? messages.slice(1) : messages; + if (contentMessages.length === 0) { + return undefined; + } + + const cutPoint = findCutPoint(contentMessages, settings.keepRecentTokens); + const historyEnd = cutPoint.isSplitTurn + ? cutPoint.turnStartIndex + : cutPoint.firstKeptIndex; + const messagesToSummarize = contentMessages.slice(0, historyEnd); + const turnPrefixMessages = cutPoint.isSplitTurn + ? contentMessages.slice(cutPoint.turnStartIndex, cutPoint.firstKeptIndex) + : []; + const retainedTail = contentMessages.slice(cutPoint.firstKeptIndex); + + const fileOps = createFileOps(); + for (const message of messagesToSummarize) { + extractFileOpsFromMessage(message, fileOps); + } + for (const message of turnPrefixMessages) { + extractFileOpsFromMessage(message, fileOps); + } + + return { + messagesToSummarize, + turnPrefixMessages, + retainedTail, + isSplitTurn: cutPoint.isSplitTurn, + tokensBefore: estimateContextTokens(messages), + previousSummary, + fileOps, + settings, + }; +}; + +const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Read the conversation and produce a structured summary; do not continue the conversation or answer any questions in it.`; + +const SUMMARIZATION_PROMPT = `The conversation block below is a session checkpoint. Create a structured summary that another LLM can use to continue the work. + +Use this format: + +## Goal + +## Constraints & Preferences + +## Progress +### Done +### In Progress +### Blocked + +## Key Decisions + +## Next Steps + +## Critical Context + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const UPDATE_SUMMARIZATION_PROMPT = `The conversation block below contains new messages to incorporate into the existing summary in . + +Rules: +- Preserve all existing information from the previous summary. +- Add new progress, decisions, and context from the new messages. +- Move completed items from In Progress to Done. +- Update Next Steps based on what was accomplished. +- Preserve exact file paths, function names, and error messages. + +Use this format: + +## Goal + +## Constraints & Preferences + +## Progress +### Done +### In Progress +### Blocked + +## Key Decisions + +## Next Steps + +## Critical Context + +Keep each section concise.`; + +const TURN_PREFIX_SUMMARIZATION_PROMPT = `The conversation block is the prefix of a turn that was too large to keep. The suffix is retained. + +Summarize the prefix for the retained suffix: + +## Original Request + +## Early Progress + +## Context for Suffix + +Be concise and focus on information needed to understand the retained suffix.`; + +const summaryPromptText = ( + messages: ContextMessage[], + previousSummary?: string, + customInstructions?: string, +): string => { + const conversationText = serializeConversation(messages); + const parts = [`\n${conversationText}\n`]; + if (previousSummary) { + parts.push(`\n${previousSummary}\n`); + } + const basePrompt = previousSummary + ? UPDATE_SUMMARIZATION_PROMPT + : SUMMARIZATION_PROMPT; + const prompt = customInstructions + ? `${basePrompt}\n\nAdditional focus: ${customInstructions}` + : basePrompt; + parts.push(prompt); + return parts.join('\n\n'); +}; + +export const generateSummary = async ( + messages: ContextMessage[], + options: GenerateSummaryOptions, +): Promise => { + const text = await options.summarize([ + { role: 'system', content: SUMMARIZATION_SYSTEM_PROMPT }, + { + role: 'user', + content: summaryPromptText( + messages, + options.previousSummary, + options.customInstructions, + ), + }, + ]); + return text?.trim() || 'No summary generated'; +}; + +const generateTurnPrefixSummary = async ( + messages: ContextMessage[], + options: GenerateSummaryOptions, +): Promise => { + const conversationText = serializeConversation(messages); + const text = await options.summarize([ + { role: 'system', content: SUMMARIZATION_SYSTEM_PROMPT }, + { + role: 'user', + content: `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`, + }, + ]); + return text?.trim() || 'No turn prefix summary generated'; +}; + +export const compact = async ( + preparation: CompactionPreparation, + options: GenerateSummaryOptions, +): Promise => { + const { + messagesToSummarize, + turnPrefixMessages, + retainedTail, + isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + } = preparation; + + if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { + return null; + } + + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + let summary: string; + + if (isSplitTurn && turnPrefixMessages.length > 0) { + const historyText = + messagesToSummarize.length > 0 + ? await generateSummary(messagesToSummarize, { + summarize: options.summarize, + previousSummary, + customInstructions: options.customInstructions, + }) + : 'No prior history before this turn.'; + const turnPrefixText = await generateTurnPrefixSummary(turnPrefixMessages, { + summarize: options.summarize, + }); + summary = `${historyText}\n\nTurn Context\n${turnPrefixText}`; + } else { + summary = await generateSummary(messagesToSummarize, { + summarize: options.summarize, + previousSummary, + customInstructions: options.customInstructions, + }); + } + + summary += formatFileOperations(readFiles, modifiedFiles); + + return { + summary, + tokensBefore, + retainedTail, + readFiles, + modifiedFiles, + }; +}; + +export const persistCompactionEntry = ( + sessionId: string, + result: CompactResult, + store: SessionStoreLike, + options: { branchId?: string; lane?: string } = {}, +): EntryLike => { + const payload: CompactionEntryPayload = { + kind: 'compaction', + summary: result.summary, + tokensBefore: result.tokensBefore, + retainedTail: result.retainedTail, + readFiles: result.readFiles, + modifiedFiles: result.modifiedFiles, + }; + + return store.appendEntry(sessionId, { + type: 'compaction', + payload, + branchId: options.branchId, + lane: options.lane, + }); +}; + +export const compactionEntryToMessage = (entry: EntryLike): ContextMessage | null => { + if (entry.type !== 'compaction') { + return null; + } + const payload = entry.payload as Partial | null; + if (!payload || typeof payload.summary !== 'string') { + return null; + } + return createSummaryMessage(payload.summary); +}; + diff --git a/packages/agent-core/src/harness/compaction/index.ts b/packages/agent-core/src/harness/compaction/index.ts new file mode 100644 index 0000000..52d476d --- /dev/null +++ b/packages/agent-core/src/harness/compaction/index.ts @@ -0,0 +1,3 @@ +export * from './branch-summarization'; +export * from './compaction'; +export * from './utils'; diff --git a/packages/agent-core/src/harness/compaction/utils.ts b/packages/agent-core/src/harness/compaction/utils.ts new file mode 100644 index 0000000..dc3577d --- /dev/null +++ b/packages/agent-core/src/harness/compaction/utils.ts @@ -0,0 +1,202 @@ +import type { ContextMessage } from '../session/context/context-types'; +import type { EntryLike } from '../session/sessionInfo/session-repository'; + +/** 摘要阶段累积的文件读写信息,后续追加到摘要里让模型保留文件上下文。 */ +export interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +export const createFileOps = (): FileOperations => ({ + read: new Set(), + written: new Set(), + edited: new Set(), +}); + +const safeJsonStringify = (value: unknown): string => { + try { + return JSON.stringify(value) ?? 'undefined'; + } catch { + return '[unserializable]'; + } +}; + +const safeParseJson = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return undefined; + } +}; + +const getPathValue = (target: Record): string | undefined => { + const argsValue = target.arguments; + const args = + argsValue && typeof argsValue === 'object' + ? (argsValue as Record) + : typeof argsValue === 'string' + ? (safeParseJson(argsValue) as Record | undefined) + : undefined; + + const pathValue = + args && typeof args.path === 'string' + ? args.path + : typeof target.path === 'string' + ? target.path + : typeof target.requestedPath === 'string' + ? target.requestedPath + : undefined; + + return pathValue; +}; + +/** + * 从 assistant 协议 JSON 或工具结果 JSON 中提取路径。 + * 当前上下文没有结构化 tool call,只能靠内容里的 JSON 字段还原。 + */ +export const extractFileOpsFromContent = ( + content: string, + fileOps: FileOperations, + toolName?: string, +): void => { + if (!content.startsWith('{') && !content.startsWith('[')) { + return; + } + + const parsed = safeParseJson(content); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return; + } + + const target = parsed as Record; + const resolvedTool = + typeof target.tool === 'string' ? target.tool : toolName ?? ''; + const pathValue = getPathValue(target); + if (!pathValue) { + return; + } + + const normalizedTool = resolvedTool.toLowerCase(); + if ( + normalizedTool === 'read_file' || + normalizedTool === 'read' || + normalizedTool === 'search' || + normalizedTool === 'git_diff' || + resolvedTool === 'read_file' + ) { + fileOps.read.add(pathValue); + return; + } + if (normalizedTool === 'write_file' || normalizedTool === 'write') { + fileOps.written.add(pathValue); + return; + } + if (normalizedTool === 'edit') { + fileOps.edited.add(pathValue); + return; + } + if (typeof target.content === 'string' && target.content.includes('requestedPath')) { + fileOps.read.add(pathValue); + } +}; + +export const extractFileOpsFromMessage = ( + message: ContextMessage, + fileOps: FileOperations, +): void => { + extractFileOpsFromContent(message.content, fileOps); +}; + +export const extractFileOpsFromEntry = ( + entry: EntryLike, + fileOps: FileOperations, +): void => { + if (!entry.payload || typeof entry.payload !== 'object') { + return; + } + const payload = entry.payload as Record; + if (typeof payload.content === 'string') { + const toolName = typeof payload.tool === 'string' ? payload.tool : undefined; + extractFileOpsFromContent(payload.content, fileOps, toolName); + } +}; + +export const computeFileLists = (fileOps: FileOperations): { + readFiles: string[]; + modifiedFiles: string[]; +} => { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readFiles = [...fileOps.read].filter((file) => !modified.has(file)).sort(); + const modifiedFiles = [...modified].sort(); + return { readFiles, modifiedFiles }; +}; + +export const formatFileOperations = ( + readFiles: string[], + modifiedFiles: string[], +): string => { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`\n${readFiles.join('\n')}\n`); + } + if (modifiedFiles.length > 0) { + sections.push(`\n${modifiedFiles.join('\n')}\n`); + } + if (sections.length === 0) { + return ''; + } + return `\n\n${sections.join('\n\n')}`; +}; + +const TOOL_RESULT_MAX_CHARS = 2000; + +export const truncateForSummary = (text: string, maxChars: number): string => { + if (text.length <= maxChars) { + return text; + } + const truncatedChars = text.length - maxChars; + return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`; +}; + +export const estimateTokens = (message: ContextMessage): number => { + const chars = message.content?.length ?? 0; + return Math.ceil(chars / 4) + 4; +}; + +export const estimateContextTokens = (messages: ContextMessage[]): number => + messages.reduce((total, message) => total + estimateTokens(message), 0) + 2; + +const serializedAssistantContent = (content: string): string => { + const parsed = safeParseJson(content); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return content; + } + + const target = parsed as Record; + if (target.type !== 'tool_call' || typeof target.tool !== 'string') { + return content; + } + + const args = target.arguments; + const argsText = + args && typeof args === 'object' + ? Object.entries(args as Record) + .map(([key, value]) => `${key}=${safeJsonStringify(value)}`) + .join(', ') + : safeJsonStringify(args); + return `tool_call ${target.tool}(${argsText})`; +}; + +/** 把当前 ContextMessage 序列化成适合摘要 prompt 的纯文本。 */ +export const serializeConversation = (messages: ContextMessage[]): string => + messages + .map((message) => { + const role = message.role === 'assistant' ? 'Assistant' : 'User'; + const text = + message.role === 'assistant' + ? serializedAssistantContent(message.content) + : message.content; + return `[${role}]: ${truncateForSummary(text, TOOL_RESULT_MAX_CHARS)}`; + }) + .join('\n\n'); diff --git a/packages/agent-core/src/harness/context/builder.ts b/packages/agent-core/src/harness/context/builder.ts deleted file mode 100644 index cbd3a4a..0000000 --- a/packages/agent-core/src/harness/context/builder.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { ContextBuilder } from '@agent-core/harness/context/context-builder'; -export type { - ContextMessage, - MessageRole, - RuntimeContext, -} from '@agent-core/harness/context/context-types'; -export { buildRuntimeContext } from '@agent-core/harness/context/runtime-context'; diff --git a/packages/agent-core/src/harness/core/loop.ts b/packages/agent-core/src/harness/core/loop.ts index 1a98732..a824b60 100644 --- a/packages/agent-core/src/harness/core/loop.ts +++ b/packages/agent-core/src/harness/core/loop.ts @@ -1,21 +1,20 @@ -import { streamLLM } from '@agent-core/harness/core/llm'; -import { ContextBuilder } from '@agent-core/harness/context/context-builder'; -import { buildRuntimeContext } from '@agent-core/harness/context/runtime-context'; -import type { ContextMessage } from '@agent-core/harness/context/context-types'; -import { systemPrompt } from '@agent-core/harness/prompt/system'; -import { toolPrompt } from '@agent-core/harness/prompt/tool'; -import { getModePrompt } from '@agent-core/harness/prompt/modes'; -import type { StreamHandlers } from '@agent-core/harness/core/llm'; -import type { TaskState } from '@agent-core/harness/core/state'; +import { callLLM, streamLLM, type StreamHandlers } from './llm'; +import { ContextBuilder } from '../session/context/context-builder'; +import { buildRuntimeContext } from '../session/context/runtime-context'; +import type { ContextMessage } from '../session/context/context-types'; +import { systemPrompt } from '../prompt/system'; +import { toolPrompt } from '../prompt/tool'; +import { getModePrompt } from '../prompt/modes'; +import type { TaskState } from './state'; import { extractFinalText, parseAgentResponse, shouldContinueLoop, -} from '@agent-core/harness/protocol/parser'; -import { isToolCallAction } from '@agent-core/harness/protocol/action'; -import { executeToolCall } from '@agent-core/harness/tools/executor'; -import { promoteStableFact } from '@agent-core/harness/context/memory/memory-writer'; -import { retrieveMemoryForTask } from '@agent-core/harness/context/memory/memory-retriever'; +} from '../protocol/parser'; +import { isToolCallAction } from '../protocol/action'; +import { executeToolCall } from '../session/tools/executor'; +import { promoteStableFact } from '../session/memory/memory-writer'; +import { retrieveMemoryForTask } from '../session/memory/memory-retriever'; import { appendTaskEntry, appendTaskRecord, @@ -23,7 +22,18 @@ import { getSharedSessionStoreOrNull, readTaskHistory, type SessionStoreLike, -} from '@agent-core/harness/session/session-repository'; +} from '../session/sessionInfo/session-repository'; +import { + DEFAULT_COMPACTION_SETTINGS, + compact, + createSummaryMessage, + persistCompactionEntry, + prepareMessagesToCompact, + shouldCompact, + type CompactionSettings, + type SummarizeFn, +} from '../compaction/compaction'; +import { estimateContextTokens } from '../compaction/utils'; const contextBuilder = new ContextBuilder(8000); @@ -32,6 +42,10 @@ export interface RunLoopOptions { persist?: boolean; /** 自定义 SessionStore,测试时可传入 :memory: 实例 */ sessionStore?: SessionStoreLike; + /** 是否启用上下文压缩,可传 false 关闭,或传自定义阈值 */ + compaction?: boolean | CompactionSettings; + /** 测试或自托管 LLM 时注入摘要生成函数 */ + summarize?: SummarizeFn; } export const runLoop = async ( @@ -43,6 +57,15 @@ export const runLoop = async ( const store = options.persist === true ? (options.sessionStore ?? getSharedSessionStoreOrNull()) : null; + const compactionEnabled = + options.compaction !== false && + (store !== null || options.compaction === true || typeof options.compaction === 'object'); + const compactionSettings: CompactionSettings | null = compactionEnabled + ? typeof options.compaction === 'object' + ? options.compaction + : DEFAULT_COMPACTION_SETTINGS + : null; + const summarize: SummarizeFn = options.summarize ?? (async (messages) => callLLM(messages)); if (store) { ensureTaskSession(task, store); @@ -50,6 +73,35 @@ export const runLoop = async ( history.push(...readTaskHistory(task, { limit: 100 }, store)); } + const maybeCompact = async (historyToCompact: ContextMessage[]): Promise => { + if (!compactionSettings) { + return; + } + const contextWindow = + compactionSettings.contextWindow ?? + DEFAULT_COMPACTION_SETTINGS.contextWindow ?? + 8000; + if (!shouldCompact(estimateContextTokens(historyToCompact), contextWindow, compactionSettings)) { + return; + } + + const preparation = prepareMessagesToCompact(historyToCompact, compactionSettings); + if (!preparation) { + return; + } + const result = await compact(preparation, { summarize }); + if (!result) { + return; + } + + historyToCompact.length = 0; + historyToCompact.push(createSummaryMessage(result.summary), ...result.retainedTail); + if (store) { + persistCompactionEntry(task.id, result, store); + } + handlers.onTrace?.(`上下文已压缩到摘要,保留最近 ${result.retainedTail.length} 条消息`); + }; + let step = 0; const maxSteps = 10; @@ -57,6 +109,7 @@ export const runLoop = async ( step++; try { handlers.onTrace?.(`第 ${step} 轮开始,正在请求模型...`); + await maybeCompact(history); const memory = retrieveMemoryForTask(task.input); const runtimeContext = buildRuntimeContext(contextBuilder, { diff --git a/packages/agent-core/src/harness/prompt/system.ts b/packages/agent-core/src/harness/prompt/system.ts index 8a43887..19cd009 100644 --- a/packages/agent-core/src/harness/prompt/system.ts +++ b/packages/agent-core/src/harness/prompt/system.ts @@ -25,7 +25,7 @@ export const systemPrompt = ` - 每次仅调用 1 个工具,单步最小化 - 不重复调用同一工具做相同事情 - 涉及“桌面/当前目录/工作区/下载目录”等环境路径时,先调用 get_environment - - 写文件只用 write_file;不要用 run_command 间接写文件 + - 写文件只用 write_file;不要用 bash 间接写文件 4. 路径规则: - 用户说“桌面”时,优先使用 get_environment 返回的 desktop 绝对路径 diff --git a/packages/agent-core/src/harness/prompt/tool.ts b/packages/agent-core/src/harness/prompt/tool.ts index 4ca3e99..cfc0918 100644 --- a/packages/agent-core/src/harness/prompt/tool.ts +++ b/packages/agent-core/src/harness/prompt/tool.ts @@ -1,5 +1,5 @@ export const toolPrompt = ` -可用工具仅有以下 6 个: +可用工具仅有以下 7 个: 1. get_environment() - 返回 cwd、home、desktop、documents、downloads、temp @@ -13,22 +13,22 @@ export const toolPrompt = ` - 写入文件(自动创建父目录) - path 支持:绝对路径、相对路径、~/...、Desktop/...、桌面/...、desktop:/... -4. run_command(command: string, cwd?: string) - - 执行终端命令 - - 仅在读写文件无法直接满足目标时使用 +4. search(query: string, path?: string, glob?: string, caseSensitive?: boolean, fixedStrings?: boolean, maxResults?: number) + - 使用 ripgrep 搜索文件内容,返回包含文件名、行列号和匹配文本的结果 + - path 可选,支持 ~/...、Desktop/...、桌面/...、desktop:/... + - 默认智能区分大小写;fixedStrings 为 true 时按纯文本搜索 + +5. bash(command: string, cwd?: string) + - 使用 Bash 执行终端命令,可用于查看目录和执行项目脚本 + - 仅在专用工具无法直接满足目标时使用 - cwd 可选,支持 ~/...、Desktop/...、桌面/...、desktop:/... -5. list_files(path: string) - - 查看目录结构 - - path 支持:绝对路径、相对路径、~/...、Desktop/...、桌面/...、desktop:/... +6. git_diff(cwd?: string, staged?: boolean, path?: string) + - 查看 Git 工作区差异;staged 为 true 时查看暂存区差异 + - path 可选,用于限制到仓库内的指定路径 + - cwd 可选,支持 ~/...、Desktop/...、桌面/...、desktop:/... -6. ocr_image(path: string, lang?: string) +7. ocr_image(path: string, lang?: string) - 识别图片中的文字,返回识别文本和置信度 - path 支持:绝对路径、相对路径、~/...、Desktop/...、桌面/...、desktop:/... - - lang 可选,默认 eng;中文可传 chi_sim+eng - -约束: -- 禁止调用未列出的工具。 -- 工具参数必须完整且类型正确。 -- 同一步不要混用多个动作;一次只做一件事。 `; diff --git a/packages/agent-core/src/harness/session/context/builder.ts b/packages/agent-core/src/harness/session/context/builder.ts new file mode 100644 index 0000000..c3ac36f --- /dev/null +++ b/packages/agent-core/src/harness/session/context/builder.ts @@ -0,0 +1,7 @@ +export { ContextBuilder } from './context-builder'; +export type { + ContextMessage, + MessageRole, + RuntimeContext, +} from './context-types'; +export { buildRuntimeContext } from './runtime-context'; diff --git a/packages/agent-core/src/harness/context/context-builder.ts b/packages/agent-core/src/harness/session/context/context-builder.ts similarity index 93% rename from packages/agent-core/src/harness/context/context-builder.ts rename to packages/agent-core/src/harness/session/context/context-builder.ts index d40220e..f1eb1e7 100644 --- a/packages/agent-core/src/harness/context/context-builder.ts +++ b/packages/agent-core/src/harness/session/context/context-builder.ts @@ -1,5 +1,5 @@ import { get_encoding, type Tiktoken } from 'tiktoken'; -import type { ContextMessage } from '@agent-core/harness/context/context-types'; +import type { ContextMessage } from './context-types'; export class ContextBuilder { private readonly encoder: Tiktoken; diff --git a/packages/agent-core/src/harness/context/context-summarizer.ts b/packages/agent-core/src/harness/session/context/context-summarizer.ts similarity index 91% rename from packages/agent-core/src/harness/context/context-summarizer.ts rename to packages/agent-core/src/harness/session/context/context-summarizer.ts index 7782b06..11c9d84 100644 --- a/packages/agent-core/src/harness/context/context-summarizer.ts +++ b/packages/agent-core/src/harness/session/context/context-summarizer.ts @@ -1,4 +1,4 @@ -import type { ContextMessage } from '@agent-core/harness/context/context-types'; +import type { ContextMessage } from './context-types'; export interface HistorySummary { summary: string; diff --git a/packages/agent-core/src/harness/context/context-types.ts b/packages/agent-core/src/harness/session/context/context-types.ts similarity index 100% rename from packages/agent-core/src/harness/context/context-types.ts rename to packages/agent-core/src/harness/session/context/context-types.ts diff --git a/packages/agent-core/src/harness/context/runtime-context.ts b/packages/agent-core/src/harness/session/context/runtime-context.ts similarity index 85% rename from packages/agent-core/src/harness/context/runtime-context.ts rename to packages/agent-core/src/harness/session/context/runtime-context.ts index bda4173..eb470a6 100644 --- a/packages/agent-core/src/harness/context/runtime-context.ts +++ b/packages/agent-core/src/harness/session/context/runtime-context.ts @@ -1,11 +1,11 @@ import type { TaskState } from '@agent-core/harness/core/state'; -import { createTaskContext } from '@agent-core/harness/context/task-context'; -import { summarizeHistory } from '@agent-core/harness/context/context-summarizer'; -import { ContextBuilder } from '@agent-core/harness/context/context-builder'; +import { createTaskContext } from './task-context'; +import { summarizeHistory } from './context-summarizer'; +import { ContextBuilder } from './context-builder'; import type { ContextMessage, RuntimeContext, -} from '@agent-core/harness/context/context-types'; +} from './context-types'; export interface BuildRuntimeContextInput { system: string; diff --git a/packages/agent-core/src/harness/context/task-context.ts b/packages/agent-core/src/harness/session/context/task-context.ts similarity index 100% rename from packages/agent-core/src/harness/context/task-context.ts rename to packages/agent-core/src/harness/session/context/task-context.ts diff --git a/packages/agent-core/src/harness/session/index.ts b/packages/agent-core/src/harness/session/index.ts new file mode 100644 index 0000000..42dd4ee --- /dev/null +++ b/packages/agent-core/src/harness/session/index.ts @@ -0,0 +1,9 @@ +export * from './context/builder'; +export * from './memory/memory-retriever'; +export * from './memory/memory-schema'; +export * from './memory/memory-store'; +export * from './memory/memory-writer'; +export * from './policy/guard'; +export * from './policy/modes'; +export * from './sessionInfo/session-repository'; +export * from './tools'; diff --git a/packages/agent-core/src/harness/context/memory/memory-retriever.ts b/packages/agent-core/src/harness/session/memory/memory-retriever.ts similarity index 92% rename from packages/agent-core/src/harness/context/memory/memory-retriever.ts rename to packages/agent-core/src/harness/session/memory/memory-retriever.ts index a7878df..ddeb3d1 100644 --- a/packages/agent-core/src/harness/context/memory/memory-retriever.ts +++ b/packages/agent-core/src/harness/session/memory/memory-retriever.ts @@ -1,4 +1,4 @@ -import { memoryStore } from '@agent-core/harness/context/memory/memory-store'; +import { memoryStore } from '@agent-core/harness/session/memory/memory-store'; export interface RetrievedMemory { longFacts: string[]; diff --git a/packages/agent-core/src/harness/context/memory/memory-schema.ts b/packages/agent-core/src/harness/session/memory/memory-schema.ts similarity index 100% rename from packages/agent-core/src/harness/context/memory/memory-schema.ts rename to packages/agent-core/src/harness/session/memory/memory-schema.ts diff --git a/packages/agent-core/src/harness/context/memory/memory-store.ts b/packages/agent-core/src/harness/session/memory/memory-store.ts similarity index 94% rename from packages/agent-core/src/harness/context/memory/memory-store.ts rename to packages/agent-core/src/harness/session/memory/memory-store.ts index 2dc76b0..73acfdb 100644 --- a/packages/agent-core/src/harness/context/memory/memory-store.ts +++ b/packages/agent-core/src/harness/session/memory/memory-store.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { LongMemoryItem, MemorySnapshot, -} from '@agent-core/harness/context/memory/memory-schema'; +} from '@agent-core/harness/session/memory/memory-schema'; export class MemoryStore { private readonly longMemory: LongMemoryItem[] = []; diff --git a/packages/agent-core/src/harness/context/memory/memory-writer.ts b/packages/agent-core/src/harness/session/memory/memory-writer.ts similarity index 83% rename from packages/agent-core/src/harness/context/memory/memory-writer.ts rename to packages/agent-core/src/harness/session/memory/memory-writer.ts index d5662fe..5a53bd7 100644 --- a/packages/agent-core/src/harness/context/memory/memory-writer.ts +++ b/packages/agent-core/src/harness/session/memory/memory-writer.ts @@ -1,6 +1,6 @@ import type { TaskState } from '@core/state'; -import type { ContextMessage } from '@agent-core/harness/context/context-types'; -import { memoryStore } from '@agent-core/harness/context/memory/memory-store'; +import type { ContextMessage } from '@agent-core/harness/session/context/context-types'; +import { memoryStore } from '@agent-core/harness/session/memory/memory-store'; const countStableMentions = (messages: ContextMessage[], text: string): number => messages.reduce((count, item) => { diff --git a/packages/agent-core/src/harness/policy/guard.ts b/packages/agent-core/src/harness/session/policy/guard.ts similarity index 82% rename from packages/agent-core/src/harness/policy/guard.ts rename to packages/agent-core/src/harness/session/policy/guard.ts index b9492ea..458f453 100644 --- a/packages/agent-core/src/harness/policy/guard.ts +++ b/packages/agent-core/src/harness/session/policy/guard.ts @@ -1,13 +1,13 @@ import type { AgentMode } from '@agent-core/harness/core/state'; -import { modePolicies } from '@agent-core/harness/policy/modes'; +import { modePolicies } from './modes'; const toolPermissionByName = (toolName: string, mode: AgentMode): boolean => { const policy = modePolicies[mode]; if (mode === 'plan') { return false; } - if (toolName === 'run_command') { - return policy.allowRunCommand; + if (toolName === 'bash') { + return policy.allowBash; } if (toolName === 'write_file') { return policy.allowWriteFile; diff --git a/packages/agent-core/src/harness/policy/modes.ts b/packages/agent-core/src/harness/session/policy/modes.ts similarity index 75% rename from packages/agent-core/src/harness/policy/modes.ts rename to packages/agent-core/src/harness/session/policy/modes.ts index e97babf..dc8ee2d 100644 --- a/packages/agent-core/src/harness/policy/modes.ts +++ b/packages/agent-core/src/harness/session/policy/modes.ts @@ -1,17 +1,17 @@ import type { AgentMode } from '@agent-core/harness/core/state'; export interface ModePolicy { - readonly allowRunCommand: boolean; + readonly allowBash: boolean; readonly allowWriteFile: boolean; } export const modePolicies: Record = { plan: { - allowRunCommand: false, + allowBash: false, allowWriteFile: false, }, build: { - allowRunCommand: true, + allowBash: true, allowWriteFile: true, }, }; diff --git a/packages/agent-core/src/harness/session/session-repository.ts b/packages/agent-core/src/harness/session/sessionInfo/session-repository.ts similarity index 85% rename from packages/agent-core/src/harness/session/session-repository.ts rename to packages/agent-core/src/harness/session/sessionInfo/session-repository.ts index 5912ff3..a4904ba 100644 --- a/packages/agent-core/src/harness/session/session-repository.ts +++ b/packages/agent-core/src/harness/session/sessionInfo/session-repository.ts @@ -1,5 +1,5 @@ import type { TaskState } from '@agent-core/harness/core/state'; -import type { ContextMessage } from '@agent-core/harness/context/context-types'; +import type { ContextMessage } from '../context/context-types'; /** 默认泳道名称,与 session-sqlite store 保持一致 */ export const DEFAULT_LANE = 'default'; @@ -270,9 +270,28 @@ const entryToContextMessage = (entry: EntryLike): ContextMessage | null => { if (entry.type === 'tool') { return { role: 'user', content: payload.content }; } + if (entry.type === 'compaction' || entry.type === 'branch_summary') { + const summary = (payload as { summary?: unknown }).summary; + if (typeof summary !== 'string') { + return null; + } + const prefix = entry.type === 'compaction' ? '[历史摘要]' : '[分支摘要]'; + return { role: 'user', content: `${prefix}\n${summary}` }; + } return null; }; +const isContextMessage = (value: unknown): value is ContextMessage => { + if (!value || typeof value !== 'object') { + return false; + } + const message = value as { role?: unknown; content?: unknown }; + return ( + (message.role === 'user' || message.role === 'assistant') && + typeof message.content === 'string' + ); +}; + /** * 读取任务会话历史,供 runLoop 恢复上下文使用。 * 只返回 assistant/tool 条目,user 指令由 buildRuntimeContext 单独拼接。 @@ -285,6 +304,32 @@ export const readTaskHistory = ( // getEntries 按 seq 升序返回,这里取最近 limit 条,避免只拿到最早的历史。 const limit = options.limit ?? 1000; const entries = store.getEntries(task.id, { limit: Math.max(limit, 1000) }); + + let lastCompactionIndex = -1; + for (let index = entries.length - 1; index >= 0; index--) { + if (entries[index].type === 'compaction') { + lastCompactionIndex = index; + break; + } + } + + if (lastCompactionIndex >= 0) { + const compaction = entries[lastCompactionIndex]; + const payload = compaction.payload as { retainedTail?: unknown } | null; + const retainedTail = Array.isArray(payload?.retainedTail) + ? payload.retainedTail.filter(isContextMessage) + : []; + const history: ContextMessage[] = [ + ...(entryToContextMessage(compaction) ? [entryToContextMessage(compaction)!] : []), + ...retainedTail, + ...entries + .slice(lastCompactionIndex + 1) + .map(entryToContextMessage) + .filter((item): item is ContextMessage => item !== null), + ]; + return history.slice(-limit); + } + return entries .slice(-limit) .map(entryToContextMessage) diff --git a/packages/agent-core/src/harness/tools/runCommand.ts b/packages/agent-core/src/harness/session/tools/bash.ts similarity index 55% rename from packages/agent-core/src/harness/tools/runCommand.ts rename to packages/agent-core/src/harness/session/tools/bash.ts index 9914446..86d5414 100644 --- a/packages/agent-core/src/harness/tools/runCommand.ts +++ b/packages/agent-core/src/harness/session/tools/bash.ts @@ -1,18 +1,18 @@ -import { exec } from 'node:child_process'; +import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { resolveUserPath } from '@agent-core/harness/tools/pathUtils'; +import { resolveUserPath } from './pathUtils'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); -export const runCommandTool = { - name: 'run_command', - description: 'Run a command', +export const bashTool = { + name: 'bash', + description: 'Run a command with Bash', parameters: { type: 'object', properties: { command: { type: 'string', - description: 'The command to run', + description: 'The Bash command to run', }, cwd: { type: 'string', @@ -22,17 +22,20 @@ export const runCommandTool = { }, required: ['command'], }, - run: async (input: any) => { - const { command, cwd } = input; - if (!command || typeof command !== 'string') { + run: async (input: unknown) => { + const value = input as { command?: unknown; cwd?: unknown }; + const { command, cwd } = value; + if (typeof command !== 'string' || !command.trim()) { throw new Error('Invalid command'); } + const resolvedCwd = typeof cwd === 'string' && cwd.trim() ? resolveUserPath(cwd) : undefined; - const { stdout, stderr } = await execAsync(command, { + const { stdout, stderr } = await execFileAsync('bash', ['-lc', command], { maxBuffer: 10 * 1024 * 1024, cwd: resolvedCwd, }); + return { command, cwd: resolvedCwd || process.cwd(), diff --git a/packages/agent-core/src/harness/tools/executor.ts b/packages/agent-core/src/harness/session/tools/executor.ts similarity index 93% rename from packages/agent-core/src/harness/tools/executor.ts rename to packages/agent-core/src/harness/session/tools/executor.ts index 955d4b3..a039c83 100644 --- a/packages/agent-core/src/harness/tools/executor.ts +++ b/packages/agent-core/src/harness/session/tools/executor.ts @@ -1,5 +1,5 @@ -import { tools } from '@agent-core/harness/tools'; -import { enforceToolPermission } from '@agent-core/harness/policy/guard'; +import { tools } from './'; +import { enforceToolPermission } from '../policy/guard'; import type { AgentMode } from '@agent-core/harness/core/state'; import type { ToolCallAction } from '@agent-core/harness/protocol/action'; import { createToolResultObservation } from '@agent-core/harness/protocol/observation'; diff --git a/packages/agent-core/src/harness/tools/getEnvironment.ts b/packages/agent-core/src/harness/session/tools/getEnvironment.ts similarity index 94% rename from packages/agent-core/src/harness/tools/getEnvironment.ts rename to packages/agent-core/src/harness/session/tools/getEnvironment.ts index e224a0a..a0d9941 100644 --- a/packages/agent-core/src/harness/tools/getEnvironment.ts +++ b/packages/agent-core/src/harness/session/tools/getEnvironment.ts @@ -1,6 +1,6 @@ import { access } from 'node:fs/promises'; import process from 'node:process'; -import { getKnownLocations } from '@agent-core/harness/tools/pathUtils'; +import { getKnownLocations } from './pathUtils'; const exists = async (path: string) => { try { @@ -19,7 +19,7 @@ export const getEnvironmentTool = { type: 'object', properties: {}, }, - run: async () => { + run: async (_input: unknown = {}) => { const locations = getKnownLocations(); return { diff --git a/packages/agent-core/src/harness/session/tools/gitDiff.ts b/packages/agent-core/src/harness/session/tools/gitDiff.ts new file mode 100644 index 0000000..fdaf997 --- /dev/null +++ b/packages/agent-core/src/harness/session/tools/gitDiff.ts @@ -0,0 +1,72 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { resolveUserPath } from './pathUtils'; + +const execFileAsync = promisify(execFile); + +export const gitDiffTool = { + name: 'git_diff', + description: 'Show Git working tree or staged changes', + parameters: { + type: 'object', + properties: { + cwd: { + type: 'string', + description: + 'Optional Git repository directory. Supports aliases like desktop:/, ~/ and temp:/.', + }, + staged: { + type: 'boolean', + description: 'Show staged changes instead of unstaged changes', + }, + path: { + type: 'string', + description: 'Optional repository-relative path to limit the diff', + }, + }, + }, + run: async (input: unknown = {}) => { + const value = input as { + cwd?: unknown; + staged?: unknown; + path?: unknown; + }; + const { cwd, staged, path } = value; + + if (cwd !== undefined && typeof cwd !== 'string') { + throw new Error('Invalid cwd'); + } + if (staged !== undefined && typeof staged !== 'boolean') { + throw new Error('Invalid staged flag'); + } + if (path !== undefined && (typeof path !== 'string' || !path.trim())) { + throw new Error('Invalid path'); + } + + const resolvedCwd = + typeof cwd === 'string' && cwd.trim() + ? resolveUserPath(cwd) + : process.cwd(); + const args = ['diff', '--no-ext-diff', '--no-color']; + if (staged) { + args.push('--cached'); + } + if (typeof path === 'string') { + args.push('--', path); + } + + const { stdout, stderr } = await execFileAsync('git', args, { + maxBuffer: 10 * 1024 * 1024, + cwd: resolvedCwd, + }); + + return { + cwd: resolvedCwd, + staged: staged === true, + path: typeof path === 'string' ? path : null, + hasChanges: stdout.length > 0, + stdout, + stderr, + }; + }, +}; diff --git a/packages/agent-core/src/harness/session/tools/index.ts b/packages/agent-core/src/harness/session/tools/index.ts new file mode 100644 index 0000000..ad63d90 --- /dev/null +++ b/packages/agent-core/src/harness/session/tools/index.ts @@ -0,0 +1,25 @@ +import { getEnvironmentTool } from './getEnvironment'; +import { bashTool } from './bash'; +import { ocrImageTool } from './ocr'; +import { gitDiffTool } from './gitDiff'; +import { readFileTool } from './readFile'; +import { searchTool } from './search'; +import { writeFileTool } from './writeFile'; +export { + bashTool, + getEnvironmentTool, + gitDiffTool, + ocrImageTool, + readFileTool, + searchTool, + writeFileTool, +}; +export const tools = [ + getEnvironmentTool, + readFileTool, + writeFileTool, + searchTool, + bashTool, + gitDiffTool, + ocrImageTool, +]; diff --git a/packages/agent-core/src/harness/tools/ocr.ts b/packages/agent-core/src/harness/session/tools/ocr.ts similarity index 96% rename from packages/agent-core/src/harness/tools/ocr.ts rename to packages/agent-core/src/harness/session/tools/ocr.ts index accd1e9..074454a 100644 --- a/packages/agent-core/src/harness/tools/ocr.ts +++ b/packages/agent-core/src/harness/session/tools/ocr.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises'; import Tesseract from 'tesseract.js'; -import { resolveUserPath } from '@tools/pathUtils'; +import { resolveUserPath } from './pathUtils'; const DEFAULT_LANG = 'eng'; diff --git a/packages/agent-core/src/harness/tools/pathUtils.ts b/packages/agent-core/src/harness/session/tools/pathUtils.ts similarity index 100% rename from packages/agent-core/src/harness/tools/pathUtils.ts rename to packages/agent-core/src/harness/session/tools/pathUtils.ts diff --git a/packages/agent-core/src/harness/tools/readFile.ts b/packages/agent-core/src/harness/session/tools/readFile.ts similarity index 90% rename from packages/agent-core/src/harness/tools/readFile.ts rename to packages/agent-core/src/harness/session/tools/readFile.ts index 046337e..4871e43 100644 --- a/packages/agent-core/src/harness/tools/readFile.ts +++ b/packages/agent-core/src/harness/session/tools/readFile.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { resolveUserPath } from '@agent-core/harness/tools/pathUtils'; +import { resolveUserPath } from './pathUtils'; export const readFileTool = { name: 'read_file', diff --git a/packages/agent-core/src/harness/session/tools/search.ts b/packages/agent-core/src/harness/session/tools/search.ts new file mode 100644 index 0000000..0470c62 --- /dev/null +++ b/packages/agent-core/src/harness/session/tools/search.ts @@ -0,0 +1,171 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { resolveUserPath } from './pathUtils'; + +const execFileAsync = promisify(execFile); +const DEFAULT_MAX_RESULTS = 100; +const MAX_RESULTS = 1000; + +export const searchTool = { + name: 'search', + description: 'Search file contents with ripgrep', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Text or regular expression to search for', + }, + path: { + type: 'string', + description: + 'Optional file or directory to search. Supports aliases like desktop:/, ~/ and temp:/.', + }, + glob: { + type: 'string', + description: 'Optional ripgrep glob used to include or exclude files', + }, + caseSensitive: { + type: 'boolean', + description: 'Whether matching should be case-sensitive', + }, + fixedStrings: { + type: 'boolean', + description: 'Whether to treat the query as literal text instead of a regular expression', + }, + maxResults: { + type: 'number', + description: `Maximum number of matching lines to return, up to ${MAX_RESULTS}`, + }, + }, + required: ['query'], + }, + run: async (input: unknown) => { + const value = input as { + query?: unknown; + path?: unknown; + glob?: unknown; + caseSensitive?: unknown; + fixedStrings?: unknown; + maxResults?: unknown; + }; + const query = validateRequiredString(value.query, 'query'); + const searchPath = validateOptionalString(value.path, 'path') ?? '.'; + const glob = validateOptionalString(value.glob, 'glob'); + const caseSensitive = validateOptionalBoolean( + value.caseSensitive, + 'caseSensitive', + ); + const fixedStrings = validateOptionalBoolean( + value.fixedStrings, + 'fixedStrings', + ); + const maxResults = validateMaxResults(value.maxResults); + const resolvedPath = resolveUserPath(searchPath); + const args = ['--line-number', '--column', '--no-heading', '--color', 'never']; + + if (caseSensitive === false) { + args.push('--ignore-case'); + } else if (caseSensitive === true) { + args.push('--case-sensitive'); + } else { + args.push('--smart-case'); + } + if (fixedStrings) { + args.push('--fixed-strings'); + } + if (glob) { + args.push('--glob', glob); + } + args.push('--max-count', String(maxResults), '--', query, resolvedPath); + + try { + const { stdout, stderr } = await execFileAsync('rg', args, { + maxBuffer: 10 * 1024 * 1024, + }); + const matches = stdout ? stdout.trimEnd().split('\n') : []; + + return { + query, + path: resolvedPath, + glob, + caseSensitive: caseSensitive ?? 'smart', + fixedStrings: fixedStrings ?? false, + maxResults, + matchCount: matches.length, + matches, + stderr, + }; + } catch (error) { + if (isNoMatchError(error)) { + return { + query, + path: resolvedPath, + glob, + caseSensitive: caseSensitive ?? 'smart', + fixedStrings: fixedStrings ?? false, + maxResults, + matchCount: 0, + matches: [], + stderr: error.stderr ?? '', + }; + } + throw error; + } + }, +}; + +const validateRequiredString = (value: unknown, name: string): string => { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Invalid ${name}`); + } + return value; +}; + +const validateOptionalString = ( + value: unknown, + name: string, +): string | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Invalid ${name}`); + } + return value; +}; + +const validateOptionalBoolean = ( + value: unknown, + name: string, +): boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'boolean') { + throw new Error(`Invalid ${name} flag`); + } + return value; +}; + +const validateMaxResults = (value: unknown): number => { + if (value === undefined) { + return DEFAULT_MAX_RESULTS; + } + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < 1 || + value > MAX_RESULTS + ) { + throw new Error('Invalid maxResults'); + } + return value; +}; + +const isNoMatchError = ( + error: unknown, +): error is Error & { code: number; stdout?: string; stderr?: string } => + error instanceof Error && + 'code' in error && + (error as { code?: unknown }).code === 1; diff --git a/packages/agent-core/src/harness/tools/writeFile.ts b/packages/agent-core/src/harness/session/tools/writeFile.ts similarity index 93% rename from packages/agent-core/src/harness/tools/writeFile.ts rename to packages/agent-core/src/harness/session/tools/writeFile.ts index 34468bb..499742f 100644 --- a/packages/agent-core/src/harness/tools/writeFile.ts +++ b/packages/agent-core/src/harness/session/tools/writeFile.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises'; import pathModule from 'node:path'; -import { resolveUserPath } from '@agent-core/harness/tools/pathUtils'; +import { resolveUserPath } from './pathUtils'; export const writeFileTool = { name: 'write_file', diff --git a/packages/agent-core/src/harness/tools/index.ts b/packages/agent-core/src/harness/tools/index.ts deleted file mode 100644 index a5a68a0..0000000 --- a/packages/agent-core/src/harness/tools/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { getEnvironmentTool } from '@agent-core/harness/tools/getEnvironment'; -import { listFilesTool } from '@agent-core/harness/tools/listFiles'; -import { readFileTool } from '@agent-core/harness/tools/readFile'; -import { runCommandTool } from '@agent-core/harness/tools/runCommand'; -import { writeFileTool } from '@agent-core/harness/tools/writeFile'; -export { - getEnvironmentTool, - listFilesTool, - readFileTool, - runCommandTool, - writeFileTool, -}; -export const tools = [ - getEnvironmentTool, - listFilesTool, - readFileTool, - runCommandTool, - writeFileTool, -]; diff --git a/packages/agent-core/src/harness/tools/listFiles.ts b/packages/agent-core/src/harness/tools/listFiles.ts deleted file mode 100644 index 9a97e7c..0000000 --- a/packages/agent-core/src/harness/tools/listFiles.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readdir } from 'node:fs/promises'; -import pathModule from 'node:path'; -import { resolveUserPath } from '@agent-core/harness/tools/pathUtils'; - -export const listFilesTool = { - name: 'list_files', - description: 'List files in a directory', - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'The path to list files in', - }, - }, - required: ['path'], - }, - run: async (input: any) => { - const { path } = input; - if (!path || typeof path !== 'string') { - throw new Error('Invalid path'); - } - const resolvedPath = resolveUserPath(path); - const entries = await readdir(resolvedPath, { withFileTypes: true }); - return { - requestedPath: path, - path: resolvedPath, - entries: entries.map((entry) => ({ - name: entry.name, - path: pathModule.join(resolvedPath, entry.name), - type: entry.isDirectory() ? 'directory' : 'file', - })), - }; - }, -}; diff --git a/packages/agent-core/src/harness/utils/shell.ts b/packages/agent-core/src/harness/utils/shell.ts new file mode 100644 index 0000000..b8ea198 --- /dev/null +++ b/packages/agent-core/src/harness/utils/shell.ts @@ -0,0 +1,91 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { truncateOutput, type TruncateOptions } from '@agent-core/harness/utils/truncate'; +const execFileAsync = promisify(execFile); + +export interface ExecShellOptions { + command: string; + cwd?: string; + maxBufferBytes?: number; + maxOutputLines?: number; + maxOutputBytes?: number; + shell?: string; + shellFlag?: string; +} + +export interface ExecShellResult { + command: string; + cwd: string; + exitCode: number; + stdout: string; + stderr: string; + truncated: boolean; +} + +export const execShell = async ( + options: ExecShellOptions, +): Promise => { + const { + command, + cwd, + maxBufferBytes = 10 * 1024 * 1024, + maxOutputLines, + maxOutputBytes, + shell = process.env.SHELL || 'bash', + shellFlag = '-lc', + } = options; + + if (!command || typeof command !== 'string' || !command.trim()) { + throw new Error('Invalid command'); + } + + const { stdout, stderr } = await execFileAsync(shell, [shellFlag, command], { + maxBuffer: maxBufferBytes, + cwd, + }); + + return buildResult({ command, cwd, stdout, stderr }, { + maxOutputLines, + maxOutputBytes, + }); +}; + +interface ShellLimits { + maxOutputLines?: number; + maxOutputBytes?: number; +} + +const buildResult = ( + raw: { command: string; cwd?: string; stdout: string; stderr: string }, + limits: ShellLimits, +): ExecShellResult => { + const wouldTruncate = (value: string): boolean => { + if (limits.maxOutputLines !== undefined && value.split('\n').length > limits.maxOutputLines) { + return true; + } + if (limits.maxOutputBytes !== undefined && Buffer.byteLength(value, 'utf8') > limits.maxOutputBytes) { + return true; + } + return false; + }; + + const truncated = wouldTruncate(raw.stdout) || wouldTruncate(raw.stderr); + const options: TruncateOptions = {}; + if (limits.maxOutputLines !== undefined) { + options.maxLines = limits.maxOutputLines; + } + if (limits.maxOutputBytes !== undefined) { + options.maxBytes = limits.maxOutputBytes; + } + const stdout = truncateOutput(raw.stdout, options); + const stderr = truncateOutput(raw.stderr, options); + + return { + command: raw.command, + cwd: raw.cwd || process.cwd(), + exitCode: 0, + stdout, + stderr, + truncated, + }; +}; diff --git a/packages/agent-core/src/harness/utils/truncate.ts b/packages/agent-core/src/harness/utils/truncate.ts new file mode 100644 index 0000000..077be6b --- /dev/null +++ b/packages/agent-core/src/harness/utils/truncate.ts @@ -0,0 +1,66 @@ +import { Buffer } from 'node:buffer'; + +export interface TruncateOptions { + maxLines?: number; + maxBytes?: number; + suffix?: string; +} + +const DEFAULT_SUFFIX = '\n[输出已截断]'; + +export function truncateByBytes(text: string, maxBytes: number, suffix = DEFAULT_SUFFIX): string { + if (maxBytes <= 0) { + return ''; + } + + const total = Buffer.byteLength(text, 'utf8'); + if (total <= maxBytes) { + return text; + } + + const suffixBytes = Buffer.byteLength(suffix, 'utf8'); + const target = Math.max(0, maxBytes - suffixBytes); + + let left = 0; + let right = text.length; + while (left < right) { + const mid = (left + right + 1) >>> 1; + if (Buffer.byteLength(text.slice(0, mid), 'utf8') <= target) { + left = mid; + } else { + right = mid - 1; + } + } + + return `${text.slice(0, left)}${suffix}`; +} + +export function truncateByLines(text: string, maxLines: number, suffix = DEFAULT_SUFFIX): string { + if (maxLines <= 0) { + return ''; + } + + const lines = text.split('\n'); + if (lines.length <= maxLines) { + return text; + } + + const kept = lines.slice(0, maxLines).join('\n'); + const dropped = lines.length - maxLines; + return `${kept}\n[输出已截断,省略 ${dropped} 行]`; +} + +export function truncateOutput( + text: string, + options: TruncateOptions = {}, + suffix = DEFAULT_SUFFIX, +): string { + let result = text; + if (options.maxLines !== undefined) { + result = truncateByLines(result, options.maxLines, suffix); + } + if (options.maxBytes !== undefined) { + result = truncateByBytes(result, options.maxBytes, suffix); + } + return result; +} diff --git a/packages/agent-core/src/utils/json.ts b/packages/agent-core/src/utils/json.ts index e69de29..bbfc8be 100644 --- a/packages/agent-core/src/utils/json.ts +++ b/packages/agent-core/src/utils/json.ts @@ -0,0 +1,15 @@ +export const parseJson = (text: string, fallback: T): T => { + try { + return JSON.parse(text) as T; + } catch { + return fallback; + } +}; + +export const stringifyJson = (value: unknown, fallback = ''): string => { + try { + return JSON.stringify(value) ?? fallback; + } catch { + return fallback; + } +}; diff --git a/packages/agent-core/src/utils/logger.ts b/packages/agent-core/src/utils/logger.ts index e69de29..eb1e8b7 100644 --- a/packages/agent-core/src/utils/logger.ts +++ b/packages/agent-core/src/utils/logger.ts @@ -0,0 +1,20 @@ +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const write = (level: LogLevel, ...args: unknown[]): void => { + const method = + level === 'error' + ? console.error + : level === 'warn' + ? console.warn + : level === 'info' + ? console.info + : console.debug; + method(...args); +}; + +export const logger = { + debug: (...args: unknown[]): void => write('debug', ...args), + info: (...args: unknown[]): void => write('info', ...args), + warn: (...args: unknown[]): void => write('warn', ...args), + error: (...args: unknown[]): void => write('error', ...args), +}; diff --git a/packages/agent-core/src/web/export.ts b/packages/agent-core/src/web/export.ts index 5a45dd8..ecae32a 100644 --- a/packages/agent-core/src/web/export.ts +++ b/packages/agent-core/src/web/export.ts @@ -4,7 +4,7 @@ import type { EntryLike, SessionStoreLike, SessionStatsLike, -} from '../harness/session/session-repository'; +} from '../harness/session/sessionInfo/session-repository'; /** GitHub Pages 客户端读取的会话快照格式。 */ export interface WebEntry { diff --git a/packages/session-sqlite/package.json b/packages/session-sqlite/package.json index 6dbbdfc..d38282f 100644 --- a/packages/session-sqlite/package.json +++ b/packages/session-sqlite/package.json @@ -11,5 +11,8 @@ }, "engines": { "node": ">=22.5.0" + }, + "dependencies": { + "sqlite-vec": "^0.1.9" } } diff --git a/source/app.tsx b/source/app.tsx index 54fb93b..47cc2e1 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -7,17 +7,17 @@ import { llmModel } from '@agent-core/harness/core/llm'; import TextInput from 'ink-text-input'; import Spinner from 'ink-spinner'; import { get_encoding } from 'tiktoken'; -import { memoryStore } from '@agent-core/harness/context/memory/memory-store'; +import { memoryStore } from '@agent-core/harness/session/memory/memory-store'; import { getSharedSessionStore, loadRecentFeed, loadRecentHistory, setSharedSessionStore, type RecentHistoryItem, -} from '@agent-core/harness/session/session-repository'; +} from '@agent-core/harness/session/sessionInfo/session-repository'; import { writeWebExport } from '@web/export'; import { SessionStore } from '../packages/session-sqlite/src/index'; -import { resolveUserPath } from '@agent-core/harness/tools/pathUtils'; +import { resolveUserPath } from '@agent-core/harness/session/tools/pathUtils'; const encoding = get_encoding('cl100k_base'); diff --git a/tests/agent-core-utils.spec.ts b/tests/agent-core-utils.spec.ts new file mode 100644 index 0000000..f95d025 --- /dev/null +++ b/tests/agent-core-utils.spec.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { parseJson, stringifyJson } from '../packages/agent-core/src/utils/json'; +import { logger } from '../packages/agent-core/src/utils/logger'; + +describe('agent-core utils', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('parseJson returns parsed object and uses fallback for invalid input', () => { + expect(parseJson('{"ok":true}', null)).toEqual({ ok: true }); + expect(parseJson('bad', { ok: false })).toEqual({ ok: false }); + }); + + it('stringifyJson serializes values and falls back on failure', () => { + expect(stringifyJson({ ok: true })).toBe('{"ok":true}'); + expect(stringifyJson(undefined, 'fallback')).toBe('fallback'); + expect(stringifyJson(1n, 'fallback')).toBe('fallback'); + }); + + it('logger routes levels to console methods', () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + logger.info('a'); + logger.warn('b'); + logger.error('c'); + + expect(info).toHaveBeenCalledWith('a'); + expect(warn).toHaveBeenCalledWith('b'); + expect(error).toHaveBeenCalledWith('c'); + }); +}); diff --git a/tests/agent-session-runloop.spec.ts b/tests/agent-session-runloop.spec.ts index 6e4c728..e17009c 100644 --- a/tests/agent-session-runloop.spec.ts +++ b/tests/agent-session-runloop.spec.ts @@ -11,7 +11,7 @@ import { SessionStore } from '../packages/session-sqlite/src/index'; import { closeSharedSessionStore, setSharedSessionStore, -} from '@agent-core/harness/session/session-repository'; +} from '@agent-core/harness/session/sessionInfo/session-repository'; describe('runLoop SQLite 持久化', () => { let store: SessionStore; @@ -65,4 +65,42 @@ describe('runLoop SQLite 持久化', () => { objective: task.objective, }); }); + + it('触发压缩后仍能继续执行当前任务', async () => { + vi.mocked(streamLLM) + .mockResolvedValueOnce( + JSON.stringify({ + type: 'tool_call', + tool: 'get_environment', + arguments: {}, + message: `inspect environment ${'a'.repeat(200)}`, + }), + ) + .mockResolvedValueOnce( + JSON.stringify({ + type: 'final', + tool: null, + arguments: null, + message: 'done', + }), + ); + const summarize = vi.fn(async () => '压缩摘要'); + + const task = createTaskState('task'); + const res = await runLoop(task, {}, { + persist: true, + sessionStore: store, + compaction: { + enabled: true, + contextWindow: 50, + reserveTokens: 1, + keepRecentTokens: 1, + }, + summarize, + }); + + expect(res).toBe('done'); + expect(summarize).toHaveBeenCalledTimes(1); + expect(store.getEntries(task.id).some((entry) => entry.type === 'compaction')).toBe(true); + }); }); diff --git a/tests/compaction.spec.ts b/tests/compaction.spec.ts new file mode 100644 index 0000000..d001992 --- /dev/null +++ b/tests/compaction.spec.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SessionStore } from '../packages/session-sqlite/src/index'; +import type { ContextMessage } from '@agent-core/harness/session/context/context-types'; +import { + compact, + createSummaryMessage, + findCutPoint, + isSummaryMessage, + persistCompactionEntry, + prepareMessagesToCompact, + readPreviousSummary, + shouldCompact, + type CompactionSettings, + type MessageLike, +} from '@agent-core/harness/compaction/compaction'; +import { + branchSummaryEntryToMessage, + collectEntriesForBranchSummary, + generateBranchSummary, + persistBranchSummaryEntry, + prepareBranchEntries, +} from '@agent-core/harness/compaction/branch-summarization'; +import { + computeFileLists, + estimateContextTokens, + extractFileOpsFromMessage, +} from '@agent-core/harness/compaction/utils'; + +const settings: CompactionSettings = { + enabled: true, + reserveTokens: 100, + keepRecentTokens: 40, + contextWindow: 800, +}; + +const mkMessages = (): ContextMessage[] => [ + { role: 'user', content: 'u1-0123456789' }, + { role: 'assistant', content: `a1-${'x'.repeat(180)}` }, + { role: 'user', content: 'u2-0123456789' }, + { role: 'assistant', content: `a2-${'x'.repeat(180)}` }, +]; + +describe('compaction', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('根据阈值启用压缩并按字符估算 token', () => { + expect(shouldCompact(699, 800, settings)).toBe(false); + expect(shouldCompact(701, 800, settings)).toBe(true); + expect(shouldCompact(701, 800, { ...settings, enabled: false })).toBe(false); + + const messages: ContextMessage[] = [ + { role: 'user', content: 'abcd' }, + { role: 'assistant', content: 'e'.repeat(20) }, + ]; + const expected = Math.ceil(4 / 4) + 4 + Math.ceil(20 / 4) + 4 + 2; + expect(estimateContextTokens(messages)).toBe(expected); + }); + + it('切割点保留最近内容,并在 turn 中间单独摘要前缀', () => { + const messages = mkMessages(); + const cutPoint = findCutPoint(messages, settings.keepRecentTokens); + expect(cutPoint.isSplitTurn).toBe(true); + + const prep = prepareMessagesToCompact(messages, settings); + expect(prep?.isSplitTurn).toBe(true); + expect(prep?.messagesToSummarize.map((message) => message.content)).toEqual([ + messages[0].content, + messages[1].content, + ]); + expect(prep?.turnPrefixMessages.map((message) => message.content)).toEqual([ + messages[2].content, + ]); + expect(prep?.retainedTail.map((message) => message.content)).toEqual([ + messages[3].content, + ]); + }); + + it('已存在摘要时增量更新,提取文件上下文并持久化', async () => { + const store = new SessionStore({ dbPath: ':memory:' }); + const session = store.createSession({ id: 's1', cwd: '/tmp/w' }); + let seenPrompt = ''; + const summarize = vi.fn(async (messages: MessageLike[]) => { + seenPrompt = messages[1]?.content ?? ''; + return '新增摘要'; + }); + const messages: ContextMessage[] = [ + createSummaryMessage('旧摘要'), + { + role: 'assistant', + content: JSON.stringify({ + type: 'tool_call', + tool: 'read_file', + arguments: { path: 'packages/agent-core/src/index.ts' }, + message: 'reading index', + }), + }, + { role: 'user', content: 'tool result '.repeat(60) }, + ]; + const prep = prepareMessagesToCompact(messages, settings); + + expect(prep?.previousSummary).toBe('旧摘要'); + const result = await compact(prep!, { summarize }); + + expect(result?.summary).toContain('新增摘要'); + expect(result?.readFiles).toContain('packages/agent-core/src/index.ts'); + expect(seenPrompt).toContain(''); + + const entry = persistCompactionEntry(session.id, result!, store); + expect(entry.type).toBe('compaction'); + const payload = entry.payload as { summary: string; retainedTail: ContextMessage[] }; + expect(payload.summary).toContain('新增摘要'); + expect(payload.retainedTail).toEqual(result?.retainedTail); + }); + + it('摘要消息可直接识别和读取', () => { + const summary = createSummaryMessage('目标摘要'); + expect(isSummaryMessage(summary)).toBe(true); + expect(readPreviousSummary(summary)).toBe('目标摘要'); + }); + + it('文件操作提取区分为读取和修改文件', () => { + const fileOpsResult = createFileOpsWithMessages(); + expect(fileOpsResult.readFiles).toEqual(['src/read.ts']); + expect(fileOpsResult.modifiedFiles).toEqual(['src/write.ts']); + }); +}); + +describe('branch summarization', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('收集旧分支条目,生成摘要并持久化成 branch_summary', async () => { + const store = new SessionStore({ dbPath: ':memory:' }); + store.createSession({ id: 's2', cwd: '/tmp/w' }); + const root = store.appendEntry('s2', { + id: 'root', + type: 'user', + payload: { content: 'root' }, + }); + store.appendEntry('s2', { + id: 'main-a', + parentId: root.id, + type: 'assistant', + payload: { content: 'main a' }, + }); + store.appendEntry('s2', { + id: 'branch-b', + parentId: root.id, + type: 'assistant', + payload: { content: 'branch b' }, + }); + store.appendEntry('s2', { + id: 'main-c', + parentId: 'main-a', + type: 'assistant', + payload: { content: 'main c' }, + }); + + const collected = collectEntriesForBranchSummary( + store, + 's2', + 'branch-b', + 'main-c', + ); + expect(collected.commonAncestorId).toBe(root.id); + expect(collected.entries.map((entry) => entry.id)).toEqual(['branch-b']); + + const prep = prepareBranchEntries(collected.entries, 1000); + expect(prep.messages).toEqual([{ role: 'assistant', content: 'branch b' }]); + + const result = await generateBranchSummary(collected.entries, { + summarize: vi.fn(async () => '分支探索摘要'), + }); + expect(result.summary).toContain('分支探索摘要'); + + const entry = persistBranchSummaryEntry('s2', result, store, { + parentId: 'main-c', + }); + expect(entry.type).toBe('branch_summary'); + expect(branchSummaryEntryToMessage(entry)?.content).toContain('[分支摘要]'); + }); +}); + +const createFileOpsWithMessages = (): { readFiles: string[]; modifiedFiles: string[] } => { + const fileOps = { + read: new Set(), + written: new Set(), + edited: new Set(), + }; + extractFileOpsFromMessage( + { + role: 'assistant', + content: JSON.stringify({ + type: 'tool_call', + tool: 'read_file', + arguments: { path: 'src/read.ts' }, + }), + }, + fileOps, + ); + extractFileOpsFromMessage( + { + role: 'assistant', + content: JSON.stringify({ + type: 'tool_call', + tool: 'write_file', + arguments: { path: 'src/write.ts' }, + }), + }, + fileOps, + ); + return computeFileLists(fileOps); +}; diff --git a/source/export-web.ts b/tests/export-web.ts similarity index 100% rename from source/export-web.ts rename to tests/export-web.ts diff --git a/tests/ocr-image.spec.ts b/tests/ocr-image.spec.ts index f25ba4d..3c36426 100644 --- a/tests/ocr-image.spec.ts +++ b/tests/ocr-image.spec.ts @@ -14,6 +14,7 @@ vi.mock('tesseract.js', () => ({ })); import { ocrImageTool } from '@tools/ocr'; +import { executeToolCall } from '@tools/executor'; let tempDir: string | undefined; @@ -86,6 +87,41 @@ describe('ocr_image tool', () => { ); }); + it('executor dispatches ocr_image through the registered tool list', async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-ocr-')); + const imagePath = path.join(tempDir, 'executor.png'); + await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const terminate = vi.fn().mockResolvedValue(undefined); + const recognize = vi.fn().mockResolvedValue({ + data: { + text: 'Executor OCR', + confidence: 88, + }, + }); + createWorker.mockResolvedValue({ recognize, terminate }); + + const execution = await executeToolCall('build', { + type: 'tool_call', + tool: 'ocr_image', + arguments: { + path: imagePath, + lang: 'eng', + }, + message: 'recognize image', + }); + + expect(JSON.parse(execution.content)).toMatchObject({ + ok: true, + tool: 'ocr_image', + result: { + text: 'Executor OCR', + confidence: 88, + }, + }); + expect(terminate).toHaveBeenCalled(); + }); + it('rejects a missing path before starting OCR', async () => { await expect(ocrImageTool.run({})).rejects.toThrow('Invalid path'); expect(createWorker).not.toHaveBeenCalled(); diff --git a/tests/session-repository.spec.ts b/tests/session-repository.spec.ts index 6008000..44cb2fd 100644 --- a/tests/session-repository.spec.ts +++ b/tests/session-repository.spec.ts @@ -10,7 +10,7 @@ import { loadRecentHistory, readTaskHistory, setSharedSessionStore, -} from '@agent-core/harness/session/session-repository'; +} from '@agent-core/harness/session/sessionInfo/session-repository'; describe('SessionRepository', () => { let store: SessionStore; diff --git a/tests/tools.spec.ts b/tests/tools.spec.ts index d3ebc31..cc77cfc 100644 --- a/tests/tools.spec.ts +++ b/tests/tools.spec.ts @@ -1,15 +1,24 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { promisify } from 'node:util'; import { afterEach, describe, expect, it } from 'vitest'; import { + bashTool, getEnvironmentTool, - listFilesTool, + gitDiffTool, + ocrImageTool, readFileTool, + searchTool, + tools, writeFileTool, } from '@tools'; +import { executeToolCall } from '@tools/executor'; import { resolveUserPath } from '@tools/pathUtils'; +import { toolPrompt } from '@prompt/tool'; +const execFileAsync = promisify(execFile); const previousDesktopDir = process.env.AGENT_DESKTOP_DIR; let tempDesktop: string | undefined; @@ -22,6 +31,61 @@ afterEach(async () => { }); describe('tools', () => { + it('registers every tool advertised in the prompt', () => { + expect(tools.map((tool) => tool.name)).toEqual([ + 'get_environment', + 'read_file', + 'write_file', + 'search', + 'bash', + 'git_diff', + 'ocr_image', + ]); + expect(ocrImageTool.name).toBe('ocr_image'); + expect(toolPrompt).toContain('可用工具仅有以下 7 个'); + expect(toolPrompt).not.toContain('list_files'); + expect(toolPrompt).not.toContain('run_command'); + for (const tool of tools) { + expect(toolPrompt).toContain(tool.name); + } + }); + + it('executor resolves registered tools and reports unknown tools', async () => { + const success = await executeToolCall('build', { + type: 'tool_call', + tool: 'get_environment', + arguments: {}, + message: 'inspect environment', + }); + expect(JSON.parse(success.content)).toMatchObject({ + ok: true, + tool: 'get_environment', + }); + + const unknown = await executeToolCall('build', { + type: 'tool_call', + tool: 'missing_tool', + arguments: {}, + message: 'unknown', + }); + expect(JSON.parse(unknown.content)).toMatchObject({ + ok: false, + tool: 'missing_tool', + error: 'Unknown tool: missing_tool', + }); + + const blocked = await executeToolCall('plan', { + type: 'tool_call', + tool: 'read_file', + arguments: { path: 'README.md' }, + message: 'blocked', + }); + expect(JSON.parse(blocked.content)).toMatchObject({ + ok: false, + tool: 'read_file', + }); + }); + it('reports local environment locations', async () => { tempDesktop = await mkdtemp(path.join(os.tmpdir(), 'agent-desktop-')); process.env.AGENT_DESKTOP_DIR = tempDesktop; @@ -51,17 +115,6 @@ describe('tools', () => { path: 'desktop:/hello-agent.txt', }); expect(readResult.content).toBe('hello desktop'); - - const listResult = await listFilesTool.run({ path: 'Desktop' }); - expect(listResult.path).toBe(tempDesktop); - expect(listResult.entries).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: 'hello-agent.txt', - type: 'file', - }), - ]), - ); }); it('resolves relative paths inside the current working directory', () => { @@ -69,4 +122,128 @@ describe('tools', () => { path.join(process.cwd(), 'README.md'), ); }); + + it('searches file contents with path, glob and result limits', async () => { + tempDesktop = await mkdtemp(path.join(os.tmpdir(), 'agent-search-')); + process.env.AGENT_DESKTOP_DIR = tempDesktop; + await writeFile(path.join(tempDesktop, 'one.ts'), 'Alpha needle\nneedle again\n'); + await writeFile(path.join(tempDesktop, 'two.txt'), 'needle ignored\n'); + + const result = await searchTool.run({ + query: 'needle', + path: 'desktop:/', + glob: '*.ts', + caseSensitive: false, + fixedStrings: true, + maxResults: 1, + }); + + expect(result).toMatchObject({ + path: tempDesktop, + glob: '*.ts', + caseSensitive: false, + fixedStrings: true, + maxResults: 1, + matchCount: 1, + }); + expect(result.matches[0]).toContain('one.ts:1:7:Alpha needle'); + }); + + it('returns an empty search result and validates arguments', async () => { + tempDesktop = await mkdtemp(path.join(os.tmpdir(), 'agent-search-')); + process.env.AGENT_DESKTOP_DIR = tempDesktop; + await writeFile(path.join(tempDesktop, 'sample.txt'), 'content\n'); + + const result = await searchTool.run({ + query: 'missing', + path: 'desktop:/', + }); + expect(result.matchCount).toBe(0); + expect(result.matches).toEqual([]); + + await expect(searchTool.run({ query: ' ' })).rejects.toThrow( + 'Invalid query', + ); + await expect( + searchTool.run({ query: 'content', caseSensitive: 'yes' }), + ).rejects.toThrow('Invalid caseSensitive flag'); + await expect( + searchTool.run({ query: 'content', maxResults: 0 }), + ).rejects.toThrow('Invalid maxResults'); + }); + + it('runs Bash commands with a resolved working directory', async () => { + tempDesktop = await mkdtemp(path.join(os.tmpdir(), 'agent-bash-')); + process.env.AGENT_DESKTOP_DIR = tempDesktop; + + const result = await bashTool.run({ + command: '[[ -n "$BASH_VERSION" ]] && printf bash', + cwd: 'desktop:/', + }); + + expect(result.cwd).toBe(tempDesktop); + expect(result.stdout).toBe('bash'); + }); + + it('shows unstaged and staged Git diffs with optional path filtering', async () => { + tempDesktop = await mkdtemp(path.join(os.tmpdir(), 'agent-git-diff-')); + process.env.AGENT_DESKTOP_DIR = tempDesktop; + const filePath = path.join(tempDesktop, 'sample.txt'); + + await execFileAsync('git', ['init'], { cwd: tempDesktop }); + await writeFile(filePath, 'one\n', 'utf8'); + await execFileAsync('git', ['add', 'sample.txt'], { cwd: tempDesktop }); + await writeFile(filePath, 'two\n', 'utf8'); + + const unstaged = await gitDiffTool.run({ + cwd: 'desktop:/', + path: 'sample.txt', + }); + expect(unstaged).toMatchObject({ + cwd: tempDesktop, + staged: false, + path: 'sample.txt', + hasChanges: true, + }); + expect(unstaged.stdout).toContain('-one'); + expect(unstaged.stdout).toContain('+two'); + + await execFileAsync('git', ['add', 'sample.txt'], { cwd: tempDesktop }); + const staged = await gitDiffTool.run({ + cwd: 'desktop:/', + staged: true, + }); + expect(staged.staged).toBe(true); + expect(staged.hasChanges).toBe(true); + expect(staged.stdout).toContain('+two'); + + const cleanWorkingTree = await gitDiffTool.run({ cwd: 'desktop:/' }); + expect(cleanWorkingTree.hasChanges).toBe(false); + expect(cleanWorkingTree.stdout).toBe(''); + }); + + it('validates Git diff arguments', async () => { + await expect(gitDiffTool.run({ staged: 'yes' })).rejects.toThrow( + 'Invalid staged flag', + ); + await expect(gitDiffTool.run({ path: ' ' })).rejects.toThrow( + 'Invalid path', + ); + }); + + it('executor converts Bash failures into failed observations', async () => { + const execution = await executeToolCall('build', { + type: 'tool_call', + tool: 'bash', + arguments: { + command: 'exit 7', + }, + message: 'should fail', + }); + + expect(JSON.parse(execution.content)).toMatchObject({ + ok: false, + tool: 'bash', + }); + }); }); diff --git a/tests/utils-truncate-shell.spec.ts b/tests/utils-truncate-shell.spec.ts new file mode 100644 index 0000000..ef0e6ae --- /dev/null +++ b/tests/utils-truncate-shell.spec.ts @@ -0,0 +1,92 @@ +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { truncateByBytes, truncateByLines, truncateOutput } from '@utils/truncate'; +import { execShell } from '@utils/shell'; + +describe('truncate', () => { + it('truncates by line count and reports dropped lines', () => { + expect(truncateByLines('a\nb\nc', 2)).toBe('a\nb\n[输出已截断,省略 1 行]'); + }); + + it('keeps text unchanged when within limits', () => { + expect(truncateByLines('a\nb', 5)).toBe('a\nb'); + expect(truncateByLines('', 5)).toBe(''); + }); + + it('returns empty when maxLines is zero or negative', () => { + expect(truncateByLines('a\nb', 0)).toBe(''); + expect(truncateByLines('a\nb', -1)).toBe(''); + }); + + it('truncates by bytes without splitting multibyte characters', () => { + const text = '中文内容测试,long tail here'; + const maxBytes = 24; + const result = truncateByBytes(text, maxBytes); + expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual(maxBytes); + expect(result.endsWith('[输出已截断]')).toBe(true); + }); + + it('does not split multibyte characters at the cut point', () => { + const text = '中文内容测试,这是一段更长的中文内容用于字节截断测试'; + const result = truncateByBytes(text, 21); + expect(result).toContain('[输出已截断]'); + expect(Buffer.byteLength(text, 'utf8')).toBeGreaterThan( + Buffer.byteLength(result.slice(0, result.indexOf('[输出已截断]')), 'utf8'), + ); + }); + + it('keeps text unchanged when within byte budget', () => { + const text = 'hello'; + expect(truncateByBytes(text, 100)).toBe(text); + expect(truncateByBytes('', 100)).toBe(''); + }); + + it('returns empty when maxBytes is zero or negative', () => { + expect(truncateByBytes('hello', 0)).toBe(''); + expect(truncateByBytes('hello', -5)).toBe(''); + }); + + it('applies lines then bytes in order via truncateOutput', () => { + const lines = Array.from({ length: 10 }, (_, i) => `line-${i}`).join('\n'); + const result = truncateOutput(lines, { maxLines: 3, maxBytes: 1000 }); + expect(result.split('\n').length).toBe(4); + expect(result).toContain('[输出已截断'); + }); +}); + +describe('execShell', () => { + it('runs a command and returns output', async () => { + const result = await execShell({ command: 'printf hello' }); + expect(result.stdout).toBe('hello'); + expect(result.stderr).toBe(''); + expect(result.exitCode).toBe(0); + expect(result.truncated).toBe(false); + expect(result.cwd).toBe(process.cwd()); + }); + + it('throws on empty command', async () => { + await expect(execShell({ command: ' ' })).rejects.toThrow('Invalid command'); + }); + + it('truncates long output by lines', async () => { + const command = 'printf "a\\nb\\nc\\n"'; + const result = await execShell({ command, maxOutputLines: 2 }); + expect(result.truncated).toBe(true); + expect(result.stdout).toContain('[输出已截断'); + }); + + it('respects custom working directory', async () => { + const cwd = await import('node:fs/promises').then((fs) => + fs.mkdtemp(path.join(os.tmpdir(), 'agent-shell-')), + ); + try { + const result = await execShell({ command: 'pwd', cwd }); + const real = await import('node:fs/promises').then((fs) => fs.realpath(cwd)); + expect(result.stdout.trim()).toBe(real); + expect(result.cwd).toBe(cwd); + } finally { + await import('node:fs/promises').then((fs) => fs.rm(cwd, { recursive: true, force: true })); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 417daae..f26785d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,8 @@ "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, - "types": ["node"], + "types": ["node", "vite/client"], + "allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true, "noEmit": true, "skipLibCheck": true, @@ -13,21 +14,23 @@ "paths": { "@agent-core/*": ["./packages/agent-core/src/*"], "@core/*": ["./packages/agent-core/src/harness/core/*"], - "@tools": ["./packages/agent-core/src/harness/tools/index.ts"], - "@tools/*": ["./packages/agent-core/src/harness/tools/*"], "@config/*": ["./packages/agent-core/src/config/*"], "@types/*": ["./packages/agent-core/src/types/*"], - "@utils/*": ["./packages/agent-core/src/utils/*"], + "@utils/*": ["./packages/agent-core/src/harness/utils/*"], "@web/*": ["./packages/agent-core/src/web/*"], "@prompt/*": ["./packages/agent-core/src/harness/prompt/*"], - "@policy/*": ["./packages/agent-core/src/harness/policy/*"], + "@tools": ["./packages/agent-core/src/harness/session/tools/index.ts"], + "@tools/*": ["./packages/agent-core/src/harness/session/tools/*"], + "@policy/*": ["./packages/agent-core/src/harness/session/policy/*"], "@protocol/*": ["./packages/agent-core/src/harness/protocol/*"] } }, "include": [ "packages/agent-core/src/**/*.ts", "source/**/*.ts", - "source/**/*.tsx" + "source/**/*.tsx", + "tests/**/*.ts", + "vitest.config.ts" ], "exclude": ["node_modules", "dist", "apps"], "composite": true diff --git a/vitest.config.ts b/vitest.config.ts index 0f2b863..a80ab5e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,14 +14,14 @@ export default defineConfig({ alias: [ { find: '@agent-core', replacement: path.resolve(__dirname, './packages/agent-core/src') }, { find: '@core', replacement: path.resolve(__dirname, './packages/agent-core/src/harness/core') }, - { find: /^@tools$/, replacement: path.resolve(__dirname, './packages/agent-core/src/harness/tools/index.ts') }, - { find: /^@tools\//, replacement: path.resolve(__dirname, './packages/agent-core/src/harness/tools') + '/' }, { find: '@config', replacement: path.resolve(__dirname, './packages/agent-core/src/config') }, { find: '@types', replacement: path.resolve(__dirname, './packages/agent-core/src/types') }, - { find: '@utils', replacement: path.resolve(__dirname, './packages/agent-core/src/utils') }, + { find: '@utils', replacement: path.resolve(__dirname, './packages/agent-core/src/harness/utils') }, { find: '@web', replacement: path.resolve(__dirname, './packages/agent-core/src/web') }, { find: '@prompt', replacement: path.resolve(__dirname, './packages/agent-core/src/harness/prompt') }, - { find: '@policy', replacement: path.resolve(__dirname, './packages/agent-core/src/harness/policy') }, + { find: /^@tools$/, replacement: path.resolve(__dirname, './packages/agent-core/src/harness/session/tools/index.ts') }, + { find: /^@tools\//, replacement: path.resolve(__dirname, './packages/agent-core/src/harness/session/tools') + '/' }, + { find: '@policy', replacement: path.resolve(__dirname, './packages/agent-core/src/harness/session/policy') }, { find: '@protocol', replacement: path.resolve(__dirname, './packages/agent-core/src/harness/protocol') }, ], },