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
Binary file modified .DS_Store
Binary file not shown.
4 changes: 0 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ OPENAI_API_KEY=
# 留空时使用 OpenAI 默认地址;DeepSeek 可填写 https://api.deepseek.com
OPENAI_API_BASE_URL=xxx

# Optional: memory persistence file path
# 默认 .agent-memory/memory.json,通常无需修改
# AGENT_MEMORY_FILE=.agent-memory/memory.json

# Required: OpenAI-compatible model name
# 必填,无默认值;未配置时 CLI 会提示你设置 OPENAI_MODEL
OPENAI_MODEL=xxx
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,4 @@ out
.agent-memory/
bundle.*
docs/*
/test/
pnpm-workspace.yaml
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ call-code 是一个本地运行的终端编程 Agent(CLI coding agent),基
- 双执行模式:`PLAN` 模式只允许生成计划和读取环境,`BUILD` 模式可以写入文件、执行命令并推进任务。
- 本地工具集:内置 `get_environment`、`read_file`、`write_file`、`list_files`、`run_command` 五个工具。
- 结构化响应协议:模型输出统一为 `tool_call` 或 `final` 的 JSON action,循环解析并继续执行。
- 本地记忆:短期记忆按任务保存,长期记忆按主题沉淀,并持久化到 `.agent-memory/memory.json`
- 本地记忆:短期记忆按任务保存,长期记忆按主题沉淀,仅在进程内使用,不写入本地 JSON
- 上下文预算:运行时基于 token 估算对历史消息做裁剪,减少超出模型上下文的风险。

## 架构
Expand All @@ -30,7 +30,7 @@ agent-core 核心层
├─ policy/ PLAN / BUILD 模式下的工具权限
├─ tools/ get_environment / read_file / write_file
│ list_files / run_command
├─ memory/ short / long 记忆,持久化到 .agent-memory
├─ memory/ short / long 记忆(仅存内存,不落盘 JSON)
└─ prompt/ 系统提示词、工具说明与模式提示词
│ OpenAI chat.completions 请求(支持流式)
Expand Down Expand Up @@ -62,8 +62,8 @@ packages/
prompt/ # 系统提示词、工具说明、模式提示词
tools/ # 环境、文件、命令等本地工具
policy/ # PLAN/BUILD 模式下的工具权限
test/ # 本地单元测试(已加入 .gitignore)
vitest.config.ts # Vitest 测试配置
tests/ # 项目统一单元测试
vitest.config.ts # Vitest 测试配置
Comment on lines +65 to +66
```

## 快速开始
Expand All @@ -85,7 +85,6 @@ pnpm dev
| `OPENAI_API_KEY` | 必填,OpenAI 兼容 API 的 Key。 |
| `OPENAI_API_BASE_URL` | 可选,自定义 OpenAI 兼容 base URL。 |
| `OPENAI_MODEL` | 必填,模型名称,无默认值;未配置时 CLI 会提示。 |
| `AGENT_MEMORY_FILE` | 可选,记忆持久化文件路径,默认 `.agent-memory/memory.json`。 |
| `AGENT_DESKTOP_DIR` | 可选,覆盖桌面目录路径,便于测试或自定义工作环境。 |

## 常用命令
Expand All @@ -108,7 +107,7 @@ pnpm run build:agent-core

## 测试说明

测试文件统一放在项目根目录的 `test/` 下,不放在包内。`/test/` 已加入 `.gitignore`,这些测试作为本地验证文件使用
测试文件统一放在项目根目录的 `tests/` 目录下,根目录的 `vitest.config.ts` 会统一收集并运行

## License

Expand Down
129 changes: 3 additions & 126 deletions packages/agent-core/src/memory/memory-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type {
LongMemoryItem,
MemorySnapshot,
Expand All @@ -9,25 +7,19 @@ import type {

export interface MemoryStoreConfig {
shortLimit: number;
memoryFile: string;
}

const DEFAULT_CONFIG: MemoryStoreConfig = {
shortLimit: 40,
memoryFile:
process.env.AGENT_MEMORY_FILE ??
path.resolve(process.cwd(), '.agent-memory', 'memory.json'),
};

export class MemoryStore {
private readonly shortMemory: ShortMemoryItem[] = [];
private readonly longMemory: LongMemoryItem[] = [];
private readonly config: MemoryStoreConfig;
private lastError: string | null = null;
private readonly shortLimit: number;

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

addShort(
Expand All @@ -43,12 +35,11 @@ export class MemoryStore {
};

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

this.persist();
return item;
}

Expand All @@ -64,7 +55,6 @@ export class MemoryStore {
found.updatedAt = now;
found.sourceCount = Math.max(found.sourceCount, input.sourceCount);
found.confidence = found.confidence === 'confirmed' ? found.confidence : input.confidence;
this.persist();
return found;
}

Expand All @@ -76,7 +66,6 @@ export class MemoryStore {
...input,
};
this.longMemory.push(item);
this.persist();
return item;
}

Expand All @@ -94,13 +83,11 @@ export class MemoryStore {
clearShort(taskId?: string) {
if (!taskId) {
this.shortMemory.splice(0, this.shortMemory.length);
this.persist();
return;
}

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

snapshot(): MemorySnapshot {
Expand All @@ -109,116 +96,6 @@ export class MemoryStore {
long: this.listLong(),
};
}

getMemoryFile(): string {
return this.config.memoryFile;
}

getLastError(): string | null {
return this.lastError;
}

private load() {
try {
this.ensureStorageFile();
const raw = fs.readFileSync(this.config.memoryFile, 'utf8').trim();
if (!raw) {
this.persist();
return;
}

const parsed = JSON.parse(raw) as Partial<MemorySnapshot>;
const short = Array.isArray(parsed.short) ? parsed.short : [];
const long = Array.isArray(parsed.long) ? parsed.long : [];

this.shortMemory.splice(
0,
this.shortMemory.length,
...short.filter(isShortMemoryItem).slice(-this.config.shortLimit),
);
this.longMemory.splice(
0,
this.longMemory.length,
...long.filter(isLongMemoryItem),
);
this.lastError = null;
} catch (error) {
this.shortMemory.splice(0, this.shortMemory.length);
this.longMemory.splice(0, this.longMemory.length);
this.lastError = `Failed to load memory file ${this.config.memoryFile}: ${
error instanceof Error ? error.message : String(error)
}`;
}
}

private persist() {
try {
fs.mkdirSync(path.dirname(this.config.memoryFile), { recursive: true });
fs.writeFileSync(
this.config.memoryFile,
`${JSON.stringify(this.snapshot(), null, 2)}\n`,
'utf8',
);
this.lastError = null;
} catch (error) {
this.lastError = `Failed to persist memory file ${this.config.memoryFile}: ${
error instanceof Error ? error.message : String(error)
}`;
}
}

private ensureStorageFile() {
fs.mkdirSync(path.dirname(this.config.memoryFile), { recursive: true });
if (!fs.existsSync(this.config.memoryFile)) {
fs.writeFileSync(
this.config.memoryFile,
`${JSON.stringify({ short: [], long: [] }, null, 2)}\n`,
'utf8',
);
}
}
}

const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === 'string');

const isMemoryBase = (
value: Partial<ShortMemoryItem | LongMemoryItem>,
): value is ShortMemoryItem | LongMemoryItem =>
typeof value.id === 'string' &&
typeof value.createdAt === 'string' &&
typeof value.updatedAt === 'string' &&
(typeof value.taskId === 'string' || typeof value.taskId === 'undefined');

const isShortMemoryItem = (value: unknown): value is ShortMemoryItem => {
if (!value || typeof value !== 'object') {
return false;
}

const item = value as Partial<ShortMemoryItem>;
return (
isMemoryBase(item) &&
item.kind === 'short' &&
['user', 'assistant', 'system', 'tool'].includes(item.role ?? '') &&
typeof item.content === 'string' &&
isStringArray(item.tags)
);
};

const isLongMemoryItem = (value: unknown): value is LongMemoryItem => {
if (!value || typeof value !== 'object') {
return false;
}

const item = value as Partial<LongMemoryItem>;
return (
isMemoryBase(item) &&
item.kind === 'long' &&
typeof item.topic === 'string' &&
typeof item.content === 'string' &&
['confirmed', 'stable'].includes(item.confidence ?? '') &&
typeof item.sourceCount === 'number'
);
};

export const memoryStore = new MemoryStore();
3 changes: 0 additions & 3 deletions source/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,14 +435,11 @@ const App = () => {

case '/memory': {
const snapshot = memoryStore.snapshot();
const lastError = memoryStore.getLastError();
showCommandMessage(
[
'Memory 概览',
`短期记忆: ${snapshot.short.length}`,
`长期记忆: ${snapshot.long.length}`,
`文件: ${memoryStore.getMemoryFile()}`,
`最近错误: ${lastError ?? '无'}`,
].join('\n'),
);
return true;
Expand Down
27 changes: 27 additions & 0 deletions tests/agent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { vi, describe, it, expect } from 'vitest';

// Mock runLoop to avoid calling the real loop implementation
vi.mock('@core/loop', () => ({
runLoop: vi.fn(async () => 'handled:task'),
}));

import { agent } from '@core/agent';
import { runLoop } from '@core/loop';

describe('agent', () => {
it('calls runLoop with input and returns the final response', async () => {
const res = await agent('task');
expect(runLoop).toHaveBeenCalledTimes(1);
expect(runLoop).toHaveBeenCalledWith(
expect.objectContaining({
input: 'task',
mode: 'build',
objective: 'task',
constraints: [],
workspace: undefined,
}),
{},
);
expect(res).toBe('handled:task');
});
});
67 changes: 67 additions & 0 deletions tests/runLoop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';

// Mock streamLLM to ensure no network calls during tests
vi.mock('@core/llm', () => ({
streamLLM: vi.fn(),
}));

import { runLoop } from '@core/loop';
import { streamLLM } from '@core/llm';
import { createTaskState } from '@core/state';

describe('runLoop', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('executes a tool call and continues until final', async () => {
vi.mocked(streamLLM)
.mockResolvedValueOnce(
JSON.stringify({
type: 'tool_call',
tool: 'get_environment',
arguments: {},
message: 'inspect environment',
}),
)
.mockResolvedValueOnce(
JSON.stringify({
type: 'final',
tool: null,
arguments: null,
message: '环境已感知',
}),
);

const traces: string[] = [];
const res = await runLoop(createTaskState('看看当前环境'), {
onTrace: (message) => traces.push(message),
});

expect(streamLLM).toHaveBeenCalledTimes(2);
expect(traces).toContain('工具 get_environment 执行成功,继续下一轮');
expect(res).toBe('环境已感知');
});

it('returns plain text instead of raw json when loop stops on non-final JSON', async () => {
vi.mocked(streamLLM).mockResolvedValueOnce(
JSON.stringify({
type: 'status',
tool: null,
arguments: null,
message: '仅输出文本',
}),
);

const res = await runLoop(createTaskState('只要文本输出'));
expect(res).toBe('仅输出文本');
});

it('uses fallback stop signal when model output is non-protocol text', async () => {
vi.mocked(streamLLM).mockResolvedValueOnce('已完成:全部处理完毕');

const res = await runLoop(createTaskState('测试非协议完成'));
expect(res).toBe('已完成:全部处理完毕');
expect(streamLLM).toHaveBeenCalledTimes(1);
});
});
Loading
Loading