Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ out
.remix-serve-cache
.remix-serve-node_modules
.env/*
.agent-memory/
.agent-sessions/
*.tsbuildinfo
bundle.*
docs/*
pnpm-workspace.yaml
61 changes: 61 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Agent 开发与测试约束

本文件是 call-code 项目给 AI Agent 的协作约定。开始改动前先读一遍,并遵守下列规则。

## 项目概览

- 技术栈:TypeScript + React/Ink 终端 CLI,包管理器是 pnpm。
- 入口:`source/app.tsx` 是 CLI 应用;核心逻辑在 `packages/agent-core`。
- 会话历史存储:`packages/session-sqlite`,基于 Node 内置 `node:sqlite`。
- 测试:统一放在根目录 `tests/`,使用 Vitest,命名 `*.spec.ts` 或 `*.test.ts`。
- 环境变量:模板见 `.env.example`,本地配置放 `.env.local`,禁止提交密钥。

## 开发约束

1. 保持改动范围最小,只修改与需求直接相关的文件,不做顺手重构。
2. 优先复用现有模块、函数、类型和模式,不重复造轮子。
3. 不新增依赖,除非确实必要;能用 Node 内置能力解决的问题优先使用内置能力。
4. 禁止把密钥、Token、数据库口令等敏感信息写入代码、测试或文档。
5. 模型名、API 地址等可配置项必须通过环境变量读取,不要硬编码。
6. 写 SQLite 相关代码时使用 `node:sqlite`,不使用额外的数据库依赖。
7. 遵循现有命名风格,保持 TypeScript 严格模式可编译。
8. 注释使用中文,注释要说明“为什么”而不是复述代码。
9. 生成文件(如 `dist/`、数据库文件、临时文件)不应进入最终改动,除非项目已有提交先例。
10. 不要回退或覆盖用户已有的未提交改动;与任务无关的文件保持不动。
11. 禁止输出 `=======`、`----------` 等装饰性横线或符号,代码和文档都不要使用。
12. 避免明显 AI 风格的写法:空泛套话、机械复述、模板化命名、无意义的装饰字符。

## 测试约束

1. 测试文件只放在根目录 `tests/` 下,不放进 `packages/`。
2. 每个功能改动都应补充对应测试,覆盖正常路径和关键边界。
3. 测试应快速、可重复、不依赖网络;需要数据库时使用 `:memory:` 或临时文件。
4. 不得在测试中访问真实用户数据、真实 API 或外部服务。
5. 修改会话存储相关代码时,至少覆盖会话创建、条目追加、泳道/分支、记录、统计和租约。
6. 修改完成后必须运行完整测试:
```bash
pnpm test
```
7. 提交前必须通过类型检查:
```bash
pnpm typecheck
pnpm exec tsc -p packages/session-sqlite/tsconfig.json --noEmit
```
8. 如测试失败,先修复问题再继续,不允许跳过或注释测试。

## 提交流程

1. 每次完成一项任务后必须自行执行 `git commit`,无需等待用户要求;提交信息使用中文并采用 Conventional Commits 风格,例如:
- `feat: 增加 XX 功能`
- `fix: 修复 XX 问题`
- `refactor: 重构 XX 逻辑`
2. 只暂存与本次改动相关的文件,不把无关文件(如未跟踪的 `AGENTS.md`、个人配置)带进提交。
3. 提交前检查 `git status` 和 `git diff`,确认没有遗漏或误改。
4. 改动完成后如实汇报:改了哪些文件、测试结果、提交号。

## 通用工作流

- 遇到不确定的实现细节时,先阅读相关文件和已有测试,再决定方案。
- 搜索代码优先使用 `rg`,避免大范围全文扫描。
- 改动较大时先给出简短计划,再逐步实现并保持阶段性汇报。
- 保持仓库干净:不留下调试日志、临时文件或无用注释。
8 changes: 0 additions & 8 deletions packages/agent-core/src/context/runtime-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export interface BuildRuntimeContextInput {
system: string;
history: ContextMessage[];
task: TaskState;
shortMemory?: string;
longMemory?: string[];
includeHistorySummary?: boolean;
}
Expand All @@ -38,13 +37,6 @@ export const buildRuntimeContext = (
});
}

if (input.shortMemory) {
messages.push({
role: 'system',
content: `短期记忆:\n${input.shortMemory}`,
});
}

if (input.includeHistorySummary) {
const summary = summarizeHistory(input.history);
if (summary) {
Expand Down
4 changes: 1 addition & 3 deletions packages/agent-core/src/core/agent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { runLoop } from '@core/loop';
import type { StreamHandlers } from '@core/llm';
import { createTaskState, type AgentMode } from '@core/state';
import { writeShortMemory } from '@agent-core/memory/memory-writer';

export interface AgentOptions {
mode?: AgentMode;
Expand All @@ -16,7 +15,6 @@ export const agent = async (
options: AgentOptions = {},
): Promise<string> => {
const task = createTaskState(input, options);
writeShortMemory(task, 'user', task.input, ['task-input']);
const res = await runLoop(task, handlers);
const res = await runLoop(task, handlers, { persist: true });
return res || '';
};
61 changes: 49 additions & 12 deletions packages/agent-core/src/core/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,42 @@ import {
} from '@protocol/parser';
import { isToolCallAction } from '@protocol/action';
import { executeToolCall } from '@tools/executor';
import {
archiveShortMemory,
promoteStableFact,
writeShortMemory,
} from '@agent-core/memory/memory-writer';
import { promoteStableFact } from '@agent-core/memory/memory-writer';
import { retrieveMemoryForTask } from '@agent-core/memory/memory-retriever';
import {
appendTaskEntry,
appendTaskRecord,
ensureTaskSession,
getSharedSessionStoreOrNull,
readTaskHistory,
type SessionStoreLike,
} from '@agent-core/session/session-repository';

const contextBuilder = new ContextBuilder(8000);

export interface RunLoopOptions {
/** 是否将会话历史写入 SQLite */
persist?: boolean;
/** 自定义 SessionStore,测试时可传入 :memory: 实例 */
sessionStore?: SessionStoreLike;
}

export const runLoop = async (
task: TaskState,
handlers: StreamHandlers = {},
options: RunLoopOptions = {},
): Promise<string> => {
const history: ContextMessage[] = [];
const store = options.persist === true
? (options.sessionStore ?? getSharedSessionStoreOrNull())
: null;

if (store) {
ensureTaskSession(task, store);
appendTaskEntry(task, { role: 'user', content: task.input, tags: ['task-input'] }, store);
history.push(...readTaskHistory(task, { limit: 100 }, store));
}

let step = 0;
const maxSteps = 10;

Expand All @@ -36,12 +58,11 @@ export const runLoop = async (
try {
handlers.onTrace?.(`第 ${step} 轮开始,正在请求模型...`);

const memory = retrieveMemoryForTask(task.input, task.id);
const memory = retrieveMemoryForTask(task.input);
const runtimeContext = buildRuntimeContext(contextBuilder, {
system: `${systemPrompt}\n${getModePrompt(task.mode)}\n${toolPrompt}`,
history,
task,
shortMemory: memory.shortSummary,
longMemory: memory.longFacts,
includeHistorySummary: step > 1,
});
Expand All @@ -67,33 +88,49 @@ export const runLoop = async (
role: 'assistant',
content: res,
});
writeShortMemory(task, 'assistant', res, ['model-response']);
if (store) {
appendTaskEntry(task, { role: 'assistant', content: res, tags: ['model-response'] }, store);
}

const parsed = parseAgentResponse(res);
if (parsed && isToolCallAction(parsed)) {
if (store) {
appendTaskRecord(task, {
type: 'tool_call',
opKind: parsed.tool,
payload: parsed,
}, store);
}
const execution = await executeToolCall(task.mode, parsed);
history.push({
role: 'user',
content: execution.content,
});
writeShortMemory(task, 'tool', execution.content, ['tool-result', parsed.tool]);
if (store) {
appendTaskEntry(task, { role: 'tool', content: execution.content, tool: parsed.tool, tags: ['tool-result', parsed.tool] }, store);
appendTaskRecord(task, {
type: 'tool_result',
opKind: parsed.tool,
payload: {
tool: parsed.tool,
content: execution.content,
},
}, store);
}
handlers.onTrace?.(execution.trace);
continue;
}

if (!shouldContinueLoop(res)) {
archiveShortMemory(task);
promoteStableFact(task, 'task-objective', task.objective, history);
return extractFinalText(res);
}

handlers.onTrace?.(`第 ${step} 轮判断任务未完成,准备进入下一轮`);
} catch (error) {
archiveShortMemory(task);
return `执行出错: ${error instanceof Error ? error.message : String(error)}`;
}
}

archiveShortMemory(task);
return '已超出最大循环次数';
};
20 changes: 2 additions & 18 deletions packages/agent-core/src/memory/memory-retriever.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,19 @@
import { summarizeHistory } from '@agent-core/context/context-summarizer';
import type { ContextMessage } from '@agent-core/context/context-types';
import { memoryStore } from '@agent-core/memory/memory-store';

export interface RetrievedMemory {
shortSummary?: string;
longFacts: string[];
}

const hasAny = (text: string, keywords: string[]): boolean =>
keywords.some((keyword) => text.includes(keyword));

export const retrieveMemoryForTask = (
taskInput: string,
taskId: string,
): RetrievedMemory => {
export const retrieveMemoryForTask = (taskInput: string): RetrievedMemory => {
const lowerInput = taskInput.toLowerCase();
const keywords = lowerInput
.split(/\s+/)
.map((item) => item.trim())
.filter((item) => item.length >= 2);

const shortItems = memoryStore.listShort(taskId);
const shortMessages: ContextMessage[] = shortItems.map((item) => ({
role: item.role === 'tool' ? 'assistant' : item.role,
content: item.content,
}));
const shortSummary = summarizeHistory(shortMessages, {
maxItems: 6,
maxItemChars: 100,
})?.summary;

const longFacts = memoryStore
.listLong()
.filter((item) => {
Expand All @@ -42,5 +26,5 @@ export const retrieveMemoryForTask = (
.slice(-8)
.map((item) => `${item.topic}: ${item.content}`);

return { shortSummary, longFacts };
return { longFacts };
};
12 changes: 0 additions & 12 deletions packages/agent-core/src/memory/memory-schema.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
export type MemoryKind = 'short' | 'long';

export interface MemoryBase {
id: string;
createdAt: string;
updatedAt: string;
taskId?: string;
}

export interface ShortMemoryItem extends MemoryBase {
kind: 'short';
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
tags: string[];
}

export interface LongMemoryItem extends MemoryBase {
kind: 'long';
topic: string;
Expand All @@ -22,9 +13,6 @@ export interface LongMemoryItem extends MemoryBase {
sourceCount: number;
}

export type MemoryItem = ShortMemoryItem | LongMemoryItem;

export interface MemorySnapshot {
short: ShortMemoryItem[];
long: LongMemoryItem[];
}
54 changes: 0 additions & 54 deletions packages/agent-core/src/memory/memory-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,10 @@ import { randomUUID } from 'node:crypto';
import type {
LongMemoryItem,
MemorySnapshot,
ShortMemoryItem,
} from '@agent-core/memory/memory-schema';

export interface MemoryStoreConfig {
shortLimit: number;
}

const DEFAULT_CONFIG: MemoryStoreConfig = {
shortLimit: 40,
};

export class MemoryStore {
private readonly shortMemory: ShortMemoryItem[] = [];
private readonly longMemory: LongMemoryItem[] = [];
private readonly shortLimit: number;

constructor(config: Partial<MemoryStoreConfig> = {}) {
this.shortLimit = config.shortLimit ?? DEFAULT_CONFIG.shortLimit;
}

addShort(
input: Omit<ShortMemoryItem, 'id' | 'createdAt' | 'updatedAt' | 'kind'>,
): ShortMemoryItem {
const now = new Date().toISOString();
const item: ShortMemoryItem = {
id: randomUUID(),
kind: 'short',
createdAt: now,
updatedAt: now,
...input,
};

this.shortMemory.push(item);
const overflow = this.shortMemory.length - this.shortLimit;
if (overflow > 0) {
this.shortMemory.splice(0, overflow);
}

return item;
}

upsertLong(
input: Omit<LongMemoryItem, 'id' | 'createdAt' | 'updatedAt' | 'kind'>,
Expand Down Expand Up @@ -69,30 +33,12 @@ export class MemoryStore {
return item;
}

listShort(taskId?: string): ShortMemoryItem[] {
if (!taskId) {
return [...this.shortMemory];
}
return this.shortMemory.filter((item) => item.taskId === taskId);
}

listLong(): LongMemoryItem[] {
return [...this.longMemory];
}

clearShort(taskId?: string) {
if (!taskId) {
this.shortMemory.splice(0, this.shortMemory.length);
return;
}

const kept = this.shortMemory.filter((item) => item.taskId !== taskId);
this.shortMemory.splice(0, this.shortMemory.length, ...kept);
}

snapshot(): MemorySnapshot {
return {
short: this.listShort(),
long: this.listLong(),
};
}
Expand Down
Loading
Loading