From b6925f6f174627a8ab8ae1a861458449ba8bb663 Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 03:18:49 +0800 Subject: [PATCH 01/42] refactor: migrate LLM layer to AI SDK and restructure core Replace the openai SDK with Vercel AI SDK: - streamText-based streaming (onChunk now streams to the TUI) - messages use AI SDK CoreMessage; responses are GenerateTextResult directly - remove hand-written wire protocol layer (toOpenAIMessage, buildChatCompletionRequest, response parsing) - provider factory registry (ai_provider.ts) dispatches by provider name, with openai-compatible as the default wire path - reasoning_content handled natively by the openai-compatible provider - TokenUsage replaced by AI SDK LanguageModelUsage Restructure core source into domain modules: - llm/ (provider registry, streaming, model profiles) - session/ (runtime, hooks, defaults, compaction, slash commands) - history/ (jsonl sinks, parsers, index, workspace) - prompt/ (system prompt, memory injection) - skills/, mcp/ --- package.json | 1 - packages/core/README.md | 37 -- packages/core/package.json | 6 +- .../src/{runtime => history}/history.test.ts | 2 +- .../core/src/{runtime => history}/history.ts | 0 .../history_index.test.ts | 0 .../src/{runtime => history}/history_index.ts | 0 .../history_parser.test.ts | 0 .../{runtime => history}/history_parser.ts | 7 +- .../{runtime => history}/workspace.test.ts | 0 .../src/{runtime => history}/workspace.ts | 0 packages/core/src/index.ts | 26 +- packages/core/src/llm/ai_provider.test.ts | 87 +++++ packages/core/src/llm/ai_provider.ts | 56 +++ packages/core/src/llm/ai_stream.test.ts | 188 ++++++++++ packages/core/src/llm/ai_stream.ts | 85 +++++ .../{runtime => llm}/model_profile.test.ts | 49 +-- .../src/{runtime => llm}/model_profile.ts | 39 +- .../src/{runtime => mcp}/mcp_admin.test.ts | 0 .../core/src/{runtime => mcp}/mcp_admin.ts | 0 .../src/{runtime => prompt}/memory.test.ts | 67 ++-- .../core/src/{runtime => prompt}/prompt.md | 0 .../src/{runtime => prompt}/prompt.test.ts | 0 .../core/src/{runtime => prompt}/prompt.ts | 2 +- packages/core/src/runtime/defaults.ts | 279 -------------- .../compact_prompt.test.ts | 36 +- .../{runtime => session}/compact_prompt.ts | 29 +- .../src/{runtime => session}/defaults.test.ts | 21 +- packages/core/src/session/defaults.ts | 148 ++++++++ .../defaults.with_default_deps.test.ts | 350 ++++++++---------- .../file_suggestions.test.ts | 0 .../{runtime => session}/file_suggestions.ts | 0 .../src/{runtime => session}/hooks.test.ts | 98 +++-- .../core/src/{runtime => session}/hooks.ts | 9 +- .../core/src/{runtime => session}/session.ts | 6 +- .../session_hooks.test.ts | 268 +++++++------- .../{runtime => session}/session_runtime.ts | 73 ++-- .../session_runtime_helpers.test.ts | 23 +- .../session_runtime_helpers.ts | 100 +++-- .../src/{runtime => session}/slash/index.ts | 0 .../slash/registry.test.ts | 0 .../{runtime => session}/slash/registry.ts | 0 .../src/{runtime => session}/slash/types.ts | 0 .../src/{runtime => skills}/skills.test.ts | 2 +- .../core/src/{runtime => skills}/skills.ts | 0 .../{runtime => skills}/skills_admin.test.ts | 0 .../src/{runtime => skills}/skills_admin.ts | 2 +- packages/core/src/types.ts | 108 ++---- packages/core/src/utils/tokenizer.test.ts | 70 ++-- packages/core/src/utils/tokenizer.ts | 29 +- packages/core/tsup.config.ts | 12 +- packages/tui/src/commands/index.tsx | 2 +- .../tui/src/features/composer/Composer.tsx | 2 +- .../src/features/composer/fileSuggestions.ts | 2 +- .../tui/src/features/timeline/chatTimeline.ts | 8 +- packages/tui/src/shared/types/index.ts | 4 +- pnpm-lock.yaml | 129 ++++++- tsup.config.ts | 2 +- 58 files changed, 1349 insertions(+), 1115 deletions(-) delete mode 100644 packages/core/README.md rename packages/core/src/{runtime => history}/history.test.ts (99%) rename packages/core/src/{runtime => history}/history.ts (100%) rename packages/core/src/{runtime => history}/history_index.test.ts (100%) rename packages/core/src/{runtime => history}/history_index.ts (100%) rename packages/core/src/{runtime => history}/history_parser.test.ts (100%) rename packages/core/src/{runtime => history}/history_parser.ts (97%) rename packages/core/src/{runtime => history}/workspace.test.ts (100%) rename packages/core/src/{runtime => history}/workspace.ts (100%) create mode 100644 packages/core/src/llm/ai_provider.test.ts create mode 100644 packages/core/src/llm/ai_provider.ts create mode 100644 packages/core/src/llm/ai_stream.test.ts create mode 100644 packages/core/src/llm/ai_stream.ts rename packages/core/src/{runtime => llm}/model_profile.test.ts (54%) rename packages/core/src/{runtime => llm}/model_profile.ts (70%) rename packages/core/src/{runtime => mcp}/mcp_admin.test.ts (100%) rename packages/core/src/{runtime => mcp}/mcp_admin.ts (100%) rename packages/core/src/{runtime => prompt}/memory.test.ts (83%) rename packages/core/src/{runtime => prompt}/prompt.md (100%) rename packages/core/src/{runtime => prompt}/prompt.test.ts (100%) rename packages/core/src/{runtime => prompt}/prompt.ts (98%) delete mode 100644 packages/core/src/runtime/defaults.ts rename packages/core/src/{runtime => session}/compact_prompt.test.ts (76%) rename packages/core/src/{runtime => session}/compact_prompt.ts (56%) rename packages/core/src/{runtime => session}/defaults.test.ts (59%) create mode 100644 packages/core/src/session/defaults.ts rename packages/core/src/{runtime => session}/defaults.with_default_deps.test.ts (51%) rename packages/core/src/{runtime => session}/file_suggestions.test.ts (100%) rename packages/core/src/{runtime => session}/file_suggestions.ts (100%) rename packages/core/src/{runtime => session}/hooks.test.ts (80%) rename packages/core/src/{runtime => session}/hooks.ts (93%) rename packages/core/src/{runtime => session}/session.ts (82%) rename packages/core/src/{runtime => session}/session_hooks.test.ts (85%) rename packages/core/src/{runtime => session}/session_runtime.ts (94%) rename packages/core/src/{runtime => session}/session_runtime_helpers.test.ts (90%) rename packages/core/src/{runtime => session}/session_runtime_helpers.ts (76%) rename packages/core/src/{runtime => session}/slash/index.ts (100%) rename packages/core/src/{runtime => session}/slash/registry.test.ts (100%) rename packages/core/src/{runtime => session}/slash/registry.ts (100%) rename packages/core/src/{runtime => session}/slash/types.ts (100%) rename packages/core/src/{runtime => skills}/skills.test.ts (98%) rename packages/core/src/{runtime => skills}/skills.ts (100%) rename packages/core/src/{runtime => skills}/skills_admin.test.ts (100%) rename packages/core/src/{runtime => skills}/skills_admin.ts (99%) diff --git a/package.json b/package.json index b9bd6bf..848c65e 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "ipaddr.js": "^2.3.0", "jsdom": "^28.1.0", "marked": "^17.0.1", - "openai": "^6.10.0", "pastel": "^4.0.1", "react": "^19.2.4", "react-reconciler": "^0.33.0", diff --git a/packages/core/README.md b/packages/core/README.md deleted file mode 100644 index 1375ee3..0000000 --- a/packages/core/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# @memo/core Overview - -Core provides the central capabilities of **Memo Code**: the ReAct loop, session state management, default dependency wiring (LLM/tools/prompt/history), config loading, and shared types/utilities. The design goal is "thick Core, thin UI": UI handles interaction and callbacks, while Core owns behavior. - -## Directory Layout - -- `config/` - - `config.ts`: Reads `~/.memo/config.toml` (providers, sessions path), handles provider selection, session path building, and config writes. -- `runtime/` - - `prompt.ts/xml`: System prompt loading. - - `history.ts`: JSONL history sink and event construction. - - `defaults.ts`: Default dependency completion (toolset, LLM, prompt, history sink, tokenizer). - - `session.ts`: Session/Turn runtime, executes ReAct loop, writes events, tracks token usage. -- `types.ts`: Shared types (`AgentDeps`, `Session/Turn`, `TokenUsage`, `HistoryEvent`, etc.). -- `utils/` - - Utility functions (assistant output parsing, message wrappers). - - `tokenizer.ts`: tiktoken-based tokenizer helpers. -- `index.ts`: Package entry, exports core modules and types. - -## Key Flows - -- `createAgentSession(deps, options)`: Creates a Session, fills default dependencies, loads prompt, and returns an object with `runTurn`. -- `withDefaultDeps`: Injects default toolset, LLM client, prompt, history sink (writes to `~/.memo/sessions/YY/MM/DD/.jsonl`), and tokenizer based on config and overrides. -- Session history: JSONL events (`session_start/turn_start/assistant/action/observation/final/turn_end/session_end`) with metadata like provider, model, tokenizer, and token usage. -- Config: `~/.memo/config.toml` (overridable via `MEMO_HOME`). If missing, UI setup flow is triggered. - -## Usage Example - -```ts -import { createAgentSession } from '@memo/core' - -const session = await createAgentSession({ onAssistantStep: console.log }, { mode: 'interactive' }) -const turn = await session.runTurn('Hello') -await session.close() -``` - -If you provide custom tool/LLM/prompt/sink dependencies, override the related fields in `deps/options`. Defaults select the current provider and write sessions in the user directory. diff --git a/packages/core/package.json b/packages/core/package.json index 83cd87a..86c359c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,12 +15,14 @@ "version": "0.1.0", "private": true, "scripts": { - "build": "tsup --config tsup.config.ts && node -e \"const { copyFileSync } = require('node:fs'); copyFileSync('src/runtime/prompt.md', 'dist/prompt.md');\"", + "build": "tsup --config tsup.config.ts && node -e \"const { copyFileSync } = require('node:fs'); copyFileSync('src/prompt/prompt.md', 'dist/prompt.md');\"", "test": "vitest run" }, "dependencies": { "ignore": "^7.0.5", - "zod": "^4.3.6" + "zod": "^4.3.6", + "ai": "^6.0.0", + "@ai-sdk/openai-compatible": "^2.0.0" }, "devDependencies": { "vitest": "^2.1.8" diff --git a/packages/core/src/runtime/history.test.ts b/packages/core/src/history/history.test.ts similarity index 99% rename from packages/core/src/runtime/history.test.ts rename to packages/core/src/history/history.test.ts index fff7a4c..e090c99 100644 --- a/packages/core/src/runtime/history.test.ts +++ b/packages/core/src/history/history.test.ts @@ -3,7 +3,7 @@ import { unlink, readFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { JsonlHistorySink, createHistoryEvent } from '@memo/core/runtime/history' +import { JsonlHistorySink, createHistoryEvent } from '@memo/core/history/history' const getTempFilePath = () => join(tmpdir(), `memo-test-${Date.now()}.jsonl`) diff --git a/packages/core/src/runtime/history.ts b/packages/core/src/history/history.ts similarity index 100% rename from packages/core/src/runtime/history.ts rename to packages/core/src/history/history.ts diff --git a/packages/core/src/runtime/history_index.test.ts b/packages/core/src/history/history_index.test.ts similarity index 100% rename from packages/core/src/runtime/history_index.test.ts rename to packages/core/src/history/history_index.test.ts diff --git a/packages/core/src/runtime/history_index.ts b/packages/core/src/history/history_index.ts similarity index 100% rename from packages/core/src/runtime/history_index.ts rename to packages/core/src/history/history_index.ts diff --git a/packages/core/src/runtime/history_parser.test.ts b/packages/core/src/history/history_parser.test.ts similarity index 100% rename from packages/core/src/runtime/history_parser.test.ts rename to packages/core/src/history/history_parser.test.ts diff --git a/packages/core/src/runtime/history_parser.ts b/packages/core/src/history/history_parser.ts similarity index 97% rename from packages/core/src/runtime/history_parser.ts rename to packages/core/src/history/history_parser.ts index 0ebf9d2..3b4d9fe 100644 --- a/packages/core/src/runtime/history_parser.ts +++ b/packages/core/src/history/history_parser.ts @@ -177,9 +177,10 @@ function parseEventLine(line: string, index: number): SessionEventItem | null { function accumulateTokenUsage(target: TokenUsageSummary, source: Record | undefined): void { if (!source) return - const prompt = asNumber(source.prompt) - const completion = asNumber(source.completion) - const total = asNumber(source.total) + // meta.tokens now uses AI SDK LanguageModelUsage shape; keep reading legacy prompt/completion fields too. + const prompt = asNumber(source.inputTokens) ?? asNumber(source.prompt) + const completion = asNumber(source.outputTokens) ?? asNumber(source.completion) + const total = asNumber(source.totalTokens) ?? asNumber(source.total) if (prompt !== null) target.prompt += Math.floor(prompt) if (completion !== null) target.completion += Math.floor(completion) diff --git a/packages/core/src/runtime/workspace.test.ts b/packages/core/src/history/workspace.test.ts similarity index 100% rename from packages/core/src/runtime/workspace.test.ts rename to packages/core/src/history/workspace.test.ts diff --git a/packages/core/src/runtime/workspace.ts b/packages/core/src/history/workspace.ts similarity index 100% rename from packages/core/src/runtime/workspace.ts rename to packages/core/src/history/workspace.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e972079..84ac055 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,18 +1,18 @@ -/** @file Core package entry point, aggregates runtime/config/tools common APIs. */ +/** @file Core package entry point, aggregates domain modules (config/llm/session/history/...). */ export * from './types' -export * from './runtime/prompt' -export * from './runtime/skills' -export * from './runtime/history' -export * from './runtime/history_parser' -export * from './runtime/history_index' -export * from './runtime/workspace' -export * from './runtime/file_suggestions' -export * from './runtime/slash' -export * from './runtime/mcp_admin' -export * from './runtime/skills_admin' -export * from './runtime/defaults' +export * from './prompt/prompt' +export * from './skills/skills' +export * from './history/history' +export * from './history/history_parser' +export * from './history/history_index' +export * from './history/workspace' +export * from './session/file_suggestions' +export * from './session/slash' +export * from './mcp/mcp_admin' +export * from './skills/skills_admin' +export * from './session/defaults' export * from './config/config' export * from './utils/utils' export * from './utils/tokenizer' -export * from './runtime/session' +export * from './session/session' export * from './api_types' diff --git a/packages/core/src/llm/ai_provider.test.ts b/packages/core/src/llm/ai_provider.test.ts new file mode 100644 index 0000000..d966a05 --- /dev/null +++ b/packages/core/src/llm/ai_provider.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { getProviderFactory } from '@memo/core/llm/ai_provider' +import type { ModelProfile } from '@memo/core/llm/model_profile' + +const state = vi.hoisted(() => ({ + createCalls: [] as unknown[], +})) + +vi.mock('@ai-sdk/openai-compatible', () => ({ + createOpenAICompatible: vi.fn((options: unknown) => { + state.createCalls.push(options) + return (model: string) => ({ model }) + }), +})) + +const PROFILE: ModelProfile = { + wireApi: 'chat_completions', + supportsParallelToolCalls: false, + supportsReasoningContent: false, + isFallback: false, +} + +describe('getProviderFactory', () => { + beforeEach(() => { + state.createCalls = [] + }) + + test('dispatches deepseek to openai-compatible with default base URL', () => { + const factory = getProviderFactory({ name: 'deepseek' }) + expect(factory.kind).toBe('openai-compatible') + factory.build({ name: 'deepseek', env_api_key: 'DEEPSEEK_API_KEY', model: 'deepseek-chat' }, 'secret') + expect(state.createCalls[0]).toEqual({ + name: 'deepseek', + apiKey: 'secret', + baseURL: 'https://api.deepseek.com', + includeUsage: true, + }) + }) + + test('config base_url overrides the default', () => { + const factory = getProviderFactory({ name: 'deepseek' }) + factory.build( + { + name: 'deepseek', + env_api_key: 'DEEPSEEK_API_KEY', + model: 'deepseek-chat', + base_url: 'https://proxy.local/v1', + }, + 'secret', + ) + expect((state.createCalls[0] as { baseURL: string }).baseURL).toBe('https://proxy.local/v1') + }) + + test('falls back to openai-compatible for unknown providers', () => { + const factory = getProviderFactory({ name: 'my-custom-vendor' }) + expect(factory.kind).toBe('openai-compatible') + factory.build({ name: 'my-custom-vendor', env_api_key: 'X_KEY', model: 'm' }, 'secret') + expect((state.createCalls[0] as { baseURL: string }).baseURL).toBe('https://api.openai.com/v1') + expect((state.createCalls[0] as { name: string }).name).toBe('my-custom-vendor') + }) + + test('anthropic is registered but throws on build until wired', () => { + const factory = getProviderFactory({ name: 'anthropic' }) + expect(factory.kind).toBe('anthropic') + expect(() => + factory.build({ name: 'anthropic', env_api_key: 'ANTHROPIC_API_KEY', model: 'claude' }, 'secret'), + ).toThrow('not yet wired to AI SDK') + }) + + test('is case/whitespace insensitive on provider name', () => { + expect(getProviderFactory({ name: ' DeepSeek ' }).kind).toBe('openai-compatible') + }) +}) + +describe('buildProviderOptions', () => { + test('no options when parallel tool calls unsupported', () => { + const factory = getProviderFactory({ name: 'deepseek' }) + expect(factory.buildProviderOptions(PROFILE)).toBeUndefined() + }) + + test('parallel_tool_calls passthrough when supported', () => { + const factory = getProviderFactory({ name: 'deepseek' }) + expect(factory.buildProviderOptions({ ...PROFILE, supportsParallelToolCalls: true })).toEqual({ + parallel_tool_calls: true, + }) + }) +}) diff --git a/packages/core/src/llm/ai_provider.ts b/packages/core/src/llm/ai_provider.ts new file mode 100644 index 0000000..02bcc10 --- /dev/null +++ b/packages/core/src/llm/ai_provider.ts @@ -0,0 +1,56 @@ +/** @file AI SDK provider factory registry: dispatch by provider name to AI SDK providers. */ +import { createOpenAICompatible, type OpenAICompatibleProvider } from '@ai-sdk/openai-compatible' +import type { JSONValue } from 'ai' +import type { ProviderConfig } from '@memo/core/config/config' +import type { ModelProfile } from '@memo/core/llm/model_profile' + +/** Wire API kinds supported by the registry (future: responses / messages). */ +export type ProviderKind = 'openai-compatible' | 'openai' | 'anthropic' + +export type AIProviderFactory = { + kind: ProviderKind + /** Build an AI SDK provider instance (callable: factory(config, apiKey)('model-id')). */ + build: (config: ProviderConfig, apiKey: string) => OpenAICompatibleProvider + /** + * Request-level providerOptions for non-standard wire fields. + * Keyed by the provider instance name (config.name) inside streamCallLLM. + */ + buildProviderOptions: (profile: ModelProfile) => Record | undefined +} + +function openAICompatibleFactory(defaultBaseURL?: string): AIProviderFactory { + return { + kind: 'openai-compatible', + build: (config, apiKey) => + createOpenAICompatible({ + name: config.name, + apiKey, + // Same default as the OpenAI SDK when base_url is unset. + baseURL: config.base_url ?? defaultBaseURL ?? 'https://api.openai.com/v1', + // Stream usage back through stream_options.include_usage. + includeUsage: true, + }), + buildProviderOptions: (profile) => + profile.supportsParallelToolCalls ? { parallel_tool_calls: true } : undefined, + } +} + +const REGISTRY: Readonly> = { + deepseek: openAICompatibleFactory('https://api.deepseek.com'), + // Extend here when wiring new providers: + // - 'openai': switch to @ai-sdk/openai (chat completions or Responses API); buildProviderOptions uses camelCase parallelToolCalls. + // - 'anthropic': switch to @ai-sdk/anthropic (Messages API); wire format no longer OpenAI-compatible. + anthropic: { + kind: 'anthropic', + build: () => { + throw new Error("Provider 'anthropic' requires the Anthropic Messages API; not yet wired to AI SDK") + }, + buildProviderOptions: () => undefined, + }, +} + +/** Dispatch by provider name; unknown names fall back to OpenAI-compatible (config.base_url decides the endpoint). */ +export function getProviderFactory(config: Pick): AIProviderFactory { + const name = config.name.trim().toLowerCase() + return REGISTRY[name] ?? openAICompatibleFactory() +} diff --git a/packages/core/src/llm/ai_stream.test.ts b/packages/core/src/llm/ai_stream.test.ts new file mode 100644 index 0000000..18cc24b --- /dev/null +++ b/packages/core/src/llm/ai_stream.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { buildStreamTools, streamCallLLM } from '@memo/core/llm/ai_stream' +import type { AIProviderFactory } from '@memo/core/llm/ai_provider' +import type { ModelProfile } from '@memo/core/llm/model_profile' +import type { ChatMessage, ToolDefinition } from '@memo/core/types' + +const state = vi.hoisted(() => ({ + streamTextParams: [] as unknown[], + parts: [] as unknown[], + final: {} as Record, +})) + +vi.mock('ai', () => ({ + streamText: vi.fn((params: unknown) => { + state.streamTextParams.push(params) + return makeStreamResult() + }), + jsonSchema: (schema: unknown) => schema, +})) + +function makeStreamResult() { + async function* gen() { + yield* state.parts + } + return { + fullStream: gen(), + // AI SDK v6 awaitable properties (Promise.resolve values are awaitable). + text: Promise.resolve(state.final.text ?? ''), + reasoningText: Promise.resolve(state.final.reasoning), + toolCalls: Promise.resolve(state.final.toolCalls ?? []), + usage: Promise.resolve(state.final.usage), + finishReason: Promise.resolve(state.final.finishReason ?? 'stop'), + } +} + +const PROFILE: ModelProfile = { + wireApi: 'chat_completions', + supportsParallelToolCalls: false, + supportsReasoningContent: false, + isFallback: false, +} + +const FACTORY: AIProviderFactory = { + kind: 'openai-compatible', + build: () => ((model: string) => model) as never, + buildProviderOptions: () => undefined, +} + +function baseParams(overrides: Record = {}) { + return { + provider: { name: 'mock', env_api_key: 'MOCK_API_KEY', model: 'mock-model', base_url: 'https://mock.local/v1' }, + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hi' }] as ChatMessage[], + toolDefinitions: [] as ToolDefinition[], + profile: PROFILE, + factory: FACTORY, + ...overrides, + } +} + +function textDelta(text: string) { + return { type: 'text-delta', id: 't', text } +} + +describe('buildStreamTools', () => { + test('returns undefined for empty tool list', () => { + expect(buildStreamTools([])).toBeUndefined() + }) + + test('forces top-level type object on schemas', () => { + const tools = buildStreamTools([ + { name: 't', description: 'd', input_schema: { properties: { a: { type: 'string' } } } }, + ]) + expect(tools?.t).toEqual({ + description: 'd', + inputSchema: { properties: { a: { type: 'string' } }, type: 'object' }, + }) + const withObject = buildStreamTools([ + { name: 't2', description: 'd2', input_schema: { type: 'object', properties: {} } }, + ]) + expect(withObject?.t2?.inputSchema).toEqual({ type: 'object', properties: {} }) + }) +}) + +describe('streamCallLLM', () => { + beforeEach(() => { + state.streamTextParams = [] + state.parts = [] + state.final = { + text: '', + toolCalls: [], + usage: { inputTokens: 11, outputTokens: 7, totalTokens: 18 }, + finishReason: 'stop', + } + }) + + test('streams text deltas through onChunk and returns assembled result', async () => { + state.parts = [textDelta('Hel'), textDelta('lo'), textDelta(' world')] + state.final = { text: 'Hello world', toolCalls: [], usage: state.final.usage, finishReason: 'stop' } + const chunks: string[] = [] + const result = await streamCallLLM(baseParams({ onChunk: (chunk: string) => chunks.push(chunk) })) + + expect(chunks).toEqual(['Hel', 'lo', ' world']) + expect(result.text).toBe('Hello world') + expect(result.toolCalls).toEqual([]) + }) + + test('returns reasoningText as reasoning', async () => { + state.parts = [] + state.final = { + text: 'answer', + reasoning: 'thinking', + toolCalls: [], + usage: state.final.usage, + finishReason: 'stop', + } + const result = await streamCallLLM(baseParams()) + + expect(result.reasoning).toBe('thinking') + }) + + test('returns toolCalls and usage from the final result', async () => { + state.parts = [] + state.final = { + text: 'using tools', + toolCalls: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'echo', input: { value: 1 } }], + usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 }, + finishReason: 'tool-calls', + } + const result = await streamCallLLM(baseParams()) + + expect(result.toolCalls).toHaveLength(1) + expect(result.toolCalls[0]).toMatchObject({ toolCallId: 'call-1', toolName: 'echo' }) + expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 }) + expect(result.finishReason).toBe('tool-calls') + }) + + test('throws error part payloads', async () => { + state.parts = [{ type: 'error', error: new Error('provider exploded') }] + await expect(streamCallLLM(baseParams())).rejects.toThrow('provider exploded') + }) + + test('normalizes aborted streams to AbortError', async () => { + state.parts = [{ type: 'error', error: new Error('fetch failed') }] + const controller = new AbortController() + controller.abort() + await expect(streamCallLLM(baseParams({ signal: controller.signal }))).rejects.toMatchObject({ + name: 'AbortError', + }) + }) + + test('omits tools and toolChoice when no tool definitions', async () => { + state.parts = [] + await streamCallLLM(baseParams()) + + const params = state.streamTextParams[0] as { tools?: unknown; toolChoice?: unknown } + expect(params.tools).toBeUndefined() + expect(params.toolChoice).toBeUndefined() + }) + + test('passes tools, toolChoice auto, abortSignal and providerOptions', async () => { + const signal = new AbortController().signal + const factory: AIProviderFactory = { + kind: 'openai-compatible', + build: () => ((model: string) => model) as never, + buildProviderOptions: () => ({ parallel_tool_calls: true }), + } + state.parts = [] + await streamCallLLM( + baseParams({ + factory, + signal, + toolDefinitions: [{ name: 't', description: 'd', input_schema: { type: 'object' } }], + }), + ) + + const params = state.streamTextParams[0] as { + tools?: unknown + toolChoice?: unknown + abortSignal?: AbortSignal + providerOptions?: unknown + } + expect(params.tools).toBeDefined() + expect(params.toolChoice).toBe('auto') + expect(params.abortSignal).toBe(signal) + expect(params.providerOptions).toEqual({ mock: { parallel_tool_calls: true } }) + }) +}) diff --git a/packages/core/src/llm/ai_stream.ts b/packages/core/src/llm/ai_stream.ts new file mode 100644 index 0000000..6ef810d --- /dev/null +++ b/packages/core/src/llm/ai_stream.ts @@ -0,0 +1,85 @@ +/** @file Default streaming LLM call backed by AI SDK streamText. */ +import { jsonSchema, streamText, type ModelMessage } from 'ai' +import type { LLMResult, ToolDefinition } from '@memo/core/types' +import type { ProviderConfig } from '@memo/core/config/config' +import type { ModelProfile } from '@memo/core/llm/model_profile' +import type { AIProviderFactory } from '@memo/core/llm/ai_provider' + +export type StreamCallLLMParams = { + provider: ProviderConfig + apiKey: string + /** CoreMessage[] (ChatMessage alias) — passed to streamText as-is. */ + messages: ModelMessage[] + toolDefinitions: ToolDefinition[] + profile: ModelProfile + factory: AIProviderFactory + onChunk?: (chunk: string) => void + signal?: AbortSignal +} + +/** ToolDefinition[] → AI SDK tools map; forces a top-level type: 'object' (vercel/ai#7924). */ +export function buildStreamTools(toolDefinitions: ToolDefinition[]) { + if (toolDefinitions.length === 0) return undefined + return Object.fromEntries( + toolDefinitions.map((tool) => [ + tool.name, + { + description: tool.description, + inputSchema: jsonSchema( + tool.input_schema?.type === 'object' ? tool.input_schema : { ...tool.input_schema, type: 'object' }, + ), + }, + ]), + ) +} + +/** Normalize stream errors so callers can detect aborts via name/message matching. */ +export function normalizeStreamError(err: unknown, signal?: AbortSignal): Error { + if (signal?.aborted) { + const aborted = new Error('Request aborted') + aborted.name = 'AbortError' + return aborted + } + if (err instanceof Error && err.name === 'AbortError') return err + if (err instanceof Error && /aborted/i.test(err.message)) { + const aborted = new Error(err.message) + aborted.name = 'AbortError' + return aborted + } + return err instanceof Error ? err : new Error(String(err)) +} + +/** Default callLLM implementation: stream via AI SDK, return the final GenerateTextResult. */ +export async function streamCallLLM(params: StreamCallLLMParams): Promise { + const { provider, apiKey, messages, toolDefinitions, profile, factory, onChunk, signal } = params + const tools = buildStreamTools(toolDefinitions) + const model = factory.build(provider, apiKey)(provider.model) + const requestProviderOptions = factory.buildProviderOptions(profile) + + const result = streamText({ + model, + messages, + tools, + toolChoice: tools ? 'auto' : undefined, + abortSignal: signal, + // Non-standard wire fields (e.g. parallel_tool_calls) pass through under the provider instance name. + providerOptions: requestProviderOptions ? { [provider.name]: requestProviderOptions } : undefined, + }) + + try { + for await (const part of result.fullStream) { + if (part.type === 'text-delta') onChunk?.(part.text) + else if (part.type === 'error') throw part.error + } + } catch (err) { + throw normalizeStreamError(err, signal) + } + // StreamTextResult exposes awaitable properties that resolve once the stream finishes. + return { + text: await result.text, + reasoning: (await result.reasoningText) ?? undefined, + toolCalls: await result.toolCalls, + usage: await result.usage, + finishReason: await result.finishReason, + } +} diff --git a/packages/core/src/runtime/model_profile.test.ts b/packages/core/src/llm/model_profile.test.ts similarity index 54% rename from packages/core/src/runtime/model_profile.test.ts rename to packages/core/src/llm/model_profile.test.ts index 5980b3e..4bc9851 100644 --- a/packages/core/src/runtime/model_profile.test.ts +++ b/packages/core/src/llm/model_profile.test.ts @@ -1,24 +1,5 @@ import { describe, expect, test } from 'vitest' -import { buildChatCompletionRequest, resolveModelProfile, type ModelProfile } from '@memo/core/runtime/model_profile' -import type { ToolDefinition } from '@memo/core/types' - -function sampleProfile(overrides: Partial = {}): ModelProfile { - return { - wireApi: 'chat_completions', - supportsParallelToolCalls: false, - supportsReasoningContent: false, - isFallback: false, - ...overrides, - } -} - -const SAMPLE_TOOLS: ToolDefinition[] = [ - { - name: 'read_file', - description: 'Read a file', - input_schema: { type: 'object', properties: { file_path: { type: 'string' } } }, - }, -] +import { resolveModelProfile } from '@memo/core/llm/model_profile' describe('resolveModelProfile', () => { test('uses conservative fallback when no local override exists', () => { @@ -78,31 +59,3 @@ describe('resolveModelProfile', () => { expect(resolved.profile.supportsParallelToolCalls).toBe(false) }) }) - -describe('buildChatCompletionRequest', () => { - test('enables parallel tool calls only when profile supports it', () => { - const request = buildChatCompletionRequest({ - model: 'gpt-5', - messages: [{ role: 'user', content: 'hi' }], - toolDefinitions: SAMPLE_TOOLS, - profile: sampleProfile({ supportsParallelToolCalls: true }), - }) - - expect(request.tool_choice).toBe('auto') - expect(Array.isArray(request.tools)).toBe(true) - expect((request as Record).parallel_tool_calls).toBe(true) - }) - - test('omits tool config and parallel flag when no tools are present', () => { - const request = buildChatCompletionRequest({ - model: 'gpt-5', - messages: [{ role: 'user', content: 'hi' }], - toolDefinitions: [], - profile: sampleProfile({ supportsParallelToolCalls: true }), - }) - - expect(request.tools).toBeUndefined() - expect(request.tool_choice).toBeUndefined() - expect((request as Record).parallel_tool_calls).toBeUndefined() - }) -}) diff --git a/packages/core/src/runtime/model_profile.ts b/packages/core/src/llm/model_profile.ts similarity index 70% rename from packages/core/src/runtime/model_profile.ts rename to packages/core/src/llm/model_profile.ts index d6703ec..c2eafaf 100644 --- a/packages/core/src/runtime/model_profile.ts +++ b/packages/core/src/llm/model_profile.ts @@ -1,8 +1,7 @@ -import OpenAI from 'openai' import type { ModelProfileOverride, ProviderConfig } from '@memo/core/config/config' -import type { ToolDefinition } from '@memo/core/types' -export type ModelWireApi = 'chat_completions' +/** Wire API kinds; the provider factory registry dispatches on these (future: responses / messages). */ +export type ModelWireApi = 'chat_completions' | 'responses' | 'messages' export type ModelProfile = { wireApi: ModelWireApi @@ -93,37 +92,3 @@ export function resolveModelProfile( }, } } - -function toChatCompletionTools(toolDefinitions: ToolDefinition[]) { - if (toolDefinitions.length === 0) return undefined - - return toolDefinitions.map((tool) => ({ - type: 'function' as const, - function: { - name: tool.name, - description: tool.description, - parameters: tool.input_schema, - }, - })) -} - -export function buildChatCompletionRequest(params: { - model: string - messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] - toolDefinitions: ToolDefinition[] - profile: ModelProfile -}): OpenAI.Chat.Completions.ChatCompletionCreateParams { - const tools = toChatCompletionTools(params.toolDefinitions) - const request: OpenAI.Chat.Completions.ChatCompletionCreateParams = { - model: params.model, - messages: params.messages, - tools, - tool_choice: tools ? 'auto' : undefined, - } - - if (tools && params.profile.supportsParallelToolCalls) { - ;(request as Record).parallel_tool_calls = true - } - - return request -} diff --git a/packages/core/src/runtime/mcp_admin.test.ts b/packages/core/src/mcp/mcp_admin.test.ts similarity index 100% rename from packages/core/src/runtime/mcp_admin.test.ts rename to packages/core/src/mcp/mcp_admin.test.ts diff --git a/packages/core/src/runtime/mcp_admin.ts b/packages/core/src/mcp/mcp_admin.ts similarity index 100% rename from packages/core/src/runtime/mcp_admin.ts rename to packages/core/src/mcp/mcp_admin.ts diff --git a/packages/core/src/runtime/memory.test.ts b/packages/core/src/prompt/memory.test.ts similarity index 83% rename from packages/core/src/runtime/memory.test.ts rename to packages/core/src/prompt/memory.test.ts index 50f27e3..69e439b 100644 --- a/packages/core/src/runtime/memory.test.ts +++ b/packages/core/src/prompt/memory.test.ts @@ -4,8 +4,15 @@ import { join } from 'node:path' import { tmpdir, userInfo } from 'node:os' import { describe, test, beforeAll, afterAll } from 'vitest' import { writeFile, rm, mkdir } from 'node:fs/promises' -import { createAgentSession, createTokenCounter } from '@memo/core' -import { loadSystemPrompt } from '@memo/core/runtime/prompt' +import { createAgentSession, createTokenCounter, type ChatMessage } from '@memo/core' +import { loadSystemPrompt } from '@memo/core/prompt/prompt' +import { emptyUsage } from '@memo/core/session/session_runtime_helpers' + +/** System messages carry string content; parts arrays (assistant/tool) are not expected here. */ +function systemPromptOf(history: ChatMessage[]): string { + const content = history[0]?.content + return typeof content === 'string' ? content : '' +} let tempHome: string let prevMemoHome: string | undefined @@ -43,8 +50,10 @@ describe('runtime prompt injection', () => { const session = await createAgentSession( { callLLM: async () => ({ - content: [{ type: 'text', text: 'ok' }], - stop_reason: 'end_turn', + text: 'ok', + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', }), historySinks: [], tokenCounter: createTokenCounter('cl100k_base'), @@ -52,7 +61,7 @@ describe('runtime prompt injection', () => { { mode: 'interactive' }, ) try { - const systemPrompt = session.history[0]?.content ?? '' + const systemPrompt = systemPromptOf(session.history) assert.ok(!systemPrompt.includes('Long-Term Memory')) assert.ok(!systemPrompt.includes('用户偏好:中文回答')) } finally { @@ -64,8 +73,10 @@ describe('runtime prompt injection', () => { const session = await createAgentSession( { callLLM: async () => ({ - content: [{ type: 'text', text: 'ok' }], - stop_reason: 'end_turn', + text: 'ok', + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', }), historySinks: [], tokenCounter: createTokenCounter('cl100k_base'), @@ -73,7 +84,7 @@ describe('runtime prompt injection', () => { { mode: 'interactive' }, ) try { - const systemPrompt = session.history[0]?.content ?? '' + const systemPrompt = systemPromptOf(session.history) assert.ok(systemPrompt.includes(process.cwd()), 'system prompt should include pwd') assert.ok(systemPrompt.includes(userInfo().username), 'system prompt should include username') assert.match(systemPrompt, /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:/, 'system prompt should include ISO date') @@ -92,8 +103,10 @@ describe('runtime prompt injection', () => { const session = await createAgentSession( { callLLM: async () => ({ - content: [{ type: 'text', text: 'ok' }], - stop_reason: 'end_turn', + text: 'ok', + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', }), loadPrompt: () => loadSystemPrompt({ cwd: projectRoot }), historySinks: [], @@ -103,7 +116,7 @@ describe('runtime prompt injection', () => { ) try { - const systemPrompt = session.history[0]?.content ?? '' + const systemPrompt = systemPromptOf(session.history) assert.ok(systemPrompt.includes('Project AGENTS.md (Startup Root)')) assert.ok(systemPrompt.includes(agentsPath)) assert.ok(systemPrompt.includes(marker)) @@ -125,8 +138,10 @@ describe('runtime prompt injection', () => { const session = await createAgentSession( { callLLM: async () => ({ - content: [{ type: 'text', text: 'ok' }], - stop_reason: 'end_turn', + text: 'ok', + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', }), loadPrompt: () => loadSystemPrompt({ cwd: projectRoot, memoHome: tempHome }), historySinks: [], @@ -136,7 +151,7 @@ describe('runtime prompt injection', () => { ) try { - const systemPrompt = session.history[0]?.content ?? '' + const systemPrompt = systemPromptOf(session.history) assert.ok(systemPrompt.includes('## User Personality Context (SOUL.md)')) assert.ok(systemPrompt.includes(soulPath)) assert.ok(systemPrompt.includes(soulMarker)) @@ -173,8 +188,10 @@ description: ${marker} const session = await createAgentSession( { callLLM: async () => ({ - content: [{ type: 'text', text: 'ok' }], - stop_reason: 'end_turn', + text: 'ok', + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', }), loadPrompt: () => loadSystemPrompt({ @@ -190,7 +207,7 @@ description: ${marker} ) try { - const systemPrompt = session.history[0]?.content ?? '' + const systemPrompt = systemPromptOf(session.history) assert.ok(systemPrompt.includes('## Skills')) assert.ok(systemPrompt.includes('### Available skills')) assert.ok(systemPrompt.includes(`- doc-writing: ${marker} (file: ${skillPath})`)) @@ -234,8 +251,10 @@ description: disabled marker const session = await createAgentSession( { callLLM: async () => ({ - content: [{ type: 'text', text: 'ok' }], - stop_reason: 'end_turn', + text: 'ok', + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', }), loadPrompt: () => loadSystemPrompt({ @@ -252,7 +271,7 @@ description: disabled marker ) try { - const systemPrompt = session.history[0]?.content ?? '' + const systemPrompt = systemPromptOf(session.history) assert.ok(systemPrompt.includes('enabled-skill')) assert.ok(!systemPrompt.includes('disabled-skill')) } finally { @@ -280,8 +299,10 @@ name: broken-skill const session = await createAgentSession( { callLLM: async () => ({ - content: [{ type: 'text', text: 'ok' }], - stop_reason: 'end_turn', + text: 'ok', + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', }), loadPrompt: () => loadSystemPrompt({ @@ -297,7 +318,7 @@ name: broken-skill ) try { - const systemPrompt = session.history[0]?.content ?? '' + const systemPrompt = systemPromptOf(session.history) assert.ok(!systemPrompt.includes('## Skills')) assert.ok(!systemPrompt.includes('broken-skill')) } finally { diff --git a/packages/core/src/runtime/prompt.md b/packages/core/src/prompt/prompt.md similarity index 100% rename from packages/core/src/runtime/prompt.md rename to packages/core/src/prompt/prompt.md diff --git a/packages/core/src/runtime/prompt.test.ts b/packages/core/src/prompt/prompt.test.ts similarity index 100% rename from packages/core/src/runtime/prompt.test.ts rename to packages/core/src/prompt/prompt.test.ts diff --git a/packages/core/src/runtime/prompt.ts b/packages/core/src/prompt/prompt.ts similarity index 98% rename from packages/core/src/runtime/prompt.ts rename to packages/core/src/prompt/prompt.ts index 2d17fbd..2f3f133 100644 --- a/packages/core/src/runtime/prompt.ts +++ b/packages/core/src/prompt/prompt.ts @@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join, dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { loadSkills, renderSkillsSection } from '@memo/core/runtime/skills' +import { loadSkills, renderSkillsSection } from '@memo/core/skills/skills' const TEMPLATE_PATTERN = /{{\s*([\w.-]+)\s*}}/g const SOUL_PLACEHOLDER_PATTERN = /{{\s*soul_section\s*}}/ diff --git a/packages/core/src/runtime/defaults.ts b/packages/core/src/runtime/defaults.ts deleted file mode 100644 index 5606392..0000000 --- a/packages/core/src/runtime/defaults.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** @file Session default dependency assembly: toolset, LLM, history sinks, tokenizer, etc. */ -import { NATIVE_TOOLS } from '@memo/tools' -import OpenAI from 'openai' -import { createTokenCounter } from '@memo/core/utils/tokenizer' -import { buildSessionPath, getSessionsDir, loadMemoConfig, selectProvider } from '@memo/core/config/config' -import { JsonlHistorySink } from '@memo/core/runtime/history' -import { buildChatCompletionRequest, resolveModelProfile } from '@memo/core/runtime/model_profile' -import { loadSystemPrompt as defaultLoadPrompt } from '@memo/core/runtime/prompt' -import { ToolRouter } from '@memo/tools/router' -import type { - AgentSessionDeps, - AgentSessionOptions, - CallLLM, - ChatMessage, - HistorySink, - TokenCounter, - ToolRegistry, -} from '@memo/core/types' -import type { MCPServerConfig } from '@memo/core/config/config' - -export function filterMcpServersBySelection( - servers: Record | undefined, - activeNames: string[] | undefined, -): Record | undefined { - if (!servers) return servers - if (!activeNames) return servers - - const selected = new Set(activeNames.map((name) => name.trim()).filter(Boolean)) - if (selected.size === 0) return {} - - const filtered: Record = {} - for (const [name, config] of Object.entries(servers)) { - if (selected.has(name)) { - filtered[name] = config - } - } - return filtered -} - -export function parseToolArguments( - raw: string, -): { ok: true; data: unknown } | { ok: false; raw: string; error: string } { - try { - return { ok: true, data: JSON.parse(raw) } - } catch (err) { - return { ok: false, raw, error: (err as Error).message } - } -} - -function toOpenAIMessage(message: ChatMessage): OpenAI.Chat.Completions.ChatCompletionMessageParam { - if (message.role === 'assistant') { - const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam & { - reasoning_content?: string - } = { - role: 'assistant', - content: message.content, - tool_calls: message.tool_calls?.map((toolCall) => ({ - id: toolCall.id, - type: toolCall.type, - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments, - }, - })), - } - if (message.reasoning_content) { - assistantMessage.reasoning_content = message.reasoning_content - } - return assistantMessage as OpenAI.Chat.Completions.ChatCompletionMessageParam - } - if (message.role === 'tool') { - return { - role: 'tool', - content: message.content, - tool_call_id: message.tool_call_id, - } - } - return { - role: message.role, - content: message.content, - } -} - -function extractReasoningContent( - message: OpenAI.Chat.Completions.ChatCompletionMessage | undefined, -): string | undefined { - const raw = (message as { reasoning_content?: unknown } | undefined)?.reasoning_content - if (typeof raw !== 'string') return undefined - const trimmed = raw.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -function isChatCompletionResponse(value: unknown): value is OpenAI.Chat.Completions.ChatCompletion { - if (!value || typeof value !== 'object') return false - return Array.isArray((value as { choices?: unknown }).choices) -} - -/** - * Complete dependencies with default strategy (tools, callLLM, prompt, history sinks, tokenizer). - * Caller can provide only callbacks/overrides, rest use default implementations. - */ -export async function withDefaultDeps( - deps: AgentSessionDeps, - options: AgentSessionOptions, - sessionId: string, -): Promise<{ - tools: ToolRegistry - callLLM: CallLLM - loadPrompt: () => Promise - historySinks: HistorySink[] - tokenCounter: TokenCounter - dispose: () => Promise - historyFilePath?: string -}> { - const loaded = await loadMemoConfig() - const config = loaded.config - - // 1. Initialize ToolRouter - const router = new ToolRouter() - - // 2. Register built-in tools - router.registerNativeTools(NATIVE_TOOLS) - - // 3. Load external MCP tools (follows MEMO_HOME) - await router.loadMcpServers(filterMcpServersBySelection(config.mcp_servers, options.activeMcpServers), { - memoHome: loaded.home, - storeMode: config.mcp_oauth_credentials_store_mode, - callbackPort: config.mcp_oauth_callback_port, - }) - - // 4. Merge user custom tools (deps.tools has highest priority) - if (deps.tools) { - for (const [name, tool] of Object.entries(deps.tools)) { - // User custom tools override同名 tools in router - router.registerNativeTool({ - name, - description: tool.description, - source: 'native', - inputSchema: { type: 'object' }, // Simplified, should convert from tool in practice - execute: tool.execute, - }) - } - } - - // 5. Get final tool registry - const combinedTools = router.toRegistry() - - // 6. Build loadPrompt (includes tool descriptions) - const loadPrompt = async () => { - let basePrompt = deps.loadPrompt - ? await deps.loadPrompt() - : await defaultLoadPrompt({ - cwd: options.cwd, - memoHome: loaded.home, - activeSkillPaths: config.active_skills, - }) - - // Inject tool descriptions into prompt (for non-Tool Use API mode) - const toolDescriptions = router.generateToolDescriptions() - if (toolDescriptions) { - basePrompt += `\n\n${toolDescriptions}` - } - - return basePrompt - } - - // 7. Generate tool definitions (for Tool Use API) - const toolDefinitions = router.generateToolDefinitions() - - const sessionsDir = getSessionsDir(loaded, options) - const historyFilePath = buildSessionPath(sessionsDir, sessionId) - const defaultHistorySink = new JsonlHistorySink(historyFilePath) - - return { - tools: combinedTools, - dispose: async () => { - if (deps.dispose) await deps.dispose() - await router.dispose() - }, - callLLM: - deps.callLLM ?? - (async (messages, _onChunk, callOptions) => { - const provider = selectProvider(config, options.providerName) - const apiKey = - process.env[provider.env_api_key] ?? process.env.OPENAI_API_KEY ?? process.env.DEEPSEEK_API_KEY - if (!apiKey) { - throw new Error(`Missing env var ${provider.env_api_key} (or OPENAI_API_KEY/DEEPSEEK_API_KEY)`) - } - const client = new OpenAI({ - apiKey, - baseURL: provider.base_url, - }) - const openAIMessages = messages.map(toOpenAIMessage) - const { profile: modelProfile } = resolveModelProfile(provider, config.model_profiles) - - const effectiveToolDefinitions = callOptions?.tools ?? toolDefinitions - const request = buildChatCompletionRequest({ - model: provider.model, - messages: openAIMessages, - toolDefinitions: effectiveToolDefinitions, - profile: modelProfile, - }) - - const data = await client.chat.completions.create(request, { - signal: callOptions?.signal, - }) - if (!isChatCompletionResponse(data)) { - throw new Error('Streaming response is not supported in core callLLM') - } - - const message = data.choices?.[0]?.message - const reasoningContent = extractReasoningContent(message) - - // 检查是否有工具调用 - if (message?.tool_calls && message.tool_calls.length > 0) { - const content: Array< - { type: 'text'; text: string } | { type: 'tool_use'; id: string; name: string; input: unknown } - > = [] - - // 添加文本内容(如果有) - if (message.content) { - content.push({ type: 'text', text: message.content }) - } - - // 添加工具调用 - for (const toolCall of message.tool_calls) { - if (toolCall.type === 'function') { - const parsedArgs = parseToolArguments(toolCall.function.arguments) - if (parsedArgs.ok) { - content.push({ - type: 'tool_use', - id: toolCall.id, - name: toolCall.function.name, - input: parsedArgs.data, - }) - } else { - content.push({ - type: 'text', - text: `[tool_use parse error] ${parsedArgs.error}; raw: ${parsedArgs.raw}`, - }) - } - } - } - - const hasToolUse = content.some((c) => c.type === 'tool_use') - return { - content, - reasoning_content: reasoningContent, - stop_reason: hasToolUse ? 'tool_use' : 'end_turn', - usage: { - prompt: data.usage?.prompt_tokens ?? undefined, - completion: data.usage?.completion_tokens ?? undefined, - total: data.usage?.total_tokens ?? undefined, - }, - } - } - - // 普通文本响应 - const content = message?.content - if (typeof content !== 'string') { - throw new Error('OpenAI-compatible API returned empty content') - } - return { - content: [{ type: 'text', text: content }], - reasoning_content: reasoningContent, - stop_reason: 'end_turn', - usage: { - prompt: data.usage?.prompt_tokens ?? undefined, - completion: data.usage?.completion_tokens ?? undefined, - total: data.usage?.total_tokens ?? undefined, - }, - } - }), - loadPrompt, - historySinks: deps.historySinks ?? [defaultHistorySink], - tokenCounter: deps.tokenCounter ?? createTokenCounter(options.tokenizerModel), - historyFilePath: historyFilePath, - } -} diff --git a/packages/core/src/runtime/compact_prompt.test.ts b/packages/core/src/session/compact_prompt.test.ts similarity index 76% rename from packages/core/src/runtime/compact_prompt.test.ts rename to packages/core/src/session/compact_prompt.test.ts index 8a4234f..0667000 100644 --- a/packages/core/src/runtime/compact_prompt.test.ts +++ b/packages/core/src/session/compact_prompt.test.ts @@ -5,7 +5,7 @@ import { buildCompactionUserPrompt, CONTEXT_SUMMARY_PREFIX, isContextSummaryMessage, -} from '@memo/core/runtime/compact_prompt' +} from '@memo/core/session/compact_prompt' describe('compact_prompt', () => { test('buildCompactionUserPrompt formats assistant tool calls and tool messages', () => { @@ -13,23 +13,21 @@ describe('compact_prompt', () => { const messages: ChatMessage[] = [ { role: 'assistant', - content: 'planning', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { - name: 'exec_command', - arguments: '{}', - }, - }, + content: [ + { type: 'text', text: 'planning' }, + { type: 'tool-call', toolCallId: 'call-1', toolName: 'exec_command', input: {} }, ], }, { role: 'tool', - content: longToolOutput, - tool_call_id: 'call-1', - name: 'exec_command', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'exec_command', + output: { type: 'text', value: longToolOutput }, + }, + ], }, ] @@ -53,8 +51,14 @@ describe('compact_prompt', () => { }, { role: 'tool', - content: ' \r\nresult line\r\n ', - tool_call_id: 'call-2', + content: [ + { + type: 'tool-result', + toolCallId: 'call-2', + toolName: '', + output: { type: 'text', value: ' \r\nresult line\r\n ' }, + }, + ], }, ] diff --git a/packages/core/src/runtime/compact_prompt.ts b/packages/core/src/session/compact_prompt.ts similarity index 56% rename from packages/core/src/runtime/compact_prompt.ts rename to packages/core/src/session/compact_prompt.ts index ad7dc38..d0da967 100644 --- a/packages/core/src/runtime/compact_prompt.ts +++ b/packages/core/src/session/compact_prompt.ts @@ -25,20 +25,35 @@ function normalizeContent(content: string): string { function messageToTranscriptLine(message: ChatMessage, index: number): string { const role = message.role.toUpperCase() - if (message.role === 'assistant' && message.tool_calls?.length) { - const toolNames = message.tool_calls.map((toolCall) => toolCall.function.name).join(', ') - return `[${index}] ${role} (tool_calls: ${toolNames})\n${normalizeContent(message.content)}` + if (message.role === 'assistant') { + const parts = Array.isArray(message.content) ? message.content : [] + const toolCalls = parts.filter((part) => part.type === 'tool-call') + const text = + typeof message.content === 'string' + ? message.content + : parts + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('') + if (toolCalls.length) { + const toolNames = toolCalls.map((part) => part.toolName).join(', ') + return `[${index}] ${role} (tool_calls: ${toolNames})\n${normalizeContent(text)}` + } + return `[${index}] ${role}\n${normalizeContent(text)}` } if (message.role === 'tool') { - const toolName = message.name ? ` (${message.name})` : '' - return `[${index}] ${role}${toolName}\n${normalizeContent(message.content)}` + const part = Array.isArray(message.content) ? message.content[0] : undefined + const toolName = part?.type === 'tool-result' ? part.toolName : '' + const text = part?.type === 'tool-result' && part.output.type === 'text' ? part.output.value : '' + return `[${index}] ${role}${toolName ? ` (${toolName})` : ''}\n${normalizeContent(text)}` } - return `[${index}] ${role}\n${normalizeContent(message.content)}` + const content = typeof message.content === 'string' ? message.content : '' + return `[${index}] ${role}\n${normalizeContent(content)}` } export function isContextSummaryMessage(message: ChatMessage): boolean { if (message.role !== 'user') return false - return message.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`) + return typeof message.content === 'string' && message.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`) } export function buildCompactionUserPrompt(messages: ChatMessage[]): string { diff --git a/packages/core/src/runtime/defaults.test.ts b/packages/core/src/session/defaults.test.ts similarity index 59% rename from packages/core/src/runtime/defaults.test.ts rename to packages/core/src/session/defaults.test.ts index 26bdbb8..67cc085 100644 --- a/packages/core/src/runtime/defaults.test.ts +++ b/packages/core/src/session/defaults.test.ts @@ -1,24 +1,5 @@ import { describe, expect, test } from 'vitest' -import { parseToolArguments, filterMcpServersBySelection } from '@memo/core/runtime/defaults' - -describe('parseToolArguments', () => { - test('parses valid JSON string', () => { - const res = parseToolArguments('{"a":1}') - expect(res.ok).toBe(true) - if (res.ok) { - expect(res.data).toEqual({ a: 1 }) - } - }) - - test('returns error when JSON invalid', () => { - const res = parseToolArguments('这不是json') - expect(res.ok).toBe(false) - if (!res.ok) { - expect(res.raw).toBe('这不是json') - expect(res.error.length).toBeGreaterThan(0) - } - }) -}) +import { filterMcpServersBySelection } from '@memo/core/session/defaults' describe('filterMcpServersBySelection', () => { const servers = { diff --git a/packages/core/src/session/defaults.ts b/packages/core/src/session/defaults.ts new file mode 100644 index 0000000..b5b4ab2 --- /dev/null +++ b/packages/core/src/session/defaults.ts @@ -0,0 +1,148 @@ +/** @file Session default dependency assembly: toolset, LLM, history sinks, tokenizer, etc. */ +import { NATIVE_TOOLS } from '@memo/tools' +import { createTokenCounter } from '@memo/core/utils/tokenizer' +import { buildSessionPath, getSessionsDir, loadMemoConfig, selectProvider } from '@memo/core/config/config' +import { JsonlHistorySink } from '@memo/core/history/history' +import { resolveModelProfile } from '@memo/core/llm/model_profile' +import { streamCallLLM } from '@memo/core/llm/ai_stream' +import { getProviderFactory } from '@memo/core/llm/ai_provider' +import { loadSystemPrompt as defaultLoadPrompt } from '@memo/core/prompt/prompt' +import { ToolRouter } from '@memo/tools/router' +import type { + AgentSessionDeps, + AgentSessionOptions, + CallLLM, + HistorySink, + TokenCounter, + ToolRegistry, +} from '@memo/core/types' +import type { MCPServerConfig } from '@memo/core/config/config' + +export function filterMcpServersBySelection( + servers: Record | undefined, + activeNames: string[] | undefined, +): Record | undefined { + if (!servers) return servers + if (!activeNames) return servers + + const selected = new Set(activeNames.map((name) => name.trim()).filter(Boolean)) + if (selected.size === 0) return {} + + const filtered: Record = {} + for (const [name, config] of Object.entries(servers)) { + if (selected.has(name)) { + filtered[name] = config + } + } + return filtered +} + +/** + * Complete dependencies with default strategy (tools, callLLM, prompt, history sinks, tokenizer). + * Caller can provide only callbacks/overrides, rest use default implementations. + */ +export async function withDefaultDeps( + deps: AgentSessionDeps, + options: AgentSessionOptions, + sessionId: string, +): Promise<{ + tools: ToolRegistry + callLLM: CallLLM + loadPrompt: () => Promise + historySinks: HistorySink[] + tokenCounter: TokenCounter + dispose: () => Promise + historyFilePath?: string +}> { + const loaded = await loadMemoConfig() + const config = loaded.config + + // 1. Initialize ToolRouter + const router = new ToolRouter() + + // 2. Register built-in tools + router.registerNativeTools(NATIVE_TOOLS) + + // 3. Load external MCP tools (follows MEMO_HOME) + await router.loadMcpServers(filterMcpServersBySelection(config.mcp_servers, options.activeMcpServers), { + memoHome: loaded.home, + storeMode: config.mcp_oauth_credentials_store_mode, + callbackPort: config.mcp_oauth_callback_port, + }) + + // 4. Merge user custom tools (deps.tools has highest priority) + if (deps.tools) { + for (const [name, tool] of Object.entries(deps.tools)) { + // User custom tools override同名 tools in router + router.registerNativeTool({ + name, + description: tool.description, + source: 'native', + inputSchema: { type: 'object' }, // Simplified, should convert from tool in practice + execute: tool.execute, + }) + } + } + + // 5. Get final tool registry + const combinedTools = router.toRegistry() + + // 6. Build loadPrompt (includes tool descriptions) + const loadPrompt = async () => { + let basePrompt = deps.loadPrompt + ? await deps.loadPrompt() + : await defaultLoadPrompt({ + cwd: options.cwd, + memoHome: loaded.home, + activeSkillPaths: config.active_skills, + }) + + // Inject tool descriptions into prompt (for non-Tool Use API mode) + const toolDescriptions = router.generateToolDescriptions() + if (toolDescriptions) { + basePrompt += `\n\n${toolDescriptions}` + } + + return basePrompt + } + + // 7. Generate tool definitions (for Tool Use API) + const toolDefinitions = router.generateToolDefinitions() + + const sessionsDir = getSessionsDir(loaded, options) + const historyFilePath = buildSessionPath(sessionsDir, sessionId) + const defaultHistorySink = new JsonlHistorySink(historyFilePath) + + return { + tools: combinedTools, + dispose: async () => { + if (deps.dispose) await deps.dispose() + await router.dispose() + }, + callLLM: + deps.callLLM ?? + (async (messages, onChunk, callOptions) => { + const provider = selectProvider(config, options.providerName) + const apiKey = + process.env[provider.env_api_key] ?? process.env.OPENAI_API_KEY ?? process.env.DEEPSEEK_API_KEY + if (!apiKey) { + throw new Error(`Missing env var ${provider.env_api_key} (or OPENAI_API_KEY/DEEPSEEK_API_KEY)`) + } + const { profile: modelProfile } = resolveModelProfile(provider, config.model_profiles) + return streamCallLLM({ + provider, + apiKey, + messages, + toolDefinitions: callOptions?.tools ?? toolDefinitions, + profile: modelProfile, + factory: getProviderFactory(provider), + onChunk, + signal: callOptions?.signal, + }) + }), + loadPrompt, + historySinks: deps.historySinks ?? [defaultHistorySink], + tokenCounter: deps.tokenCounter ?? createTokenCounter(options.tokenizerModel), + historyFilePath: historyFilePath, + } +} diff --git a/packages/core/src/runtime/defaults.with_default_deps.test.ts b/packages/core/src/session/defaults.with_default_deps.test.ts similarity index 51% rename from packages/core/src/runtime/defaults.with_default_deps.test.ts rename to packages/core/src/session/defaults.with_default_deps.test.ts index 0400fbc..55c5922 100644 --- a/packages/core/src/runtime/defaults.with_default_deps.test.ts +++ b/packages/core/src/session/defaults.with_default_deps.test.ts @@ -1,6 +1,15 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import type { AgentSessionDeps, AgentSessionOptions, ChatMessage, ToolRegistry } from '@memo/core/types' +import type { + AgentSessionDeps, + AgentSessionOptions, + ChatMessage, + LLMResult, + ToolDefinition, + ToolRegistry, +} from '@memo/core/types' import type { MCPServerConfig } from '@memo/core/config/config' +import type { AIProviderFactory } from '@memo/core/llm/ai_provider' +import { emptyUsage } from '@memo/core/session/session_runtime_helpers' import type { Tool } from '@memo/tools/router' const state = vi.hoisted(() => ({ @@ -43,30 +52,32 @@ const state = vi.hoisted(() => ({ execute: async () => ({ content: [{ type: 'text', text: 'ok' }] }), } as Tool, } as ToolRegistry, - buildRequestCalls: [] as unknown[], loadMcpServersCalls: [] as unknown[], registerNativeToolsCalls: [] as unknown[], registerNativeToolCalls: [] as unknown[], - openaiCtorCalls: [] as unknown[], - openaiCreateCalls: [] as unknown[], historySinkPaths: [] as string[], routerDisposed: 0, createTokenCounterCalls: [] as Array, promptText: 'SYSTEM_PROMPT', - openaiResponse: { - choices: [ - { - message: { - content: 'ok', - }, - }, - ], + streamCalls: [] as unknown[], + factoryLookups: [] as unknown[], + factory: { + kind: 'openai-compatible', + build: vi.fn(), + buildProviderOptions: vi.fn(() => undefined), + } as unknown as AIProviderFactory, + llmResponse: { + text: 'ok', + toolCalls: [], usage: { - prompt_tokens: 11, - completion_tokens: 7, - total_tokens: 18, + inputTokens: 11, + outputTokens: 7, + totalTokens: 18, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { reasoningTokens: undefined }, }, - } as Record, + finishReason: 'stop', + } as LLMResult, })) vi.mock('@memo/tools', () => ({ @@ -80,7 +91,7 @@ vi.mock('@memo/core/config/config', () => ({ buildSessionPath: vi.fn(() => state.sessionPath), })) -vi.mock('@memo/core/runtime/history', () => ({ +vi.mock('@memo/core/history/history', () => ({ JsonlHistorySink: class JsonlHistorySink { constructor(path: string) { state.historySinkPaths.push(path) @@ -88,15 +99,25 @@ vi.mock('@memo/core/runtime/history', () => ({ }, })) -vi.mock('@memo/core/runtime/model_profile', () => ({ +vi.mock('@memo/core/llm/model_profile', () => ({ resolveModelProfile: vi.fn(() => ({ profile: { supportsParallelToolCalls: true } })), - buildChatCompletionRequest: vi.fn((request: unknown) => { - state.buildRequestCalls.push(request) - return request +})) + +vi.mock('@memo/core/llm/ai_stream', () => ({ + streamCallLLM: vi.fn(async (params: unknown) => { + state.streamCalls.push(params) + return state.llmResponse }), })) -vi.mock('@memo/core/runtime/prompt', () => ({ +vi.mock('@memo/core/llm/ai_provider', () => ({ + getProviderFactory: vi.fn((provider: unknown) => { + state.factoryLookups.push(provider) + return state.factory + }), +})) + +vi.mock('@memo/core/prompt/prompt', () => ({ loadSystemPrompt: vi.fn(async () => state.promptText), })) @@ -145,50 +166,24 @@ vi.mock('@memo/tools/router', () => ({ }, })) -vi.mock('openai', () => ({ - default: class OpenAI { - chat = { - completions: { - create: async (request: unknown, options: unknown) => { - state.openaiCreateCalls.push({ request, options }) - return state.openaiResponse - }, - }, - } - - constructor(config: unknown) { - state.openaiCtorCalls.push(config) - } - }, -})) - describe('withDefaultDeps (default path)', () => { beforeEach(() => { - state.buildRequestCalls = [] state.loadMcpServersCalls = [] state.registerNativeToolsCalls = [] state.registerNativeToolCalls = [] - state.openaiCtorCalls = [] - state.openaiCreateCalls = [] state.historySinkPaths = [] state.routerDisposed = 0 state.createTokenCounterCalls = [] state.toolDescriptions = '## Tools\n- mock_tool' state.promptText = 'SYSTEM_PROMPT' - state.openaiResponse = { - choices: [ - { - message: { - content: 'ok', - }, - }, - ], - usage: { - prompt_tokens: 11, - completion_tokens: 7, - total_tokens: 18, - }, - } + state.streamCalls = [] + state.factoryLookups = [] + state.llmResponse = { + text: 'ok', + toolCalls: [], + usage: { ...emptyUsage(), inputTokens: 11, outputTokens: 7, totalTokens: 18 }, + finishReason: 'stop', + } as LLMResult delete process.env.MOCK_API_KEY delete process.env.OPENAI_API_KEY delete process.env.DEEPSEEK_API_KEY @@ -201,7 +196,7 @@ describe('withDefaultDeps (default path)', () => { }) test('builds default deps with injected tool descriptions and default sinks', async () => { - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/session/defaults') const resolved = await withDefaultDeps( {}, @@ -220,7 +215,7 @@ describe('withDefaultDeps (default path)', () => { }) test('respects provided deps overrides (callLLM/historySinks/tokenCounter/loadPrompt/dispose)', async () => { - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/session/defaults') const callLLM = vi.fn(async () => ({ content: [{ type: 'text' as const, text: 'override' }], stop_reason: 'end_turn' as const, @@ -258,7 +253,7 @@ describe('withDefaultDeps (default path)', () => { }) test('throws when provider api key is missing', async () => { - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/session/defaults') const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3') await expect(resolved.callLLM([{ role: 'user', content: 'hello' } as ChatMessage])).rejects.toThrow( @@ -266,55 +261,48 @@ describe('withDefaultDeps (default path)', () => { ) }) - test('falls back to OPENAI_API_KEY when provider key is missing', async () => { + test('falls back to OPENAI_API_KEY and delegates to streamCallLLM', async () => { process.env.OPENAI_API_KEY = 'openai-fallback-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/session/defaults') const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3b') + const messages = [{ role: 'user', content: 'hello' } as ChatMessage] - await resolved.callLLM([{ role: 'user', content: 'hello' } as ChatMessage]) - expect(state.openaiCtorCalls[0]).toEqual({ - apiKey: 'openai-fallback-key', - baseURL: 'https://mock.local/v1', + const response = await resolved.callLLM(messages) + + expect(response).toEqual(state.llmResponse) + expect(state.factoryLookups).toEqual([state.selectedProvider]) + const call = state.streamCalls[0] as { + provider: typeof state.selectedProvider + apiKey: string + messages: unknown[] + toolDefinitions: unknown[] + factory: unknown + } + expect(call.apiKey).toBe('openai-fallback-key') + expect(call.provider).toEqual({ + name: 'mock', + env_api_key: 'MOCK_API_KEY', + model: 'mock-model', + base_url: 'https://mock.local/v1', }) + expect(call.messages).toEqual(messages) + expect(call.factory).toBe(state.factory) }) - test('maps tool calls into tool_use blocks and keeps parse errors as text', async () => { + test('passes call options (tools/signal) and forwards structured LLM response', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - const callOptionsTools = [{ type: 'function', function: { name: 'override', parameters: {} } }] + const { withDefaultDeps } = await import('@memo/core/session/defaults') + const callOptionsTools: ToolDefinition[] = [ + { name: 'override', description: 'override tool', input_schema: { type: 'object' } }, + ] const signal = new AbortController().signal - state.openaiResponse = { - choices: [ - { - message: { - content: 'assistant text', - reasoning_content: ' reasoned ', - tool_calls: [ - { - id: 'call-ok', - type: 'function', - function: { name: 'echo', arguments: '{"value":1}' }, - }, - { - id: 'call-bad', - type: 'function', - function: { name: 'echo', arguments: '{bad-json' }, - }, - { - id: 'call-skip', - type: 'other', - function: { name: 'ignored', arguments: '{}' }, - }, - ], - }, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, + state.llmResponse = { + text: 'assistant text', + reasoning: 'reasoned', + toolCalls: [{ type: 'tool-call', toolCallId: 'call-ok', toolName: 'echo', input: { value: 1 } }], + usage: { ...emptyUsage(), inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + finishReason: 'tool-calls', } const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-4') @@ -322,21 +310,26 @@ describe('withDefaultDeps (default path)', () => { [ { role: 'assistant', - content: '', - reasoning_content: 'reasoning content', - tool_calls: [ + content: [ + { type: 'reasoning' as const, text: 'reasoning content' }, { - id: 'prev-call', - type: 'function', - function: { name: 'read_file', arguments: '{}' }, + type: 'tool-call', + toolCallId: 'prev-call', + toolName: 'read_file', + input: {}, }, ], }, { role: 'tool', - content: 'observation', - tool_call_id: 'prev-call', - name: 'read_file', + content: [ + { + type: 'tool-result', + toolCallId: 'prev-call', + toolName: 'read_file', + output: { type: 'text', value: 'observation' }, + }, + ], }, { role: 'user', content: 'continue' }, ], @@ -344,117 +337,66 @@ describe('withDefaultDeps (default path)', () => { { tools: callOptionsTools, signal }, ) - expect(response.stop_reason).toBe('tool_use') - expect(response.reasoning_content).toBe('reasoned') - expect(response.usage).toEqual({ prompt: 10, completion: 5, total: 15 }) - expect(response.content[0]).toEqual({ type: 'text', text: 'assistant text' }) - expect(response.content).toContainEqual({ - type: 'tool_use', - id: 'call-ok', - name: 'echo', - input: { value: 1 }, - }) - expect( - response.content.some( - (item) => - item.type === 'text' && - item.text.startsWith('[tool_use parse error]') && - item.text.includes('{bad-json'), - ), - ).toBe(true) + expect(response).toEqual(state.llmResponse) - expect(state.openaiCtorCalls[0]).toEqual({ - apiKey: 'test-key', - baseURL: 'https://mock.local/v1', - }) - - expect(state.buildRequestCalls).toHaveLength(1) - const request = state.buildRequestCalls[0] as { - toolDefinitions: unknown[] + const call = state.streamCalls[0] as { + provider: typeof state.selectedProvider + apiKey: string messages: Array> + toolDefinitions: unknown[] + profile: unknown + factory: unknown + signal: AbortSignal } - expect(request.toolDefinitions).toEqual(callOptionsTools) - expect(request.messages.some((msg) => msg.role === 'tool' && msg.tool_call_id === 'prev-call')).toBe(true) + expect(call.apiKey).toBe('test-key') + expect(call.toolDefinitions).toEqual(callOptionsTools) + expect(call.signal).toBe(signal) + expect(call.profile).toEqual({ supportsParallelToolCalls: true }) + expect(call.factory).toBe(state.factory) expect( - request.messages.some((msg) => msg.role === 'assistant' && msg.reasoning_content === 'reasoning content'), + (call.messages[0] as { content: Array<{ type: string }> }).content.some( + (part) => part.type === 'reasoning', + ), + ).toBe(true) + expect( + (call.messages[0] as { content: Array<{ type: string }> }).content.some( + (part) => part.type === 'tool-call', + ), + ).toBe(true) + expect( + (call.messages[1] as { content: Array<{ type: string }> }).content.some( + (part) => part.type === 'tool-result', + ), ).toBe(true) - - expect(state.openaiCreateCalls).toHaveLength(1) - expect((state.openaiCreateCalls[0] as { options: { signal: AbortSignal } }).options.signal).toBe(signal) - }) - - test('returns end_turn when tool_calls has no usable function calls', async () => { - process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - - state.openaiResponse = { - choices: [ - { - message: { - content: '', - tool_calls: [{ id: 'call-non-fn', type: 'other' }], - }, - }, - ], - usage: { - prompt_tokens: 1, - completion_tokens: 0, - total_tokens: 1, - }, - } - - const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-5') - const response = await resolved.callLLM([{ role: 'user', content: 'x' } as ChatMessage]) - expect(response.stop_reason).toBe('end_turn') - expect(response.content).toEqual([]) }) - test('returns plain text end_turn response with usage', async () => { + test('forwards plain text response with usage', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - - state.openaiResponse = { - choices: [ - { - message: { - content: 'plain assistant answer', - reasoning_content: ' concise reason ', - }, - }, - ], - usage: { - prompt_tokens: 3, - completion_tokens: 4, - total_tokens: 7, - }, + const { withDefaultDeps } = await import('@memo/core/session/defaults') + + state.llmResponse = { + text: 'plain assistant answer', + reasoning: 'concise reason', + toolCalls: [], + usage: { ...emptyUsage(), inputTokens: 3, outputTokens: 4, totalTokens: 7 }, + finishReason: 'stop', } const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-5b') const response = await resolved.callLLM([{ role: 'user', content: 'x' } as ChatMessage]) - expect(response.stop_reason).toBe('end_turn') - expect(response.reasoning_content).toBe('concise reason') - expect(response.content).toEqual([{ type: 'text', text: 'plain assistant answer' }]) - expect(response.usage).toEqual({ prompt: 3, completion: 4, total: 7 }) + expect(response.finishReason).toBe('stop') + expect(response.reasoning).toBe('concise reason') + expect(response.text).toBe('plain assistant answer') + expect(response.usage.inputTokens).toBe(3) + expect(response.usage.outputTokens).toBe(4) + expect(response.usage.totalTokens).toBe(7) }) - test('throws when provider returns non-string content without tool calls', async () => { + test('propagates streamCallLLM errors (e.g. empty content)', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - - state.openaiResponse = { - choices: [ - { - message: { - content: null, - }, - }, - ], - usage: { - prompt_tokens: 1, - completion_tokens: 1, - total_tokens: 2, - }, - } + const { withDefaultDeps } = await import('@memo/core/session/defaults') + const { streamCallLLM } = await import('@memo/core/llm/ai_stream') + vi.mocked(streamCallLLM).mockRejectedValueOnce(new Error('OpenAI-compatible API returned empty content')) const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-6') await expect(resolved.callLLM([{ role: 'user', content: 'x' } as ChatMessage])).rejects.toThrow( diff --git a/packages/core/src/runtime/file_suggestions.test.ts b/packages/core/src/session/file_suggestions.test.ts similarity index 100% rename from packages/core/src/runtime/file_suggestions.test.ts rename to packages/core/src/session/file_suggestions.test.ts diff --git a/packages/core/src/runtime/file_suggestions.ts b/packages/core/src/session/file_suggestions.ts similarity index 100% rename from packages/core/src/runtime/file_suggestions.ts rename to packages/core/src/session/file_suggestions.ts diff --git a/packages/core/src/runtime/hooks.test.ts b/packages/core/src/session/hooks.test.ts similarity index 80% rename from packages/core/src/runtime/hooks.test.ts rename to packages/core/src/session/hooks.test.ts index f26099a..f8ece21 100644 --- a/packages/core/src/runtime/hooks.test.ts +++ b/packages/core/src/session/hooks.test.ts @@ -6,9 +6,9 @@ import type { ObservationHookPayload, FinalHookPayload, ChatMessage, - AssistantToolCall, } from '@memo/core/types' -import { buildHookRunners, runHook, snapshotHistory } from '@memo/core/runtime/hooks' +import { buildHookRunners, runHook, snapshotHistory } from '@memo/core/session/hooks' +import { emptyUsage } from '@memo/core/session/session_runtime_helpers' describe('buildHookRunners', () => { test('creates empty hook map when no hooks provided', () => { @@ -219,7 +219,7 @@ describe('runHook', () => { turn: 1, finalText: 'done', status: 'ok', - turnUsage: { prompt: 10, completion: 5, total: 15 }, + turnUsage: { ...emptyUsage(), inputTokens: 10, outputTokens: 5, totalTokens: 15 }, steps: [], } @@ -295,62 +295,56 @@ describe('snapshotHistory', () => { expect(snapshot[0]).toEqual(history[0]) }) - test('deeply copies tool_calls function objects', () => { - const toolCall: AssistantToolCall = { - id: 'call-1', - type: 'function', - function: { - name: 'test_tool', - arguments: '{"arg": "value"}', + test('deeply copies tool-call part inputs', () => { + const history: ChatMessage[] = [ + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'test_tool', input: { arg: 'value' } }], }, - } - const history: ChatMessage[] = [{ role: 'assistant', content: '', tool_calls: [toolCall] }] + ] const snapshot = snapshotHistory(history) expect(snapshot).not.toBe(history) - const histMsg0 = history[0] as { tool_calls: AssistantToolCall[] } - const snapMsg0 = snapshot[0] as { tool_calls: AssistantToolCall[] } - expect(snapMsg0.tool_calls).not.toBe(histMsg0.tool_calls) - expect(snapMsg0.tool_calls[0]).not.toBe(histMsg0.tool_calls[0]) - expect(snapMsg0.tool_calls[0]?.function).not.toBe(histMsg0.tool_calls[0]?.function) - expect(snapMsg0.tool_calls[0]?.function).toEqual(histMsg0.tool_calls[0]?.function) + const histMsg0 = history[0] as { content: Array<{ type: string; input: unknown }> } + const snapMsg0 = snapshot[0] as { content: Array<{ type: string; input: unknown }> } + expect(snapMsg0.content).not.toBe(histMsg0.content) + expect(snapMsg0.content[0]).not.toBe(histMsg0.content[0]) + expect(snapMsg0.content[0]?.input).not.toBe(histMsg0.content[0]?.input) + expect(snapMsg0.content[0]?.input).toEqual(histMsg0.content[0]?.input) }) - test('handles multiple tool_calls', () => { + test('handles multiple tool-call parts', () => { const history: ChatMessage[] = [ { role: 'assistant', - content: 'using tools', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { name: 'tool1', arguments: '{}' }, - }, - { - id: 'call-2', - type: 'function', - function: { name: 'tool2', arguments: '{}' }, - }, + content: [ + { type: 'text', text: 'using tools' }, + { type: 'tool-call', toolCallId: 'call-1', toolName: 'tool1', input: {} }, + { type: 'tool-call', toolCallId: 'call-2', toolName: 'tool2', input: {} }, ], }, ] const snapshot = snapshotHistory(history) - const snapMsg0 = snapshot[0] as { tool_calls: AssistantToolCall[] } - expect(snapMsg0.tool_calls).toHaveLength(2) - const histMsg0 = history[0] as { tool_calls: AssistantToolCall[] } - expect(snapMsg0.tool_calls[0]).not.toBe(histMsg0.tool_calls[0]) - expect(snapMsg0.tool_calls[1]).not.toBe(histMsg0.tool_calls[1]) + const snapMsg0 = snapshot[0] as { content: Array<{ type: string }> } + expect(snapMsg0.content.filter((part) => part.type === 'tool-call')).toHaveLength(2) + const histMsg0 = history[0] as { content: Array<{ type: string }> } + expect(snapMsg0.content[1]).not.toBe(histMsg0.content[1]) + expect(snapMsg0.content[2]).not.toBe(histMsg0.content[2]) }) test('creates deep copy of tool messages', () => { const history: ChatMessage[] = [ { role: 'tool', - content: 'tool result', - tool_call_id: 'call-1', - name: 'test_tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'test_tool', + output: { type: 'text', value: 'tool result' }, + }, + ], }, ] const snapshot = snapshotHistory(history) @@ -365,19 +359,21 @@ describe('snapshotHistory', () => { { role: 'user', content: 'hello' }, { role: 'assistant', - content: 'response', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { name: 'tool', arguments: '{}' }, - }, + content: [ + { type: 'text', text: 'response' }, + { type: 'tool-call', toolCallId: 'call-1', toolName: 'tool', input: {} }, ], }, { role: 'tool', - content: 'result', - tool_call_id: 'call-1', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'tool', + output: { type: 'text', value: 'result' }, + }, + ], }, ] const snapshot = snapshotHistory(history) @@ -387,8 +383,8 @@ describe('snapshotHistory', () => { expect(snapshot[1]?.role).toBe('user') expect(snapshot[2]?.role).toBe('assistant') expect(snapshot[3]?.role).toBe('tool') - const histMsg2 = history[2] as { tool_calls: AssistantToolCall[] } - const snapMsg2 = snapshot[2] as { tool_calls: AssistantToolCall[] } - expect(snapMsg2.tool_calls[0]?.function).not.toBe(histMsg2.tool_calls[0]?.function) + const histMsg2 = history[2] as { content: Array<{ type: string; input: unknown }> } + const snapMsg2 = snapshot[2] as { content: Array<{ type: string; input: unknown }> } + expect(snapMsg2.content[1]?.input).not.toBe(histMsg2.content[1]?.input) }) }) diff --git a/packages/core/src/runtime/hooks.ts b/packages/core/src/session/hooks.ts similarity index 93% rename from packages/core/src/runtime/hooks.ts rename to packages/core/src/session/hooks.ts index ea9b611..9a664f1 100644 --- a/packages/core/src/runtime/hooks.ts +++ b/packages/core/src/session/hooks.ts @@ -96,13 +96,12 @@ export async function runHook(map: HookRunnerMap, name: K, p export function snapshotHistory(history: ChatMessage[]): ChatMessage[] { return history.map((msg) => { - if (msg.role === 'assistant' && msg.tool_calls?.length) { + if (msg.role === 'assistant' && Array.isArray(msg.content)) { return { ...msg, - tool_calls: msg.tool_calls.map((toolCall) => ({ - ...toolCall, - function: { ...toolCall.function }, - })), + content: msg.content.map((part) => + part.type === 'tool-call' ? { ...part, input: structuredClone(part.input) } : part, + ), } } return { ...msg } diff --git a/packages/core/src/runtime/session.ts b/packages/core/src/session/session.ts similarity index 82% rename from packages/core/src/runtime/session.ts rename to packages/core/src/session/session.ts index 48d4579..0d7384c 100644 --- a/packages/core/src/runtime/session.ts +++ b/packages/core/src/session/session.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' -import { withDefaultDeps } from '@memo/core/runtime/defaults' -import { DEFAULT_SESSION_MODE } from '@memo/core/runtime/session_runtime_helpers' -import { AgentSessionImpl } from '@memo/core/runtime/session_runtime' +import { withDefaultDeps } from '@memo/core/session/defaults' +import { DEFAULT_SESSION_MODE } from '@memo/core/session/session_runtime_helpers' +import { AgentSessionImpl } from '@memo/core/session/session_runtime' import type { AgentSession, AgentSessionDeps, AgentSessionOptions } from '@memo/core/types' /** diff --git a/packages/core/src/runtime/session_hooks.test.ts b/packages/core/src/session/session_hooks.test.ts similarity index 85% rename from packages/core/src/runtime/session_hooks.test.ts rename to packages/core/src/session/session_hooks.test.ts index 19f6e86..e81d330 100644 --- a/packages/core/src/runtime/session_hooks.test.ts +++ b/packages/core/src/session/session_hooks.test.ts @@ -2,9 +2,10 @@ import assert from 'node:assert' import { describe, test } from 'vitest' import { createAgentSession, createTokenCounter } from '@memo/core' -import type { ChatMessage, HistoryEvent, LLMResponse, TokenCounter } from '@memo/core' +import type { ChatMessage, HistoryEvent, LLMResult, TokenCounter } from '@memo/core' import type { Tool } from '@memo/tools/router' -import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX } from '@memo/core/runtime/compact_prompt' +import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX } from '@memo/core/session/compact_prompt' +import { emptyUsage } from '@memo/core/session/session_runtime_helpers' const echoTool: Tool = { name: 'echo', @@ -32,40 +33,35 @@ const readNoteTool: Tool = { }, } -function toolUseResponse(id: string, name: string, input: unknown, text?: string): LLMResponse { +function toolUseResponse(id: string, name: string, input: unknown, text?: string): LLMResult { return { - content: [ - ...(text ? [{ type: 'text' as const, text }] : []), - { - type: 'tool_use' as const, - id, - name, - input, - }, - ], - stop_reason: 'tool_use', + text: text ?? '', + toolCalls: [{ type: 'tool-call', toolCallId: id, toolName: name, input }], + usage: emptyUsage(), + finishReason: 'tool-calls', } } -function multiToolUseResponse(calls: Array<{ id: string; name: string; input: unknown }>, text?: string): LLMResponse { +function multiToolUseResponse(calls: Array<{ id: string; name: string; input: unknown }>, text?: string): LLMResult { return { - content: [ - ...(text ? [{ type: 'text' as const, text }] : []), - ...calls.map((call) => ({ - type: 'tool_use' as const, - id: call.id, - name: call.name, - input: call.input, - })), - ], - stop_reason: 'tool_use', + text: text ?? '', + toolCalls: calls.map((call) => ({ + type: 'tool-call', + toolCallId: call.id, + toolName: call.name, + input: call.input, + })), + usage: emptyUsage(), + finishReason: 'tool-calls', } } -function endTurnResponse(text: string = 'done'): LLMResponse { +function endTurnResponse(text: string = 'done'): LLMResult { return { - content: [{ type: 'text' as const, text }], - stop_reason: 'end_turn', + text, + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', } } @@ -78,6 +74,24 @@ function createLengthTokenCounter(): TokenCounter { } } +/** Extract tool-call ids from an assistant message's parts (CoreMessage shape). */ +function assistantToolCallIds(message: ChatMessage): string[] { + if (message.role !== 'assistant' || !Array.isArray(message.content)) return [] + return message.content.filter((part) => part.type === 'tool-call').map((part) => part.toolCallId) +} + +/** Extract tool-result details from a tool message's parts (CoreMessage shape). */ +function toolMessageDetails(message: ChatMessage): { toolCallId: string; toolName: string; text: string } | null { + if (message.role !== 'tool') return null + const part = message.content[0] + if (!part || part.type !== 'tool-result') return null + return { + toolCallId: part.toolCallId, + toolName: part.toolName, + text: part.output.type === 'text' ? part.output.value : '', + } +} + function hasInvalidToolProtocol(messages: ChatMessage[]): boolean { let pendingToolCallIds = new Set() for (const message of messages) { @@ -85,15 +99,17 @@ function hasInvalidToolProtocol(messages: ChatMessage[]): boolean { if (message.role !== 'tool') { return true } - if (!pendingToolCallIds.has(message.tool_call_id)) { + const details = toolMessageDetails(message) + if (!details || !pendingToolCallIds.has(details.toolCallId)) { return true } - pendingToolCallIds.delete(message.tool_call_id) + pendingToolCallIds.delete(details.toolCallId) continue } - if (message.role === 'assistant' && message.tool_calls?.length) { - pendingToolCallIds = new Set(message.tool_calls.map((toolCall) => toolCall.id)) + const toolCallIds = assistantToolCallIds(message) + if (toolCallIds.length) { + pendingToolCallIds = new Set(toolCallIds) continue } @@ -106,7 +122,7 @@ function hasInvalidToolProtocol(messages: ChatMessage[]): boolean { describe('session hooks & middleware', () => { test('invokes hooks and middlewares in order', async () => { - const outputs: LLMResponse[] = [toolUseResponse('action-1', 'echo', { text: 'foo' }), endTurnResponse('done')] + const outputs: LLMResult[] = [toolUseResponse('action-1', 'echo', { text: 'foo' }), endTurnResponse('done')] const hookLog: string[] = [] const session = await createAgentSession( { @@ -168,7 +184,7 @@ describe('session hooks & middleware', () => { }) test('executes action from structured tool_use with accompanying text', async () => { - const outputs: LLMResponse[] = [ + const outputs: LLMResult[] = [ toolUseResponse('action-1', 'echo', { text: 'hi' }, 'demo'), endTurnResponse('done'), ] @@ -202,7 +218,7 @@ describe('session hooks & middleware', () => { }) test('reuses previous assistant text when end_turn arrives empty after tool call', async () => { - const outputs: LLMResponse[] = [ + const outputs: LLMResult[] = [ toolUseResponse('action-1', 'echo', { text: 'x' }, '这是最终答案'), endTurnResponse(''), ] @@ -226,7 +242,7 @@ describe('session hooks & middleware', () => { }) test('warns after three identical tool calls', async () => { - const outputs: LLMResponse[] = [ + const outputs: LLMResult[] = [ toolUseResponse('loop-1', 'echo', { text: 'loop' }), toolUseResponse('loop-2', 'echo', { text: 'loop' }), toolUseResponse('loop-3', 'echo', { text: 'loop' }), @@ -256,7 +272,7 @@ describe('session hooks & middleware', () => { }) test('bypasses approval in dangerous mode', async () => { - const outputs: LLMResponse[] = [toolUseResponse('action-1', 'echo', { text: 'safe' }), endTurnResponse('done')] + const outputs: LLMResult[] = [toolUseResponse('action-1', 'echo', { text: 'safe' }), endTurnResponse('done')] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -277,7 +293,7 @@ describe('session hooks & middleware', () => { }) test('uses risk-based approvals in once tool permission mode', async () => { - const outputs: LLMResponse[] = [ + const outputs: LLMResult[] = [ toolUseResponse('action-1', 'read_note', { topic: 'memo' }), endTurnResponse('done'), ] @@ -306,10 +322,7 @@ describe('session hooks & middleware', () => { }) test('blocks tool calls when tool permission mode is none', async () => { - const outputs: LLMResponse[] = [ - toolUseResponse('action-1', 'echo', { text: 'blocked' }), - endTurnResponse('done'), - ] + const outputs: LLMResult[] = [toolUseResponse('action-1', 'echo', { text: 'blocked' }), endTurnResponse('done')] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -325,13 +338,13 @@ describe('session hooks & middleware', () => { assert.strictEqual(result.status, 'error') assert.ok(result.finalText.includes('Tool usage is disabled')) assert.strictEqual(result.steps[0]?.observation, undefined) - const deniedToolMessage = session.history.find( - (message) => message.role === 'tool' && message.tool_call_id === 'action-1', - ) + const deniedToolMessage = session.history + .map(toolMessageDetails) + .find((details) => details?.toolCallId === 'action-1') assert.ok(deniedToolMessage, 'tool message should be recorded for denied tool_call_id') - if (deniedToolMessage?.role === 'tool') { + if (deniedToolMessage) { assert.ok( - deniedToolMessage.content.includes('tools are disabled'), + deniedToolMessage.text.includes('tools are disabled'), 'tool message should explain why execution was skipped', ) } @@ -341,7 +354,7 @@ describe('session hooks & middleware', () => { }) test('rejects native tool input via validateInput before execute', async () => { - const outputs: LLMResponse[] = [toolUseResponse('action-1', 'read_text_file', {}), endTurnResponse('done')] + const outputs: LLMResult[] = [toolUseResponse('action-1', 'read_text_file', {}), endTurnResponse('done')] const session = await createAgentSession( { callLLM: async () => outputs.shift() ?? endTurnResponse('done'), @@ -362,23 +375,7 @@ describe('session hooks & middleware', () => { test('emits structured tool execution metadata in history events', async () => { const events: HistoryEvent[] = [] - const outputs = [ - { - content: [ - { - type: 'tool_use' as const, - id: 'action-1', - name: 'echo', - input: { text: 'x' }, - }, - ], - stop_reason: 'tool_use' as const, - }, - { - content: [{ type: 'text' as const, text: 'done' }], - stop_reason: 'end_turn' as const, - }, - ] + const outputs = [toolUseResponse('action-1', 'echo', { text: 'x' }), endTurnResponse('done')] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -427,7 +424,7 @@ describe('session hooks & middleware', () => { }) test('records structured tool call/result messages without json fallback payloads', async () => { - const outputs: LLMResponse[] = [toolUseResponse('action-1', 'echo', { text: 'x' }), endTurnResponse('done')] + const outputs: LLMResult[] = [toolUseResponse('action-1', 'echo', { text: 'x' }), endTurnResponse('done')] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -442,30 +439,36 @@ describe('session hooks & middleware', () => { const result = await session.runTurn('meta') assert.strictEqual(result.finalText, 'done') - const assistantToolMessage = session.history.find( - (message) => - message.role === 'assistant' && message.tool_calls?.some((toolCall) => toolCall.id === 'action-1'), + const assistantToolMessage = session.history.find((message) => + assistantToolCallIds(message).includes('action-1'), ) assert.ok(assistantToolMessage, 'assistant tool_calls message should exist') - const toolResultMessage = session.history.find( - (message) => message.role === 'tool' && message.tool_call_id === 'action-1', - ) + const toolResultMessage = session.history + .map(toolMessageDetails) + .find((details) => details?.toolCallId === 'action-1') assert.ok(toolResultMessage, 'tool result message should exist') - if (toolResultMessage?.role === 'tool') { - assert.strictEqual(toolResultMessage.content, 'echo:x') - assert.strictEqual(toolResultMessage.name, 'echo') + if (toolResultMessage) { + assert.strictEqual(toolResultMessage.text, 'echo:x') + assert.strictEqual(toolResultMessage.toolName, 'echo') } assert.ok( !session.history.some( - (message) => message.role === 'assistant' && message.content.startsWith('{"tool":'), + (message) => + message.role === 'assistant' && + typeof message.content === 'string' && + typeof message.content === 'string' && + message.content.startsWith('{"tool":'), ), 'assistant history should not contain plain-text tool json payloads', ) assert.ok( !session.history.some( - (message) => message.role === 'user' && message.content.includes('"observation"'), + (message) => + message.role === 'user' && + typeof message.content === 'string' && + message.content.includes('"observation"'), ), 'history should not inject observation json through user messages', ) @@ -476,19 +479,7 @@ describe('session hooks & middleware', () => { test('emits structured rejection metadata in final event', async () => { const events: HistoryEvent[] = [] - const outputs = [ - { - content: [ - { - type: 'tool_use' as const, - id: 'reject-1', - name: 'echo', - input: { text: 'x' }, - }, - ], - stop_reason: 'tool_use' as const, - }, - ] + const outputs = [toolUseResponse('reject-1', 'echo', { text: 'x' })] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -508,9 +499,9 @@ describe('session hooks & middleware', () => { try { const result = await session.runTurn('meta') assert.strictEqual(result.status, 'cancelled') - const toolMessage = session.history.find( - (message) => message.role === 'tool' && message.tool_call_id === 'reject-1', - ) + const toolMessage = session.history + .map(toolMessageDetails) + .find((details) => details?.toolCallId === 'reject-1') assert.ok(toolMessage, 'tool message should exist for rejected tool_call_id') const finalEvent = [...events].reverse().find((event) => event.type === 'final') assert.ok(finalEvent, 'final event should exist') @@ -525,7 +516,7 @@ describe('session hooks & middleware', () => { }) test('records tool messages for all tool_call_ids on fail_fast rejection', async () => { - const outputs: LLMResponse[] = [ + const outputs: LLMResult[] = [ multiToolUseResponse([ { id: 'reject-1', name: 'echo', input: { text: 'a' } }, { id: 'reject-2', name: 'echo', input: { text: 'b' } }, @@ -546,17 +537,13 @@ describe('session hooks & middleware', () => { const result = await session.runTurn('meta') assert.strictEqual(result.status, 'cancelled') - const first = session.history.find( - (message) => message.role === 'tool' && message.tool_call_id === 'reject-1', - ) - const second = session.history.find( - (message) => message.role === 'tool' && message.tool_call_id === 'reject-2', - ) + const first = session.history.map(toolMessageDetails).find((details) => details?.toolCallId === 'reject-1') + const second = session.history.map(toolMessageDetails).find((details) => details?.toolCallId === 'reject-2') assert.ok(first, 'first tool_call_id should have a matching tool message') assert.ok(second, 'second tool_call_id should have a matching tool message') - if (second?.role === 'tool') { + if (second) { assert.ok( - second.content.includes('Skipped tool execution after previous rejection'), + second.text.includes('Skipped tool execution after previous rejection'), 'missing tool execution should be represented as skipped observation', ) } @@ -567,7 +554,7 @@ describe('session hooks & middleware', () => { test('fails with model_protocol_error when model emits plain-text tool json', async () => { const events: HistoryEvent[] = [] - const outputs: LLMResponse[] = [endTurnResponse('{"tool":"echo","input":{"text":"x"}}')] + const outputs: LLMResult[] = [endTurnResponse('{"tool":"echo","input":{"text":"x"}}')] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -604,10 +591,12 @@ describe('session hooks & middleware', () => { }) test('falls back to generic final error when model returns no actionable content', async () => { - const outputs: LLMResponse[] = [ + const outputs: LLMResult[] = [ { - content: [], - stop_reason: 'stop_sequence', + text: '', + toolCalls: [], + usage: emptyUsage(), + finishReason: 'stop', }, ] const session = await createAgentSession( @@ -633,7 +622,7 @@ describe('session hooks & middleware', () => { }) test('does not treat unknown tool json text as protocol violation', async () => { - const outputs: LLMResponse[] = [endTurnResponse('{"tool":"unknown","input":{}}')] + const outputs: LLMResult[] = [endTurnResponse('{"tool":"unknown","input":{}}')] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -654,7 +643,7 @@ describe('session hooks & middleware', () => { }) test('emits context usage hooks at turn start and each step', async () => { - const outputs: LLMResponse[] = [toolUseResponse('action-1', 'echo', { text: 'x' }), endTurnResponse('done')] + const outputs: LLMResult[] = [toolUseResponse('action-1', 'echo', { text: 'x' }), endTurnResponse('done')] const phases: string[] = [] const session = await createAgentSession( @@ -684,7 +673,7 @@ describe('session hooks & middleware', () => { }) test('auto compaction is triggered at threshold and runs at most once per turn', async () => { - const outputs: LLMResponse[] = [ + const outputs: LLMResult[] = [ toolUseResponse('action-1', 'echo', { text: 'x' }), toolUseResponse('action-2', 'echo', { text: 'y' }), endTurnResponse('done'), @@ -814,7 +803,10 @@ describe('session hooks & middleware', () => { assert.strictEqual(regularLLMCalls, 0) assert.strictEqual( session.history.some( - (message) => message.role === 'user' && message.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`), + (message) => + message.role === 'user' && + typeof message.content === 'string' && + message.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`), ), false, ) @@ -858,12 +850,9 @@ describe('session hooks & middleware', () => { test('manual compaction rebuilds history with user-only context and summary', async () => { const assistantToolCall = { - id: 'call_function_l5suo7l5etii_1', - type: 'function' as const, - function: { - name: 'exec_command', - arguments: '{}', - }, + toolCallId: 'call_function_l5suo7l5etii_1', + toolName: 'exec_command', + input: {}, } let sawCompactionCall = false @@ -893,12 +882,20 @@ describe('session hooks & middleware', () => { try { session.history.push( { role: 'user', content: 'u1' }, - { role: 'assistant', content: '', tool_calls: [assistantToolCall] }, + { + role: 'assistant', + content: [{ type: 'tool-call', ...assistantToolCall }], + }, { role: 'tool', - content: 'tool-result', - tool_call_id: assistantToolCall.id, - name: 'exec_command', + content: [ + { + type: 'tool-result', + toolCallId: assistantToolCall.toolCallId, + toolName: assistantToolCall.toolName, + output: { type: 'text', value: 'tool-result' }, + }, + ], }, { role: 'assistant', content: 'a1' }, { role: 'user', content: 'u2' }, @@ -919,7 +916,10 @@ describe('session hooks & middleware', () => { assert.strictEqual(hasInvalidToolProtocol(session.history), false) assert.ok( session.history.some( - (message) => message.role === 'user' && message.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`), + (message) => + message.role === 'user' && + typeof message.content === 'string' && + message.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`), ), 'summary message should be preserved in compacted history', ) @@ -929,12 +929,7 @@ describe('session hooks & middleware', () => { 'compacted history should drop tool result messages', ) assert.strictEqual( - session.history.some( - (message) => - message.role === 'assistant' && - Array.isArray(message.tool_calls) && - message.tool_calls.length > 0, - ), + session.history.some((message) => assistantToolCallIds(message).length > 0), false, 'compacted history should drop assistant tool-call messages', ) @@ -981,11 +976,13 @@ describe('session hooks & middleware', () => { const retainedUserMessage = session.history[1] assert.strictEqual(retainedUserMessage?.role, 'user') assert.strictEqual(retainedUserMessage?.content.length, 20_000) - assert.ok(retainedUserMessage?.content.startsWith('a')) + const retainedContent = retainedUserMessage?.content + assert.ok(typeof retainedContent === 'string' && retainedContent.startsWith('a')) const summaryMessage = session.history[2] assert.strictEqual(summaryMessage?.role, 'user') - assert.ok(summaryMessage?.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`)) + const summaryContent = summaryMessage?.content + assert.ok(typeof summaryContent === 'string' && summaryContent.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`)) } finally { await session.close() } @@ -1021,7 +1018,8 @@ describe('session hooks & middleware', () => { const summaryMessage = session.history[session.history.length - 1] assert.strictEqual(summaryMessage?.role, 'user') - assert.ok(summaryMessage?.content.endsWith('summary\n\nnext')) + const summaryContent = summaryMessage?.content + assert.ok(typeof summaryContent === 'string' && summaryContent.endsWith('summary\n\nnext')) } finally { await session.close() } @@ -1057,10 +1055,14 @@ describe('session hooks & middleware', () => { assert.strictEqual(result.status, 'success') const summaryMessages = session.history.filter( - (message) => message.role === 'user' && message.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`), + (message) => + message.role === 'user' && + typeof message.content === 'string' && + message.content.startsWith(`${CONTEXT_SUMMARY_PREFIX}\n`), ) assert.strictEqual(summaryMessages.length, 1) - assert.ok(summaryMessages[0]?.content.endsWith('new summary')) + const lastSummary = summaryMessages[0]?.content + assert.ok(typeof lastSummary === 'string' && lastSummary.endsWith('new summary')) assert.strictEqual( session.history.some((message) => message.content === oldSummary), false, @@ -1233,7 +1235,7 @@ describe('session hooks & middleware', () => { const events: HistoryEvent[] = [] const generatedTitles: string[] = [] const calls: Array<{ options: unknown }> = [] - const outputs: LLMResponse[] = [endTurnResponse('done')] + const outputs: LLMResult[] = [endTurnResponse('done')] const session = await createAgentSession( { @@ -1275,7 +1277,7 @@ describe('session hooks & middleware', () => { test('emits session title only once across multiple turns', async () => { const events: HistoryEvent[] = [] - const outputs: LLMResponse[] = [endTurnResponse('done'), endTurnResponse('done-again')] + const outputs: LLMResult[] = [endTurnResponse('done'), endTurnResponse('done-again')] const session = await createAgentSession( { diff --git a/packages/core/src/runtime/session_runtime.ts b/packages/core/src/session/session_runtime.ts similarity index 94% rename from packages/core/src/runtime/session_runtime.ts rename to packages/core/src/session/session_runtime.ts index 887b631..c9e8112 100644 --- a/packages/core/src/runtime/session_runtime.ts +++ b/packages/core/src/session/session_runtime.ts @@ -1,13 +1,13 @@ /** @file Session/Turn runtime core: handles ReAct loop, tool scheduling, and event logging. */ import { randomUUID } from 'node:crypto' -import { createHistoryEvent } from '@memo/core/runtime/history' +import { createHistoryEvent } from '@memo/core/history/history' import { buildThinking } from '@memo/core/utils/utils' import { buildCompactionUserPrompt, CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX, isContextSummaryMessage, -} from '@memo/core/runtime/compact_prompt' +} from '@memo/core/session/compact_prompt' import type { ChatMessage, AgentSession, @@ -22,12 +22,12 @@ import type { SessionMode, ToolPermissionMode, TokenCounter, - TokenUsage, ToolRegistry, TurnResult, TurnStatus, } from '@memo/core/types' -import { buildHookRunners, runHook, snapshotHistory, type HookRunnerMap } from '@memo/core/runtime/hooks' +import type { LanguageModelUsage } from 'ai' +import { buildHookRunners, runHook, snapshotHistory, type HookRunnerMap } from '@memo/core/session/hooks' import { createToolOrchestrator, type ToolApprovalHooks, @@ -53,7 +53,7 @@ import { resolveToolPermission, stableStringify, toToolHistoryMessage, -} from '@memo/core/runtime/session_runtime_helpers' +} from '@memo/core/session/session_runtime_helpers' import type { ApprovalRequest, ApprovalDecision } from '@memo/tools/approval' const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 80 @@ -70,7 +70,7 @@ export class AgentSessionImpl implements AgentSession { private turnIndex = 0 private tokenCounter: TokenCounter private sinks: HistorySink[] - private sessionUsage: TokenUsage = emptyUsage() + private sessionUsage: LanguageModelUsage = emptyUsage() private startedAt = Date.now() private hooks: HookRunnerMap private closed = false @@ -248,7 +248,7 @@ export class AgentSessionImpl implements AgentSession { (message): message is ChatMessage & { role: 'user' } => message.role === 'user' && !isContextSummaryMessage(message), ) - .map((message) => message.content) + .map((message) => (typeof message.content === 'string' ? message.content : '')) const retainedUserMessages = this.selectCompactionUserMessages(userMessages).map( (content) => ({ role: 'user', content }) as ChatMessage, ) @@ -553,8 +553,7 @@ export class AgentSessionImpl implements AgentSession { let assistantText = '' let toolUseBlocks: Array<{ id: string; name: string; input: unknown }> = [] - let usageFromLLM: Partial | undefined - let stopReason: string | undefined + let usageFromLLM: Partial | undefined let reasoningContent: string | undefined let receivedAssistantChunk = false try { @@ -571,7 +570,6 @@ export class AgentSessionImpl implements AgentSession { const normalized = normalizeLLMResponse(llmResult) assistantText = normalized.textContent toolUseBlocks = normalized.toolUseBlocks - stopReason = normalized.stopReason usageFromLLM = normalized.usage reasoningContent = normalized.reasoningContent if (assistantText.trim().length > 0) { @@ -648,9 +646,13 @@ export class AgentSessionImpl implements AgentSession { } assistantHistoryMessage = { role: 'assistant', - content: assistantText, - reasoning_content: reasoningContent, - tool_calls: buildAssistantToolCalls(toolUseBlocks), + content: [ + ...(assistantText ? [{ type: 'text' as const, text: assistantText }] : []), + ...(reasoningContent + ? [{ type: 'reasoning' as const, text: reasoningContent }] + : []), + ...buildAssistantToolCalls(toolUseBlocks), + ], } } else { parsed = {} @@ -659,8 +661,10 @@ export class AgentSessionImpl implements AgentSession { parsed = { final: assistantText } assistantHistoryMessage = { role: 'assistant', - content: assistantText, - reasoning_content: reasoningContent, + content: [ + ...(assistantText ? [{ type: 'text' as const, text: assistantText }] : []), + ...(reasoningContent ? [{ type: 'reasoning' as const, text: reasoningContent }] : []), + ], } } else { // 没有内容,视为空响应 @@ -668,13 +672,14 @@ export class AgentSessionImpl implements AgentSession { } // 使用 LLM 返回的 usage 作为用量记录。本地 tokenizer 仅用于预估(压缩触发、上下文超限检查),不作为用量上报的 fallback。 - const stepUsage: TokenUsage = usageFromLLM + const stepUsage: LanguageModelUsage = usageFromLLM ? { - prompt: usageFromLLM.prompt ?? 0, - completion: usageFromLLM.completion ?? 0, - total: usageFromLLM.total ?? 0, + ...emptyUsage(), + inputTokens: usageFromLLM.inputTokens ?? 0, + outputTokens: usageFromLLM.outputTokens ?? 0, + totalTokens: usageFromLLM.totalTokens ?? 0, } - : { prompt: 0, completion: 0, total: 0 } + : emptyUsage() accumulateUsage(turnUsage, stepUsage) accumulateUsage(this.sessionUsage, stepUsage) @@ -742,9 +747,14 @@ export class AgentSessionImpl implements AgentSession { for (const block of toolUseBlocks) { this.history.push({ role: 'tool', - content: TOOL_SKIPPED_DISABLED_MESSAGE, - tool_call_id: block.id, - name: block.name, + content: [ + { + type: 'tool-result', + toolCallId: block.id, + toolName: block.name, + output: { type: 'text', value: TOOL_SKIPPED_DISABLED_MESSAGE }, + }, + ], }) } status = 'error' @@ -1004,9 +1014,14 @@ export class AgentSessionImpl implements AgentSession { this.history.push({ role: 'tool', - content: observation, - tool_call_id: result.actionId, - name: parsed.action.tool, + content: [ + { + type: 'tool-result', + toolCallId: result.actionId, + toolName: parsed.action.tool, + output: { type: 'text', value: observation }, + }, + ], }) const lastStep = steps[steps.length - 1] if (lastStep) { @@ -1037,11 +1052,11 @@ export class AgentSessionImpl implements AgentSession { continue } - // 检查是否是最终回复(end_turn 或有 final 字段) - if (stopReason === 'end_turn' || parsed.final) { + // 检查是否是最终回复(无工具调用或有 final 字段) + if (toolUseBlocks.length === 0 || parsed.final) { this.resetActionRepetition() const shouldFallbackFromPreviousText = - stopReason === 'end_turn' && + toolUseBlocks.length === 0 && !parsed.final && assistantText.trim().length === 0 && Boolean(lastNonEmptyAssistantText) && diff --git a/packages/core/src/runtime/session_runtime_helpers.test.ts b/packages/core/src/session/session_runtime_helpers.test.ts similarity index 90% rename from packages/core/src/runtime/session_runtime_helpers.test.ts rename to packages/core/src/session/session_runtime_helpers.test.ts index 6047a84..7695c5f 100644 --- a/packages/core/src/runtime/session_runtime_helpers.test.ts +++ b/packages/core/src/session/session_runtime_helpers.test.ts @@ -12,19 +12,19 @@ import { stableStringify, toToolHistoryMessage, truncateSessionTitle, -} from '@memo/core/runtime/session_runtime_helpers' +} from '@memo/core/session/session_runtime_helpers' describe('accumulateUsage', () => { test('uses explicit total when provided', () => { const usage = emptyUsage() - accumulateUsage(usage, { prompt: 2, completion: 3, total: 100 }) - expect(usage).toEqual({ prompt: 2, completion: 3, total: 100 }) + accumulateUsage(usage, { inputTokens: 2, outputTokens: 3, totalTokens: 100 }) + expect(usage).toEqual({ ...emptyUsage(), inputTokens: 2, outputTokens: 3, totalTokens: 100 }) }) - test('falls back to prompt + completion when total is absent', () => { + test('falls back to input + output when total is absent', () => { const usage = emptyUsage() - accumulateUsage(usage, { prompt: 2, completion: 3 }) - expect(usage).toEqual({ prompt: 2, completion: 3, total: 5 }) + accumulateUsage(usage, { inputTokens: 2, outputTokens: 3 }) + expect(usage).toEqual({ ...emptyUsage(), inputTokens: 2, outputTokens: 3, totalTokens: 5 }) }) }) @@ -158,9 +158,14 @@ describe('tool result helpers', () => { }) expect(message).toEqual({ role: 'tool', - content: 'content', - tool_call_id: 'call-1', - name: 'read_file', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'read_file', + output: { type: 'text', value: 'content' }, + }, + ], }) }) diff --git a/packages/core/src/runtime/session_runtime_helpers.ts b/packages/core/src/session/session_runtime_helpers.ts similarity index 76% rename from packages/core/src/runtime/session_runtime_helpers.ts rename to packages/core/src/session/session_runtime_helpers.ts index 817bf7e..775cc5d 100644 --- a/packages/core/src/runtime/session_runtime_helpers.ts +++ b/packages/core/src/session/session_runtime_helpers.ts @@ -1,16 +1,13 @@ +import type { LanguageModelUsage, ToolCallPart } from 'ai' import type { AgentSessionOptions, - AssistantToolCall, ChatMessage, HistoryEvent, HistorySink, - LLMResponse, + LLMResult, SessionMode, - TextBlock, - TokenUsage, ToolPermissionMode, ToolRegistry, - ToolUseBlock, } from '@memo/core/types' import type { ToolActionResult, ToolActionStatus } from '@memo/tools/orchestrator' @@ -71,42 +68,62 @@ export function resolveToolPermission(options: AgentSessionOptions): ResolvedToo } } -export function emptyUsage(): TokenUsage { - return { prompt: 0, completion: 0, total: 0 } +export function emptyUsage(): LanguageModelUsage { + return { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { reasoningTokens: undefined }, + } } -export function accumulateUsage(target: TokenUsage, delta?: Partial) { +export function accumulateUsage(target: LanguageModelUsage, delta?: Partial) { if (!delta) return - const promptDelta = delta.prompt ?? 0 - const completionDelta = delta.completion ?? 0 - const totalDelta = delta.total ?? promptDelta + completionDelta - target.prompt += promptDelta - target.completion += completionDelta - target.total += totalDelta + const inputDelta = delta.inputTokens ?? 0 + const outputDelta = delta.outputTokens ?? 0 + const totalDelta = delta.totalTokens ?? inputDelta + outputDelta + target.inputTokens = (target.inputTokens ?? 0) + inputDelta + target.outputTokens = (target.outputTokens ?? 0) + outputDelta + target.totalTokens = (target.totalTokens ?? 0) + totalDelta +} + +export function parseToolArguments( + raw: string, +): { ok: true; data: unknown } | { ok: false; raw: string; error: string } { + try { + return { ok: true, data: JSON.parse(raw) } + } catch (err) { + return { ok: false, raw, error: (err as Error).message } + } } -export function normalizeLLMResponse(raw: LLMResponse): { +/** Extract session-level fields from an AI SDK GenerateTextResult. */ +export function normalizeLLMResponse(raw: LLMResult): { textContent: string toolUseBlocks: Array<{ id: string; name: string; input: unknown }> reasoningContent?: string - stopReason?: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' - usage?: Partial + usage?: Partial } { - const textBlocks = raw.content.filter((block): block is TextBlock => block.type === 'text') - const toolBlocks = raw.content.filter((block): block is ToolUseBlock => block.type === 'tool_use') - + let textContent = raw.text + const toolUseBlocks: Array<{ id: string; name: string; input: unknown }> = [] + for (const call of raw.toolCalls) { + if (typeof call.input === 'string') { + const parsed = parseToolArguments(call.input) + if (parsed.ok) { + toolUseBlocks.push({ id: call.toolCallId, name: call.toolName, input: parsed.data }) + } else { + textContent = `${textContent}\n[tool_use parse error] ${parsed.error}; raw: ${parsed.raw}`.trim() + } + } else { + toolUseBlocks.push({ id: call.toolCallId, name: call.toolName, input: call.input }) + } + } return { - textContent: textBlocks.map((b) => b.text).join('\n'), - toolUseBlocks: toolBlocks.map((b) => ({ - id: b.id, - name: b.name, - input: b.input, - })), + textContent, + toolUseBlocks, reasoningContent: - typeof raw.reasoning_content === 'string' && raw.reasoning_content.trim().length > 0 - ? raw.reasoning_content - : undefined, - stopReason: raw.stop_reason, + typeof raw.reasoning === 'string' && raw.reasoning.trim().length > 0 ? raw.reasoning : undefined, usage: raw.usage, } } @@ -173,14 +190,12 @@ function stableStringifyWithSeen(value: unknown, seen: WeakSet, depth: n export function buildAssistantToolCalls( toolUseBlocks: Array<{ id: string; name: string; input: unknown }>, -): AssistantToolCall[] { +): ToolCallPart[] { return toolUseBlocks.map((block) => ({ - id: block.id, - type: 'function', - function: { - name: block.name, - arguments: stableStringify(block.input), - }, + type: 'tool-call', + toolCallId: block.id, + toolName: block.name, + input: block.input, })) } @@ -248,9 +263,14 @@ export function fallbackSessionTitleFromPrompt(input: string): string { export function toToolHistoryMessage(result: ToolActionResult): ChatMessage { return { role: 'tool', - content: result.observation, - tool_call_id: result.actionId, - name: result.tool, + content: [ + { + type: 'tool-result', + toolCallId: result.actionId, + toolName: result.tool, + output: { type: 'text', value: result.observation }, + }, + ], } } diff --git a/packages/core/src/runtime/slash/index.ts b/packages/core/src/session/slash/index.ts similarity index 100% rename from packages/core/src/runtime/slash/index.ts rename to packages/core/src/session/slash/index.ts diff --git a/packages/core/src/runtime/slash/registry.test.ts b/packages/core/src/session/slash/registry.test.ts similarity index 100% rename from packages/core/src/runtime/slash/registry.test.ts rename to packages/core/src/session/slash/registry.test.ts diff --git a/packages/core/src/runtime/slash/registry.ts b/packages/core/src/session/slash/registry.ts similarity index 100% rename from packages/core/src/runtime/slash/registry.ts rename to packages/core/src/session/slash/registry.ts diff --git a/packages/core/src/runtime/slash/types.ts b/packages/core/src/session/slash/types.ts similarity index 100% rename from packages/core/src/runtime/slash/types.ts rename to packages/core/src/session/slash/types.ts diff --git a/packages/core/src/runtime/skills.test.ts b/packages/core/src/skills/skills.test.ts similarity index 98% rename from packages/core/src/runtime/skills.test.ts rename to packages/core/src/skills/skills.test.ts index 83874b8..83f61b9 100644 --- a/packages/core/src/runtime/skills.test.ts +++ b/packages/core/src/skills/skills.test.ts @@ -3,7 +3,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, test } from 'vitest' -import { loadSkills } from '@memo/core/runtime/skills' +import { loadSkills } from '@memo/core/skills/skills' async function makeTempDir(prefix: string) { const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`) diff --git a/packages/core/src/runtime/skills.ts b/packages/core/src/skills/skills.ts similarity index 100% rename from packages/core/src/runtime/skills.ts rename to packages/core/src/skills/skills.ts diff --git a/packages/core/src/runtime/skills_admin.test.ts b/packages/core/src/skills/skills_admin.test.ts similarity index 100% rename from packages/core/src/runtime/skills_admin.test.ts rename to packages/core/src/skills/skills_admin.test.ts diff --git a/packages/core/src/runtime/skills_admin.ts b/packages/core/src/skills/skills_admin.ts similarity index 99% rename from packages/core/src/runtime/skills_admin.ts rename to packages/core/src/skills/skills_admin.ts index 697f6fe..d2721fc 100644 --- a/packages/core/src/runtime/skills_admin.ts +++ b/packages/core/src/skills/skills_admin.ts @@ -2,7 +2,7 @@ import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promise import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { loadMemoConfig, writeMemoConfig, type MemoConfig } from '../config/config.js' -import { normalizeWorkspacePath } from './workspace.js' +import { normalizeWorkspacePath } from '../history/workspace.js' import type { SkillRecord } from '../api_types.js' type SkillScope = 'project' | 'global' diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cc1f5ae..2dbf873 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,8 +1,24 @@ /** @file Common type declarations shared between Core and Runtime (reused by UI/Tools). */ +import type { FinishReason, LanguageModelUsage, ModelMessage, ToolCallPart } from 'ai' import type { ApprovalRequest, ApprovalDecision } from '@memo/tools/approval' import type { ToolActionStatus } from '@memo/tools/orchestrator' export type { ApprovalDecision, ApprovalRequest } from '@memo/tools/approval' export type { ToolActionStatus } from '@memo/tools/orchestrator' +export type { FinishReason, LanguageModelUsage } from 'ai' + +/** AI SDK generation result subset returned by CallLLM (all fields are AI SDK types). */ +export type LLMResult = { + /** Full generated text. */ + text: string + /** Reasoning output (DeepSeek thinking trace). */ + reasoning?: string + /** Tool calls made during generation. */ + toolCalls: ToolCallPart[] + /** Token usage. */ + usage: LanguageModelUsage + /** Finish reason. */ + finishReason: FinishReason +} /** * Basic type declarations for Agent layer, covering conversation messages, @@ -11,50 +27,8 @@ export type { ToolActionStatus } from '@memo/tools/orchestrator' */ export type Role = 'system' | 'user' | 'assistant' | 'tool' -/** Structured tool calls from Assistant (OpenAI tool_calls compatible format). */ -export type AssistantToolCall = { - id: string - type: 'function' - function: { - name: string - arguments: string - } -} - -/** Model-side messages: compatible with plain text and structured tool calls/results. */ -export type ChatMessage = - | { - /** System message. */ - role: 'system' - /** Message content. */ - content: string - } - | { - /** User message. */ - role: 'user' - /** Message content. */ - content: string - } - | { - /** Assistant text or structured tool calls. */ - role: 'assistant' - /** Assistant text; can be empty string for pure tool calls. */ - content: string - /** Optional DeepSeek thinking trace required for subsequent tool-call rounds. */ - reasoning_content?: string - /** Structured tool calls list (if any). */ - tool_calls?: AssistantToolCall[] - } - | { - /** Tool result message (corresponds to a tool_call). */ - role: 'tool' - /** Tool output text. */ - content: string - /** Corresponds to assistant.tool_calls[*].id. */ - tool_call_id: string - /** Optional tool name for debugging. */ - name?: string - } +/** Model-side messages: AI SDK ModelMessage (plain text or structured parts). */ +export type ChatMessage = ModelMessage /** Single-step debug record for replay and observability. */ export type AgentStepTrace = { @@ -66,18 +40,8 @@ export type AgentStepTrace = { parsed: ParsedAssistant /** Tool observation for this step (if any). */ observation?: string - /** Token statistics for this step. */ - tokenUsage: TokenUsage -} - -/** Token usage statistics: prompt/completion/total. */ -export type TokenUsage = { - /** Input prompt tokens. */ - prompt: number - /** Model generation tokens. */ - completion: number - /** Total tokens (prompt+completion if model doesn't return it). */ - total: number + /** Token statistics for this step (AI SDK LanguageModelUsage). */ + tokenUsage: LanguageModelUsage } export type CompactReason = 'auto' | 'manual' @@ -106,7 +70,7 @@ export type TokenCounter = { dispose: () => void } -/** Tool Use Block - tool call request */ +/** Tool Use Block - tool call request (internal agent-loop structure). */ export type ToolUseBlock = { type: 'tool_use' /** Unique ID for the tool call */ @@ -117,28 +81,6 @@ export type ToolUseBlock = { input: unknown } -/** Text Block - text content */ -export type TextBlock = { - type: 'text' - /** Text content */ - text: string -} - -/** Content Block - can be text or tool call */ -export type ContentBlock = TextBlock | ToolUseBlock - -/** LLM response (unified structured content blocks). */ -export type LLMResponse = { - /** Structured content blocks (text + tool calls). */ - content: ContentBlock[] - /** Optional DeepSeek thinking trace for protocol-compatible follow-up requests. */ - reasoning_content?: string - /** Stop reason. */ - stop_reason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' - /** Token usage returned by model (optional). */ - usage?: Partial -} - /** Representation of parsed LLM output as action/final structure. */ export type ParsedAssistant = { /** Tool to call and its parameters. */ @@ -170,7 +112,7 @@ export type CallLLM = ( messages: ChatMessage[], onChunk?: (chunk: string) => void, options?: CallLLMOptions, -) => Promise +) => Promise /** * Dependency injection collection required by runAgent. @@ -252,7 +194,7 @@ export type TurnResult = { /** 错误信息(若有)。 */ errorMessage?: string /** 本轮 token 统计。 */ - tokenUsage: TokenUsage + tokenUsage: LanguageModelUsage } export type TurnStartHookPayload = { @@ -293,8 +235,8 @@ export type FinalHookPayload = { finalText: string status: TurnStatus errorMessage?: string - tokenUsage?: TokenUsage - turnUsage: TokenUsage + tokenUsage?: LanguageModelUsage + turnUsage: LanguageModelUsage steps: AgentStepTrace[] } diff --git a/packages/core/src/utils/tokenizer.test.ts b/packages/core/src/utils/tokenizer.test.ts index 575d4d7..793100c 100644 --- a/packages/core/src/utils/tokenizer.test.ts +++ b/packages/core/src/utils/tokenizer.test.ts @@ -110,9 +110,14 @@ describe('createTokenCounter', () => { const messages: ChatMessage[] = [ { role: 'tool', - content: 'Tool execution result', - tool_call_id: 'call-123', - name: 'test_tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-123', + toolName: 'test_tool', + output: { type: 'text', value: 'Tool execution result' }, + }, + ], }, ] const count = counter.countMessages(messages) @@ -147,13 +152,9 @@ describe('createTokenCounter', () => { const messagesWithToolCalls: ChatMessage[] = [ { role: 'assistant', - content: 'Let me check', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { name: 'read_file', arguments: '{"path": "test.txt"}' }, - }, + content: [ + { type: 'text', text: 'Let me check' }, + { type: 'tool-call', toolCallId: 'call-1', toolName: 'read_file', input: { path: 'test.txt' } }, ], }, ] @@ -164,31 +165,27 @@ describe('createTokenCounter', () => { }) test('counts reasoning_content in assistant message', () => { + const toolCallParts = [ + { + type: 'tool-call' as const, + toolCallId: 'call-1', + toolName: 'read_file', + input: { path: 'README.md' }, + }, + ] const messagesWithReasoning: ChatMessage[] = [ { role: 'assistant', - content: '', - reasoning_content: 'I should inspect file A before using read_file.', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { name: 'read_file', arguments: '{"path":"README.md"}' }, - }, + content: [ + { type: 'reasoning', text: 'I should inspect file A before using read_file.' }, + ...toolCallParts, ], }, ] const messagesWithoutReasoning: ChatMessage[] = [ { role: 'assistant', - content: '', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { name: 'read_file', arguments: '{"path":"README.md"}' }, - }, - ], + content: toolCallParts, }, ] const withReasoning = counter.countMessages(messagesWithReasoning) @@ -200,8 +197,14 @@ describe('createTokenCounter', () => { const messages: ChatMessage[] = [ { role: 'tool', - content: 'Result', - tool_call_id: 'call-abc123', + content: [ + { + type: 'tool-result', + toolCallId: 'call-abc123', + toolName: '', + output: { type: 'text', value: 'Result' }, + }, + ], }, ] const count = counter.countMessages(messages) @@ -212,9 +215,14 @@ describe('createTokenCounter', () => { const messages: ChatMessage[] = [ { role: 'tool', - content: 'Result', - tool_call_id: 'call-1', - name: 'my_tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'my_tool', + output: { type: 'text', value: 'Result' }, + }, + ], }, ] const count = counter.countMessages(messages) diff --git a/packages/core/src/utils/tokenizer.ts b/packages/core/src/utils/tokenizer.ts index b28e830..8bb340d 100644 --- a/packages/core/src/utils/tokenizer.ts +++ b/packages/core/src/utils/tokenizer.ts @@ -24,16 +24,33 @@ function safeEncodingFactory(model?: string): { model: string; factory: Encoding function messagePayloadForCounting(message: ChatMessage): string { if (message.role === 'assistant') { - const reasoning = message.reasoning_content ? `\n${message.reasoning_content}` : '' - if (message.tool_calls?.length) { - return `${message.content}${reasoning}\n${JSON.stringify(message.tool_calls)}` + const parts = Array.isArray(message.content) ? message.content : [] + const text = + typeof message.content === 'string' + ? message.content + : parts + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('') + const reasoning = parts + .filter((part) => part.type === 'reasoning') + .map((part) => part.text) + .join('\n') + const toolCalls = parts.filter((part) => part.type === 'tool-call') + const reasoningSuffix = reasoning ? `\n${reasoning}` : '' + if (toolCalls.length) { + return `${text}${reasoningSuffix}\n${JSON.stringify(toolCalls)}` } - return `${message.content}${reasoning}` + return `${text}${reasoningSuffix}` } if (message.role === 'tool') { - return `${message.content}\n${message.tool_call_id}\n${message.name ?? ''}` + const part = Array.isArray(message.content) ? message.content[0] : undefined + const text = part?.type === 'tool-result' && part.output.type === 'text' ? part.output.value : '' + const toolCallId = part?.type === 'tool-result' ? part.toolCallId : '' + const toolName = part?.type === 'tool-result' ? part.toolName : '' + return `${text}\n${toolCallId}\n${toolName}` } - return message.content + return typeof message.content === 'string' ? message.content : '' } /** Create a reusable token counter for prompt size estimation (compaction trigger, context overflow check). */ diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 88e4ace..e1c4db1 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -11,5 +11,15 @@ export default defineConfig({ minify: false, splitting: false, bundle: true, - external: ['@dqbd/tiktoken', '@mozilla/readability', 'ipaddr.js', 'jsdom', 'robots-parser', 'turndown', 'undici'], + external: [ + '@dqbd/tiktoken', + '@mozilla/readability', + 'ipaddr.js', + 'jsdom', + 'robots-parser', + 'turndown', + 'undici', + 'ai', + '@ai-sdk/openai-compatible', + ], }) diff --git a/packages/tui/src/commands/index.tsx b/packages/tui/src/commands/index.tsx index a799a8b..d355d98 100644 --- a/packages/tui/src/commands/index.tsx +++ b/packages/tui/src/commands/index.tsx @@ -192,7 +192,7 @@ function PlainMode({ opts, question: initialQuestion }: { opts: zod.infer { const startedAt = turnView.startedAt ?? Date.now() const durationMs = Math.max(0, Date.now() - startedAt) - const promptTokens = action.tokenUsage?.prompt ?? turnView.contextPromptTokens + const promptTokens = action.tokenUsage?.inputTokens ?? turnView.contextPromptTokens return { ...turnView, finalText: action.finalText, diff --git a/packages/tui/src/shared/types/index.ts b/packages/tui/src/shared/types/index.ts index 07bdb0d..6f1f5f1 100644 --- a/packages/tui/src/shared/types/index.ts +++ b/packages/tui/src/shared/types/index.ts @@ -1,4 +1,4 @@ -import type { TokenUsage, TurnStatus } from '@memo/core' +import type { LanguageModelUsage, TurnStatus } from '@memo/core' export const TOOL_STATUS = { PENDING: 'pending', @@ -32,7 +32,7 @@ export type TurnView = { steps: StepView[] status?: TurnStatus errorMessage?: string - tokenUsage?: TokenUsage + tokenUsage?: LanguageModelUsage contextPromptTokens?: number finalText?: string startedAt?: number diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f79ee3..bac1973 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: marked: specifier: ^17.0.1 version: 17.0.1 - openai: - specifier: ^6.10.0 - version: 6.17.0(ws@8.19.0)(zod@4.3.6) pastel: specifier: ^4.0.1 version: 4.0.1(ink@6.7.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(zod@4.3.6) @@ -105,6 +102,12 @@ importers: packages/core: dependencies: + '@ai-sdk/openai-compatible': + specifier: ^2.0.0 + version: 2.0.63(zod@4.3.6) + ai: + specifier: ^6.0.0 + version: 6.0.238(zod@4.3.6) ignore: specifier: ^7.0.5 version: 7.0.5 @@ -177,6 +180,28 @@ packages: '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@ai-sdk/gateway@3.0.160': + resolution: {integrity: sha512-yL024nICtDX74vFkfFJl8t7uHlA3BXl/5C+Ah92dPunI0U2WPCPhTnmiWu4zEYTjZmmvFOC3pDojniLsHkUFqA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai-compatible@2.0.63': + resolution: {integrity: sha512-EmrD7iRboidulu6yHfMiMhd6RQSw8KrIWhNLK8vl5brQZbIjXkyhUU+FULZM3P4m46Vatzx8u3vX1w/qmFUmqA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.41': + resolution: {integrity: sha512-I7hhjfw01yEI8NkuAsT8Mv6xbWFr/lqLXMdaJQ2zWfXEpxog1eT7skDcv1+RY29/+5btzH8wD+vVvy48bk9oNQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.14': + resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} @@ -560,6 +585,10 @@ packages: '@noble/hashes': optional: true + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -681,6 +710,10 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxfmt/binding-android-arm-eabi@0.61.0': resolution: {integrity: sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1067,6 +1100,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1085,6 +1121,10 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitest/coverage-v8@2.1.9': resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} peerDependencies: @@ -1136,6 +1176,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@6.0.238: + resolution: {integrity: sha512-XBIrxDzOrUAOqf+mkvzpOY67SRJoa5X99iMGAuCIcsey302+QW4AWpAJIyRuE3jJxmslWO4K3ToSeBUl1DeguA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1461,6 +1507,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -1762,6 +1812,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -1974,18 +2027,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - openai@6.17.0: - resolution: {integrity: sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -2491,6 +2532,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@6.23.0: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} @@ -2733,6 +2778,31 @@ snapshots: '@acemir/cssom@0.9.31': {} + '@ai-sdk/gateway@3.0.160(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.41(zod@4.3.6) + '@vercel/oidc': 3.2.0 + zod: 4.3.6 + + '@ai-sdk/openai-compatible@2.0.63(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.41(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.41(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + undici: 5.29.0 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.14': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.5': dependencies: ansi-styles: 6.2.3 @@ -2961,6 +3031,8 @@ snapshots: optionalDependencies: '@noble/hashes': 1.8.0 + '@fastify/busboy@2.1.1': {} + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -3129,6 +3201,8 @@ snapshots: '@open-draft/until@2.1.0': optional: true + '@opentelemetry/api@1.9.1': {} + '@oxfmt/binding-android-arm-eabi@0.61.0': optional: true @@ -3321,6 +3395,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@types/estree@1.0.8': {} '@types/node@22.19.7': @@ -3341,6 +3417,8 @@ snapshots: '@types/statuses@2.0.6': optional: true + '@vercel/oidc@3.2.0': {} + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.19.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.7)(typescript@5.9.3))(terser@5.46.0))': dependencies: '@ampproject/remapping': 2.3.0 @@ -3418,6 +3496,14 @@ snapshots: agent-base@7.1.4: {} + ai@6.0.238(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.160(zod@4.3.6) + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.41(zod@4.3.6) + '@opentelemetry/api': 1.9.1 + zod: 4.3.6 + ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -3735,6 +3821,8 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource-parser@3.1.0: {} + eventsource@3.0.7: dependencies: eventsource-parser: 3.0.6 @@ -4085,6 +4173,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + lightningcss-android-arm64@1.30.2: optional: true @@ -4292,11 +4382,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - openai@6.17.0(ws@8.19.0)(zod@4.3.6): - optionalDependencies: - ws: 8.19.0 - zod: 4.3.6 - outvariant@1.4.3: optional: true @@ -4840,6 +4925,10 @@ snapshots: undici-types@7.16.0: optional: true + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@6.23.0: {} undici@7.22.0: {} diff --git a/tsup.config.ts b/tsup.config.ts index d075876..72146d3 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ options.jsx = 'automatic' }, async onSuccess() { - copyFileSync(join('packages/core/src/runtime/prompt.md'), join('dist/prompt.md')) + copyFileSync(join('packages/core/src/prompt/prompt.md'), join('dist/prompt.md')) mkdirSync(join('dist/task-prompts'), { recursive: true }) cpSync(join('packages/tui/src/task-prompts'), join('dist/task-prompts'), { recursive: true, From 21180699eefe4a9fc931adae44a41bc0a7c9f82d Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 03:19:19 +0800 Subject: [PATCH 02/42] docs(core): restore README and update directory layout for domain modules --- packages/core/README.md | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/core/README.md diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..51382e1 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,52 @@ +# @memo/core Overview + +Core provides the central capabilities of **Memo Code**: the ReAct loop, session state management, default dependency wiring (LLM/tools/prompt/history), config loading, and shared types/utilities. The design goal is "thick Core, thin UI": UI handles interaction and callbacks, while Core owns behavior. + +## Directory Layout + +- `config/` + - `config.ts`: Reads `~/.memo/config.toml` (providers, sessions path), handles provider selection, session path building, and config writes. +- `llm/` + - `ai_provider.ts`: AI SDK provider factory registry (dispatch by provider name; openai-compatible default). + - `ai_stream.ts`: Default streaming LLM call via AI SDK `streamText`. + - `model_profile.ts`: Model capability resolution (parallel tool calls, reasoning, context window). +- `session/` + - `session_runtime.ts`: Session/Turn runtime, executes ReAct loop, writes events, tracks token usage. + - `session_runtime_helpers.ts`: Usage accumulation, LLM result normalization, message helpers. + - `defaults.ts`: Default dependency completion (toolset, LLM, prompt, history sink, tokenizer). + - `hooks.ts`: Hook/middleware runners and history snapshotting. + - `compact_prompt.ts`: Context compaction prompt building. + - `file_suggestions.ts`: File suggestion helpers for the composer. + - `slash/`: Slash command specs and registry. +- `history/` + - `history.ts`: JSONL history sink and event construction. + - `history_parser.ts` / `history_index.ts`: Session log parsing and indexing. + - `workspace.ts`: Workspace path normalization. +- `prompt/` + - `prompt.ts` + `prompt.md`: System prompt loading (runtime context, AGENTS.md/SOUL.md/skills injection). +- `skills/` / `mcp/` + - Skill management and MCP server admin. +- `types.ts`: Shared types (`AgentDeps`, `Session/Turn`, `LanguageModelUsage`, `HistoryEvent`, etc.). +- `utils/` + - Utility functions (assistant output parsing, message wrappers). + - `tokenizer.ts`: tiktoken-based tokenizer helpers. +- `index.ts`: Package entry, exports core modules and types. + +## Key Flows + +- `createAgentSession(deps, options)`: Creates a Session, fills default dependencies, loads prompt, and returns an object with `runTurn`. +- `withDefaultDeps`: Injects default toolset, LLM client, prompt, history sink (writes to `~/.memo/sessions/YY/MM/DD/.jsonl`), and tokenizer based on config and overrides. +- Session history: JSONL events (`session_start/turn_start/assistant/action/observation/final/turn_end/session_end`) with metadata like provider, model, tokenizer, and token usage. +- Config: `~/.memo/config.toml` (overridable via `MEMO_HOME`). If missing, UI setup flow is triggered. + +## Usage Example + +```ts +import { createAgentSession } from '@memo/core' + +const session = await createAgentSession({ onAssistantStep: console.log }, { mode: 'interactive' }) +const turn = await session.runTurn('Hello') +await session.close() +``` + +If you provide custom tool/LLM/prompt/sink dependencies, override the related fields in `deps/options`. Defaults select the current provider and write sessions in the user directory. From 39fa595f4646a9c9aee510036c3dcd66eaaf20a9 Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 15:33:04 +0800 Subject: [PATCH 03/42] refactor(core): rename session to agent kernel, dissolve helpers bag - agent/loop.ts: ReAct loop with session state, usage, permissions, abort handling (session_runtime + session_runtime_helpers merged) - agent/messages.ts: message construction and LLM result normalization - stableStringify stays with the loop (tools/ has its own copy) - remove the 'helpers' bag directory; every module now has a clear name --- packages/core/README.md | 11 +- .../{session => agent}/compact_prompt.test.ts | 2 +- .../src/{session => agent}/compact_prompt.ts | 0 .../src/{session => agent}/defaults.test.ts | 2 +- .../core/src/{session => agent}/defaults.ts | 0 .../defaults.with_default_deps.test.ts | 16 +- .../file_suggestions.test.ts | 0 .../{session => agent}/file_suggestions.ts | 0 .../core/src/{session => agent}/hooks.test.ts | 4 +- packages/core/src/{session => agent}/hooks.ts | 0 .../loop.test.ts} | 6 +- .../session_runtime.ts => agent/loop.ts} | 191 ++++++++++- packages/core/src/agent/messages.ts | 128 ++++++++ .../core/src/{session => agent}/session.ts | 6 +- .../{session => agent}/session_hooks.test.ts | 4 +- .../src/{session => agent}/slash/index.ts | 0 .../{session => agent}/slash/registry.test.ts | 0 .../src/{session => agent}/slash/registry.ts | 0 .../src/{session => agent}/slash/types.ts | 0 packages/core/src/index.ts | 8 +- packages/core/src/prompt/memory.test.ts | 2 +- .../src/session/session_runtime_helpers.ts | 301 ------------------ .../tui/src/features/composer/Composer.tsx | 2 +- .../src/features/composer/fileSuggestions.ts | 2 +- 24 files changed, 336 insertions(+), 349 deletions(-) rename packages/core/src/{session => agent}/compact_prompt.test.ts (98%) rename packages/core/src/{session => agent}/compact_prompt.ts (100%) rename packages/core/src/{session => agent}/defaults.test.ts (92%) rename packages/core/src/{session => agent}/defaults.ts (100%) rename packages/core/src/{session => agent}/defaults.with_default_deps.test.ts (95%) rename packages/core/src/{session => agent}/file_suggestions.test.ts (100%) rename packages/core/src/{session => agent}/file_suggestions.ts (100%) rename packages/core/src/{session => agent}/hooks.test.ts (99%) rename packages/core/src/{session => agent}/hooks.ts (100%) rename packages/core/src/{session/session_runtime_helpers.test.ts => agent/loop.test.ts} (98%) rename packages/core/src/{session/session_runtime.ts => agent/loop.ts} (89%) create mode 100644 packages/core/src/agent/messages.ts rename packages/core/src/{session => agent}/session.ts (80%) rename packages/core/src/{session => agent}/session_hooks.test.ts (99%) rename packages/core/src/{session => agent}/slash/index.ts (100%) rename packages/core/src/{session => agent}/slash/registry.test.ts (100%) rename packages/core/src/{session => agent}/slash/registry.ts (100%) rename packages/core/src/{session => agent}/slash/types.ts (100%) delete mode 100644 packages/core/src/session/session_runtime_helpers.ts diff --git a/packages/core/README.md b/packages/core/README.md index 51382e1..2ef1983 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,14 +10,15 @@ Core provides the central capabilities of **Memo Code**: the ReAct loop, session - `ai_provider.ts`: AI SDK provider factory registry (dispatch by provider name; openai-compatible default). - `ai_stream.ts`: Default streaming LLM call via AI SDK `streamText`. - `model_profile.ts`: Model capability resolution (parallel tool calls, reasoning, context window). -- `session/` - - `session_runtime.ts`: Session/Turn runtime, executes ReAct loop, writes events, tracks token usage. - - `session_runtime_helpers.ts`: Usage accumulation, LLM result normalization, message helpers. - - `defaults.ts`: Default dependency completion (toolset, LLM, prompt, history sink, tokenizer). +- `agent/` — the agent loop kernel (minimal, readable, replaceable) + - `loop.ts`: ReAct loop (observe → think → act → record), session state, token usage, permissions, abort handling. + - `messages.ts`: Message construction and LLM result normalization (AI SDK `ModelMessage`/`GenerateTextResult`). + - `session.ts`: `createAgentSession` factory. + - `defaults.ts`: Composition root — default dependency completion (toolset, LLM, prompt, history sink, tokenizer). - `hooks.ts`: Hook/middleware runners and history snapshotting. - `compact_prompt.ts`: Context compaction prompt building. - - `file_suggestions.ts`: File suggestion helpers for the composer. - `slash/`: Slash command specs and registry. + - `file_suggestions.ts`: File suggestion helpers for the composer. - `history/` - `history.ts`: JSONL history sink and event construction. - `history_parser.ts` / `history_index.ts`: Session log parsing and indexing. diff --git a/packages/core/src/session/compact_prompt.test.ts b/packages/core/src/agent/compact_prompt.test.ts similarity index 98% rename from packages/core/src/session/compact_prompt.test.ts rename to packages/core/src/agent/compact_prompt.test.ts index 0667000..1bfd514 100644 --- a/packages/core/src/session/compact_prompt.test.ts +++ b/packages/core/src/agent/compact_prompt.test.ts @@ -5,7 +5,7 @@ import { buildCompactionUserPrompt, CONTEXT_SUMMARY_PREFIX, isContextSummaryMessage, -} from '@memo/core/session/compact_prompt' +} from '@memo/core/agent/compact_prompt' describe('compact_prompt', () => { test('buildCompactionUserPrompt formats assistant tool calls and tool messages', () => { diff --git a/packages/core/src/session/compact_prompt.ts b/packages/core/src/agent/compact_prompt.ts similarity index 100% rename from packages/core/src/session/compact_prompt.ts rename to packages/core/src/agent/compact_prompt.ts diff --git a/packages/core/src/session/defaults.test.ts b/packages/core/src/agent/defaults.test.ts similarity index 92% rename from packages/core/src/session/defaults.test.ts rename to packages/core/src/agent/defaults.test.ts index 67cc085..6561c5d 100644 --- a/packages/core/src/session/defaults.test.ts +++ b/packages/core/src/agent/defaults.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { filterMcpServersBySelection } from '@memo/core/session/defaults' +import { filterMcpServersBySelection } from '@memo/core/agent/defaults' describe('filterMcpServersBySelection', () => { const servers = { diff --git a/packages/core/src/session/defaults.ts b/packages/core/src/agent/defaults.ts similarity index 100% rename from packages/core/src/session/defaults.ts rename to packages/core/src/agent/defaults.ts diff --git a/packages/core/src/session/defaults.with_default_deps.test.ts b/packages/core/src/agent/defaults.with_default_deps.test.ts similarity index 95% rename from packages/core/src/session/defaults.with_default_deps.test.ts rename to packages/core/src/agent/defaults.with_default_deps.test.ts index 55c5922..2271e95 100644 --- a/packages/core/src/session/defaults.with_default_deps.test.ts +++ b/packages/core/src/agent/defaults.with_default_deps.test.ts @@ -9,7 +9,7 @@ import type { } from '@memo/core/types' import type { MCPServerConfig } from '@memo/core/config/config' import type { AIProviderFactory } from '@memo/core/llm/ai_provider' -import { emptyUsage } from '@memo/core/session/session_runtime_helpers' +import { emptyUsage } from '@memo/core/agent/loop' import type { Tool } from '@memo/tools/router' const state = vi.hoisted(() => ({ @@ -196,7 +196,7 @@ describe('withDefaultDeps (default path)', () => { }) test('builds default deps with injected tool descriptions and default sinks', async () => { - const { withDefaultDeps } = await import('@memo/core/session/defaults') + const { withDefaultDeps } = await import('@memo/core/agent/defaults') const resolved = await withDefaultDeps( {}, @@ -215,7 +215,7 @@ describe('withDefaultDeps (default path)', () => { }) test('respects provided deps overrides (callLLM/historySinks/tokenCounter/loadPrompt/dispose)', async () => { - const { withDefaultDeps } = await import('@memo/core/session/defaults') + const { withDefaultDeps } = await import('@memo/core/agent/defaults') const callLLM = vi.fn(async () => ({ content: [{ type: 'text' as const, text: 'override' }], stop_reason: 'end_turn' as const, @@ -253,7 +253,7 @@ describe('withDefaultDeps (default path)', () => { }) test('throws when provider api key is missing', async () => { - const { withDefaultDeps } = await import('@memo/core/session/defaults') + const { withDefaultDeps } = await import('@memo/core/agent/defaults') const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3') await expect(resolved.callLLM([{ role: 'user', content: 'hello' } as ChatMessage])).rejects.toThrow( @@ -263,7 +263,7 @@ describe('withDefaultDeps (default path)', () => { test('falls back to OPENAI_API_KEY and delegates to streamCallLLM', async () => { process.env.OPENAI_API_KEY = 'openai-fallback-key' - const { withDefaultDeps } = await import('@memo/core/session/defaults') + const { withDefaultDeps } = await import('@memo/core/agent/defaults') const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3b') const messages = [{ role: 'user', content: 'hello' } as ChatMessage] @@ -291,7 +291,7 @@ describe('withDefaultDeps (default path)', () => { test('passes call options (tools/signal) and forwards structured LLM response', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/session/defaults') + const { withDefaultDeps } = await import('@memo/core/agent/defaults') const callOptionsTools: ToolDefinition[] = [ { name: 'override', description: 'override tool', input_schema: { type: 'object' } }, ] @@ -372,7 +372,7 @@ describe('withDefaultDeps (default path)', () => { test('forwards plain text response with usage', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/session/defaults') + const { withDefaultDeps } = await import('@memo/core/agent/defaults') state.llmResponse = { text: 'plain assistant answer', @@ -394,7 +394,7 @@ describe('withDefaultDeps (default path)', () => { test('propagates streamCallLLM errors (e.g. empty content)', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/session/defaults') + const { withDefaultDeps } = await import('@memo/core/agent/defaults') const { streamCallLLM } = await import('@memo/core/llm/ai_stream') vi.mocked(streamCallLLM).mockRejectedValueOnce(new Error('OpenAI-compatible API returned empty content')) diff --git a/packages/core/src/session/file_suggestions.test.ts b/packages/core/src/agent/file_suggestions.test.ts similarity index 100% rename from packages/core/src/session/file_suggestions.test.ts rename to packages/core/src/agent/file_suggestions.test.ts diff --git a/packages/core/src/session/file_suggestions.ts b/packages/core/src/agent/file_suggestions.ts similarity index 100% rename from packages/core/src/session/file_suggestions.ts rename to packages/core/src/agent/file_suggestions.ts diff --git a/packages/core/src/session/hooks.test.ts b/packages/core/src/agent/hooks.test.ts similarity index 99% rename from packages/core/src/session/hooks.test.ts rename to packages/core/src/agent/hooks.test.ts index f8ece21..f3223b6 100644 --- a/packages/core/src/session/hooks.test.ts +++ b/packages/core/src/agent/hooks.test.ts @@ -7,8 +7,8 @@ import type { FinalHookPayload, ChatMessage, } from '@memo/core/types' -import { buildHookRunners, runHook, snapshotHistory } from '@memo/core/session/hooks' -import { emptyUsage } from '@memo/core/session/session_runtime_helpers' +import { buildHookRunners, runHook, snapshotHistory } from '@memo/core/agent/hooks' +import { emptyUsage } from '@memo/core/agent/loop' describe('buildHookRunners', () => { test('creates empty hook map when no hooks provided', () => { diff --git a/packages/core/src/session/hooks.ts b/packages/core/src/agent/hooks.ts similarity index 100% rename from packages/core/src/session/hooks.ts rename to packages/core/src/agent/hooks.ts diff --git a/packages/core/src/session/session_runtime_helpers.test.ts b/packages/core/src/agent/loop.test.ts similarity index 98% rename from packages/core/src/session/session_runtime_helpers.test.ts rename to packages/core/src/agent/loop.test.ts index 7695c5f..a3472a0 100644 --- a/packages/core/src/session/session_runtime_helpers.test.ts +++ b/packages/core/src/agent/loop.test.ts @@ -2,17 +2,15 @@ import { describe, expect, test, vi } from 'vitest' import type { HistorySink } from '@memo/core/types' import { accumulateUsage, - completeToolResultsForProtocol, emitEventToSinks, emptyUsage, fallbackSessionTitleFromPrompt, isAbortError, normalizeSessionTitle, - parseTextToolCall, stableStringify, - toToolHistoryMessage, truncateSessionTitle, -} from '@memo/core/session/session_runtime_helpers' +} from '@memo/core/agent/loop' +import { completeToolResultsForProtocol, parseTextToolCall, toToolHistoryMessage } from '@memo/core/agent/messages' describe('accumulateUsage', () => { test('uses explicit total when provided', () => { diff --git a/packages/core/src/session/session_runtime.ts b/packages/core/src/agent/loop.ts similarity index 89% rename from packages/core/src/session/session_runtime.ts rename to packages/core/src/agent/loop.ts index c9e8112..175c8f0 100644 --- a/packages/core/src/session/session_runtime.ts +++ b/packages/core/src/agent/loop.ts @@ -7,7 +7,7 @@ import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX, isContextSummaryMessage, -} from '@memo/core/session/compact_prompt' +} from '@memo/core/agent/compact_prompt' import type { ChatMessage, AgentSession, @@ -27,7 +27,7 @@ import type { TurnStatus, } from '@memo/core/types' import type { LanguageModelUsage } from 'ai' -import { buildHookRunners, runHook, snapshotHistory, type HookRunnerMap } from '@memo/core/session/hooks' +import { buildHookRunners, runHook, snapshotHistory, type HookRunnerMap } from '@memo/core/agent/hooks' import { createToolOrchestrator, type ToolApprovalHooks, @@ -36,25 +36,14 @@ import { } from '@memo/tools/orchestrator' import { runWithRuntimeContext } from '@memo/tools/runtime/context' import { - DEFAULT_CONTEXT_WINDOW, - DEFAULT_SESSION_MODE, - TOOL_ACTION_SUCCESS_STATUS, - TOOL_DISABLED_ERROR_MESSAGE, - TOOL_SKIPPED_DISABLED_MESSAGE, - accumulateUsage, buildAssistantToolCalls, completeToolResultsForProtocol, - emitEventToSinks, - emptyUsage, - fallbackSessionTitleFromPrompt, - isAbortError, normalizeLLMResponse, parseTextToolCall, - resolveToolPermission, - stableStringify, toToolHistoryMessage, -} from '@memo/core/session/session_runtime_helpers' +} from './messages' import type { ApprovalRequest, ApprovalDecision } from '@memo/tools/approval' +import type { ToolActionStatus } from '@memo/tools/orchestrator' const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 80 const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000 @@ -1207,3 +1196,175 @@ export class AgentSessionImpl implements AgentSession { await emitEventToSinks(event, this.sinks) } } + +// --- Agent loop constants and helpers --------------------------------------------- + +export const DEFAULT_SESSION_MODE: SessionMode = 'interactive' +export const DEFAULT_CONTEXT_WINDOW = 120_000 +export const TOOL_ACTION_SUCCESS_STATUS: ToolActionStatus = 'success' +export const TOOL_DISABLED_ERROR_MESSAGE = + 'Tool usage is disabled in the current permission mode. Switch to /tools once or /tools full to enable tools.' +export const SESSION_TITLE_MAX_CHARS = 60 +export const TOOL_SKIPPED_DISABLED_MESSAGE = 'Tool execution skipped: tools are disabled in current permission mode.' + +export type ResolvedToolPermission = { + mode: ToolPermissionMode | 'auto' + toolsDisabled: boolean + dangerous: boolean + approvalMode: 'auto' | 'strict' +} + +function writeStructuredError(payload: Record) { + process.stderr.write(`${JSON.stringify(payload)}\n`) +} + +export function resolveToolPermission(options: AgentSessionOptions): ResolvedToolPermission { + if (options.toolPermissionMode === 'none') { + return { + mode: 'none', + toolsDisabled: true, + dangerous: false, + approvalMode: 'auto', + } + } + + if (options.toolPermissionMode === 'once') { + return { + mode: 'once', + toolsDisabled: false, + dangerous: false, + approvalMode: 'auto', + } + } + + if (options.toolPermissionMode === 'full') { + return { + mode: 'full', + toolsDisabled: false, + dangerous: true, + approvalMode: 'auto', + } + } + + const dangerous = options.dangerous ?? false + return { + mode: dangerous ? 'full' : 'auto', + toolsDisabled: false, + dangerous, + approvalMode: 'auto', + } +} + +export function emptyUsage(): LanguageModelUsage { + return { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + } +} + +export function accumulateUsage(target: LanguageModelUsage, delta?: Partial) { + if (!delta) return + const inputDelta = delta.inputTokens ?? 0 + const outputDelta = delta.outputTokens ?? 0 + const totalDelta = delta.totalTokens ?? inputDelta + outputDelta + target.inputTokens = (target.inputTokens ?? 0) + inputDelta + target.outputTokens = (target.outputTokens ?? 0) + outputDelta + target.totalTokens = (target.totalTokens ?? 0) + totalDelta +} + +export async function emitEventToSinks(event: HistoryEvent, sinks: HistorySink[]) { + for (const sink of sinks) { + try { + await sink.append(event) + } catch (err) { + writeStructuredError({ + level: 'error', + event: 'history_sink_append_failed', + sink: sink.constructor?.name || 'anonymous_sink', + message: (err as Error).message, + }) + } + } +} + +export function isAbortError(err: unknown): err is Error { + if (!(err instanceof Error)) return false + if (err.name === 'AbortError') return true + + const message = err.message?.toLowerCase?.() ?? '' + return ( + message.includes('request was aborted') || + message.includes('operation was aborted') || + message.includes('aborted') + ) +} + +export function truncateSessionTitle(input: string): string { + if (input.length <= SESSION_TITLE_MAX_CHARS) return input + return `${input.slice(0, SESSION_TITLE_MAX_CHARS - 3).trimEnd()}...` +} + +export function normalizeSessionTitle(raw: string): string { + const compact = raw + .replace(/<\s*(think|thinking)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ') + .replace(/<\s*\/?\s*(think|thinking)\b[^>]*>/gi, ' ') + .replace(/\r?\n+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!compact) return '' + const unprefixed = compact.replace(/^(title|session title|标题)\s*[::-]\s*/i, '').trim() + if (!unprefixed) return '' + const unquoted = unprefixed.replace(/^["'`“”‘’]+|["'`“”‘’]+$/g, '').trim() + if (!unquoted) return '' + return truncateSessionTitle(unquoted) +} + +export function fallbackSessionTitleFromPrompt(input: string): string { + const compact = input.replace(/\s+/g, ' ').trim() + if (!compact) return 'New Session' + + // Keep short CJK/non-space prompts readable. + if (!compact.includes(' ')) { + return compact.length <= 20 ? compact : `${compact.slice(0, 20).trimEnd()}...` + } + + const words = compact.split(' ').filter(Boolean) + const short = words.slice(0, 8).join(' ') + return truncateSessionTitle(short || compact) +} + +// Stable serialization for duplicate action detection (ensures consistent key ordering) +export function stableStringify(value: unknown): string { + return stableStringifyWithSeen(value, new WeakSet(), 0) +} + +const MAX_STABLE_STRINGIFY_DEPTH = 100 + +function stableStringifyWithSeen(value: unknown, seen: WeakSet, depth: number): string { + if (depth > MAX_STABLE_STRINGIFY_DEPTH) { + return JSON.stringify('[MaxDepthExceeded]') + } + if (typeof value === 'bigint') { + return JSON.stringify(value.toString()) + } + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (seen.has(value)) { + return JSON.stringify('[Circular]') + } + + seen.add(value) + if (Array.isArray(value)) { + const result = `[${value.map((v) => stableStringifyWithSeen(v, seen, depth + 1)).join(',')}]` + seen.delete(value) + return result + } + const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)) + const result = `{${entries + .map(([k, v]) => `${JSON.stringify(k)}:${stableStringifyWithSeen(v, seen, depth + 1)}`) + .join(',')}}` + seen.delete(value) + return result +} diff --git a/packages/core/src/agent/messages.ts b/packages/core/src/agent/messages.ts new file mode 100644 index 0000000..0c759d4 --- /dev/null +++ b/packages/core/src/agent/messages.ts @@ -0,0 +1,128 @@ +/** @file Message construction and LLM result normalization for the agent loop. */ +import type { LanguageModelUsage, ToolCallPart } from 'ai' +import type { ChatMessage, LLMResult, ToolRegistry } from '@memo/core/types' +import type { ToolActionResult } from '@memo/tools/orchestrator' + +const TOOL_SKIPPED_AFTER_REJECTION_MESSAGE = 'Skipped tool execution after previous rejection.' + +export function parseToolArguments( + raw: string, +): { ok: true; data: unknown } | { ok: false; raw: string; error: string } { + try { + return { ok: true, data: JSON.parse(raw) } + } catch (err) { + return { ok: false, raw, error: (err as Error).message } + } +} + +/** Extract session-level fields from an AI SDK GenerateTextResult. */ +export function normalizeLLMResponse(raw: LLMResult): { + textContent: string + toolUseBlocks: Array<{ id: string; name: string; input: unknown }> + reasoningContent?: string + usage?: Partial +} { + let textContent = raw.text + const toolUseBlocks: Array<{ id: string; name: string; input: unknown }> = [] + for (const call of raw.toolCalls) { + if (typeof call.input === 'string') { + const parsed = parseToolArguments(call.input) + if (parsed.ok) { + toolUseBlocks.push({ id: call.toolCallId, name: call.toolName, input: parsed.data }) + } else { + textContent = `${textContent}\n[tool_use parse error] ${parsed.error}; raw: ${parsed.raw}`.trim() + } + } else { + toolUseBlocks.push({ id: call.toolCallId, name: call.toolName, input: call.input }) + } + } + return { + textContent, + toolUseBlocks, + reasoningContent: + typeof raw.reasoning === 'string' && raw.reasoning.trim().length > 0 ? raw.reasoning : undefined, + usage: raw.usage, + } +} + +/** ToolUseBlock[] → AI SDK tool-call parts (for assistant history messages). */ +export function buildAssistantToolCalls( + toolUseBlocks: Array<{ id: string; name: string; input: unknown }>, +): ToolCallPart[] { + return toolUseBlocks.map((block) => ({ + type: 'tool-call', + toolCallId: block.id, + toolName: block.name, + input: block.input, + })) +} + +/** Parse a plain-text tool call (legacy text protocol fallback). */ +export function parseTextToolCall(text: string, tools: ToolRegistry): { tool: string; input: unknown } | null { + const trimmed = text.trim() + if (!trimmed) return null + + const candidates = [trimmed] + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) + if (fenced?.[1]) { + candidates.push(fenced[1].trim()) + } + + for (const candidate of candidates) { + if (!candidate.startsWith('{') || !candidate.endsWith('}')) continue + try { + const parsed = JSON.parse(candidate) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue + const obj = parsed as Record + const tool = typeof obj.tool === 'string' ? obj.tool.trim() : '' + if (!tool || !Object.prototype.hasOwnProperty.call(tools, tool)) continue + return { tool, input: obj.input ?? {} } + } catch { + // Ignore invalid json + } + } + + return null +} + +/** Tool action result → tool history message (CoreMessage shape). */ +export function toToolHistoryMessage(result: ToolActionResult): ChatMessage { + return { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: result.actionId, + toolName: result.tool, + output: { type: 'text', value: result.observation }, + }, + ], + } +} + +/** Fill missing tool results (rejection/abort) so every requested call has a protocol-complete result. */ +export function completeToolResultsForProtocol( + requested: Array<{ id: string; name: string }>, + actual: ToolActionResult[], + hasRejection: boolean, +): ToolActionResult[] { + const byActionId = new Map(actual.map((result) => [result.actionId, result])) + return requested.map((block) => { + const found = byActionId.get(block.id) + if (found) { + return found + } + return { + actionId: block.id, + tool: block.name, + status: hasRejection ? 'approval_denied' : 'execution_failed', + errorType: hasRejection ? 'approval_denied' : 'execution_failed', + success: false, + observation: hasRejection + ? `${TOOL_SKIPPED_AFTER_REJECTION_MESSAGE} ${block.name}` + : `Tool result missing for ${block.name}; execution aborted before producing output.`, + durationMs: 0, + rejected: hasRejection ? true : undefined, + } + }) +} diff --git a/packages/core/src/session/session.ts b/packages/core/src/agent/session.ts similarity index 80% rename from packages/core/src/session/session.ts rename to packages/core/src/agent/session.ts index 0d7384c..8620290 100644 --- a/packages/core/src/session/session.ts +++ b/packages/core/src/agent/session.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' -import { withDefaultDeps } from '@memo/core/session/defaults' -import { DEFAULT_SESSION_MODE } from '@memo/core/session/session_runtime_helpers' -import { AgentSessionImpl } from '@memo/core/session/session_runtime' +import { withDefaultDeps } from '@memo/core/agent/defaults' +import { DEFAULT_SESSION_MODE } from '@memo/core/agent/loop' +import { AgentSessionImpl } from '@memo/core/agent/loop' import type { AgentSession, AgentSessionDeps, AgentSessionOptions } from '@memo/core/types' /** diff --git a/packages/core/src/session/session_hooks.test.ts b/packages/core/src/agent/session_hooks.test.ts similarity index 99% rename from packages/core/src/session/session_hooks.test.ts rename to packages/core/src/agent/session_hooks.test.ts index e81d330..7b4fa2a 100644 --- a/packages/core/src/session/session_hooks.test.ts +++ b/packages/core/src/agent/session_hooks.test.ts @@ -4,8 +4,8 @@ import { describe, test } from 'vitest' import { createAgentSession, createTokenCounter } from '@memo/core' import type { ChatMessage, HistoryEvent, LLMResult, TokenCounter } from '@memo/core' import type { Tool } from '@memo/tools/router' -import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX } from '@memo/core/session/compact_prompt' -import { emptyUsage } from '@memo/core/session/session_runtime_helpers' +import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX } from '@memo/core/agent/compact_prompt' +import { emptyUsage } from '@memo/core/agent/loop' const echoTool: Tool = { name: 'echo', diff --git a/packages/core/src/session/slash/index.ts b/packages/core/src/agent/slash/index.ts similarity index 100% rename from packages/core/src/session/slash/index.ts rename to packages/core/src/agent/slash/index.ts diff --git a/packages/core/src/session/slash/registry.test.ts b/packages/core/src/agent/slash/registry.test.ts similarity index 100% rename from packages/core/src/session/slash/registry.test.ts rename to packages/core/src/agent/slash/registry.test.ts diff --git a/packages/core/src/session/slash/registry.ts b/packages/core/src/agent/slash/registry.ts similarity index 100% rename from packages/core/src/session/slash/registry.ts rename to packages/core/src/agent/slash/registry.ts diff --git a/packages/core/src/session/slash/types.ts b/packages/core/src/agent/slash/types.ts similarity index 100% rename from packages/core/src/session/slash/types.ts rename to packages/core/src/agent/slash/types.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 84ac055..04d076e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,13 +6,13 @@ export * from './history/history' export * from './history/history_parser' export * from './history/history_index' export * from './history/workspace' -export * from './session/file_suggestions' -export * from './session/slash' +export * from './agent/file_suggestions' +export * from './agent/slash' export * from './mcp/mcp_admin' export * from './skills/skills_admin' -export * from './session/defaults' +export * from './agent/defaults' export * from './config/config' export * from './utils/utils' export * from './utils/tokenizer' -export * from './session/session' +export * from './agent/session' export * from './api_types' diff --git a/packages/core/src/prompt/memory.test.ts b/packages/core/src/prompt/memory.test.ts index 69e439b..3430060 100644 --- a/packages/core/src/prompt/memory.test.ts +++ b/packages/core/src/prompt/memory.test.ts @@ -6,7 +6,7 @@ import { describe, test, beforeAll, afterAll } from 'vitest' import { writeFile, rm, mkdir } from 'node:fs/promises' import { createAgentSession, createTokenCounter, type ChatMessage } from '@memo/core' import { loadSystemPrompt } from '@memo/core/prompt/prompt' -import { emptyUsage } from '@memo/core/session/session_runtime_helpers' +import { emptyUsage } from '@memo/core/agent/loop' /** System messages carry string content; parts arrays (assistant/tool) are not expected here. */ function systemPromptOf(history: ChatMessage[]): string { diff --git a/packages/core/src/session/session_runtime_helpers.ts b/packages/core/src/session/session_runtime_helpers.ts deleted file mode 100644 index 775cc5d..0000000 --- a/packages/core/src/session/session_runtime_helpers.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { LanguageModelUsage, ToolCallPart } from 'ai' -import type { - AgentSessionOptions, - ChatMessage, - HistoryEvent, - HistorySink, - LLMResult, - SessionMode, - ToolPermissionMode, - ToolRegistry, -} from '@memo/core/types' -import type { ToolActionResult, ToolActionStatus } from '@memo/tools/orchestrator' - -export const DEFAULT_SESSION_MODE: SessionMode = 'interactive' -export const DEFAULT_CONTEXT_WINDOW = 120_000 -export const TOOL_ACTION_SUCCESS_STATUS: ToolActionStatus = 'success' -export const TOOL_DISABLED_ERROR_MESSAGE = - 'Tool usage is disabled in the current permission mode. Switch to /tools once or /tools full to enable tools.' -export const SESSION_TITLE_MAX_CHARS = 60 -export const TOOL_SKIPPED_AFTER_REJECTION_MESSAGE = 'Skipped tool execution after previous rejection.' -export const TOOL_SKIPPED_DISABLED_MESSAGE = 'Tool execution skipped: tools are disabled in current permission mode.' - -export type ResolvedToolPermission = { - mode: ToolPermissionMode | 'auto' - toolsDisabled: boolean - dangerous: boolean - approvalMode: 'auto' | 'strict' -} - -function writeStructuredError(payload: Record) { - process.stderr.write(`${JSON.stringify(payload)}\n`) -} - -export function resolveToolPermission(options: AgentSessionOptions): ResolvedToolPermission { - if (options.toolPermissionMode === 'none') { - return { - mode: 'none', - toolsDisabled: true, - dangerous: false, - approvalMode: 'auto', - } - } - - if (options.toolPermissionMode === 'once') { - return { - mode: 'once', - toolsDisabled: false, - dangerous: false, - approvalMode: 'auto', - } - } - - if (options.toolPermissionMode === 'full') { - return { - mode: 'full', - toolsDisabled: false, - dangerous: true, - approvalMode: 'auto', - } - } - - const dangerous = options.dangerous ?? false - return { - mode: dangerous ? 'full' : 'auto', - toolsDisabled: false, - dangerous, - approvalMode: 'auto', - } -} - -export function emptyUsage(): LanguageModelUsage { - return { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, - outputTokenDetails: { reasoningTokens: undefined }, - } -} - -export function accumulateUsage(target: LanguageModelUsage, delta?: Partial) { - if (!delta) return - const inputDelta = delta.inputTokens ?? 0 - const outputDelta = delta.outputTokens ?? 0 - const totalDelta = delta.totalTokens ?? inputDelta + outputDelta - target.inputTokens = (target.inputTokens ?? 0) + inputDelta - target.outputTokens = (target.outputTokens ?? 0) + outputDelta - target.totalTokens = (target.totalTokens ?? 0) + totalDelta -} - -export function parseToolArguments( - raw: string, -): { ok: true; data: unknown } | { ok: false; raw: string; error: string } { - try { - return { ok: true, data: JSON.parse(raw) } - } catch (err) { - return { ok: false, raw, error: (err as Error).message } - } -} - -/** Extract session-level fields from an AI SDK GenerateTextResult. */ -export function normalizeLLMResponse(raw: LLMResult): { - textContent: string - toolUseBlocks: Array<{ id: string; name: string; input: unknown }> - reasoningContent?: string - usage?: Partial -} { - let textContent = raw.text - const toolUseBlocks: Array<{ id: string; name: string; input: unknown }> = [] - for (const call of raw.toolCalls) { - if (typeof call.input === 'string') { - const parsed = parseToolArguments(call.input) - if (parsed.ok) { - toolUseBlocks.push({ id: call.toolCallId, name: call.toolName, input: parsed.data }) - } else { - textContent = `${textContent}\n[tool_use parse error] ${parsed.error}; raw: ${parsed.raw}`.trim() - } - } else { - toolUseBlocks.push({ id: call.toolCallId, name: call.toolName, input: call.input }) - } - } - return { - textContent, - toolUseBlocks, - reasoningContent: - typeof raw.reasoning === 'string' && raw.reasoning.trim().length > 0 ? raw.reasoning : undefined, - usage: raw.usage, - } -} - -export async function emitEventToSinks(event: HistoryEvent, sinks: HistorySink[]) { - for (const sink of sinks) { - try { - await sink.append(event) - } catch (err) { - writeStructuredError({ - level: 'error', - event: 'history_sink_append_failed', - sink: sink.constructor?.name || 'anonymous_sink', - message: (err as Error).message, - }) - } - } -} - -export function isAbortError(err: unknown): err is Error { - if (!(err instanceof Error)) return false - if (err.name === 'AbortError') return true - - const message = err.message?.toLowerCase?.() ?? '' - return ( - message.includes('request was aborted') || - message.includes('operation was aborted') || - message.includes('aborted') - ) -} - -// Stable serialization for duplicate action detection (ensures consistent key ordering) -export function stableStringify(value: unknown): string { - return stableStringifyWithSeen(value, new WeakSet(), 0) -} - -const MAX_STABLE_STRINGIFY_DEPTH = 100 - -function stableStringifyWithSeen(value: unknown, seen: WeakSet, depth: number): string { - if (depth > MAX_STABLE_STRINGIFY_DEPTH) { - return JSON.stringify('[MaxDepthExceeded]') - } - if (typeof value === 'bigint') { - return JSON.stringify(value.toString()) - } - if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' - if (seen.has(value)) { - return JSON.stringify('[Circular]') - } - - seen.add(value) - if (Array.isArray(value)) { - const result = `[${value.map((v) => stableStringifyWithSeen(v, seen, depth + 1)).join(',')}]` - seen.delete(value) - return result - } - const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)) - const result = `{${entries - .map(([k, v]) => `${JSON.stringify(k)}:${stableStringifyWithSeen(v, seen, depth + 1)}`) - .join(',')}}` - seen.delete(value) - return result -} - -export function buildAssistantToolCalls( - toolUseBlocks: Array<{ id: string; name: string; input: unknown }>, -): ToolCallPart[] { - return toolUseBlocks.map((block) => ({ - type: 'tool-call', - toolCallId: block.id, - toolName: block.name, - input: block.input, - })) -} - -export function parseTextToolCall(text: string, tools: ToolRegistry): { tool: string; input: unknown } | null { - const trimmed = text.trim() - if (!trimmed) return null - - const candidates = [trimmed] - const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) - if (fenced?.[1]) { - candidates.push(fenced[1].trim()) - } - - for (const candidate of candidates) { - if (!candidate.startsWith('{') || !candidate.endsWith('}')) continue - try { - const parsed = JSON.parse(candidate) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue - const obj = parsed as Record - const tool = typeof obj.tool === 'string' ? obj.tool.trim() : '' - if (!tool || !Object.prototype.hasOwnProperty.call(tools, tool)) continue - return { tool, input: obj.input ?? {} } - } catch { - // Ignore invalid json - } - } - - return null -} - -export function truncateSessionTitle(input: string): string { - if (input.length <= SESSION_TITLE_MAX_CHARS) return input - return `${input.slice(0, SESSION_TITLE_MAX_CHARS - 3).trimEnd()}...` -} - -export function normalizeSessionTitle(raw: string): string { - const compact = raw - .replace(/<\s*(think|thinking)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ') - .replace(/<\s*\/?\s*(think|thinking)\b[^>]*>/gi, ' ') - .replace(/\r?\n+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - if (!compact) return '' - const unprefixed = compact.replace(/^(title|session title|标题)\s*[::-]\s*/i, '').trim() - if (!unprefixed) return '' - const unquoted = unprefixed.replace(/^["'`“”‘’]+|["'`“”‘’]+$/g, '').trim() - if (!unquoted) return '' - return truncateSessionTitle(unquoted) -} - -export function fallbackSessionTitleFromPrompt(input: string): string { - const compact = input.replace(/\s+/g, ' ').trim() - if (!compact) return 'New Session' - - // Keep short CJK/non-space prompts readable. - if (!compact.includes(' ')) { - return compact.length <= 20 ? compact : `${compact.slice(0, 20).trimEnd()}...` - } - - const words = compact.split(' ').filter(Boolean) - const short = words.slice(0, 8).join(' ') - return truncateSessionTitle(short || compact) -} - -export function toToolHistoryMessage(result: ToolActionResult): ChatMessage { - return { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: result.actionId, - toolName: result.tool, - output: { type: 'text', value: result.observation }, - }, - ], - } -} - -export function completeToolResultsForProtocol( - requested: Array<{ id: string; name: string }>, - actual: ToolActionResult[], - hasRejection: boolean, -): ToolActionResult[] { - const byActionId = new Map(actual.map((result) => [result.actionId, result])) - return requested.map((block) => { - const found = byActionId.get(block.id) - if (found) { - return found - } - return { - actionId: block.id, - tool: block.name, - status: hasRejection ? 'approval_denied' : 'execution_failed', - errorType: hasRejection ? 'approval_denied' : 'execution_failed', - success: false, - observation: hasRejection - ? `${TOOL_SKIPPED_AFTER_REJECTION_MESSAGE} ${block.name}` - : `Tool result missing for ${block.name}; execution aborted before producing output.`, - durationMs: 0, - rejected: hasRejection ? true : undefined, - } - }) -} diff --git a/packages/tui/src/features/composer/Composer.tsx b/packages/tui/src/features/composer/Composer.tsx index 72543dc..2e6a34e 100644 --- a/packages/tui/src/features/composer/Composer.tsx +++ b/packages/tui/src/features/composer/Composer.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Box, Text, useInput, useStdout } from 'ink' import type { MCPServerConfig, ProviderConfig } from '@memo/core' -import { resolveSlashCommand, SLASH_SPECS, type SlashContext } from '@memo/core/session/slash' +import { resolveSlashCommand, SLASH_SPECS, type SlashContext } from '@memo/core/agent/slash' import { getFileSuggestions } from './fileSuggestions' import { loadSessionHistoryEntries, type SessionHistoryEntry } from '../session/sessionHistory' import { SuggestionPanel, type SuggestionItem } from './SuggestionPanel' diff --git a/packages/tui/src/features/composer/fileSuggestions.ts b/packages/tui/src/features/composer/fileSuggestions.ts index 2e7b179..81c17b4 100644 --- a/packages/tui/src/features/composer/fileSuggestions.ts +++ b/packages/tui/src/features/composer/fileSuggestions.ts @@ -4,7 +4,7 @@ import { normalizePath as normalizeCorePath, type FileSuggestion as CoreFileSuggestion, type FileSuggestionRequest as CoreFileSuggestionRequest, -} from '@memo/core/session/file_suggestions' +} from '@memo/core/agent/file_suggestions' import type { FileSuggestion, FileSuggestionRequest } from '../../shared/types' export function normalizePath(input: string): string { From bf17dbc1803b5c6b71f8c5617a5f8925fe2267e8 Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 15:43:08 +0800 Subject: [PATCH 04/42] refactor(core): classify slash/file-suggestions/history-read as features - features/ holds user-facing capabilities built on contracts: slash commands, file suggestions, history parser/index (resume view) - history/ keeps only the write-side JSONL sink used by the loop - workspace.ts moves to utils/ (shared by skills_admin and history features) --- packages/core/README.md | 6 +++--- .../{agent => features}/file_suggestions.test.ts | 0 .../src/{agent => features}/file_suggestions.ts | 0 .../{history => features}/history_index.test.ts | 0 .../src/{history => features}/history_index.ts | 2 +- .../{history => features}/history_parser.test.ts | 0 .../src/{history => features}/history_parser.ts | 2 +- .../core/src/{agent => features}/slash/index.ts | 0 .../src/{agent => features}/slash/registry.test.ts | 0 .../core/src/{agent => features}/slash/registry.ts | 0 .../core/src/{agent => features}/slash/types.ts | 0 packages/core/src/index.ts | 10 +++++----- packages/core/src/skills/skills_admin.ts | 2 +- .../core/src/{history => utils}/workspace.test.ts | 0 packages/core/src/{history => utils}/workspace.ts | 0 packages/tui/src/features/composer/Composer.tsx | 2 +- .../tui/src/features/composer/fileSuggestions.ts | 2 +- packages/tui/src/features/session/historyParser.ts | 14 +++++++++++++- 18 files changed, 26 insertions(+), 14 deletions(-) rename packages/core/src/{agent => features}/file_suggestions.test.ts (100%) rename packages/core/src/{agent => features}/file_suggestions.ts (100%) rename packages/core/src/{history => features}/history_index.test.ts (100%) rename packages/core/src/{history => features}/history_index.ts (99%) rename packages/core/src/{history => features}/history_parser.test.ts (100%) rename packages/core/src/{history => features}/history_parser.ts (99%) rename packages/core/src/{agent => features}/slash/index.ts (100%) rename packages/core/src/{agent => features}/slash/registry.test.ts (100%) rename packages/core/src/{agent => features}/slash/registry.ts (100%) rename packages/core/src/{agent => features}/slash/types.ts (100%) rename packages/core/src/{history => utils}/workspace.test.ts (100%) rename packages/core/src/{history => utils}/workspace.ts (100%) diff --git a/packages/core/README.md b/packages/core/README.md index 2ef1983..5cb05ec 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -17,12 +17,12 @@ Core provides the central capabilities of **Memo Code**: the ReAct loop, session - `defaults.ts`: Composition root — default dependency completion (toolset, LLM, prompt, history sink, tokenizer). - `hooks.ts`: Hook/middleware runners and history snapshotting. - `compact_prompt.ts`: Context compaction prompt building. +- `features/` — user-facing capabilities built on the contracts (not part of the loop) - `slash/`: Slash command specs and registry. - `file_suggestions.ts`: File suggestion helpers for the composer. + - `history_parser.ts` / `history_index.ts`: Session history viewing and resume (read side of the JSONL logs). - `history/` - - `history.ts`: JSONL history sink and event construction. - - `history_parser.ts` / `history_index.ts`: Session log parsing and indexing. - - `workspace.ts`: Workspace path normalization. + - `history.ts`: JSONL history sink and event construction (write side, used by the loop). - `prompt/` - `prompt.ts` + `prompt.md`: System prompt loading (runtime context, AGENTS.md/SOUL.md/skills injection). - `skills/` / `mcp/` diff --git a/packages/core/src/agent/file_suggestions.test.ts b/packages/core/src/features/file_suggestions.test.ts similarity index 100% rename from packages/core/src/agent/file_suggestions.test.ts rename to packages/core/src/features/file_suggestions.test.ts diff --git a/packages/core/src/agent/file_suggestions.ts b/packages/core/src/features/file_suggestions.ts similarity index 100% rename from packages/core/src/agent/file_suggestions.ts rename to packages/core/src/features/file_suggestions.ts diff --git a/packages/core/src/history/history_index.test.ts b/packages/core/src/features/history_index.test.ts similarity index 100% rename from packages/core/src/history/history_index.test.ts rename to packages/core/src/features/history_index.test.ts diff --git a/packages/core/src/history/history_index.ts b/packages/core/src/features/history_index.ts similarity index 99% rename from packages/core/src/history/history_index.ts rename to packages/core/src/features/history_index.ts index e627282..629d81d 100644 --- a/packages/core/src/history/history_index.ts +++ b/packages/core/src/features/history_index.ts @@ -9,7 +9,7 @@ import type { ToolUsageSummary, } from '../api_types.js' import { parseHistoryLogToSessionDetail } from './history_parser.js' -import { cwdBelongsToWorkspace } from './workspace.js' +import { cwdBelongsToWorkspace } from '../utils/workspace.js' type SessionFileMeta = { filePath: string diff --git a/packages/core/src/history/history_parser.test.ts b/packages/core/src/features/history_parser.test.ts similarity index 100% rename from packages/core/src/history/history_parser.test.ts rename to packages/core/src/features/history_parser.test.ts diff --git a/packages/core/src/history/history_parser.ts b/packages/core/src/features/history_parser.ts similarity index 99% rename from packages/core/src/history/history_parser.ts rename to packages/core/src/features/history_parser.ts index 3b4d9fe..40b314a 100644 --- a/packages/core/src/history/history_parser.ts +++ b/packages/core/src/features/history_parser.ts @@ -9,7 +9,7 @@ import type { TokenUsageSummary, ToolUsageSummary, } from '../api_types.js' -import { workspaceIdFromCwd } from './workspace.js' +import { workspaceIdFromCwd } from '../utils/workspace.js' type MutableTurnDetail = SessionTurnDetail & { byStep: Map diff --git a/packages/core/src/agent/slash/index.ts b/packages/core/src/features/slash/index.ts similarity index 100% rename from packages/core/src/agent/slash/index.ts rename to packages/core/src/features/slash/index.ts diff --git a/packages/core/src/agent/slash/registry.test.ts b/packages/core/src/features/slash/registry.test.ts similarity index 100% rename from packages/core/src/agent/slash/registry.test.ts rename to packages/core/src/features/slash/registry.test.ts diff --git a/packages/core/src/agent/slash/registry.ts b/packages/core/src/features/slash/registry.ts similarity index 100% rename from packages/core/src/agent/slash/registry.ts rename to packages/core/src/features/slash/registry.ts diff --git a/packages/core/src/agent/slash/types.ts b/packages/core/src/features/slash/types.ts similarity index 100% rename from packages/core/src/agent/slash/types.ts rename to packages/core/src/features/slash/types.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04d076e..bf786c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,11 +3,11 @@ export * from './types' export * from './prompt/prompt' export * from './skills/skills' export * from './history/history' -export * from './history/history_parser' -export * from './history/history_index' -export * from './history/workspace' -export * from './agent/file_suggestions' -export * from './agent/slash' +export * from './features/history_parser' +export * from './features/history_index' +export * from './utils/workspace' +export * from './features/file_suggestions' +export * from './features/slash' export * from './mcp/mcp_admin' export * from './skills/skills_admin' export * from './agent/defaults' diff --git a/packages/core/src/skills/skills_admin.ts b/packages/core/src/skills/skills_admin.ts index d2721fc..e11af23 100644 --- a/packages/core/src/skills/skills_admin.ts +++ b/packages/core/src/skills/skills_admin.ts @@ -2,7 +2,7 @@ import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promise import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { loadMemoConfig, writeMemoConfig, type MemoConfig } from '../config/config.js' -import { normalizeWorkspacePath } from '../history/workspace.js' +import { normalizeWorkspacePath } from '../utils/workspace.js' import type { SkillRecord } from '../api_types.js' type SkillScope = 'project' | 'global' diff --git a/packages/core/src/history/workspace.test.ts b/packages/core/src/utils/workspace.test.ts similarity index 100% rename from packages/core/src/history/workspace.test.ts rename to packages/core/src/utils/workspace.test.ts diff --git a/packages/core/src/history/workspace.ts b/packages/core/src/utils/workspace.ts similarity index 100% rename from packages/core/src/history/workspace.ts rename to packages/core/src/utils/workspace.ts diff --git a/packages/tui/src/features/composer/Composer.tsx b/packages/tui/src/features/composer/Composer.tsx index 2e6a34e..51bbd90 100644 --- a/packages/tui/src/features/composer/Composer.tsx +++ b/packages/tui/src/features/composer/Composer.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Box, Text, useInput, useStdout } from 'ink' import type { MCPServerConfig, ProviderConfig } from '@memo/core' -import { resolveSlashCommand, SLASH_SPECS, type SlashContext } from '@memo/core/agent/slash' +import { resolveSlashCommand, SLASH_SPECS, type SlashContext } from '@memo/core/features/slash' import { getFileSuggestions } from './fileSuggestions' import { loadSessionHistoryEntries, type SessionHistoryEntry } from '../session/sessionHistory' import { SuggestionPanel, type SuggestionItem } from './SuggestionPanel' diff --git a/packages/tui/src/features/composer/fileSuggestions.ts b/packages/tui/src/features/composer/fileSuggestions.ts index 81c17b4..7bb185f 100644 --- a/packages/tui/src/features/composer/fileSuggestions.ts +++ b/packages/tui/src/features/composer/fileSuggestions.ts @@ -4,7 +4,7 @@ import { normalizePath as normalizeCorePath, type FileSuggestion as CoreFileSuggestion, type FileSuggestionRequest as CoreFileSuggestionRequest, -} from '@memo/core/agent/file_suggestions' +} from '@memo/core/features/file_suggestions' import type { FileSuggestion, FileSuggestionRequest } from '../../shared/types' export function normalizePath(input: string): string { diff --git a/packages/tui/src/features/session/historyParser.ts b/packages/tui/src/features/session/historyParser.ts index 25f3f9e..0eddd31 100644 --- a/packages/tui/src/features/session/historyParser.ts +++ b/packages/tui/src/features/session/historyParser.ts @@ -46,7 +46,19 @@ function toTurnView(turn: SessionTurnDetail, sequence: number, turnIndex: number })), status: normalizeTurnStatus(turn.status), errorMessage: turn.errorMessage, - tokenUsage: turn.tokenUsage, + tokenUsage: turn.tokenUsage + ? { + inputTokens: turn.tokenUsage.prompt, + outputTokens: turn.tokenUsage.completion, + totalTokens: turn.tokenUsage.total, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + } + : undefined, finalText: toAssistantText(turn), sequence, } From c3ed5d889caaa0f1344184c5806db766ef00aa96 Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 15:45:06 +0800 Subject: [PATCH 05/42] refactor(core): organize features as modules with index exports Each feature is a directory with its own index.ts (file_suggestions/, history/, slash/) so module boundaries and exports are explicit. --- packages/core/README.md | 6 +++--- .../{ => file_suggestions}/file_suggestions.test.ts | 0 .../src/features/{ => file_suggestions}/file_suggestions.ts | 0 packages/core/src/features/file_suggestions/index.ts | 1 + .../core/src/features/{ => history}/history_index.test.ts | 0 packages/core/src/features/{ => history}/history_index.ts | 4 ++-- .../core/src/features/{ => history}/history_parser.test.ts | 0 packages/core/src/features/{ => history}/history_parser.ts | 4 ++-- packages/core/src/features/history/index.ts | 2 ++ packages/core/src/index.ts | 3 +-- 10 files changed, 11 insertions(+), 9 deletions(-) rename packages/core/src/features/{ => file_suggestions}/file_suggestions.test.ts (100%) rename packages/core/src/features/{ => file_suggestions}/file_suggestions.ts (100%) create mode 100644 packages/core/src/features/file_suggestions/index.ts rename packages/core/src/features/{ => history}/history_index.test.ts (100%) rename packages/core/src/features/{ => history}/history_index.ts (99%) rename packages/core/src/features/{ => history}/history_parser.test.ts (100%) rename packages/core/src/features/{ => history}/history_parser.ts (99%) create mode 100644 packages/core/src/features/history/index.ts diff --git a/packages/core/README.md b/packages/core/README.md index 5cb05ec..ce95299 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -17,10 +17,10 @@ Core provides the central capabilities of **Memo Code**: the ReAct loop, session - `defaults.ts`: Composition root — default dependency completion (toolset, LLM, prompt, history sink, tokenizer). - `hooks.ts`: Hook/middleware runners and history snapshotting. - `compact_prompt.ts`: Context compaction prompt building. -- `features/` — user-facing capabilities built on the contracts (not part of the loop) +- `features/` — user-facing capabilities built on the contracts (not part of the loop); one directory per module, exports via `index.ts` - `slash/`: Slash command specs and registry. - - `file_suggestions.ts`: File suggestion helpers for the composer. - - `history_parser.ts` / `history_index.ts`: Session history viewing and resume (read side of the JSONL logs). + - `file_suggestions/`: File suggestion helpers for the composer. + - `history/`: Session history viewing and resume (read side of the JSONL logs). - `history/` - `history.ts`: JSONL history sink and event construction (write side, used by the loop). - `prompt/` diff --git a/packages/core/src/features/file_suggestions.test.ts b/packages/core/src/features/file_suggestions/file_suggestions.test.ts similarity index 100% rename from packages/core/src/features/file_suggestions.test.ts rename to packages/core/src/features/file_suggestions/file_suggestions.test.ts diff --git a/packages/core/src/features/file_suggestions.ts b/packages/core/src/features/file_suggestions/file_suggestions.ts similarity index 100% rename from packages/core/src/features/file_suggestions.ts rename to packages/core/src/features/file_suggestions/file_suggestions.ts diff --git a/packages/core/src/features/file_suggestions/index.ts b/packages/core/src/features/file_suggestions/index.ts new file mode 100644 index 0000000..42979c8 --- /dev/null +++ b/packages/core/src/features/file_suggestions/index.ts @@ -0,0 +1 @@ +export * from './file_suggestions' diff --git a/packages/core/src/features/history_index.test.ts b/packages/core/src/features/history/history_index.test.ts similarity index 100% rename from packages/core/src/features/history_index.test.ts rename to packages/core/src/features/history/history_index.test.ts diff --git a/packages/core/src/features/history_index.ts b/packages/core/src/features/history/history_index.ts similarity index 99% rename from packages/core/src/features/history_index.ts rename to packages/core/src/features/history/history_index.ts index 629d81d..b036903 100644 --- a/packages/core/src/features/history_index.ts +++ b/packages/core/src/features/history/history_index.ts @@ -7,9 +7,9 @@ import type { SessionListItem, SessionListResponse, ToolUsageSummary, -} from '../api_types.js' +} from '../../api_types.js' import { parseHistoryLogToSessionDetail } from './history_parser.js' -import { cwdBelongsToWorkspace } from '../utils/workspace.js' +import { cwdBelongsToWorkspace } from '../../utils/workspace.js' type SessionFileMeta = { filePath: string diff --git a/packages/core/src/features/history_parser.test.ts b/packages/core/src/features/history/history_parser.test.ts similarity index 100% rename from packages/core/src/features/history_parser.test.ts rename to packages/core/src/features/history/history_parser.test.ts diff --git a/packages/core/src/features/history_parser.ts b/packages/core/src/features/history/history_parser.ts similarity index 99% rename from packages/core/src/features/history_parser.ts rename to packages/core/src/features/history/history_parser.ts index 40b314a..93b9b64 100644 --- a/packages/core/src/features/history_parser.ts +++ b/packages/core/src/features/history/history_parser.ts @@ -8,8 +8,8 @@ import type { SessionTurnStep, TokenUsageSummary, ToolUsageSummary, -} from '../api_types.js' -import { workspaceIdFromCwd } from '../utils/workspace.js' +} from '../../api_types.js' +import { workspaceIdFromCwd } from '../../utils/workspace.js' type MutableTurnDetail = SessionTurnDetail & { byStep: Map diff --git a/packages/core/src/features/history/index.ts b/packages/core/src/features/history/index.ts new file mode 100644 index 0000000..7243456 --- /dev/null +++ b/packages/core/src/features/history/index.ts @@ -0,0 +1,2 @@ +export * from './history_parser' +export * from './history_index' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bf786c3..615b1c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,11 +3,10 @@ export * from './types' export * from './prompt/prompt' export * from './skills/skills' export * from './history/history' -export * from './features/history_parser' -export * from './features/history_index' export * from './utils/workspace' export * from './features/file_suggestions' export * from './features/slash' +export * from './features/history' export * from './mcp/mcp_admin' export * from './skills/skills_admin' export * from './agent/defaults' From 2b24245bad6e187cb085e0bdfb88fb87df692454 Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 15:47:35 +0800 Subject: [PATCH 06/42] refactor(core): merge history write-side into features/history module - createHistoryEvent (loop behavior) moves into agent/loop.ts - JsonlHistorySink joins the features/history module (write + read in one place); the composition root injects it, the loop only knows the HistorySink contract - removes the standalone history/ directory from the core root --- packages/core/README.md | 4 +-- packages/core/src/agent/defaults.ts | 2 +- .../agent/defaults.with_default_deps.test.ts | 2 +- packages/core/src/agent/loop.ts | 24 ++++++++++++++++- .../history/history_sink.test.ts} | 3 ++- .../history/history_sink.ts} | 26 ++----------------- packages/core/src/features/history/index.ts | 1 + packages/core/src/index.ts | 1 - 8 files changed, 31 insertions(+), 32 deletions(-) rename packages/core/src/{history/history.test.ts => features/history/history_sink.test.ts} (98%) rename packages/core/src/{history/history.ts => features/history/history_sink.ts} (62%) diff --git a/packages/core/README.md b/packages/core/README.md index ce95299..155dab9 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -20,9 +20,7 @@ Core provides the central capabilities of **Memo Code**: the ReAct loop, session - `features/` — user-facing capabilities built on the contracts (not part of the loop); one directory per module, exports via `index.ts` - `slash/`: Slash command specs and registry. - `file_suggestions/`: File suggestion helpers for the composer. - - `history/`: Session history viewing and resume (read side of the JSONL logs). -- `history/` - - `history.ts`: JSONL history sink and event construction (write side, used by the loop). + - `history/`: Complete session-history module — JSONL sink (write side, injected by the composition root), parser and index (read side for resume/viewing). - `prompt/` - `prompt.ts` + `prompt.md`: System prompt loading (runtime context, AGENTS.md/SOUL.md/skills injection). - `skills/` / `mcp/` diff --git a/packages/core/src/agent/defaults.ts b/packages/core/src/agent/defaults.ts index b5b4ab2..b94eb2f 100644 --- a/packages/core/src/agent/defaults.ts +++ b/packages/core/src/agent/defaults.ts @@ -2,7 +2,7 @@ import { NATIVE_TOOLS } from '@memo/tools' import { createTokenCounter } from '@memo/core/utils/tokenizer' import { buildSessionPath, getSessionsDir, loadMemoConfig, selectProvider } from '@memo/core/config/config' -import { JsonlHistorySink } from '@memo/core/history/history' +import { JsonlHistorySink } from '@memo/core/features/history' import { resolveModelProfile } from '@memo/core/llm/model_profile' import { streamCallLLM } from '@memo/core/llm/ai_stream' import { getProviderFactory } from '@memo/core/llm/ai_provider' diff --git a/packages/core/src/agent/defaults.with_default_deps.test.ts b/packages/core/src/agent/defaults.with_default_deps.test.ts index 2271e95..6c76170 100644 --- a/packages/core/src/agent/defaults.with_default_deps.test.ts +++ b/packages/core/src/agent/defaults.with_default_deps.test.ts @@ -91,7 +91,7 @@ vi.mock('@memo/core/config/config', () => ({ buildSessionPath: vi.fn(() => state.sessionPath), })) -vi.mock('@memo/core/history/history', () => ({ +vi.mock('@memo/core/features/history', () => ({ JsonlHistorySink: class JsonlHistorySink { constructor(path: string) { state.historySinkPaths.push(path) diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 175c8f0..670fbc9 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -1,6 +1,5 @@ /** @file Session/Turn runtime core: handles ReAct loop, tool scheduling, and event logging. */ import { randomUUID } from 'node:crypto' -import { createHistoryEvent } from '@memo/core/history/history' import { buildThinking } from '@memo/core/utils/utils' import { buildCompactionUserPrompt, @@ -19,6 +18,7 @@ import type { HistoryEvent, HistorySink, ParsedAssistant, + Role, SessionMode, ToolPermissionMode, TokenCounter, @@ -1197,6 +1197,28 @@ export class AgentSessionImpl implements AgentSession { } } +/** Helper to generate structured history events. */ +export function createHistoryEvent(params: { + sessionId: string + type: HistoryEvent['type'] + turn?: number + step?: number + content?: string + role?: Role + meta?: Record +}): HistoryEvent { + return { + ts: new Date().toISOString(), + sessionId: params.sessionId, + turn: params.turn, + step: params.step, + type: params.type, + content: params.content, + role: params.role, + meta: params.meta, + } +} + // --- Agent loop constants and helpers --------------------------------------------- export const DEFAULT_SESSION_MODE: SessionMode = 'interactive' diff --git a/packages/core/src/history/history.test.ts b/packages/core/src/features/history/history_sink.test.ts similarity index 98% rename from packages/core/src/history/history.test.ts rename to packages/core/src/features/history/history_sink.test.ts index e090c99..1f28b7f 100644 --- a/packages/core/src/history/history.test.ts +++ b/packages/core/src/features/history/history_sink.test.ts @@ -3,7 +3,8 @@ import { unlink, readFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { JsonlHistorySink, createHistoryEvent } from '@memo/core/history/history' +import { JsonlHistorySink } from '@memo/core/features/history' +import { createHistoryEvent } from '@memo/core/agent/loop' const getTempFilePath = () => join(tmpdir(), `memo-test-${Date.now()}.jsonl`) diff --git a/packages/core/src/history/history.ts b/packages/core/src/features/history/history_sink.ts similarity index 62% rename from packages/core/src/history/history.ts rename to packages/core/src/features/history/history_sink.ts index df2a470..5c3db44 100644 --- a/packages/core/src/history/history.ts +++ b/packages/core/src/features/history/history_sink.ts @@ -1,7 +1,7 @@ -/** @file History event definition and JSONL Sink implementation. */ +/** @file JSONL history writer: one event per line. */ import { appendFile, mkdir } from 'node:fs/promises' import { dirname } from 'node:path' -import type { HistoryEvent, HistorySink, Role } from '@memo/core/types' +import type { HistoryEvent, HistorySink } from '@memo/core/types' /** JSONL history writer: one event per line. */ export class JsonlHistorySink implements HistorySink { @@ -39,25 +39,3 @@ export class JsonlHistorySink implements HistorySink { await this.flush() } } - -/** Helper to generate structured history events. */ -export function createHistoryEvent(params: { - sessionId: string - type: HistoryEvent['type'] - turn?: number - step?: number - content?: string - role?: Role - meta?: Record -}): HistoryEvent { - return { - ts: new Date().toISOString(), - sessionId: params.sessionId, - turn: params.turn, - step: params.step, - type: params.type, - content: params.content, - role: params.role, - meta: params.meta, - } -} diff --git a/packages/core/src/features/history/index.ts b/packages/core/src/features/history/index.ts index 7243456..48d9f0e 100644 --- a/packages/core/src/features/history/index.ts +++ b/packages/core/src/features/history/index.ts @@ -1,2 +1,3 @@ +export * from './history_sink' export * from './history_parser' export * from './history_index' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 615b1c9..0f56303 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,7 +2,6 @@ export * from './types' export * from './prompt/prompt' export * from './skills/skills' -export * from './history/history' export * from './utils/workspace' export * from './features/file_suggestions' export * from './features/slash' From 1fb12f1bfac1135663662668c58387c142c49d87 Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 16:12:08 +0800 Subject: [PATCH 07/42] refactor(core): merge tools package back into core The tools package had no build boundary (everything is bundled into the tui dist), no publish target, and core depended on it without declaring it. The agent runtime core (orchestrator/approval/router/runtime) plus the 24 built-in tool implementations now live under core/src/tools/; aliases unified to @memo/core/tools/*. --- package.json | 1 - packages/core/package.json | 5 ++- packages/core/src/agent/defaults.ts | 4 +- .../agent/defaults.with_default_deps.test.ts | 6 +-- packages/core/src/agent/loop.ts | 10 ++--- packages/core/src/agent/messages.ts | 2 +- packages/core/src/agent/session_hooks.test.ts | 2 +- packages/core/src/index.ts | 3 ++ packages/core/src/mcp/mcp_admin.test.ts | 2 +- packages/core/src/mcp/mcp_admin.ts | 2 +- .../src/tools}/approval/classifier.test.ts | 0 .../src/tools}/approval/classifier.ts | 0 .../src/tools}/approval/constants.ts | 0 .../src/tools}/approval/fingerprint.ts | 0 .../src => core/src/tools}/approval/index.ts | 0 .../src/tools}/approval/manager.test.ts | 0 .../src/tools}/approval/manager.ts | 0 .../src => core/src/tools}/approval/types.ts | 0 .../src => core/src/tools}/index.test.ts | 0 .../{tools/src => core/src/tools}/index.ts | 42 +++++++++---------- .../src/tools}/orchestrator/index.test.ts | 0 .../src/tools}/orchestrator/index.ts | 4 +- .../src/tools}/orchestrator/types.ts | 7 +++- .../src/tools}/router/index.test.ts | 0 .../src => core/src/tools}/router/index.ts | 0 .../src/tools}/router/mcp/cache_store.test.ts | 0 .../src/tools}/router/mcp/cache_store.ts | 0 .../src/tools}/router/mcp/context.ts | 0 .../src/tools}/router/mcp/index.test.ts | 0 .../src/tools}/router/mcp/index.ts | 0 .../tools}/router/mcp/oauth.runtime.test.ts | 0 .../src/tools}/router/mcp/oauth.test.ts | 0 .../src/tools}/router/mcp/oauth.ts | 0 .../src/tools}/router/mcp/pool.test.ts | 0 .../src => core/src/tools}/router/mcp/pool.ts | 0 .../src/tools}/router/native/index.test.ts | 0 .../src/tools}/router/native/index.ts | 0 .../src => core/src/tools}/router/types.ts | 0 .../src => core/src/tools}/runtime/context.ts | 0 .../src/tools}/runtime/tool_output_limits.ts | 0 .../src/tools}/tools/apply_patch.test.ts | 4 +- .../src/tools}/tools/apply_patch.ts | 8 ++-- .../src/tools}/tools/codex_tools.test.ts | 20 ++++----- .../src/tools}/tools/collab.test.ts | 2 +- .../src => core/src/tools}/tools/collab.ts | 6 +-- .../src/tools}/tools/command_guard.test.ts | 2 +- .../src/tools}/tools/command_guard.ts | 0 .../src => core/src/tools}/tools/edit_file.ts | 8 ++-- .../src/tools}/tools/exec_command.test.ts | 0 .../src/tools}/tools/exec_command.ts | 6 +-- .../src/tools}/tools/exec_runtime.test.ts | 0 .../src/tools}/tools/exec_runtime.ts | 4 +- .../src/tools}/tools/filesystem/lib.ts | 0 .../src/tools}/tools/filesystem/path-utils.ts | 0 .../tools/filesystem/path-validation.ts | 0 .../src/tools}/tools/filesystem/roots.ts | 2 +- .../src/tools}/tools/filesystem_tools.test.ts | 14 +++---- .../src/tools}/tools/get_memory.test.ts | 2 +- .../src/tools}/tools/get_memory.ts | 4 +- .../src/tools}/tools/helpers.test.ts | 2 +- .../src => core/src/tools}/tools/helpers.ts | 4 +- .../src/tools}/tools/list_directory.ts | 8 ++-- .../src => core/src/tools}/tools/mcp.test.ts | 0 .../src => core/src/tools}/tools/mcp.ts | 0 .../src/tools}/tools/mcp_resources.test.ts | 4 +- .../src/tools}/tools/mcp_resources.ts | 8 ++-- .../src/tools}/tools/read_files.ts | 8 ++-- .../src/tools}/tools/read_media_file.ts | 8 ++-- .../src/tools}/tools/read_text_file.ts | 8 ++-- .../src/tools}/tools/search_files.ts | 8 ++-- .../src/tools}/tools/shell.test.ts | 0 .../src => core/src/tools}/tools/shell.ts | 6 +-- .../src/tools}/tools/shell_command.test.ts | 0 .../src/tools}/tools/shell_command.ts | 6 +-- .../tools}/tools/shell_update_plan.test.ts | 8 ++-- .../src => core/src/tools}/tools/types.ts | 2 +- .../src/tools}/tools/update_plan.ts | 4 +- .../src/tools}/tools/webfetch.test.ts | 2 +- .../src => core/src/tools}/tools/webfetch.ts | 4 +- .../src/tools}/tools/write_file.ts | 8 ++-- .../src/tools}/tools/write_stdin.ts | 6 +-- packages/core/src/types.ts | 12 +++--- packages/tools/package.json | 20 --------- packages/tui/package.json | 1 - packages/tui/src/app/hooks/useApproval.ts | 2 +- packages/tui/src/commands/mcp/list.tsx | 4 +- packages/tui/src/commands/mcp/login.tsx | 2 +- packages/tui/src/commands/mcp/logout.tsx | 2 +- .../src/features/approval/ApprovalOverlay.tsx | 2 +- .../approval/approvalNotification.test.ts | 2 +- .../features/approval/approvalNotification.ts | 2 +- packages/tui/src/features/mcp/mcpHelpers.ts | 2 +- packages/tui/src/shared/lib/utils.ts | 2 +- pnpm-lock.yaml | 42 ++++--------------- tsconfig.json | 2 - 95 files changed, 166 insertions(+), 207 deletions(-) rename packages/{tools/src => core/src/tools}/approval/classifier.test.ts (100%) rename packages/{tools/src => core/src/tools}/approval/classifier.ts (100%) rename packages/{tools/src => core/src/tools}/approval/constants.ts (100%) rename packages/{tools/src => core/src/tools}/approval/fingerprint.ts (100%) rename packages/{tools/src => core/src/tools}/approval/index.ts (100%) rename packages/{tools/src => core/src/tools}/approval/manager.test.ts (100%) rename packages/{tools/src => core/src/tools}/approval/manager.ts (100%) rename packages/{tools/src => core/src/tools}/approval/types.ts (100%) rename packages/{tools/src => core/src/tools}/index.test.ts (100%) rename packages/{tools/src => core/src/tools}/index.ts (60%) rename packages/{tools/src => core/src/tools}/orchestrator/index.test.ts (100%) rename packages/{tools/src => core/src/tools}/orchestrator/index.ts (98%) rename packages/{tools/src => core/src/tools}/orchestrator/types.ts (94%) rename packages/{tools/src => core/src/tools}/router/index.test.ts (100%) rename packages/{tools/src => core/src/tools}/router/index.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/cache_store.test.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/cache_store.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/context.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/index.test.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/index.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/oauth.runtime.test.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/oauth.test.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/oauth.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/pool.test.ts (100%) rename packages/{tools/src => core/src/tools}/router/mcp/pool.ts (100%) rename packages/{tools/src => core/src/tools}/router/native/index.test.ts (100%) rename packages/{tools/src => core/src/tools}/router/native/index.ts (100%) rename packages/{tools/src => core/src/tools}/router/types.ts (100%) rename packages/{tools/src => core/src/tools}/runtime/context.ts (100%) rename packages/{tools/src => core/src/tools}/runtime/tool_output_limits.ts (100%) rename packages/{tools/src => core/src/tools}/tools/apply_patch.test.ts (98%) rename packages/{tools/src => core/src/tools}/tools/apply_patch.ts (98%) rename packages/{tools/src => core/src/tools}/tools/codex_tools.test.ts (93%) rename packages/{tools/src => core/src/tools}/tools/collab.test.ts (99%) rename packages/{tools/src => core/src/tools}/tools/collab.ts (98%) rename packages/{tools/src => core/src/tools}/tools/command_guard.test.ts (98%) rename packages/{tools/src => core/src/tools}/tools/command_guard.ts (100%) rename packages/{tools/src => core/src/tools}/tools/edit_file.ts (81%) rename packages/{tools/src => core/src/tools}/tools/exec_command.test.ts (100%) rename packages/{tools/src => core/src/tools}/tools/exec_command.ts (87%) rename packages/{tools/src => core/src/tools}/tools/exec_runtime.test.ts (100%) rename packages/{tools/src => core/src/tools}/tools/exec_runtime.ts (99%) rename packages/{tools/src => core/src/tools}/tools/filesystem/lib.ts (100%) rename packages/{tools/src => core/src/tools}/tools/filesystem/path-utils.ts (100%) rename packages/{tools/src => core/src/tools}/tools/filesystem/path-validation.ts (100%) rename packages/{tools/src => core/src/tools}/tools/filesystem/roots.ts (97%) rename packages/{tools/src => core/src/tools}/tools/filesystem_tools.test.ts (95%) rename packages/{tools/src => core/src/tools}/tools/get_memory.test.ts (96%) rename packages/{tools/src => core/src/tools}/tools/get_memory.ts (91%) rename packages/{tools/src => core/src/tools}/tools/helpers.test.ts (99%) rename packages/{tools/src => core/src/tools}/tools/helpers.ts (97%) rename packages/{tools/src => core/src/tools}/tools/list_directory.ts (80%) rename packages/{tools/src => core/src/tools}/tools/mcp.test.ts (100%) rename packages/{tools/src => core/src/tools}/tools/mcp.ts (100%) rename packages/{tools/src => core/src/tools}/tools/mcp_resources.test.ts (99%) rename packages/{tools/src => core/src/tools}/tools/mcp_resources.ts (98%) rename packages/{tools/src => core/src/tools}/tools/read_files.ts (82%) rename packages/{tools/src => core/src/tools}/tools/read_media_file.ts (86%) rename packages/{tools/src => core/src/tools}/tools/read_text_file.ts (86%) rename packages/{tools/src => core/src/tools}/tools/search_files.ts (80%) rename packages/{tools/src => core/src/tools}/tools/shell.test.ts (100%) rename packages/{tools/src => core/src/tools}/tools/shell.ts (89%) rename packages/{tools/src => core/src/tools}/tools/shell_command.test.ts (100%) rename packages/{tools/src => core/src/tools}/tools/shell_command.ts (88%) rename packages/{tools/src => core/src/tools}/tools/shell_update_plan.test.ts (95%) rename packages/{tools/src => core/src/tools}/tools/types.ts (96%) rename packages/{tools/src => core/src/tools}/tools/update_plan.ts (93%) rename packages/{tools/src => core/src/tools}/tools/webfetch.test.ts (99%) rename packages/{tools/src => core/src/tools}/tools/webfetch.ts (99%) rename packages/{tools/src => core/src/tools}/tools/write_file.ts (76%) rename packages/{tools/src => core/src/tools}/tools/write_stdin.ts (84%) delete mode 100644 packages/tools/package.json diff --git a/package.json b/package.json index 848c65e..48fd35b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:core": "vitest run packages/core", - "test:tools": "vitest run packages/tools", "test:tui": "vitest run packages/tui", "ci": "pnpm run format:check && pnpm run test:coverage && pnpm run build", "prepublishOnly": "pnpm run build && chmod +x dist/index.js", diff --git a/packages/core/package.json b/packages/core/package.json index 86c359c..c187245 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,7 +22,10 @@ "ignore": "^7.0.5", "zod": "^4.3.6", "ai": "^6.0.0", - "@ai-sdk/openai-compatible": "^2.0.0" + "@ai-sdk/openai-compatible": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.24.3", + "diff": "^8.0.3", + "minimatch": "^10.0.1" }, "devDependencies": { "vitest": "^2.1.8" diff --git a/packages/core/src/agent/defaults.ts b/packages/core/src/agent/defaults.ts index b94eb2f..fbd2273 100644 --- a/packages/core/src/agent/defaults.ts +++ b/packages/core/src/agent/defaults.ts @@ -1,5 +1,5 @@ /** @file Session default dependency assembly: toolset, LLM, history sinks, tokenizer, etc. */ -import { NATIVE_TOOLS } from '@memo/tools' +import { NATIVE_TOOLS } from '@memo/core/tools' import { createTokenCounter } from '@memo/core/utils/tokenizer' import { buildSessionPath, getSessionsDir, loadMemoConfig, selectProvider } from '@memo/core/config/config' import { JsonlHistorySink } from '@memo/core/features/history' @@ -7,7 +7,7 @@ import { resolveModelProfile } from '@memo/core/llm/model_profile' import { streamCallLLM } from '@memo/core/llm/ai_stream' import { getProviderFactory } from '@memo/core/llm/ai_provider' import { loadSystemPrompt as defaultLoadPrompt } from '@memo/core/prompt/prompt' -import { ToolRouter } from '@memo/tools/router' +import { ToolRouter } from '@memo/core/tools/router' import type { AgentSessionDeps, AgentSessionOptions, diff --git a/packages/core/src/agent/defaults.with_default_deps.test.ts b/packages/core/src/agent/defaults.with_default_deps.test.ts index 6c76170..4751caf 100644 --- a/packages/core/src/agent/defaults.with_default_deps.test.ts +++ b/packages/core/src/agent/defaults.with_default_deps.test.ts @@ -10,7 +10,7 @@ import type { import type { MCPServerConfig } from '@memo/core/config/config' import type { AIProviderFactory } from '@memo/core/llm/ai_provider' import { emptyUsage } from '@memo/core/agent/loop' -import type { Tool } from '@memo/tools/router' +import type { Tool } from '@memo/core/tools/router' const state = vi.hoisted(() => ({ loadedConfig: { @@ -80,7 +80,7 @@ const state = vi.hoisted(() => ({ } as LLMResult, })) -vi.mock('@memo/tools', () => ({ +vi.mock('@memo/core/tools', () => ({ NATIVE_TOOLS: [], })) @@ -134,7 +134,7 @@ vi.mock('@memo/core/utils/tokenizer', () => ({ }), })) -vi.mock('@memo/tools/router', () => ({ +vi.mock('@memo/core/tools/router', () => ({ ToolRouter: class ToolRouter { registerNativeTools(tools: unknown) { state.registerNativeToolsCalls.push(tools) diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 670fbc9..4f95d22 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -33,8 +33,8 @@ import { type ToolApprovalHooks, type ToolOrchestrator, type ToolActionResult, -} from '@memo/tools/orchestrator' -import { runWithRuntimeContext } from '@memo/tools/runtime/context' +} from '@memo/core/tools/orchestrator' +import { runWithRuntimeContext } from '@memo/core/tools/runtime/context' import { buildAssistantToolCalls, completeToolResultsForProtocol, @@ -42,8 +42,8 @@ import { parseTextToolCall, toToolHistoryMessage, } from './messages' -import type { ApprovalRequest, ApprovalDecision } from '@memo/tools/approval' -import type { ToolActionStatus } from '@memo/tools/orchestrator' +import type { ApprovalRequest, ApprovalDecision } from '@memo/core/tools/approval' +import type { ToolActionStatus } from '@memo/core/tools/orchestrator' const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 80 const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000 @@ -1225,7 +1225,7 @@ export const DEFAULT_SESSION_MODE: SessionMode = 'interactive' export const DEFAULT_CONTEXT_WINDOW = 120_000 export const TOOL_ACTION_SUCCESS_STATUS: ToolActionStatus = 'success' export const TOOL_DISABLED_ERROR_MESSAGE = - 'Tool usage is disabled in the current permission mode. Switch to /tools once or /tools full to enable tools.' + 'Tool usage is disabled in the current permission mode. Switch to /core/tools once or /core/tools full to enable tools.' export const SESSION_TITLE_MAX_CHARS = 60 export const TOOL_SKIPPED_DISABLED_MESSAGE = 'Tool execution skipped: tools are disabled in current permission mode.' diff --git a/packages/core/src/agent/messages.ts b/packages/core/src/agent/messages.ts index 0c759d4..26d25e2 100644 --- a/packages/core/src/agent/messages.ts +++ b/packages/core/src/agent/messages.ts @@ -1,7 +1,7 @@ /** @file Message construction and LLM result normalization for the agent loop. */ import type { LanguageModelUsage, ToolCallPart } from 'ai' import type { ChatMessage, LLMResult, ToolRegistry } from '@memo/core/types' -import type { ToolActionResult } from '@memo/tools/orchestrator' +import type { ToolActionResult } from '@memo/core/tools/orchestrator' const TOOL_SKIPPED_AFTER_REJECTION_MESSAGE = 'Skipped tool execution after previous rejection.' diff --git a/packages/core/src/agent/session_hooks.test.ts b/packages/core/src/agent/session_hooks.test.ts index 7b4fa2a..3f2722b 100644 --- a/packages/core/src/agent/session_hooks.test.ts +++ b/packages/core/src/agent/session_hooks.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert' import { describe, test } from 'vitest' import { createAgentSession, createTokenCounter } from '@memo/core' import type { ChatMessage, HistoryEvent, LLMResult, TokenCounter } from '@memo/core' -import type { Tool } from '@memo/tools/router' +import type { Tool } from '@memo/core/tools/router' import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX } from '@memo/core/agent/compact_prompt' import { emptyUsage } from '@memo/core/agent/loop' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0f56303..4f93314 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,8 @@ /** @file Core package entry point, aggregates domain modules (config/llm/session/history/...). */ export * from './types' +export * from './tools' +export * from './tools/approval' +export * from './tools/orchestrator' export * from './prompt/prompt' export * from './skills/skills' export * from './utils/workspace' diff --git a/packages/core/src/mcp/mcp_admin.test.ts b/packages/core/src/mcp/mcp_admin.test.ts index 100bd25..8339334 100644 --- a/packages/core/src/mcp/mcp_admin.test.ts +++ b/packages/core/src/mcp/mcp_admin.test.ts @@ -15,7 +15,7 @@ vi.mock('@memo/core/config/config', () => ({ writeMemoConfig: mocks.writeMemoConfig, })) -vi.mock('@memo/tools/router/mcp/oauth', () => ({ +vi.mock('@memo/core/tools/router/mcp/oauth', () => ({ getMcpAuthStatus: mocks.getMcpAuthStatus, loginMcpServerOAuth: mocks.loginMcpServerOAuth, logoutMcpServerOAuth: mocks.logoutMcpServerOAuth, diff --git a/packages/core/src/mcp/mcp_admin.ts b/packages/core/src/mcp/mcp_admin.ts index 21e5c78..551b655 100644 --- a/packages/core/src/mcp/mcp_admin.ts +++ b/packages/core/src/mcp/mcp_admin.ts @@ -4,7 +4,7 @@ import { loginMcpServerOAuth, logoutMcpServerOAuth, type McpAuthStatus, -} from '@memo/tools/router/mcp/oauth' +} from '@memo/core/tools/router/mcp/oauth' import type { McpServerRecord } from '../api_types.js' export class McpAdminError extends Error { diff --git a/packages/tools/src/approval/classifier.test.ts b/packages/core/src/tools/approval/classifier.test.ts similarity index 100% rename from packages/tools/src/approval/classifier.test.ts rename to packages/core/src/tools/approval/classifier.test.ts diff --git a/packages/tools/src/approval/classifier.ts b/packages/core/src/tools/approval/classifier.ts similarity index 100% rename from packages/tools/src/approval/classifier.ts rename to packages/core/src/tools/approval/classifier.ts diff --git a/packages/tools/src/approval/constants.ts b/packages/core/src/tools/approval/constants.ts similarity index 100% rename from packages/tools/src/approval/constants.ts rename to packages/core/src/tools/approval/constants.ts diff --git a/packages/tools/src/approval/fingerprint.ts b/packages/core/src/tools/approval/fingerprint.ts similarity index 100% rename from packages/tools/src/approval/fingerprint.ts rename to packages/core/src/tools/approval/fingerprint.ts diff --git a/packages/tools/src/approval/index.ts b/packages/core/src/tools/approval/index.ts similarity index 100% rename from packages/tools/src/approval/index.ts rename to packages/core/src/tools/approval/index.ts diff --git a/packages/tools/src/approval/manager.test.ts b/packages/core/src/tools/approval/manager.test.ts similarity index 100% rename from packages/tools/src/approval/manager.test.ts rename to packages/core/src/tools/approval/manager.test.ts diff --git a/packages/tools/src/approval/manager.ts b/packages/core/src/tools/approval/manager.ts similarity index 100% rename from packages/tools/src/approval/manager.ts rename to packages/core/src/tools/approval/manager.ts diff --git a/packages/tools/src/approval/types.ts b/packages/core/src/tools/approval/types.ts similarity index 100% rename from packages/tools/src/approval/types.ts rename to packages/core/src/tools/approval/types.ts diff --git a/packages/tools/src/index.test.ts b/packages/core/src/tools/index.test.ts similarity index 100% rename from packages/tools/src/index.test.ts rename to packages/core/src/tools/index.test.ts diff --git a/packages/tools/src/index.ts b/packages/core/src/tools/index.ts similarity index 60% rename from packages/tools/src/index.ts rename to packages/core/src/tools/index.ts index f09aa74..5bc05de 100644 --- a/packages/tools/src/index.ts +++ b/packages/core/src/tools/index.ts @@ -1,25 +1,25 @@ -import type { McpTool, ToolName } from '@memo/tools/tools/types' -import { shellTool } from '@memo/tools/tools/shell' -import { shellCommandTool } from '@memo/tools/tools/shell_command' -import { execCommandTool } from '@memo/tools/tools/exec_command' -import { writeStdinTool } from '@memo/tools/tools/write_stdin' -import { applyPatchTool } from '@memo/tools/tools/apply_patch' -import { readTextFileTool } from '@memo/tools/tools/read_text_file' -import { readMediaFileTool } from '@memo/tools/tools/read_media_file' -import { readFilesTool } from '@memo/tools/tools/read_files' -import { writeFileTool } from '@memo/tools/tools/write_file' -import { editFileTool } from '@memo/tools/tools/edit_file' -import { listDirectoryTool } from '@memo/tools/tools/list_directory' -import { searchFilesTool } from '@memo/tools/tools/search_files' +import type { McpTool, ToolName } from '@memo/core/tools/tools/types' +import { shellTool } from '@memo/core/tools/tools/shell' +import { shellCommandTool } from '@memo/core/tools/tools/shell_command' +import { execCommandTool } from '@memo/core/tools/tools/exec_command' +import { writeStdinTool } from '@memo/core/tools/tools/write_stdin' +import { applyPatchTool } from '@memo/core/tools/tools/apply_patch' +import { readTextFileTool } from '@memo/core/tools/tools/read_text_file' +import { readMediaFileTool } from '@memo/core/tools/tools/read_media_file' +import { readFilesTool } from '@memo/core/tools/tools/read_files' +import { writeFileTool } from '@memo/core/tools/tools/write_file' +import { editFileTool } from '@memo/core/tools/tools/edit_file' +import { listDirectoryTool } from '@memo/core/tools/tools/list_directory' +import { searchFilesTool } from '@memo/core/tools/tools/search_files' import { listMcpResourceTemplatesTool, listMcpResourcesTool, readMcpResourceTool, -} from '@memo/tools/tools/mcp_resources' -import { updatePlanTool } from '@memo/tools/tools/update_plan' -import { getMemoryTool } from '@memo/tools/tools/get_memory' -import { webfetchTool } from '@memo/tools/tools/webfetch' -import { closeAgentTool, resumeAgentTool, sendInputTool, spawnAgentTool, waitTool } from '@memo/tools/tools/collab' +} from '@memo/core/tools/tools/mcp_resources' +import { updatePlanTool } from '@memo/core/tools/tools/update_plan' +import { getMemoryTool } from '@memo/core/tools/tools/get_memory' +import { webfetchTool } from '@memo/core/tools/tools/webfetch' +import { closeAgentTool, resumeAgentTool, sendInputTool, spawnAgentTool, waitTool } from '@memo/core/tools/tools/collab' function buildCodexTools(): McpTool[] { const tools: McpTool[] = [] @@ -81,6 +81,6 @@ export const TOOL_LIST: McpTool[] = Object.values(TOOLKIT) export const NATIVE_TOOLS = TOOL_LIST export type { McpTool } -export * from '@memo/tools/approval' -export * from '@memo/tools/orchestrator' -export * from '@memo/tools/router' +export * from '@memo/core/tools/approval' +export * from '@memo/core/tools/orchestrator' +export * from '@memo/core/tools/router' diff --git a/packages/tools/src/orchestrator/index.test.ts b/packages/core/src/tools/orchestrator/index.test.ts similarity index 100% rename from packages/tools/src/orchestrator/index.test.ts rename to packages/core/src/tools/orchestrator/index.test.ts diff --git a/packages/tools/src/orchestrator/index.ts b/packages/core/src/tools/orchestrator/index.ts similarity index 98% rename from packages/tools/src/orchestrator/index.ts rename to packages/core/src/tools/orchestrator/index.ts index 1d48239..85fe06e 100644 --- a/packages/tools/src/orchestrator/index.ts +++ b/packages/core/src/tools/orchestrator/index.ts @@ -1,6 +1,6 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types' -import { createApprovalManager } from '@memo/tools/approval' -import { getMaxToolResultChars } from '@memo/tools/runtime/tool_output_limits' +import { createApprovalManager } from '@memo/core/tools/approval' +import { getMaxToolResultChars } from '@memo/core/tools/runtime/tool_output_limits' import type { ToolAction, ToolActionResult, diff --git a/packages/tools/src/orchestrator/types.ts b/packages/core/src/tools/orchestrator/types.ts similarity index 94% rename from packages/tools/src/orchestrator/types.ts rename to packages/core/src/tools/orchestrator/types.ts index bcb65eb..c6b9460 100644 --- a/packages/tools/src/orchestrator/types.ts +++ b/packages/core/src/tools/orchestrator/types.ts @@ -1,5 +1,10 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types' -import type { ApprovalDecision, ApprovalManager, ApprovalManagerConfig, ApprovalRequest } from '@memo/tools/approval' +import type { + ApprovalDecision, + ApprovalManager, + ApprovalManagerConfig, + ApprovalRequest, +} from '@memo/core/tools/approval' export type ToolValidateResult = { ok: true; data: unknown } | { ok: false; error: string } diff --git a/packages/tools/src/router/index.test.ts b/packages/core/src/tools/router/index.test.ts similarity index 100% rename from packages/tools/src/router/index.test.ts rename to packages/core/src/tools/router/index.test.ts diff --git a/packages/tools/src/router/index.ts b/packages/core/src/tools/router/index.ts similarity index 100% rename from packages/tools/src/router/index.ts rename to packages/core/src/tools/router/index.ts diff --git a/packages/tools/src/router/mcp/cache_store.test.ts b/packages/core/src/tools/router/mcp/cache_store.test.ts similarity index 100% rename from packages/tools/src/router/mcp/cache_store.test.ts rename to packages/core/src/tools/router/mcp/cache_store.test.ts diff --git a/packages/tools/src/router/mcp/cache_store.ts b/packages/core/src/tools/router/mcp/cache_store.ts similarity index 100% rename from packages/tools/src/router/mcp/cache_store.ts rename to packages/core/src/tools/router/mcp/cache_store.ts diff --git a/packages/tools/src/router/mcp/context.ts b/packages/core/src/tools/router/mcp/context.ts similarity index 100% rename from packages/tools/src/router/mcp/context.ts rename to packages/core/src/tools/router/mcp/context.ts diff --git a/packages/tools/src/router/mcp/index.test.ts b/packages/core/src/tools/router/mcp/index.test.ts similarity index 100% rename from packages/tools/src/router/mcp/index.test.ts rename to packages/core/src/tools/router/mcp/index.test.ts diff --git a/packages/tools/src/router/mcp/index.ts b/packages/core/src/tools/router/mcp/index.ts similarity index 100% rename from packages/tools/src/router/mcp/index.ts rename to packages/core/src/tools/router/mcp/index.ts diff --git a/packages/tools/src/router/mcp/oauth.runtime.test.ts b/packages/core/src/tools/router/mcp/oauth.runtime.test.ts similarity index 100% rename from packages/tools/src/router/mcp/oauth.runtime.test.ts rename to packages/core/src/tools/router/mcp/oauth.runtime.test.ts diff --git a/packages/tools/src/router/mcp/oauth.test.ts b/packages/core/src/tools/router/mcp/oauth.test.ts similarity index 100% rename from packages/tools/src/router/mcp/oauth.test.ts rename to packages/core/src/tools/router/mcp/oauth.test.ts diff --git a/packages/tools/src/router/mcp/oauth.ts b/packages/core/src/tools/router/mcp/oauth.ts similarity index 100% rename from packages/tools/src/router/mcp/oauth.ts rename to packages/core/src/tools/router/mcp/oauth.ts diff --git a/packages/tools/src/router/mcp/pool.test.ts b/packages/core/src/tools/router/mcp/pool.test.ts similarity index 100% rename from packages/tools/src/router/mcp/pool.test.ts rename to packages/core/src/tools/router/mcp/pool.test.ts diff --git a/packages/tools/src/router/mcp/pool.ts b/packages/core/src/tools/router/mcp/pool.ts similarity index 100% rename from packages/tools/src/router/mcp/pool.ts rename to packages/core/src/tools/router/mcp/pool.ts diff --git a/packages/tools/src/router/native/index.test.ts b/packages/core/src/tools/router/native/index.test.ts similarity index 100% rename from packages/tools/src/router/native/index.test.ts rename to packages/core/src/tools/router/native/index.test.ts diff --git a/packages/tools/src/router/native/index.ts b/packages/core/src/tools/router/native/index.ts similarity index 100% rename from packages/tools/src/router/native/index.ts rename to packages/core/src/tools/router/native/index.ts diff --git a/packages/tools/src/router/types.ts b/packages/core/src/tools/router/types.ts similarity index 100% rename from packages/tools/src/router/types.ts rename to packages/core/src/tools/router/types.ts diff --git a/packages/tools/src/runtime/context.ts b/packages/core/src/tools/runtime/context.ts similarity index 100% rename from packages/tools/src/runtime/context.ts rename to packages/core/src/tools/runtime/context.ts diff --git a/packages/tools/src/runtime/tool_output_limits.ts b/packages/core/src/tools/runtime/tool_output_limits.ts similarity index 100% rename from packages/tools/src/runtime/tool_output_limits.ts rename to packages/core/src/tools/runtime/tool_output_limits.ts diff --git a/packages/tools/src/tools/apply_patch.test.ts b/packages/core/src/tools/tools/apply_patch.test.ts similarity index 98% rename from packages/tools/src/tools/apply_patch.test.ts rename to packages/core/src/tools/tools/apply_patch.test.ts index df1e495..d78443d 100644 --- a/packages/tools/src/tools/apply_patch.test.ts +++ b/packages/core/src/tools/tools/apply_patch.test.ts @@ -3,8 +3,8 @@ import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promise import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterAll, beforeAll, describe, test } from 'vitest' -import { runWithRuntimeContext } from '@memo/tools/runtime/context' -import { applyPatchTool } from '@memo/tools/tools/apply_patch' +import { runWithRuntimeContext } from '@memo/core/tools/runtime/context' +import { applyPatchTool } from '@memo/core/tools/tools/apply_patch' let tempDir: string let prevWritableRoots: string | undefined diff --git a/packages/tools/src/tools/apply_patch.ts b/packages/core/src/tools/tools/apply_patch.ts similarity index 98% rename from packages/tools/src/tools/apply_patch.ts rename to packages/core/src/tools/tools/apply_patch.ts index e44aec7..186307d 100644 --- a/packages/tools/src/tools/apply_patch.ts +++ b/packages/core/src/tools/tools/apply_patch.ts @@ -1,10 +1,10 @@ import { mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' import { z } from 'zod' -import { getRuntimeCwd } from '@memo/tools/runtime/context' -import { textResult } from '@memo/tools/tools/mcp' -import { normalizePath, writePathDenyReason } from '@memo/tools/tools/helpers' -import { defineMcpTool } from '@memo/tools/tools/types' +import { getRuntimeCwd } from '@memo/core/tools/runtime/context' +import { textResult } from '@memo/core/tools/tools/mcp' +import { normalizePath, writePathDenyReason } from '@memo/core/tools/tools/helpers' +import { defineMcpTool } from '@memo/core/tools/tools/types' const BEGIN_PATCH_MARKER = '*** Begin Patch' const END_PATCH_MARKER = '*** End Patch' diff --git a/packages/tools/src/tools/codex_tools.test.ts b/packages/core/src/tools/tools/codex_tools.test.ts similarity index 93% rename from packages/tools/src/tools/codex_tools.test.ts rename to packages/core/src/tools/tools/codex_tools.test.ts index 149de7c..c425f87 100644 --- a/packages/tools/src/tools/codex_tools.test.ts +++ b/packages/core/src/tools/tools/codex_tools.test.ts @@ -3,16 +3,16 @@ import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterAll, beforeAll, describe, test } from 'vitest' -import { runWithRuntimeContext } from '@memo/tools/runtime/context' -import { execCommandTool } from '@memo/tools/tools/exec_command' -import { writeStdinTool } from '@memo/tools/tools/write_stdin' -import { applyPatchTool } from '@memo/tools/tools/apply_patch' -import { readTextFileTool } from '@memo/tools/tools/read_text_file' -import { readFilesTool } from '@memo/tools/tools/read_files' -import { listDirectoryTool } from '@memo/tools/tools/list_directory' -import { searchFilesTool } from '@memo/tools/tools/search_files' -import { updatePlanTool } from '@memo/tools/tools/update_plan' -import { getMemoryTool } from '@memo/tools/tools/get_memory' +import { runWithRuntimeContext } from '@memo/core/tools/runtime/context' +import { execCommandTool } from '@memo/core/tools/tools/exec_command' +import { writeStdinTool } from '@memo/core/tools/tools/write_stdin' +import { applyPatchTool } from '@memo/core/tools/tools/apply_patch' +import { readTextFileTool } from '@memo/core/tools/tools/read_text_file' +import { readFilesTool } from '@memo/core/tools/tools/read_files' +import { listDirectoryTool } from '@memo/core/tools/tools/list_directory' +import { searchFilesTool } from '@memo/core/tools/tools/search_files' +import { updatePlanTool } from '@memo/core/tools/tools/update_plan' +import { getMemoryTool } from '@memo/core/tools/tools/get_memory' let tempDir: string let prevWritableRoots: string | undefined diff --git a/packages/tools/src/tools/collab.test.ts b/packages/core/src/tools/tools/collab.test.ts similarity index 99% rename from packages/tools/src/tools/collab.test.ts rename to packages/core/src/tools/tools/collab.test.ts index c011391..a3f0a52 100644 --- a/packages/tools/src/tools/collab.test.ts +++ b/packages/core/src/tools/tools/collab.test.ts @@ -10,7 +10,7 @@ import { sendInputTool, spawnAgentTool, waitTool, -} from '@memo/tools/tools/collab' +} from '@memo/core/tools/tools/collab' let tempDir: string let prevCommand: string | undefined diff --git a/packages/tools/src/tools/collab.ts b/packages/core/src/tools/tools/collab.ts similarity index 98% rename from packages/tools/src/tools/collab.ts rename to packages/core/src/tools/tools/collab.ts index e17e7c7..0f2052a 100644 --- a/packages/tools/src/tools/collab.ts +++ b/packages/core/src/tools/tools/collab.ts @@ -2,9 +2,9 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { existsSync } from 'node:fs' import { resolve } from 'node:path' import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { getRuntimeCwd } from '@memo/tools/runtime/context' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { getRuntimeCwd } from '@memo/core/tools/runtime/context' type AgentStatus = 'running' | 'completed' | 'errored' | 'closed' type WaitStatus = AgentStatus | 'not_found' diff --git a/packages/tools/src/tools/command_guard.test.ts b/packages/core/src/tools/tools/command_guard.test.ts similarity index 98% rename from packages/tools/src/tools/command_guard.test.ts rename to packages/core/src/tools/tools/command_guard.test.ts index 3dd0817..ca340d8 100644 --- a/packages/tools/src/tools/command_guard.test.ts +++ b/packages/core/src/tools/tools/command_guard.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert' import { describe, test } from 'vitest' -import { detectDangerousCommand, guardDangerousCommand } from '@memo/tools/tools/command_guard' +import { detectDangerousCommand, guardDangerousCommand } from '@memo/core/tools/tools/command_guard' describe('command guard', () => { test('detects dangerous delete and disk mutation commands', () => { diff --git a/packages/tools/src/tools/command_guard.ts b/packages/core/src/tools/tools/command_guard.ts similarity index 100% rename from packages/tools/src/tools/command_guard.ts rename to packages/core/src/tools/tools/command_guard.ts diff --git a/packages/tools/src/tools/edit_file.ts b/packages/core/src/tools/tools/edit_file.ts similarity index 81% rename from packages/tools/src/tools/edit_file.ts rename to packages/core/src/tools/tools/edit_file.ts index 6869af9..ce3ec06 100644 --- a/packages/tools/src/tools/edit_file.ts +++ b/packages/core/src/tools/tools/edit_file.ts @@ -1,8 +1,8 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { applyFileEdits, validatePath } from '@memo/tools/tools/filesystem/lib' -import { resolveAllowedDirectories } from '@memo/tools/tools/filesystem/roots' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { applyFileEdits, validatePath } from '@memo/core/tools/tools/filesystem/lib' +import { resolveAllowedDirectories } from '@memo/core/tools/tools/filesystem/roots' const EDIT_FILE_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/exec_command.test.ts b/packages/core/src/tools/tools/exec_command.test.ts similarity index 100% rename from packages/tools/src/tools/exec_command.test.ts rename to packages/core/src/tools/tools/exec_command.test.ts diff --git a/packages/tools/src/tools/exec_command.ts b/packages/core/src/tools/tools/exec_command.ts similarity index 87% rename from packages/tools/src/tools/exec_command.ts rename to packages/core/src/tools/tools/exec_command.ts index babfa7a..3cdcd9c 100644 --- a/packages/tools/src/tools/exec_command.ts +++ b/packages/core/src/tools/tools/exec_command.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { startExecSession } from '@memo/tools/tools/exec_runtime' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { startExecSession } from '@memo/core/tools/tools/exec_runtime' const EXEC_COMMAND_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/exec_runtime.test.ts b/packages/core/src/tools/tools/exec_runtime.test.ts similarity index 100% rename from packages/tools/src/tools/exec_runtime.test.ts rename to packages/core/src/tools/tools/exec_runtime.test.ts diff --git a/packages/tools/src/tools/exec_runtime.ts b/packages/core/src/tools/tools/exec_runtime.ts similarity index 99% rename from packages/tools/src/tools/exec_runtime.ts rename to packages/core/src/tools/tools/exec_runtime.ts index a42ed36..9dac548 100644 --- a/packages/tools/src/tools/exec_runtime.ts +++ b/packages/core/src/tools/tools/exec_runtime.ts @@ -1,8 +1,8 @@ import { spawn } from 'node:child_process' import { EventEmitter } from 'node:events' import { resolve } from 'node:path' -import { guardDangerousCommand, splitStdinLines, trimPendingStdinBuffer } from '@memo/tools/tools/command_guard' -import { getRuntimeCwd } from '@memo/tools/runtime/context' +import { guardDangerousCommand, splitStdinLines, trimPendingStdinBuffer } from '@memo/core/tools/tools/command_guard' +import { getRuntimeCwd } from '@memo/core/tools/runtime/context' const DEFAULT_EXEC_YIELD_TIME_MS = 10_000 const DEFAULT_WRITE_YIELD_TIME_MS = 250 diff --git a/packages/tools/src/tools/filesystem/lib.ts b/packages/core/src/tools/tools/filesystem/lib.ts similarity index 100% rename from packages/tools/src/tools/filesystem/lib.ts rename to packages/core/src/tools/tools/filesystem/lib.ts diff --git a/packages/tools/src/tools/filesystem/path-utils.ts b/packages/core/src/tools/tools/filesystem/path-utils.ts similarity index 100% rename from packages/tools/src/tools/filesystem/path-utils.ts rename to packages/core/src/tools/tools/filesystem/path-utils.ts diff --git a/packages/tools/src/tools/filesystem/path-validation.ts b/packages/core/src/tools/tools/filesystem/path-validation.ts similarity index 100% rename from packages/tools/src/tools/filesystem/path-validation.ts rename to packages/core/src/tools/tools/filesystem/path-validation.ts diff --git a/packages/tools/src/tools/filesystem/roots.ts b/packages/core/src/tools/tools/filesystem/roots.ts similarity index 97% rename from packages/tools/src/tools/filesystem/roots.ts rename to packages/core/src/tools/tools/filesystem/roots.ts index 1fc7b72..c9bd5ef 100644 --- a/packages/tools/src/tools/filesystem/roots.ts +++ b/packages/core/src/tools/tools/filesystem/roots.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs' import path from 'node:path' -import { getRuntimeCwd } from '@memo/tools/runtime/context' +import { getRuntimeCwd } from '@memo/core/tools/runtime/context' import { expandHome, normalizePath } from './path-utils' const FS_ALLOWED_ROOTS_ENV = 'MEMO_FS_ALLOWED_ROOTS' diff --git a/packages/tools/src/tools/filesystem_tools.test.ts b/packages/core/src/tools/tools/filesystem_tools.test.ts similarity index 95% rename from packages/tools/src/tools/filesystem_tools.test.ts rename to packages/core/src/tools/tools/filesystem_tools.test.ts index 02013e5..c08f15a 100644 --- a/packages/tools/src/tools/filesystem_tools.test.ts +++ b/packages/core/src/tools/tools/filesystem_tools.test.ts @@ -3,13 +3,13 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promis import { basename, join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, test } from 'vitest' -import { readTextFileTool } from '@memo/tools/tools/read_text_file' -import { readMediaFileTool } from '@memo/tools/tools/read_media_file' -import { readFilesTool } from '@memo/tools/tools/read_files' -import { writeFileTool } from '@memo/tools/tools/write_file' -import { editFileTool } from '@memo/tools/tools/edit_file' -import { listDirectoryTool } from '@memo/tools/tools/list_directory' -import { searchFilesTool } from '@memo/tools/tools/search_files' +import { readTextFileTool } from '@memo/core/tools/tools/read_text_file' +import { readMediaFileTool } from '@memo/core/tools/tools/read_media_file' +import { readFilesTool } from '@memo/core/tools/tools/read_files' +import { writeFileTool } from '@memo/core/tools/tools/write_file' +import { editFileTool } from '@memo/core/tools/tools/edit_file' +import { listDirectoryTool } from '@memo/core/tools/tools/list_directory' +import { searchFilesTool } from '@memo/core/tools/tools/search_files' type ToolResult = { content?: Array<{ type: string; text?: string }>; isError?: boolean } diff --git a/packages/tools/src/tools/get_memory.test.ts b/packages/core/src/tools/tools/get_memory.test.ts similarity index 96% rename from packages/tools/src/tools/get_memory.test.ts rename to packages/core/src/tools/tools/get_memory.test.ts index 9a89f1e..6d75c87 100644 --- a/packages/tools/src/tools/get_memory.test.ts +++ b/packages/core/src/tools/tools/get_memory.test.ts @@ -3,7 +3,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterAll, beforeAll, describe, test } from 'vitest' -import { getMemoryTool } from '@memo/tools/tools/get_memory' +import { getMemoryTool } from '@memo/core/tools/tools/get_memory' let tempDir: string let prevMemoHome: string | undefined diff --git a/packages/tools/src/tools/get_memory.ts b/packages/core/src/tools/tools/get_memory.ts similarity index 91% rename from packages/tools/src/tools/get_memory.ts rename to packages/core/src/tools/tools/get_memory.ts index d23d96c..cf15e08 100644 --- a/packages/tools/src/tools/get_memory.ts +++ b/packages/core/src/tools/tools/get_memory.ts @@ -2,8 +2,8 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' const GET_MEMORY_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/helpers.test.ts b/packages/core/src/tools/tools/helpers.test.ts similarity index 99% rename from packages/tools/src/tools/helpers.test.ts rename to packages/core/src/tools/tools/helpers.test.ts index 790f8b5..ce62395 100644 --- a/packages/tools/src/tools/helpers.test.ts +++ b/packages/core/src/tools/tools/helpers.test.ts @@ -10,7 +10,7 @@ import { isWritePathAllowed, normalizePath, writePathDenyReason, -} from '@memo/tools/tools/helpers' +} from '@memo/core/tools/tools/helpers' const tempDirs: string[] = [] diff --git a/packages/tools/src/tools/helpers.ts b/packages/core/src/tools/tools/helpers.ts similarity index 97% rename from packages/tools/src/tools/helpers.ts rename to packages/core/src/tools/tools/helpers.ts index 7c699b0..d619b9f 100644 --- a/packages/tools/src/tools/helpers.ts +++ b/packages/core/src/tools/tools/helpers.ts @@ -3,8 +3,8 @@ import { homedir } from 'node:os' import { existsSync, statSync, realpathSync } from 'node:fs' import { readFile } from 'node:fs/promises' import ignore from 'ignore' -import { getRuntimeCwd } from '@memo/tools/runtime/context' -import { getMaxToolResultChars, getMaxToolResultLines } from '@memo/tools/runtime/tool_output_limits' +import { getRuntimeCwd } from '@memo/core/tools/runtime/context' +import { getMaxToolResultChars, getMaxToolResultLines } from '@memo/core/tools/runtime/tool_output_limits' /** * 生成标准化的绝对路径,避免因工作目录差异导致的路径错误。 diff --git a/packages/tools/src/tools/list_directory.ts b/packages/core/src/tools/tools/list_directory.ts similarity index 80% rename from packages/tools/src/tools/list_directory.ts rename to packages/core/src/tools/tools/list_directory.ts index f981a19..5a191a7 100644 --- a/packages/tools/src/tools/list_directory.ts +++ b/packages/core/src/tools/tools/list_directory.ts @@ -1,9 +1,9 @@ import fs from 'node:fs/promises' import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { validatePath } from '@memo/tools/tools/filesystem/lib' -import { resolveAllowedDirectories } from '@memo/tools/tools/filesystem/roots' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { validatePath } from '@memo/core/tools/tools/filesystem/lib' +import { resolveAllowedDirectories } from '@memo/core/tools/tools/filesystem/roots' const LIST_DIRECTORY_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/mcp.test.ts b/packages/core/src/tools/tools/mcp.test.ts similarity index 100% rename from packages/tools/src/tools/mcp.test.ts rename to packages/core/src/tools/tools/mcp.test.ts diff --git a/packages/tools/src/tools/mcp.ts b/packages/core/src/tools/tools/mcp.ts similarity index 100% rename from packages/tools/src/tools/mcp.ts rename to packages/core/src/tools/tools/mcp.ts diff --git a/packages/tools/src/tools/mcp_resources.test.ts b/packages/core/src/tools/tools/mcp_resources.test.ts similarity index 99% rename from packages/tools/src/tools/mcp_resources.test.ts rename to packages/core/src/tools/tools/mcp_resources.test.ts index bf40f2e..3382289 100644 --- a/packages/tools/src/tools/mcp_resources.test.ts +++ b/packages/core/src/tools/tools/mcp_resources.test.ts @@ -3,13 +3,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { afterEach, describe, test, vi } from 'vitest' -import { setActiveMcpCacheStore, setActiveMcpPool } from '@memo/tools/router/mcp/context' +import { setActiveMcpCacheStore, setActiveMcpPool } from '@memo/core/tools/router/mcp/context' import { __resetMcpResourceCacheForTests, listMcpResourceTemplatesTool, listMcpResourcesTool, readMcpResourceTool, -} from '@memo/tools/tools/mcp_resources' +} from '@memo/core/tools/tools/mcp_resources' function textPayload(result: { content?: Array<{ type: string; text?: string }> }) { const first = result.content?.find((item) => item.type === 'text') diff --git a/packages/tools/src/tools/mcp_resources.ts b/packages/core/src/tools/tools/mcp_resources.ts similarity index 98% rename from packages/tools/src/tools/mcp_resources.ts rename to packages/core/src/tools/tools/mcp_resources.ts index f0f5f71..6ad5391 100644 --- a/packages/tools/src/tools/mcp_resources.ts +++ b/packages/core/src/tools/tools/mcp_resources.ts @@ -1,8 +1,8 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { getGlobalMcpCacheStore, resetGlobalMcpCacheStoreForTests } from '@memo/tools/router/mcp/cache_store' -import { getActiveMcpCacheStore, getActiveMcpPool } from '@memo/tools/router/mcp/context' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { getGlobalMcpCacheStore, resetGlobalMcpCacheStoreForTests } from '@memo/core/tools/router/mcp/cache_store' +import { getActiveMcpCacheStore, getActiveMcpPool } from '@memo/core/tools/router/mcp/context' const LIST_MCP_RESOURCES_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/read_files.ts b/packages/core/src/tools/tools/read_files.ts similarity index 82% rename from packages/tools/src/tools/read_files.ts rename to packages/core/src/tools/tools/read_files.ts index d11ae44..4d0898d 100644 --- a/packages/tools/src/tools/read_files.ts +++ b/packages/core/src/tools/tools/read_files.ts @@ -1,8 +1,8 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { readFileContent, validatePath } from '@memo/tools/tools/filesystem/lib' -import { resolveAllowedDirectories } from '@memo/tools/tools/filesystem/roots' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { readFileContent, validatePath } from '@memo/core/tools/tools/filesystem/lib' +import { resolveAllowedDirectories } from '@memo/core/tools/tools/filesystem/roots' const READ_FILES_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/read_media_file.ts b/packages/core/src/tools/tools/read_media_file.ts similarity index 86% rename from packages/tools/src/tools/read_media_file.ts rename to packages/core/src/tools/tools/read_media_file.ts index 48cbbe9..ac21cf3 100644 --- a/packages/tools/src/tools/read_media_file.ts +++ b/packages/core/src/tools/tools/read_media_file.ts @@ -1,10 +1,10 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { validatePath } from '@memo/tools/tools/filesystem/lib' -import { resolveAllowedDirectories } from '@memo/tools/tools/filesystem/roots' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { validatePath } from '@memo/core/tools/tools/filesystem/lib' +import { resolveAllowedDirectories } from '@memo/core/tools/tools/filesystem/roots' const READ_MEDIA_FILE_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/read_text_file.ts b/packages/core/src/tools/tools/read_text_file.ts similarity index 86% rename from packages/tools/src/tools/read_text_file.ts rename to packages/core/src/tools/tools/read_text_file.ts index ed1ddf0..a50273d 100644 --- a/packages/tools/src/tools/read_text_file.ts +++ b/packages/core/src/tools/tools/read_text_file.ts @@ -1,8 +1,8 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { headFile, readFileContent, tailFile, validatePath } from '@memo/tools/tools/filesystem/lib' -import { resolveAllowedDirectories } from '@memo/tools/tools/filesystem/roots' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { headFile, readFileContent, tailFile, validatePath } from '@memo/core/tools/tools/filesystem/lib' +import { resolveAllowedDirectories } from '@memo/core/tools/tools/filesystem/roots' const READ_TEXT_FILE_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/search_files.ts b/packages/core/src/tools/tools/search_files.ts similarity index 80% rename from packages/tools/src/tools/search_files.ts rename to packages/core/src/tools/tools/search_files.ts index 15ab590..aebb728 100644 --- a/packages/tools/src/tools/search_files.ts +++ b/packages/core/src/tools/tools/search_files.ts @@ -1,8 +1,8 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { searchFilesWithValidation, validatePath } from '@memo/tools/tools/filesystem/lib' -import { resolveAllowedDirectories } from '@memo/tools/tools/filesystem/roots' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { searchFilesWithValidation, validatePath } from '@memo/core/tools/tools/filesystem/lib' +import { resolveAllowedDirectories } from '@memo/core/tools/tools/filesystem/roots' const SEARCH_FILES_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/shell.test.ts b/packages/core/src/tools/tools/shell.test.ts similarity index 100% rename from packages/tools/src/tools/shell.test.ts rename to packages/core/src/tools/tools/shell.test.ts diff --git a/packages/tools/src/tools/shell.ts b/packages/core/src/tools/tools/shell.ts similarity index 89% rename from packages/tools/src/tools/shell.ts rename to packages/core/src/tools/tools/shell.ts index c3c0c17..4b5518a 100644 --- a/packages/tools/src/tools/shell.ts +++ b/packages/core/src/tools/tools/shell.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { startExecSession } from '@memo/tools/tools/exec_runtime' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { startExecSession } from '@memo/core/tools/tools/exec_runtime' const SHELL_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/shell_command.test.ts b/packages/core/src/tools/tools/shell_command.test.ts similarity index 100% rename from packages/tools/src/tools/shell_command.test.ts rename to packages/core/src/tools/tools/shell_command.test.ts diff --git a/packages/tools/src/tools/shell_command.ts b/packages/core/src/tools/tools/shell_command.ts similarity index 88% rename from packages/tools/src/tools/shell_command.ts rename to packages/core/src/tools/tools/shell_command.ts index ce17460..3754ce9 100644 --- a/packages/tools/src/tools/shell_command.ts +++ b/packages/core/src/tools/tools/shell_command.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { startExecSession } from '@memo/tools/tools/exec_runtime' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { startExecSession } from '@memo/core/tools/tools/exec_runtime' const SHELL_COMMAND_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/shell_update_plan.test.ts b/packages/core/src/tools/tools/shell_update_plan.test.ts similarity index 95% rename from packages/tools/src/tools/shell_update_plan.test.ts rename to packages/core/src/tools/tools/shell_update_plan.test.ts index 5028104..7a6c149 100644 --- a/packages/tools/src/tools/shell_update_plan.test.ts +++ b/packages/core/src/tools/tools/shell_update_plan.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert' import { describe, test } from 'vitest' -import { shellTool } from '@memo/tools/tools/shell' -import { shellCommandTool } from '@memo/tools/tools/shell_command' -import { writeStdinTool } from '@memo/tools/tools/write_stdin' -import { updatePlanTool } from '@memo/tools/tools/update_plan' +import { shellTool } from '@memo/core/tools/tools/shell' +import { shellCommandTool } from '@memo/core/tools/tools/shell_command' +import { writeStdinTool } from '@memo/core/tools/tools/write_stdin' +import { updatePlanTool } from '@memo/core/tools/tools/update_plan' function textPayload(result: { content?: Array<{ type: string; text?: string }> }) { const first = result.content?.find((item) => item.type === 'text') diff --git a/packages/tools/src/tools/types.ts b/packages/core/src/tools/tools/types.ts similarity index 96% rename from packages/tools/src/tools/types.ts rename to packages/core/src/tools/tools/types.ts index d7e9ed4..327adad 100644 --- a/packages/tools/src/tools/types.ts +++ b/packages/core/src/tools/tools/types.ts @@ -1,5 +1,5 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types' -import type { NativeTool } from '@memo/tools/router/types' +import type { NativeTool } from '@memo/core/tools/router/types' import type { ZodTypeAny } from 'zod' // Tool-related type declarations diff --git a/packages/tools/src/tools/update_plan.ts b/packages/core/src/tools/tools/update_plan.ts similarity index 93% rename from packages/tools/src/tools/update_plan.ts rename to packages/core/src/tools/tools/update_plan.ts index 885f79c..5556a6a 100644 --- a/packages/tools/src/tools/update_plan.ts +++ b/packages/core/src/tools/tools/update_plan.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' const PLAN_ITEM_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/webfetch.test.ts b/packages/core/src/tools/tools/webfetch.test.ts similarity index 99% rename from packages/tools/src/tools/webfetch.test.ts rename to packages/core/src/tools/tools/webfetch.test.ts index f2a8a91..f14203f 100644 --- a/packages/tools/src/tools/webfetch.test.ts +++ b/packages/core/src/tools/tools/webfetch.test.ts @@ -6,7 +6,7 @@ vi.mock('node:dns/promises', () => ({ })) import { lookup } from 'node:dns/promises' -import { webfetchTool } from '@memo/tools/tools/webfetch' +import { webfetchTool } from '@memo/core/tools/tools/webfetch' const dnsLookupMock = vi.mocked(lookup) const WEBFETCH_ENV_KEYS = [ diff --git a/packages/tools/src/tools/webfetch.ts b/packages/core/src/tools/tools/webfetch.ts similarity index 99% rename from packages/tools/src/tools/webfetch.ts rename to packages/core/src/tools/tools/webfetch.ts index 69c6345..6c69b12 100644 --- a/packages/tools/src/tools/webfetch.ts +++ b/packages/core/src/tools/tools/webfetch.ts @@ -7,8 +7,8 @@ import robotsParser from 'robots-parser' import TurndownService from 'turndown' import { ProxyAgent, type Dispatcher } from 'undici' import { z } from 'zod' -import { textResult } from '@memo/tools/tools/mcp' -import { defineMcpTool } from '@memo/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { defineMcpTool } from '@memo/core/tools/tools/types' const WEBFETCH_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/write_file.ts b/packages/core/src/tools/tools/write_file.ts similarity index 76% rename from packages/tools/src/tools/write_file.ts rename to packages/core/src/tools/tools/write_file.ts index 57a3913..927b501 100644 --- a/packages/tools/src/tools/write_file.ts +++ b/packages/core/src/tools/tools/write_file.ts @@ -1,8 +1,8 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { validatePath, writeFileContent } from '@memo/tools/tools/filesystem/lib' -import { resolveAllowedDirectories } from '@memo/tools/tools/filesystem/roots' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { validatePath, writeFileContent } from '@memo/core/tools/tools/filesystem/lib' +import { resolveAllowedDirectories } from '@memo/core/tools/tools/filesystem/roots' const WRITE_FILE_INPUT_SCHEMA = z .object({ diff --git a/packages/tools/src/tools/write_stdin.ts b/packages/core/src/tools/tools/write_stdin.ts similarity index 84% rename from packages/tools/src/tools/write_stdin.ts rename to packages/core/src/tools/tools/write_stdin.ts index 4ce186a..e24eda9 100644 --- a/packages/tools/src/tools/write_stdin.ts +++ b/packages/core/src/tools/tools/write_stdin.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { defineMcpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { writeExecSession } from '@memo/tools/tools/exec_runtime' +import { defineMcpTool } from '@memo/core/tools/tools/types' +import { textResult } from '@memo/core/tools/tools/mcp' +import { writeExecSession } from '@memo/core/tools/tools/exec_runtime' const WRITE_STDIN_INPUT_SCHEMA = z .object({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2dbf873..470d9d8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,9 +1,9 @@ /** @file Common type declarations shared between Core and Runtime (reused by UI/Tools). */ import type { FinishReason, LanguageModelUsage, ModelMessage, ToolCallPart } from 'ai' -import type { ApprovalRequest, ApprovalDecision } from '@memo/tools/approval' -import type { ToolActionStatus } from '@memo/tools/orchestrator' -export type { ApprovalDecision, ApprovalRequest } from '@memo/tools/approval' -export type { ToolActionStatus } from '@memo/tools/orchestrator' +import type { ApprovalRequest, ApprovalDecision } from '@memo/core/tools/approval' +import type { ToolActionStatus } from '@memo/core/tools/orchestrator' +export type { ApprovalDecision, ApprovalRequest } from '@memo/core/tools/approval' +export type { ToolActionStatus } from '@memo/core/tools/orchestrator' export type { FinishReason, LanguageModelUsage } from 'ai' /** AI SDK generation result subset returned by CallLLM (all fields are AI SDK types). */ @@ -23,7 +23,7 @@ export type LLMResult = { /** * Basic type declarations for Agent layer, covering conversation messages, * parsing results, and dependency injection interfaces. - * Types are kept minimal for easy reuse in UI/tools layers. + * Types are kept minimal for easy reuse in UI/core/tools layers. */ export type Role = 'system' | 'user' | 'assistant' | 'tool' @@ -92,7 +92,7 @@ export type ParsedAssistant = { } /** Tool registry: keys are tool names, values are tool definitions. */ -export type ToolRegistry = Record +export type ToolRegistry = Record /** Tool definition structure (for passing to LLM API) */ export type ToolDefinition = { diff --git a/packages/tools/package.json b/packages/tools/package.json deleted file mode 100644 index e3baa65..0000000 --- a/packages/tools/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@memo-code/tools", - "type": "module", - "module": "src/index.ts", - "version": "0.1.0", - "private": true, - "scripts": { - "test": "vitest run" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.3", - "diff": "^8.0.3", - "ignore": "^5.3.1", - "minimatch": "^10.0.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "vitest": "^2.1.8" - } -} diff --git a/packages/tui/package.json b/packages/tui/package.json index 9f68c8e..f37d27f 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -10,7 +10,6 @@ "dependencies": { "@inkjs/ui": "^2.0.0", "@memo-code/core": "workspace:*", - "@memo-code/tools": "workspace:*", "ignore": "^7.0.5", "ink": "^6.7.0", "marked": "^17.0.1", diff --git a/packages/tui/src/app/hooks/useApproval.ts b/packages/tui/src/app/hooks/useApproval.ts index c559140..dd751dc 100644 --- a/packages/tui/src/app/hooks/useApproval.ts +++ b/packages/tui/src/app/hooks/useApproval.ts @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from 'react' -import type { ApprovalDecision, ApprovalRequest } from '@memo/tools/approval' +import type { ApprovalDecision, ApprovalRequest } from '@memo/core/tools/approval' export function useApproval() { const [pendingApproval, setPendingApproval] = useState(null) diff --git a/packages/tui/src/commands/mcp/list.tsx b/packages/tui/src/commands/mcp/list.tsx index ea03408..56f0812 100644 --- a/packages/tui/src/commands/mcp/list.tsx +++ b/packages/tui/src/commands/mcp/list.tsx @@ -2,9 +2,9 @@ import React, { useEffect } from 'react' import zod from 'zod' import { option } from 'pastel' import { loadMemoConfig } from '@memo/core' -import { getMcpAuthStatus } from '@memo/tools/router/mcp/oauth' +import { getMcpAuthStatus } from '@memo/core/tools/router/mcp/oauth' import { formatServer, oauthSettingsFromLoaded } from '../../features/mcp/mcpHelpers' -import type { McpAuthStatus } from '@memo/tools/router/mcp/oauth' +import type { McpAuthStatus } from '@memo/core/tools/router/mcp/oauth' export const options = zod.object({ json: zod diff --git a/packages/tui/src/commands/mcp/login.tsx b/packages/tui/src/commands/mcp/login.tsx index 199cfbe..0fbc027 100644 --- a/packages/tui/src/commands/mcp/login.tsx +++ b/packages/tui/src/commands/mcp/login.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react' import zod from 'zod' import { option, argument } from 'pastel' import { loadMemoConfig } from '@memo/core' -import { loginMcpServerOAuth } from '@memo/tools/router/mcp/oauth' +import { loginMcpServerOAuth } from '@memo/core/tools/router/mcp/oauth' import { getErrorMessage, oauthSettingsFromLoaded } from '../../features/mcp/mcpHelpers' export const options = zod.object({ diff --git a/packages/tui/src/commands/mcp/logout.tsx b/packages/tui/src/commands/mcp/logout.tsx index 161ebde..14898f8 100644 --- a/packages/tui/src/commands/mcp/logout.tsx +++ b/packages/tui/src/commands/mcp/logout.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react' import zod from 'zod' import { argument } from 'pastel' import { loadMemoConfig } from '@memo/core' -import { logoutMcpServerOAuth } from '@memo/tools/router/mcp/oauth' +import { logoutMcpServerOAuth } from '@memo/core/tools/router/mcp/oauth' import { getErrorMessage, oauthSettingsFromLoaded } from '../../features/mcp/mcpHelpers' export const args = zod.array(zod.string()).describe(argument({ name: 'name', description: 'Server name' })) diff --git a/packages/tui/src/features/approval/ApprovalOverlay.tsx b/packages/tui/src/features/approval/ApprovalOverlay.tsx index 5672179..b928ea5 100644 --- a/packages/tui/src/features/approval/ApprovalOverlay.tsx +++ b/packages/tui/src/features/approval/ApprovalOverlay.tsx @@ -1,7 +1,7 @@ import { memo } from 'react' import { Box, Text, useInput } from 'ink' import { Select, StatusMessage, type Option as SelectOption } from '@inkjs/ui' -import type { ApprovalDecision, ApprovalRequest } from '@memo/tools/approval' +import type { ApprovalDecision, ApprovalRequest } from '@memo/core/tools/approval' type ApprovalOverlayProps = { request: ApprovalRequest diff --git a/packages/tui/src/features/approval/approvalNotification.test.ts b/packages/tui/src/features/approval/approvalNotification.test.ts index ac7a31d..bd79ddd 100644 --- a/packages/tui/src/features/approval/approvalNotification.test.ts +++ b/packages/tui/src/features/approval/approvalNotification.test.ts @@ -1,4 +1,4 @@ -import type { ApprovalRequest } from '@memo/tools/approval' +import type { ApprovalRequest } from '@memo/core/tools/approval' import { describe, expect, test, vi } from 'vitest' import { buildDesktopNotificationCommand, notifyApprovalRequested } from './approvalNotification' diff --git a/packages/tui/src/features/approval/approvalNotification.ts b/packages/tui/src/features/approval/approvalNotification.ts index 219339c..21e0d43 100644 --- a/packages/tui/src/features/approval/approvalNotification.ts +++ b/packages/tui/src/features/approval/approvalNotification.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import type { ApprovalRequest } from '@memo/tools/approval' +import type { ApprovalRequest } from '@memo/core/tools/approval' const TERMINAL_BELL = '\u0007' const DESKTOP_NOTIFICATION_TITLE = 'Memo: Approval required' diff --git a/packages/tui/src/features/mcp/mcpHelpers.ts b/packages/tui/src/features/mcp/mcpHelpers.ts index 581ef23..5baeb64 100644 --- a/packages/tui/src/features/mcp/mcpHelpers.ts +++ b/packages/tui/src/features/mcp/mcpHelpers.ts @@ -1,6 +1,6 @@ /** Shared helpers for MCP CLI commands. */ import { loadMemoConfig, type MCPServerConfig } from '@memo/core' -import type { McpAuthStatus } from '@memo/tools/router/mcp/oauth' +import type { McpAuthStatus } from '@memo/core/tools/router/mcp/oauth' export function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message diff --git a/packages/tui/src/shared/lib/utils.ts b/packages/tui/src/shared/lib/utils.ts index f38b1a1..c6d99b1 100644 --- a/packages/tui/src/shared/lib/utils.ts +++ b/packages/tui/src/shared/lib/utils.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import type { ToolActionStatus } from '@memo/tools/orchestrator' +import type { ToolActionStatus } from '@memo/core/tools/orchestrator' import { TOOL_STATUS, type ToolStatus } from '../types' const TOOL_ACTION_STATUS_SUCCESS: ToolActionStatus = 'success' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bac1973..7639047 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,31 +105,18 @@ importers: '@ai-sdk/openai-compatible': specifier: ^2.0.0 version: 2.0.63(zod@4.3.6) - ai: - specifier: ^6.0.0 - version: 6.0.238(zod@4.3.6) - ignore: - specifier: ^7.0.5 - version: 7.0.5 - zod: - specifier: ^4.3.6 - version: 4.3.6 - devDependencies: - vitest: - specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.13)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.0) - - packages/tools: - dependencies: '@modelcontextprotocol/sdk': specifier: ^1.24.3 version: 1.25.3(hono@4.11.7)(zod@4.3.6) + ai: + specifier: ^6.0.0 + version: 6.0.238(zod@4.3.6) diff: specifier: ^8.0.3 version: 8.0.3 ignore: - specifier: ^5.3.1 - version: 5.3.2 + specifier: ^7.0.5 + version: 7.0.5 minimatch: specifier: ^10.0.1 version: 10.2.0 @@ -149,9 +136,6 @@ importers: '@memo-code/core': specifier: workspace:* version: link:../core - '@memo-code/tools': - specifier: workspace:* - version: link:../tools ignore: specifier: ^7.0.5 version: 7.0.5 @@ -1503,10 +1487,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -1675,10 +1655,6 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -3149,7 +3125,7 @@ snapshots: cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 7.5.1(express@5.2.1) jose: 6.1.3 @@ -3819,13 +3795,11 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.0.6: {} - eventsource-parser@3.1.0: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 expect-type@1.3.0: {} @@ -4025,8 +3999,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ignore@5.3.2: {} - ignore@7.0.5: {} indent-string@5.0.0: {} diff --git a/tsconfig.json b/tsconfig.json index e655522..b4c0bb1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,8 +31,6 @@ "paths": { "@memo/core": ["packages/core/src/index.ts"], "@memo/core/*": ["packages/core/src/*"], - "@memo/tools": ["packages/tools/src/index.ts"], - "@memo/tools/*": ["packages/tools/src/*"], "@memo-code/tui": ["packages/tui/src/index.ts"], "@memo-code/tui/*": ["packages/tui/src/*"], "@modelcontextprotocol/sdk/types": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/types.d.ts"] From 6831ddfbc0c6f3e593d121f47cbb86a89f6d329c Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 16:43:42 +0800 Subject: [PATCH 08/42] refactor(agent): delegate tool execution to AI SDK execute mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools are now declared with execute wrappers (agent/sdk_tools.ts): approval (white-list → classifier → fingerprint) runs inside execute, UI decision awaited inline; deny returns execution-denied - per-step concurrency gate (agent/step_gate.ts) serializes mutating tools and skips pending tools after a denial - loop consumes llmResult.toolResults instead of orchestrating execution; orchestrator execution implementation removed (types kept) - Tool contract output switched from MCP CallToolResult to MemoToolOutput - behavior notes: action event emitted after step completes; observation meta drops duration_ms; action ids are SDK toolCallIds --- packages/core/src/agent/defaults.ts | 4 + .../agent/defaults.with_default_deps.test.ts | 19 +- packages/core/src/agent/loop.test.ts | 51 +-- packages/core/src/agent/loop.ts | 374 +++++------------- packages/core/src/agent/messages.ts | 61 ++- packages/core/src/agent/sdk_tools.ts | 118 ++++++ packages/core/src/agent/session_hooks.test.ts | 87 +++- packages/core/src/agent/step_gate.ts | 85 ++++ packages/core/src/llm/ai_stream.test.ts | 78 ++-- packages/core/src/llm/ai_stream.ts | 36 +- packages/core/src/prompt/memory.test.ts | 7 + .../core/src/tools/orchestrator/index.test.ts | 250 ------------ packages/core/src/tools/orchestrator/index.ts | 267 +------------ packages/core/src/tools/orchestrator/types.ts | 4 +- packages/core/src/tools/router/index.test.ts | 26 +- packages/core/src/tools/router/index.ts | 5 +- packages/core/src/tools/router/mcp/index.ts | 18 +- packages/core/src/tools/router/mcp/pool.ts | 2 +- .../src/tools/router/native/index.test.ts | 12 +- packages/core/src/tools/router/types.ts | 10 +- .../core/src/tools/tools/apply_patch.test.ts | 18 +- .../core/src/tools/tools/codex_tools.test.ts | 17 +- packages/core/src/tools/tools/collab.test.ts | 27 +- .../core/src/tools/tools/exec_command.test.ts | 16 +- .../src/tools/tools/filesystem_tools.test.ts | 45 +-- .../core/src/tools/tools/get_memory.test.ts | 11 +- packages/core/src/tools/tools/mcp.test.ts | 110 +++--- packages/core/src/tools/tools/mcp.ts | 21 +- .../src/tools/tools/mcp_resources.test.ts | 21 +- packages/core/src/tools/tools/shell.test.ts | 10 +- .../src/tools/tools/shell_command.test.ts | 12 +- .../src/tools/tools/shell_update_plan.test.ts | 23 +- packages/core/src/tools/tools/types.ts | 7 +- .../core/src/tools/tools/webfetch.test.ts | 41 +- packages/core/src/types.ts | 7 +- packages/core/src/utils/tokenizer.test.ts | 38 +- 36 files changed, 743 insertions(+), 1195 deletions(-) create mode 100644 packages/core/src/agent/sdk_tools.ts create mode 100644 packages/core/src/agent/step_gate.ts delete mode 100644 packages/core/src/tools/orchestrator/index.test.ts diff --git a/packages/core/src/agent/defaults.ts b/packages/core/src/agent/defaults.ts index fbd2273..c46e527 100644 --- a/packages/core/src/agent/defaults.ts +++ b/packages/core/src/agent/defaults.ts @@ -129,13 +129,17 @@ export async function withDefaultDeps( throw new Error(`Missing env var ${provider.env_api_key} (or OPENAI_API_KEY/DEEPSEEK_API_KEY)`) } const { profile: modelProfile } = resolveModelProfile(provider, config.model_profiles) + // Compaction passes { tools: [] } to disable tools; the main turn uses the full registry. + const toolsEnabled = callOptions?.tools ? callOptions.tools.length > 0 : true return streamCallLLM({ provider, apiKey, messages, + tools: toolsEnabled ? combinedTools : undefined, toolDefinitions: callOptions?.tools ?? toolDefinitions, profile: modelProfile, factory: getProviderFactory(provider), + toolContext: callOptions?.toolContext, onChunk, signal: callOptions?.signal, }) diff --git a/packages/core/src/agent/defaults.with_default_deps.test.ts b/packages/core/src/agent/defaults.with_default_deps.test.ts index 4751caf..8ff7d37 100644 --- a/packages/core/src/agent/defaults.with_default_deps.test.ts +++ b/packages/core/src/agent/defaults.with_default_deps.test.ts @@ -49,7 +49,7 @@ const state = vi.hoisted(() => ({ description: 'mock tool', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [{ type: 'text', text: 'ok' }] }), + execute: async () => ({ type: 'text', value: 'ok' }), } as Tool, } as ToolRegistry, loadMcpServersCalls: [] as unknown[], @@ -68,7 +68,8 @@ const state = vi.hoisted(() => ({ } as unknown as AIProviderFactory, llmResponse: { text: 'ok', - toolCalls: [], + toolCalls: [] as LLMResult['toolCalls'], + toolResults: [] as LLMResult['toolResults'], usage: { inputTokens: 11, outputTokens: 7, @@ -180,7 +181,8 @@ describe('withDefaultDeps (default path)', () => { state.factoryLookups = [] state.llmResponse = { text: 'ok', - toolCalls: [], + toolCalls: [] as LLMResult['toolCalls'], + toolResults: [] as LLMResult['toolResults'], usage: { ...emptyUsage(), inputTokens: 11, outputTokens: 7, totalTokens: 18 }, finishReason: 'stop', } as LLMResult @@ -217,8 +219,11 @@ describe('withDefaultDeps (default path)', () => { test('respects provided deps overrides (callLLM/historySinks/tokenCounter/loadPrompt/dispose)', async () => { const { withDefaultDeps } = await import('@memo/core/agent/defaults') const callLLM = vi.fn(async () => ({ - content: [{ type: 'text' as const, text: 'override' }], - stop_reason: 'end_turn' as const, + text: 'override', + toolCalls: [] as LLMResult['toolCalls'], + toolResults: [] as LLMResult['toolResults'], + usage: emptyUsage(), + finishReason: 'stop' as const, })) const historySinks = [{ append: vi.fn() }] const tokenCounter = { @@ -301,6 +306,7 @@ describe('withDefaultDeps (default path)', () => { text: 'assistant text', reasoning: 'reasoned', toolCalls: [{ type: 'tool-call', toolCallId: 'call-ok', toolName: 'echo', input: { value: 1 } }], + toolResults: [] as LLMResult['toolResults'], usage: { ...emptyUsage(), inputTokens: 10, outputTokens: 5, totalTokens: 15 }, finishReason: 'tool-calls', } @@ -377,7 +383,8 @@ describe('withDefaultDeps (default path)', () => { state.llmResponse = { text: 'plain assistant answer', reasoning: 'concise reason', - toolCalls: [], + toolCalls: [] as LLMResult['toolCalls'], + toolResults: [] as LLMResult['toolResults'], usage: { ...emptyUsage(), inputTokens: 3, outputTokens: 4, totalTokens: 7 }, finishReason: 'stop', } diff --git a/packages/core/src/agent/loop.test.ts b/packages/core/src/agent/loop.test.ts index a3472a0..dd4fdbf 100644 --- a/packages/core/src/agent/loop.test.ts +++ b/packages/core/src/agent/loop.test.ts @@ -10,7 +10,7 @@ import { stableStringify, truncateSessionTitle, } from '@memo/core/agent/loop' -import { completeToolResultsForProtocol, parseTextToolCall, toToolHistoryMessage } from '@memo/core/agent/messages' +import { parseTextToolCall, toToolHistoryMessage } from '@memo/core/agent/messages' describe('accumulateUsage', () => { test('uses explicit total when provided', () => { @@ -145,14 +145,12 @@ describe('session title helpers', () => { }) describe('tool result helpers', () => { - test('toToolHistoryMessage maps tool action result into tool chat message', () => { + test('toToolHistoryMessage maps tool result part into tool chat message', () => { const message = toToolHistoryMessage({ - actionId: 'call-1', - tool: 'read_file', - status: 'success', - observation: 'content', - success: true, - durationMs: 12, + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'read_file', + output: { type: 'text', value: 'content' }, }) expect(message).toEqual({ role: 'tool', @@ -166,43 +164,6 @@ describe('tool result helpers', () => { ], }) }) - - test('completeToolResultsForProtocol fills missing results', () => { - const requested = [ - { id: 'call-1', name: 'read_file' }, - { id: 'call-2', name: 'exec_command' }, - ] - const actual = [ - { - actionId: 'call-1', - tool: 'read_file', - status: 'success' as const, - observation: 'ok', - success: true, - durationMs: 5, - }, - ] - - const failureFilled = completeToolResultsForProtocol(requested, actual, false) - expect(failureFilled).toHaveLength(2) - expect(failureFilled[0]).toMatchObject({ actionId: 'call-1', status: 'success' }) - expect(failureFilled[1]).toMatchObject({ - actionId: 'call-2', - status: 'execution_failed', - errorType: 'execution_failed', - rejected: undefined, - }) - expect(failureFilled[1]?.observation).toContain('Tool result missing for exec_command') - - const rejectionFilled = completeToolResultsForProtocol(requested, actual, true) - expect(rejectionFilled[1]).toMatchObject({ - actionId: 'call-2', - status: 'approval_denied', - errorType: 'approval_denied', - rejected: true, - }) - expect(rejectionFilled[1]?.observation).toContain('Skipped tool execution after previous rejection') - }) }) describe('isAbortError', () => { diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 4f95d22..ef7185d 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -26,19 +26,18 @@ import type { TurnResult, TurnStatus, } from '@memo/core/types' -import type { LanguageModelUsage } from 'ai' +import type { LanguageModelUsage, ToolResultPart } from 'ai' import { buildHookRunners, runHook, snapshotHistory, type HookRunnerMap } from '@memo/core/agent/hooks' -import { - createToolOrchestrator, - type ToolApprovalHooks, - type ToolOrchestrator, - type ToolActionResult, -} from '@memo/core/tools/orchestrator' +import { createApprovalManager, type ApprovalManager } from '@memo/core/tools/approval' +import type { ToolApprovalHooks } from '@memo/core/tools/orchestrator' import { runWithRuntimeContext } from '@memo/core/tools/runtime/context' +import type { ToolExecutionContext } from './sdk_tools' +import { createStepGate } from './step_gate' import { buildAssistantToolCalls, - completeToolResultsForProtocol, + mapOutputStatus, normalizeLLMResponse, + outputToObservation, parseTextToolCall, toToolHistoryMessage, } from './messages' @@ -68,7 +67,7 @@ export class AgentSessionImpl implements AgentSession { private cancelling = false private lastActionSignature: string | null = null private repeatedActionCount = 0 - private toolOrchestrator: ToolOrchestrator + private approvalManager: ApprovalManager private toolsDisabled = false private toolPermissionMode: ToolPermissionMode | 'auto' = 'auto' @@ -92,12 +91,9 @@ export class AgentSessionImpl implements AgentSession { const resolvedPermission = resolveToolPermission(options) this.toolsDisabled = resolvedPermission.toolsDisabled this.toolPermissionMode = resolvedPermission.mode - this.toolOrchestrator = createToolOrchestrator({ - tools: deps.tools, - approval: { - dangerous: resolvedPermission.dangerous, - mode: resolvedPermission.approvalMode, - }, + this.approvalManager = createApprovalManager({ + dangerous: resolvedPermission.dangerous, + mode: resolvedPermission.approvalMode, }) } @@ -389,20 +385,6 @@ export class AgentSessionImpl implements AgentSession { } } - /** 通过工具编排器执行工具调用。 */ - private async executeToolAction( - actionId: string, - toolName: string, - toolInput: unknown, - turn: number, - step: number, - ): Promise { - return this.toolOrchestrator.executeAction( - { id: actionId, name: toolName, input: toolInput }, - this.buildToolApprovalHooks(turn, step), - ) - } - private async maybeGenerateSessionTitle(turn: number, originalPrompt: string) { if (turn !== 1 || this.title) return @@ -542,9 +524,17 @@ export class AgentSessionImpl implements AgentSession { let assistantText = '' let toolUseBlocks: Array<{ id: string; name: string; input: unknown }> = [] + let toolResults: ToolResultPart[] = [] let usageFromLLM: Partial | undefined let reasoningContent: string | undefined let receivedAssistantChunk = false + const toolContext: ToolExecutionContext = { + approvalManager: this.approvalManager, + approvalHooks: this.buildToolApprovalHooks(turn, step), + toolsDisabled: this.toolsDisabled, + onRepeatedAction: (tool, input) => this.maybeWarnRepeatedAction(tool, input), + gate: createStepGate(), + } try { const llmResult = await this.deps.callLLM( this.history, @@ -554,11 +544,12 @@ export class AgentSessionImpl implements AgentSession { } this.deps.onAssistantStep?.(chunk, step) }, - { signal: abortController.signal }, + { signal: abortController.signal, toolContext }, ) const normalized = normalizeLLMResponse(llmResult) assistantText = normalized.textContent toolUseBlocks = normalized.toolUseBlocks + toolResults = normalized.toolResults usageFromLLM = normalized.usage reasoningContent = normalized.reasoningContent if (assistantText.trim().length > 0) { @@ -732,61 +723,55 @@ export class AgentSessionImpl implements AgentSession { this.history.push(assistantHistoryMessage) } - if (toolUseBlocks.length > 0 && this.toolsDisabled) { - for (const block of toolUseBlocks) { + // 工具调用已由 AI SDK 在 streamText 内执行(execute 包装器:审批/截断/禁用跳过)。 + if (toolUseBlocks.length > 0) { + // 工具禁用模式:全部跳过 → 按工具禁用错误终止 + const disabledSkipped = toolResults.some( + (tr) => tr.output.type === 'text' && tr.output.value === TOOL_SKIPPED_DISABLED_MESSAGE, + ) + if (disabledSkipped) { + status = 'error' + finalText = TOOL_DISABLED_ERROR_MESSAGE + errorMessage = TOOL_DISABLED_ERROR_MESSAGE + for (const tr of toolResults) { + this.history.push(toToolHistoryMessage(tr)) + } this.history.push({ - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: block.id, - toolName: block.name, - output: { type: 'text', value: TOOL_SKIPPED_DISABLED_MESSAGE }, - }, - ], + role: 'assistant', + content: TOOL_DISABLED_ERROR_MESSAGE, }) + await this.emitEvent('final', { + turn, + step, + content: TOOL_DISABLED_ERROR_MESSAGE, + role: 'assistant', + meta: { + error_type: 'tool_disabled', + tool_count: toolUseBlocks.length, + tools: toolUseBlocks.map((block) => block.name).join(','), + tokens: stepUsage, + }, + }) + await runHook(this.hooks, 'onFinal', { + sessionId: this.id, + turn, + step, + finalText, + status, + errorMessage, + tokenUsage: stepUsage, + turnUsage: { ...turnUsage }, + steps, + }) + break } - status = 'error' - finalText = TOOL_DISABLED_ERROR_MESSAGE - errorMessage = TOOL_DISABLED_ERROR_MESSAGE - this.history.push({ - role: 'assistant', - content: TOOL_DISABLED_ERROR_MESSAGE, - }) - await this.emitEvent('final', { - turn, - step, - content: TOOL_DISABLED_ERROR_MESSAGE, - role: 'assistant', - meta: { - error_type: 'tool_disabled', - tool_count: toolUseBlocks.length, - tools: toolUseBlocks.map((block) => block.name).join(','), - tokens: stepUsage, - }, - }) - await runHook(this.hooks, 'onFinal', { - sessionId: this.id, - turn, - step, - finalText: TOOL_DISABLED_ERROR_MESSAGE, - status, - errorMessage, - tokenUsage: stepUsage, - turnUsage: { ...turnUsage }, - steps, - }) - break - } - // 处理工具调用(支持并发执行多个工具) - if (toolUseBlocks.length > 1) { - // 重复调用防呆:对每个工具调用记录签名 + // 重复调用防呆 for (const block of toolUseBlocks) { this.maybeWarnRepeatedAction(block.name, block.input) } - // 触发 action hooks(action 字段取首个工具,parallelActions 包含全量) + // action 事件(批次级) await this.emitEvent('action', { turn, step, @@ -794,183 +779,71 @@ export class AgentSessionImpl implements AgentSession { tools: toolUseBlocks.map((b) => b.name), action_ids: toolUseBlocks.map((b) => b.id), action_id: toolUseBlocks[0]?.id, - parallel: true, + parallel: toolUseBlocks.length > 1, phase: 'dispatch', thinking: parsed.thinking, - // 保存所有工具的完整信息 - toolBlocks: toolUseBlocks.map((b) => ({ - id: b.id, - name: b.name, - input: b.input, - })), }, }) - const firstTool = toolUseBlocks[0] - if (firstTool) { - await runHook(this.hooks, 'onAction', { - sessionId: this.id, - turn, - step, - action: { - tool: firstTool.name, - input: firstTool.input, - }, - parallelActions: toolUseBlocks.map((block) => ({ - tool: block.name, - input: block.input, - })), - thinking: parsed.thinking, - history: snapshotHistory(this.history), - }) - } - - const allSupportParallel = toolUseBlocks.every((block) => { - const tool = this.deps.tools[block.name] - return Boolean(tool?.supportsParallelToolCalls) - }) - const hasMutatingTool = toolUseBlocks.some((block) => { - const tool = this.deps.tools[block.name] - return Boolean(tool?.isMutating) + await runHook(this.hooks, 'onAction', { + sessionId: this.id, + turn, + step, + action: { tool: toolUseBlocks[0]?.name ?? '', input: toolUseBlocks[0]?.input }, + parallelActions: toolUseBlocks.map((b) => ({ tool: b.name, input: b.input })), + thinking: parsed.thinking, + history: snapshotHistory(this.history), }) - const executionMode = allSupportParallel && !hasMutatingTool ? 'parallel' : 'sequential' - - const execution = await this.toolOrchestrator.executeActions( - toolUseBlocks.map((block) => ({ - id: block.id, - name: block.name, - input: block.input, - })), - { - ...this.buildToolApprovalHooks(turn, step), - executionMode, - failurePolicy: 'fail_fast', - }, - ) - const protocolResults = completeToolResultsForProtocol( - toolUseBlocks, - execution.results, - execution.hasRejection, - ) - - for (const [idx, result] of protocolResults.entries()) { - this.history.push(toToolHistoryMessage(result)) + // 逐结果回填历史 + observation 事件 + const observations: string[] = [] + const resultStatuses: ToolActionStatus[] = [] + let denied = false + for (const [idx, tr] of toolResults.entries()) { + const observation = outputToObservation(tr) + const status = mapOutputStatus(tr) + observations.push(observation) + resultStatuses.push(status) + this.history.push(toToolHistoryMessage(tr)) await this.emitEvent('observation', { turn, step, - content: result.observation, + content: observation, meta: { - tool: result.tool, + tool: tr.toolName, index: idx, - action_id: result.actionId, + action_id: tr.toolCallId, phase: 'result', - status: result.status, - error_type: result.errorType, - duration_ms: result.durationMs, - execution_mode: executionMode, + status, + error_type: status === 'success' ? undefined : status, }, }) + if (tr.output.type === 'execution-denied') denied = true } - - const combinedObservation = protocolResults - .map((result) => `[${result.tool}]: ${result.observation}`) + const combinedObservation = observations + .map((obs, i) => `[${toolResults[i]?.toolName ?? ''}]: ${obs}`) .join('\n\n') - const parallelResultStatuses = protocolResults.map((result) => result.status) - const resultStatus = - parallelResultStatuses.find((candidate) => candidate !== TOOL_ACTION_SUCCESS_STATUS) ?? - TOOL_ACTION_SUCCESS_STATUS + const hookObservation = toolResults.length > 1 ? combinedObservation : (observations[0] ?? '') const lastStep = steps[steps.length - 1] if (lastStep) { - lastStep.observation = combinedObservation + lastStep.observation = hookObservation } - // 触发 observation hook(使用合并后的结果) + const resultStatus = + resultStatuses.find((candidate) => candidate !== TOOL_ACTION_SUCCESS_STATUS) ?? + TOOL_ACTION_SUCCESS_STATUS await runHook(this.hooks, 'onObservation', { sessionId: this.id, turn, step, tool: toolUseBlocks.map((b) => b.name).join(', '), - observation: combinedObservation, + observation: hookObservation, resultStatus, - parallelResultStatuses, - history: snapshotHistory(this.history), - }) - - // 如果被拒绝,停止本轮次 - if (execution.hasRejection) { - const rejectionResult = protocolResults.find((result) => result.rejected) - status = 'cancelled' - finalText = '用户拒绝了工具执行,已停止当前操作。' - await this.emitEvent('final', { - turn, - step, - content: finalText, - role: 'assistant', - meta: { - rejected: true, - phase: 'result', - action_id: rejectionResult?.actionId, - error_type: rejectionResult?.errorType ?? 'approval_denied', - duration_ms: rejectionResult?.durationMs, - }, - }) - await runHook(this.hooks, 'onFinal', { - sessionId: this.id, - turn, - step, - finalText, - status, - tokenUsage: stepUsage, - turnUsage: { ...turnUsage }, - steps, - }) - break - } - continue - } - - // 单个工具调用 - // 注意:当 toolUseBlocks.length > 1 时,已在上面处理,这里跳过 - else if (parsed.action) { - this.maybeWarnRepeatedAction(parsed.action.tool, parsed.action.input) - const actionId = toolUseBlocks[0]?.id ?? `${turn}:${step}:single:${parsed.action.tool}` - await this.emitEvent('action', { - turn, - step, - meta: { - tool: parsed.action.tool, - input: parsed.action.input, - action_id: actionId, - phase: 'dispatch', - thinking: parsed.thinking, - }, - }) - await runHook(this.hooks, 'onAction', { - sessionId: this.id, - turn, - step, - action: parsed.action, - thinking: parsed.thinking, + parallelResultStatuses: resultStatuses, history: snapshotHistory(this.history), }) - // 使用审批流程执行工具 - const result = await this.executeToolAction( - actionId, - parsed.action.tool, - parsed.action.input, - turn, - step, - ) - - // 如果被拒绝,停止本轮次 - if (result.rejected) { - this.history.push( - toToolHistoryMessage({ - ...result, - observation: - result.observation || `User denied tool execution: ${parsed.action.tool}`, - }), - ) + // 拒绝 → 终止本轮(保持现状语义) + if (denied) { + const deniedResult = toolResults.find((tr) => tr.output.type === 'execution-denied') status = 'cancelled' finalText = '用户拒绝了工具执行,已停止当前操作。' await this.emitEvent('final', { @@ -981,9 +854,8 @@ export class AgentSessionImpl implements AgentSession { meta: { rejected: true, phase: 'result', - action_id: result.actionId, - error_type: result.errorType ?? 'approval_denied', - duration_ms: result.durationMs, + action_id: deniedResult?.toolCallId, + error_type: 'approval_denied', }, }) await runHook(this.hooks, 'onFinal', { @@ -998,46 +870,6 @@ export class AgentSessionImpl implements AgentSession { }) break } - - const observation = result.observation - - this.history.push({ - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: result.actionId, - toolName: parsed.action.tool, - output: { type: 'text', value: observation }, - }, - ], - }) - const lastStep = steps[steps.length - 1] - if (lastStep) { - lastStep.observation = observation - } - await this.emitEvent('observation', { - turn, - step, - content: observation, - meta: { - tool: parsed.action.tool, - action_id: result.actionId, - phase: 'result', - status: result.status, - error_type: result.errorType, - duration_ms: result.durationMs, - }, - }) - await runHook(this.hooks, 'onObservation', { - sessionId: this.id, - turn, - step, - tool: parsed.action.tool, - observation, - resultStatus: result.status, - history: snapshotHistory(this.history), - }) continue } @@ -1130,7 +962,7 @@ export class AgentSessionImpl implements AgentSession { this.currentAbortController = null this.cancelling = false // 清除单次授权(每次 turn 结束后) - this.toolOrchestrator.clearOnceApprovals() + this.approvalManager.clearOnceApprovals() } }) } @@ -1175,7 +1007,7 @@ export class AgentSessionImpl implements AgentSession { } this.tokenCounter.dispose() // 清理所有授权 - this.toolOrchestrator.dispose() + this.approvalManager.dispose() if (this.deps.dispose) { await this.deps.dispose() } diff --git a/packages/core/src/agent/messages.ts b/packages/core/src/agent/messages.ts index 26d25e2..e869105 100644 --- a/packages/core/src/agent/messages.ts +++ b/packages/core/src/agent/messages.ts @@ -1,9 +1,7 @@ /** @file Message construction and LLM result normalization for the agent loop. */ -import type { LanguageModelUsage, ToolCallPart } from 'ai' +import type { LanguageModelUsage, ToolCallPart, ToolResultPart } from 'ai' import type { ChatMessage, LLMResult, ToolRegistry } from '@memo/core/types' -import type { ToolActionResult } from '@memo/core/tools/orchestrator' - -const TOOL_SKIPPED_AFTER_REJECTION_MESSAGE = 'Skipped tool execution after previous rejection.' +import type { ToolActionStatus } from '@memo/core/tools/orchestrator' export function parseToolArguments( raw: string, @@ -21,6 +19,8 @@ export function normalizeLLMResponse(raw: LLMResult): { toolUseBlocks: Array<{ id: string; name: string; input: unknown }> reasoningContent?: string usage?: Partial + /** Executed tool results (AI SDK executed the tools inside streamText). */ + toolResults: ToolResultPart[] } { let textContent = raw.text const toolUseBlocks: Array<{ id: string; name: string; input: unknown }> = [] @@ -42,6 +42,7 @@ export function normalizeLLMResponse(raw: LLMResult): { reasoningContent: typeof raw.reasoning === 'string' && raw.reasoning.trim().length > 0 ? raw.reasoning : undefined, usage: raw.usage, + toolResults: raw.toolResults, } } @@ -85,44 +86,26 @@ export function parseTextToolCall(text: string, tools: ToolRegistry): { tool: st return null } -/** Tool action result → tool history message (CoreMessage shape). */ -export function toToolHistoryMessage(result: ToolActionResult): ChatMessage { +/** AI SDK ToolResultPart → tool history message (CoreMessage shape, passthrough). */ +export function toToolHistoryMessage(result: ToolResultPart): ChatMessage { return { role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: result.actionId, - toolName: result.tool, - output: { type: 'text', value: result.observation }, - }, - ], + content: [result], } } -/** Fill missing tool results (rejection/abort) so every requested call has a protocol-complete result. */ -export function completeToolResultsForProtocol( - requested: Array<{ id: string; name: string }>, - actual: ToolActionResult[], - hasRejection: boolean, -): ToolActionResult[] { - const byActionId = new Map(actual.map((result) => [result.actionId, result])) - return requested.map((block) => { - const found = byActionId.get(block.id) - if (found) { - return found - } - return { - actionId: block.id, - tool: block.name, - status: hasRejection ? 'approval_denied' : 'execution_failed', - errorType: hasRejection ? 'approval_denied' : 'execution_failed', - success: false, - observation: hasRejection - ? `${TOOL_SKIPPED_AFTER_REJECTION_MESSAGE} ${block.name}` - : `Tool result missing for ${block.name}; execution aborted before producing output.`, - durationMs: 0, - rejected: hasRejection ? true : undefined, - } - }) +/** ToolResultPart → observation display text. */ +export function outputToObservation(result: ToolResultPart): string { + const output = result.output + if (output.type === 'text' || output.type === 'error-text') return output.value + if (output.type === 'json') return JSON.stringify(output.value) + if (output.type === 'execution-denied') return output.reason ?? 'User denied tool execution' + return '(no tool output)' +} + +/** ToolResultPart → memo status ('success' | error type). */ +export function mapOutputStatus(result: ToolResultPart): ToolActionStatus { + if (result.output.type === 'execution-denied') return 'approval_denied' + if (result.output.type === 'error-text') return 'execution_failed' + return 'success' } diff --git a/packages/core/src/agent/sdk_tools.ts b/packages/core/src/agent/sdk_tools.ts new file mode 100644 index 0000000..ae550da --- /dev/null +++ b/packages/core/src/agent/sdk_tools.ts @@ -0,0 +1,118 @@ +/** @file Adapter from memo Tool registry to AI SDK tools: approval + truncation wrapper on execute. */ +import { jsonSchema, type Tool as SdkTool, type ToolExecutionOptions, type ToolSet } from 'ai' + +export type SdkToolSet = ToolSet +import type { MemoToolOutput, Tool, ToolRegistry } from '@memo/core/tools/router/types' +import type { ApprovalManager } from '@memo/core/tools/approval' +import type { ToolApprovalHooks } from '@memo/core/tools/orchestrator' +import { getMaxToolResultChars } from '@memo/core/tools/runtime/tool_output_limits' +import type { StepGate } from './step_gate' + +const TOOL_SKIPPED_AFTER_REJECTION_MESSAGE = 'Skipped tool execution after previous rejection.' +const TOOL_SKIPPED_DISABLED_MESSAGE = 'Tool execution skipped: tools are disabled in current permission mode.' + +/** Per-call context captured by the loop and closed over by the execute wrappers. */ +export type ToolExecutionContext = { + approvalManager: ApprovalManager + approvalHooks: ToolApprovalHooks + toolsDisabled: boolean + onRepeatedAction: (tool: string, input: unknown) => void + /** Fresh per streamText call. */ + gate: StepGate +} + +function escapeXmlAttr(value: string) { + return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +function buildOversizeHintXml(toolName: string, actualChars: number, maxChars: number) { + return `Tool output too long, automatically omitted. Please narrow the scope or add limit parameters and try again.` +} + +function guardToolResultOutput(toolName: string, result: MemoToolOutput): MemoToolOutput { + const maxChars = getMaxToolResultChars() + const actualChars = + result.type === 'text' || result.type === 'error-text' + ? result.value.length + : result.type === 'json' + ? JSON.stringify(result.value).length + : (result.reason?.length ?? 0) + if (actualChars <= maxChars) return result + return { type: 'text', value: buildOversizeHintXml(toolName, actualChars, maxChars) } +} + +async function executeToolCall( + tool: Tool, + input: unknown, + ctx: ToolExecutionContext, + sdkOptions: ToolExecutionOptions, +): Promise { + if (ctx.toolsDisabled) return { type: 'text', value: TOOL_SKIPPED_DISABLED_MESSAGE } + + const exclusive = tool.isMutating === true || tool.supportsParallelToolCalls === false + const permit = await ctx.gate.acquire(exclusive) + if (permit.skipped) return { type: 'text', value: TOOL_SKIPPED_AFTER_REJECTION_MESSAGE } + + try { + // ① Approval (white-list → classifier → fingerprint cache), UI decision awaited inline. + const check = ctx.approvalManager.check(tool.name, input) + if (check.needApproval) { + const request = { + toolName: check.toolName, + params: check.params, + fingerprint: check.fingerprint, + riskLevel: check.riskLevel, + reason: check.reason, + } + await ctx.approvalHooks.onApprovalRequest?.(request) + const decision = ctx.approvalHooks.requestApproval + ? await ctx.approvalHooks.requestApproval(request) + : 'deny' + ctx.approvalManager.recordDecision(check.fingerprint, decision) + await ctx.approvalHooks.onApprovalResponse?.({ fingerprint: check.fingerprint, decision }) + if (decision === 'deny') { + ctx.gate.markDenied() + return { type: 'execution-denied', reason: `User denied tool execution: ${tool.name}` } + } + } + + // ② Input validation (SDK validates against jsonSchema; keep memo validators for strict semantics). + const parsed = tool.validateInput ? tool.validateInput(input) : { ok: true as const, data: input } + if (!parsed.ok) return { type: 'error-text', value: parsed.error } + + // ③ Execute + truncate. + const raw = await tool.execute(parsed.data, { abortSignal: sdkOptions.abortSignal }) + return guardToolResultOutput(tool.name, raw) + } catch (err) { + if (sdkOptions.abortSignal?.aborted) throw err + return { type: 'error-text', value: `Tool execution failed: ${(err as Error).message}` } + } finally { + permit.release() + } +} + +/** Build AI SDK tools from the memo Tool registry, wrapping execute with approval/truncation. */ +export function buildSdkTools(tools: ToolRegistry, ctx: ToolExecutionContext): Record | undefined { + const entries = Object.values(tools) + if (entries.length === 0) return undefined + return Object.fromEntries( + entries.map((tool) => [ + tool.name, + { + description: tool.description, + inputSchema: jsonSchema( + tool.inputSchema?.type === 'object' ? tool.inputSchema : { ...tool.inputSchema, type: 'object' }, + ), + metadata: { + memo: { + source: tool.source, + isMutating: tool.isMutating, + supportsParallelToolCalls: tool.supportsParallelToolCalls, + }, + }, + execute: (input: unknown, sdkOptions: ToolExecutionOptions) => + executeToolCall(tool, input, ctx, sdkOptions), + }, + ]), + ) +} diff --git a/packages/core/src/agent/session_hooks.test.ts b/packages/core/src/agent/session_hooks.test.ts index 3f2722b..d2fb5ce 100644 --- a/packages/core/src/agent/session_hooks.test.ts +++ b/packages/core/src/agent/session_hooks.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert' import { describe, test } from 'vitest' import { createAgentSession, createTokenCounter } from '@memo/core' import type { ChatMessage, HistoryEvent, LLMResult, TokenCounter } from '@memo/core' +import type { ToolResultPart } from 'ai' import type { Tool } from '@memo/core/tools/router' import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX } from '@memo/core/agent/compact_prompt' import { emptyUsage } from '@memo/core/agent/loop' @@ -14,9 +15,7 @@ const echoTool: Tool = { inputSchema: { type: 'object', properties: { text: { type: 'string' } } }, execute: async (input: unknown) => { const { text } = input as { text: string } - return { - content: [{ type: 'text' as const, text: `echo:${text}` }], - } + return { type: 'text', value: `echo:${text}` } }, } @@ -27,22 +26,73 @@ const readNoteTool: Tool = { inputSchema: { type: 'object', properties: { topic: { type: 'string' } } }, execute: async (input: unknown) => { const { topic } = input as { topic: string } + return { type: 'text', value: `note:${topic}` } + }, +} + +type MockToolOpts = { denied?: boolean; skipped?: boolean; skippedDisabled?: boolean; invalid?: boolean } + +const TOOL_SKIPPED_DISABLED_TEXT = 'Tool execution skipped: tools are disabled in current permission mode.' +const TOOL_SKIPPED_AFTER_REJECTION_TEXT = 'Skipped tool execution after previous rejection.' + +/** Simulate the AI SDK execute wrapper output for a tool. */ +function mockToolResult(id: string, name: string, input: unknown, opts: MockToolOpts = {}): ToolResultPart { + if (opts.denied) { return { - content: [{ type: 'text' as const, text: `note:${topic}` }], + type: 'tool-result', + toolCallId: id, + toolName: name, + output: { type: 'execution-denied', reason: `User denied tool execution: ${name}` }, } - }, + } + if (opts.skippedDisabled) { + return { + type: 'tool-result', + toolCallId: id, + toolName: name, + output: { type: 'text', value: TOOL_SKIPPED_DISABLED_TEXT }, + } + } + if (opts.skipped) { + return { + type: 'tool-result', + toolCallId: id, + toolName: name, + output: { type: 'text', value: TOOL_SKIPPED_AFTER_REJECTION_TEXT }, + } + } + if (opts.invalid) { + return { + type: 'tool-result', + toolCallId: id, + toolName: name, + output: { type: 'error-text', value: `${name} invalid input: bad` }, + } + } + const params = input as { text?: string; topic?: string } + const value = + name === 'echo' && typeof params.text === 'string' + ? `echo:${params.text}` + : name === 'read_note' && typeof params.topic === 'string' + ? `note:${params.topic}` + : `${name} done` + return { type: 'tool-result', toolCallId: id, toolName: name, output: { type: 'text', value } } } -function toolUseResponse(id: string, name: string, input: unknown, text?: string): LLMResult { +function toolUseResponse(id: string, name: string, input: unknown, text?: string, opts: MockToolOpts = {}): LLMResult { return { text: text ?? '', toolCalls: [{ type: 'tool-call', toolCallId: id, toolName: name, input }], + toolResults: [mockToolResult(id, name, input, opts)], usage: emptyUsage(), finishReason: 'tool-calls', } } -function multiToolUseResponse(calls: Array<{ id: string; name: string; input: unknown }>, text?: string): LLMResult { +function multiToolUseResponse( + calls: Array<{ id: string; name: string; input: unknown; opts?: MockToolOpts }>, + text?: string, +): LLMResult { return { text: text ?? '', toolCalls: calls.map((call) => ({ @@ -51,6 +101,7 @@ function multiToolUseResponse(calls: Array<{ id: string; name: string; input: un toolName: call.name, input: call.input, })), + toolResults: calls.map((call) => mockToolResult(call.id, call.name, call.input, call.opts)), usage: emptyUsage(), finishReason: 'tool-calls', } @@ -62,6 +113,7 @@ function endTurnResponse(text: string = 'done'): LLMResult { toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], } } @@ -322,7 +374,10 @@ describe('session hooks & middleware', () => { }) test('blocks tool calls when tool permission mode is none', async () => { - const outputs: LLMResult[] = [toolUseResponse('action-1', 'echo', { text: 'blocked' }), endTurnResponse('done')] + const outputs: LLMResult[] = [ + toolUseResponse('action-1', 'echo', { text: 'blocked' }, undefined, { skippedDisabled: true }), + endTurnResponse('done'), + ] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -354,7 +409,10 @@ describe('session hooks & middleware', () => { }) test('rejects native tool input via validateInput before execute', async () => { - const outputs: LLMResult[] = [toolUseResponse('action-1', 'read_text_file', {}), endTurnResponse('done')] + const outputs: LLMResult[] = [ + toolUseResponse('action-1', 'read_text_file', {}, undefined, { invalid: true }), + endTurnResponse('done'), + ] const session = await createAgentSession( { callLLM: async () => outputs.shift() ?? endTurnResponse('done'), @@ -417,7 +475,7 @@ describe('session hooks & middleware', () => { assert.strictEqual(observationEvent.meta?.phase, 'result') assert.strictEqual(observationEvent.meta?.status, 'success') assert.strictEqual(observationEvent.meta?.error_type, undefined) - assert.strictEqual(typeof observationEvent.meta?.duration_ms, 'number') + // duration_ms no longer available: SDK ToolResultPart carries no timing. } finally { await session.close() } @@ -479,7 +537,7 @@ describe('session hooks & middleware', () => { test('emits structured rejection metadata in final event', async () => { const events: HistoryEvent[] = [] - const outputs = [toolUseResponse('reject-1', 'echo', { text: 'x' })] + const outputs = [toolUseResponse('reject-1', 'echo', { text: 'x' }, undefined, { denied: true })] const session = await createAgentSession( { tools: { echo: echoTool }, @@ -509,7 +567,7 @@ describe('session hooks & middleware', () => { assert.strictEqual(finalEvent?.meta?.phase, 'result') assert.strictEqual(finalEvent?.meta?.error_type, 'approval_denied') assert.strictEqual(finalEvent?.meta?.action_id, 'reject-1') - assert.strictEqual(typeof finalEvent?.meta?.duration_ms, 'number') + // duration_ms no longer available: SDK ToolResultPart carries no timing. } finally { await session.close() } @@ -518,8 +576,8 @@ describe('session hooks & middleware', () => { test('records tool messages for all tool_call_ids on fail_fast rejection', async () => { const outputs: LLMResult[] = [ multiToolUseResponse([ - { id: 'reject-1', name: 'echo', input: { text: 'a' } }, - { id: 'reject-2', name: 'echo', input: { text: 'b' } }, + { id: 'reject-1', name: 'echo', input: { text: 'a' }, opts: { denied: true } }, + { id: 'reject-2', name: 'echo', input: { text: 'b' }, opts: { skipped: true } }, ]), ] const session = await createAgentSession( @@ -597,6 +655,7 @@ describe('session hooks & middleware', () => { toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], }, ] const session = await createAgentSession( diff --git a/packages/core/src/agent/step_gate.ts b/packages/core/src/agent/step_gate.ts new file mode 100644 index 0000000..308072b --- /dev/null +++ b/packages/core/src/agent/step_gate.ts @@ -0,0 +1,85 @@ +/** @file Per-streamText-call tool execution gate: serializes mutating tools, skips after denial. */ + +export type StepPermit = + | { skipped: true } + | { + skipped: false + /** Must be called after the tool finishes (finally). */ + release: () => void + } + +export interface StepGate { + /** + * Acquire execution permission. + * - exclusive (mutating or non-parallel tools): runs alone, FIFO order. + * - shared (read-only parallel tools): runs concurrently unless an exclusive tool is queued/running. + * - after markDenied, every acquire returns { skipped: true }. + */ + acquire(exclusive: boolean): Promise + /** Deny the batch: subsequent tools in this step are skipped. */ + markDenied(): void +} + +export function createStepGate(): StepGate { + let denied = false + let runningShared = 0 + let exclusivePending = false + let exclusiveChain: Promise = Promise.resolve() + let idleWaiters: Array<() => void> = [] + + function isIdle() { + return runningShared === 0 && !exclusivePending + } + + function notifyIdle() { + if (!isIdle()) return + const waiters = idleWaiters + idleWaiters = [] + for (const wake of waiters) wake() + } + + function waitForIdle(): Promise { + if (isIdle()) return Promise.resolve() + return new Promise((resolve) => idleWaiters.push(resolve)) + } + + return { + async acquire(exclusive) { + if (denied) return { skipped: true } + if (exclusive) { + exclusivePending = true + await exclusiveChain + await waitForIdle() + if (denied) return { skipped: true } + let releaseExclusive!: () => void + exclusiveChain = new Promise((resolve) => (releaseExclusive = resolve)) + return { + skipped: false, + release: () => { + exclusivePending = false + releaseExclusive() + notifyIdle() + }, + } + } + // shared tool: wait for queued/running exclusive tools. + await exclusiveChain + if (exclusivePending) { + await waitForIdle() + if (denied) return { skipped: true } + } + runningShared += 1 + return { + skipped: false, + release: () => { + runningShared -= 1 + notifyIdle() + }, + } + }, + markDenied() { + denied = true + notifyIdle() + }, + } +} diff --git a/packages/core/src/llm/ai_stream.test.ts b/packages/core/src/llm/ai_stream.test.ts index 18cc24b..090229c 100644 --- a/packages/core/src/llm/ai_stream.test.ts +++ b/packages/core/src/llm/ai_stream.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' -import { buildStreamTools, streamCallLLM } from '@memo/core/llm/ai_stream' +import { streamCallLLM } from '@memo/core/llm/ai_stream' import type { AIProviderFactory } from '@memo/core/llm/ai_provider' import type { ModelProfile } from '@memo/core/llm/model_profile' -import type { ChatMessage, ToolDefinition } from '@memo/core/types' +import type { ChatMessage } from '@memo/core/types' const state = vi.hoisted(() => ({ streamTextParams: [] as unknown[], @@ -28,6 +28,7 @@ function makeStreamResult() { text: Promise.resolve(state.final.text ?? ''), reasoningText: Promise.resolve(state.final.reasoning), toolCalls: Promise.resolve(state.final.toolCalls ?? []), + toolResults: Promise.resolve(state.final.toolResults ?? []), usage: Promise.resolve(state.final.usage), finishReason: Promise.resolve(state.final.finishReason ?? 'stop'), } @@ -51,7 +52,7 @@ function baseParams(overrides: Record = {}) { provider: { name: 'mock', env_api_key: 'MOCK_API_KEY', model: 'mock-model', base_url: 'https://mock.local/v1' }, apiKey: 'test-key', messages: [{ role: 'user', content: 'hi' }] as ChatMessage[], - toolDefinitions: [] as ToolDefinition[], + toolDefinitions: [], profile: PROFILE, factory: FACTORY, ...overrides, @@ -62,26 +63,6 @@ function textDelta(text: string) { return { type: 'text-delta', id: 't', text } } -describe('buildStreamTools', () => { - test('returns undefined for empty tool list', () => { - expect(buildStreamTools([])).toBeUndefined() - }) - - test('forces top-level type object on schemas', () => { - const tools = buildStreamTools([ - { name: 't', description: 'd', input_schema: { properties: { a: { type: 'string' } } } }, - ]) - expect(tools?.t).toEqual({ - description: 'd', - inputSchema: { properties: { a: { type: 'string' } }, type: 'object' }, - }) - const withObject = buildStreamTools([ - { name: 't2', description: 'd2', input_schema: { type: 'object', properties: {} } }, - ]) - expect(withObject?.t2?.inputSchema).toEqual({ type: 'object', properties: {} }) - }) -}) - describe('streamCallLLM', () => { beforeEach(() => { state.streamTextParams = [] @@ -89,6 +70,7 @@ describe('streamCallLLM', () => { state.final = { text: '', toolCalls: [], + toolResults: [], usage: { inputTokens: 11, outputTokens: 7, totalTokens: 18 }, finishReason: 'stop', } @@ -96,7 +78,13 @@ describe('streamCallLLM', () => { test('streams text deltas through onChunk and returns assembled result', async () => { state.parts = [textDelta('Hel'), textDelta('lo'), textDelta(' world')] - state.final = { text: 'Hello world', toolCalls: [], usage: state.final.usage, finishReason: 'stop' } + state.final = { + text: 'Hello world', + toolCalls: [], + toolResults: [], + usage: state.final.usage, + finishReason: 'stop', + } const chunks: string[] = [] const result = await streamCallLLM(baseParams({ onChunk: (chunk: string) => chunks.push(chunk) })) @@ -111,6 +99,7 @@ describe('streamCallLLM', () => { text: 'answer', reasoning: 'thinking', toolCalls: [], + toolResults: [], usage: state.final.usage, finishReason: 'stop', } @@ -119,18 +108,27 @@ describe('streamCallLLM', () => { expect(result.reasoning).toBe('thinking') }) - test('returns toolCalls and usage from the final result', async () => { + test('returns toolCalls, toolResults and usage from the final result', async () => { state.parts = [] state.final = { text: 'using tools', toolCalls: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'echo', input: { value: 1 } }], + toolResults: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'echo', + output: { type: 'text', value: 'ok' }, + }, + ], usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 }, finishReason: 'tool-calls', } const result = await streamCallLLM(baseParams()) expect(result.toolCalls).toHaveLength(1) - expect(result.toolCalls[0]).toMatchObject({ toolCallId: 'call-1', toolName: 'echo' }) + expect(result.toolResults).toHaveLength(1) + expect(result.toolResults[0]).toMatchObject({ toolCallId: 'call-1', toolName: 'echo' }) expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 }) expect(result.finishReason).toBe('tool-calls') }) @@ -149,16 +147,16 @@ describe('streamCallLLM', () => { }) }) - test('omits tools and toolChoice when no tool definitions', async () => { + test('omits tools and toolChoice when no tool registry', async () => { state.parts = [] - await streamCallLLM(baseParams()) + await streamCallLLM(baseParams({ tools: undefined, toolContext: undefined })) const params = state.streamTextParams[0] as { tools?: unknown; toolChoice?: unknown } expect(params.tools).toBeUndefined() expect(params.toolChoice).toBeUndefined() }) - test('passes tools, toolChoice auto, abortSignal and providerOptions', async () => { + test('passes tools with execute wrappers and toolChoice auto when registry provided', async () => { const signal = new AbortController().signal const factory: AIProviderFactory = { kind: 'openai-compatible', @@ -166,21 +164,39 @@ describe('streamCallLLM', () => { buildProviderOptions: () => ({ parallel_tool_calls: true }), } state.parts = [] + const registry = { + echo: { + name: 'echo', + description: 'echo', + source: 'native' as const, + inputSchema: { type: 'object' }, + execute: async () => ({ type: 'text' as const, value: 'ok' }), + }, + } + const toolContext = { + approvalManager: { check: () => ({ needApproval: false as const, decision: 'auto-execute' as const }) }, + approvalHooks: {}, + toolsDisabled: false, + onRepeatedAction: () => {}, + gate: { acquire: async () => ({ skipped: false as const, release: () => {} }), markDenied: () => {} }, + } await streamCallLLM( baseParams({ factory, signal, - toolDefinitions: [{ name: 't', description: 'd', input_schema: { type: 'object' } }], + tools: registry, + toolContext, }), ) const params = state.streamTextParams[0] as { - tools?: unknown + tools?: Record toolChoice?: unknown abortSignal?: AbortSignal providerOptions?: unknown } expect(params.tools).toBeDefined() + expect(typeof params.tools?.echo).toBe('object') expect(params.toolChoice).toBe('auto') expect(params.abortSignal).toBe(signal) expect(params.providerOptions).toEqual({ mock: { parallel_tool_calls: true } }) diff --git a/packages/core/src/llm/ai_stream.ts b/packages/core/src/llm/ai_stream.ts index 6ef810d..af29990 100644 --- a/packages/core/src/llm/ai_stream.ts +++ b/packages/core/src/llm/ai_stream.ts @@ -1,6 +1,9 @@ /** @file Default streaming LLM call backed by AI SDK streamText. */ -import { jsonSchema, streamText, type ModelMessage } from 'ai' +import { streamText, type ModelMessage, type ToolResultPart } from 'ai' import type { LLMResult, ToolDefinition } from '@memo/core/types' +import type { ToolRegistry } from '@memo/core/tools/router/types' +import type { ToolExecutionContext, SdkToolSet } from '@memo/core/agent/sdk_tools' +import { buildSdkTools } from '@memo/core/agent/sdk_tools' import type { ProviderConfig } from '@memo/core/config/config' import type { ModelProfile } from '@memo/core/llm/model_profile' import type { AIProviderFactory } from '@memo/core/llm/ai_provider' @@ -10,29 +13,16 @@ export type StreamCallLLMParams = { apiKey: string /** CoreMessage[] (ChatMessage alias) — passed to streamText as-is. */ messages: ModelMessage[] + /** Complete tool registry (native + MCP + custom); undefined disables tools (compaction). */ + tools?: ToolRegistry toolDefinitions: ToolDefinition[] profile: ModelProfile factory: AIProviderFactory + toolContext?: ToolExecutionContext onChunk?: (chunk: string) => void signal?: AbortSignal } -/** ToolDefinition[] → AI SDK tools map; forces a top-level type: 'object' (vercel/ai#7924). */ -export function buildStreamTools(toolDefinitions: ToolDefinition[]) { - if (toolDefinitions.length === 0) return undefined - return Object.fromEntries( - toolDefinitions.map((tool) => [ - tool.name, - { - description: tool.description, - inputSchema: jsonSchema( - tool.input_schema?.type === 'object' ? tool.input_schema : { ...tool.input_schema, type: 'object' }, - ), - }, - ]), - ) -} - /** Normalize stream errors so callers can detect aborts via name/message matching. */ export function normalizeStreamError(err: unknown, signal?: AbortSignal): Error { if (signal?.aborted) { @@ -49,18 +39,19 @@ export function normalizeStreamError(err: unknown, signal?: AbortSignal): Error return err instanceof Error ? err : new Error(String(err)) } -/** Default callLLM implementation: stream via AI SDK, return the final GenerateTextResult. */ +/** Default callLLM implementation: stream via AI SDK, tools execute inside streamText. */ export async function streamCallLLM(params: StreamCallLLMParams): Promise { - const { provider, apiKey, messages, toolDefinitions, profile, factory, onChunk, signal } = params - const tools = buildStreamTools(toolDefinitions) + const { provider, apiKey, messages, tools, profile, factory, toolContext, onChunk, signal } = params + const sdkTools: SdkToolSet | undefined = + tools && Object.keys(tools).length > 0 && toolContext ? buildSdkTools(tools, toolContext) : undefined const model = factory.build(provider, apiKey)(provider.model) const requestProviderOptions = factory.buildProviderOptions(profile) const result = streamText({ model, messages, - tools, - toolChoice: tools ? 'auto' : undefined, + tools: sdkTools, + toolChoice: sdkTools ? 'auto' : undefined, abortSignal: signal, // Non-standard wire fields (e.g. parallel_tool_calls) pass through under the provider instance name. providerOptions: requestProviderOptions ? { [provider.name]: requestProviderOptions } : undefined, @@ -79,6 +70,7 @@ export async function streamCallLLM(params: StreamCallLLMParams): Promise { toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], }), historySinks: [], tokenCounter: createTokenCounter('cl100k_base'), @@ -77,6 +78,7 @@ describe('runtime prompt injection', () => { toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], }), historySinks: [], tokenCounter: createTokenCounter('cl100k_base'), @@ -107,6 +109,7 @@ describe('runtime prompt injection', () => { toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], }), loadPrompt: () => loadSystemPrompt({ cwd: projectRoot }), historySinks: [], @@ -142,6 +145,7 @@ describe('runtime prompt injection', () => { toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], }), loadPrompt: () => loadSystemPrompt({ cwd: projectRoot, memoHome: tempHome }), historySinks: [], @@ -192,6 +196,7 @@ description: ${marker} toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], }), loadPrompt: () => loadSystemPrompt({ @@ -255,6 +260,7 @@ description: disabled marker toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], }), loadPrompt: () => loadSystemPrompt({ @@ -303,6 +309,7 @@ name: broken-skill toolCalls: [], usage: emptyUsage(), finishReason: 'stop', + toolResults: [], }), loadPrompt: () => loadSystemPrompt({ diff --git a/packages/core/src/tools/orchestrator/index.test.ts b/packages/core/src/tools/orchestrator/index.test.ts deleted file mode 100644 index cb3a96c..0000000 --- a/packages/core/src/tools/orchestrator/index.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import assert from 'node:assert' -import { describe, test } from 'vitest' -import { z } from 'zod' -import { createToolOrchestrator } from './index' - -describe('tool orchestrator', () => { - test('stops on rejection in sequential execution', async () => { - const calls: string[] = [] - const orchestrator = createToolOrchestrator({ - tools: { - shell_command: { - name: 'shell_command', - execute: async () => { - calls.push('shell_command') - return { content: [{ type: 'text', text: 'ok' }] } - }, - }, - read_file: { - name: 'read_file', - execute: async () => { - calls.push('read_file') - return { content: [{ type: 'text', text: 'read_file' }] } - }, - }, - }, - }) - - const result = await orchestrator.executeActions( - [ - { name: 'shell_command', input: { cmd: 'echo hi' } }, - { name: 'read_file', input: { file_path: '/tmp/a.txt' } }, - ], - { - requestApproval: async () => 'deny', - }, - ) - - assert.strictEqual(result.hasRejection, true) - assert.deepStrictEqual(calls, []) - assert.strictEqual(result.results.length, 1) - assert.strictEqual(result.results[0]?.tool, 'shell_command') - assert.strictEqual(result.results[0]?.status, 'approval_denied') - assert.strictEqual(result.results[0]?.errorType, 'approval_denied') - assert.strictEqual(result.results[0]?.rejected, true) - assert.ok((result.results[0]?.durationMs ?? 0) >= 0) - assert.ok(result.results[0]?.actionId.length) - }) - - test('executes tool when approval is granted', async () => { - const orchestrator = createToolOrchestrator({ - tools: { - apply_patch: { - name: 'apply_patch', - validateInput: (input) => { - const schema = z.object({ - file_path: z.string(), - old_string: z.string(), - new_string: z.string(), - }) - const parsed = schema.safeParse(input) - return parsed.success ? { ok: true, data: parsed.data } : { ok: false, error: 'invalid input' } - }, - execute: async () => ({ - content: [{ type: 'text', text: 'written' }], - }), - }, - }, - }) - - const result = await orchestrator.executeAction( - { - name: 'apply_patch', - input: { file_path: '/tmp/a.txt', old_string: 'a', new_string: 'b' }, - }, - { requestApproval: async () => 'once' }, - ) - - assert.strictEqual(result.success, true) - assert.strictEqual(result.status, 'success') - assert.strictEqual(result.observation, 'written') - assert.ok(result.durationMs >= 0) - assert.ok(result.actionId.length) - }) - - test('auto-approves subagent tools even in strict approval mode', async () => { - let askedApproval = false - const orchestrator = createToolOrchestrator({ - tools: { - spawn_agent: { - name: 'spawn_agent', - execute: async () => ({ - content: [{ type: 'text', text: 'spawned' }], - }), - }, - }, - approval: { - mode: 'strict', - }, - }) - - const result = await orchestrator.executeAction( - { name: 'spawn_agent', input: { message: 'task' } }, - { - requestApproval: async () => { - askedApproval = true - return 'deny' - }, - }, - ) - - assert.strictEqual(askedApproval, false) - assert.strictEqual(result.success, true) - assert.strictEqual(result.status, 'success') - assert.strictEqual(result.observation, 'spawned') - }) - - test('returns unknown tool error', async () => { - const orchestrator = createToolOrchestrator({ tools: {} }) - const result = await orchestrator.executeAction( - { name: 'missing', input: {} }, - { requestApproval: async () => 'once' }, - ) - assert.strictEqual(result.success, false) - assert.strictEqual(result.status, 'tool_not_found') - assert.strictEqual(result.errorType, 'tool_not_found') - assert.strictEqual(result.observation, 'Unknown tool: missing') - }) - - test('classifies sandbox-like execution failures', async () => { - const orchestrator = createToolOrchestrator({ - tools: { - exec_command: { - name: 'exec_command', - execute: async () => { - throw new Error('Permission denied by sandbox') - }, - }, - }, - }) - const result = await orchestrator.executeAction( - { name: 'exec_command', input: { cmd: 'rm -rf /' } }, - { requestApproval: async () => 'once' }, - ) - assert.strictEqual(result.success, false) - assert.strictEqual(result.status, 'sandbox_denied') - assert.strictEqual(result.errorType, 'sandbox_denied') - assert.ok(result.observation.startsWith('Tool execution failed:')) - }) - - test('replaces oversized tool output with xml system hint', async () => { - const prevLimit = process.env.MEMO_TOOL_RESULT_MAX_CHARS - process.env.MEMO_TOOL_RESULT_MAX_CHARS = '64' - try { - const orchestrator = createToolOrchestrator({ - tools: { - read_file: { - name: 'read_file', - execute: async () => ({ - content: [{ type: 'text', text: 'x'.repeat(1000) }], - }), - }, - }, - }) - - const result = await orchestrator.executeAction( - { name: 'read_file', input: { file_path: '/tmp/a' } }, - { requestApproval: async () => 'once' }, - ) - - assert.strictEqual(result.success, true) - assert.strictEqual(result.status, 'success') - assert.ok(result.observation.startsWith(' { - let executed = false - const orchestrator = createToolOrchestrator({ - tools: { - read_file: { - name: 'read_file', - execute: async () => { - executed = true - return { content: [{ type: 'text', text: 'ok' }] } - }, - }, - }, - }) - - const result = await orchestrator.executeAction({ - name: 'read_file', - input: 'x'.repeat(100_001), - }) - - assert.strictEqual(result.success, false) - assert.strictEqual(result.status, 'input_invalid') - assert.ok(result.observation.includes('input string too large')) - assert.strictEqual(executed, false) - }) - - test('rejects non-object json input payloads', async () => { - const orchestrator = createToolOrchestrator({ - tools: { - read_file: { - name: 'read_file', - execute: async () => ({ content: [{ type: 'text', text: 'ok' }] }), - }, - }, - }) - - const result = await orchestrator.executeAction({ - name: 'read_file', - input: '[]', - }) - - assert.strictEqual(result.success, false) - assert.strictEqual(result.status, 'input_invalid') - assert.ok(result.observation.includes('expected object')) - }) - - test('rejects validateInput success payload when shape is not object', async () => { - const orchestrator = createToolOrchestrator({ - tools: { - read_file: { - name: 'read_file', - validateInput: () => ({ ok: true, data: 'not-object' }), - execute: async () => ({ content: [{ type: 'text', text: 'ok' }] }), - }, - }, - }) - - const result = await orchestrator.executeAction({ - name: 'read_file', - input: { file_path: '/tmp/a' }, - }) - - assert.strictEqual(result.success, false) - assert.strictEqual(result.status, 'input_invalid') - assert.ok(result.observation.includes('expected object')) - }) -}) diff --git a/packages/core/src/tools/orchestrator/index.ts b/packages/core/src/tools/orchestrator/index.ts index 85fe06e..c3a8091 100644 --- a/packages/core/src/tools/orchestrator/index.ts +++ b/packages/core/src/tools/orchestrator/index.ts @@ -1,269 +1,4 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types' -import { createApprovalManager } from '@memo/core/tools/approval' -import { getMaxToolResultChars } from '@memo/core/tools/runtime/tool_output_limits' -import type { - ToolAction, - ToolActionResult, - ToolActionErrorType, - ToolApprovalHooks, - ToolExecutionOptions, - ToolExecutionResult, - ToolOrchestrator, - ToolOrchestratorConfig, - OrchestratorTool, -} from './types' - -const MAX_TOOL_INPUT_STRING_CHARS = 100_000 - -function escapeXmlAttr(value: string) { - return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') -} - -function estimateCallToolResultChars(result: CallToolResult) { - let total = 0 - for (const item of result.content ?? []) { - if (item.type === 'text') { - total += item.text.length - continue - } - try { - total += JSON.stringify(item).length - } catch { - total += 100 - } - } - return total -} - -function buildOversizeHintXml(toolName: string, actualChars: number, maxChars: number) { - return `Tool output too long, automatically omitted. Please narrow the scope or add limit parameters and try again.` -} - -function guardToolResultSize(toolName: string, result: CallToolResult): CallToolResult { - const maxChars = getMaxToolResultChars() - const actualChars = estimateCallToolResultChars(result) - if (actualChars <= maxChars) return result - return { - content: [ - { - type: 'text', - text: buildOversizeHintXml(toolName, actualChars, maxChars), - }, - ], - isError: false, - } -} - -function flattenCallToolResult(result: CallToolResult): string { - const texts = - result.content?.flatMap((item) => { - if (item.type === 'text') return [item.text] - return [] - }) ?? [] - return texts.join('\n') -} - -type ParseToolInputResult = { ok: true; data: Record } | { ok: false; error: string } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function parseToolInput(tool: OrchestratorTool, rawInput: unknown): ParseToolInputResult { - let candidate: unknown = rawInput - if (typeof rawInput === 'string') { - if (rawInput.length > MAX_TOOL_INPUT_STRING_CHARS) { - return { - ok: false as const, - error: `${tool.name} invalid input: input string too large (max ${MAX_TOOL_INPUT_STRING_CHARS} chars)`, - } - } - const trimmed = rawInput.trim() - if (trimmed) { - try { - candidate = JSON.parse(trimmed) - } catch { - candidate = trimmed - } - } else { - candidate = {} - } - } - - if (!isRecord(candidate)) { - return { ok: false as const, error: `${tool.name} invalid input: expected object` } - } - - if (typeof tool.validateInput === 'function') { - const validated = tool.validateInput(candidate) - if (!validated.ok) return validated - if (!isRecord(validated.data)) { - return { ok: false as const, error: `${tool.name} invalid input: expected object` } - } - return { ok: true as const, data: validated.data } - } - - return { ok: true as const, data: candidate } -} - -function classifyExecutionError(err: unknown): ToolActionErrorType { - const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase() - if ( - message.includes('sandbox') || - message.includes('permission denied') || - message.includes('operation not permitted') || - message.includes('eacces') - ) { - return 'sandbox_denied' - } - return 'execution_failed' -} - -class ToolOrchestratorImpl implements ToolOrchestrator { - readonly approvalManager - - constructor(private config: ToolOrchestratorConfig) { - this.approvalManager = createApprovalManager(config.approval) - } - - async executeAction(action: ToolAction, options?: ToolApprovalHooks): Promise { - const startedAt = Date.now() - const actionId = action.id ?? `${action.name}:${startedAt}` - const check = this.approvalManager.check(action.name, action.input) - - if (check.needApproval) { - const request = { - toolName: check.toolName, - params: check.params, - fingerprint: check.fingerprint, - riskLevel: check.riskLevel, - reason: check.reason, - } - - await options?.onApprovalRequest?.(request) - - const decision = options?.requestApproval ? await options.requestApproval(request) : 'deny' - this.approvalManager.recordDecision(check.fingerprint, decision) - - await options?.onApprovalResponse?.({ - fingerprint: check.fingerprint, - decision, - }) - - if (decision === 'deny') { - return { - actionId, - tool: action.name, - status: 'approval_denied', - errorType: 'approval_denied', - success: false, - observation: `User denied tool execution: ${action.name}`, - durationMs: Date.now() - startedAt, - rejected: true, - } - } - } - - const tool = this.config.tools[action.name] - if (!tool) { - return { - actionId, - tool: action.name, - status: 'tool_not_found', - errorType: 'tool_not_found', - success: false, - observation: `Unknown tool: ${action.name}`, - durationMs: Date.now() - startedAt, - } - } - - try { - const parsedInput = parseToolInput(tool, action.input) - if (!parsedInput.ok) { - return { - actionId, - tool: action.name, - status: 'input_invalid', - errorType: 'input_invalid', - success: false, - observation: parsedInput.error, - durationMs: Date.now() - startedAt, - } - } - - const rawResult = await tool.execute(parsedInput.data) - const result = guardToolResultSize(action.name, rawResult) - return { - actionId, - tool: action.name, - status: 'success', - success: true, - observation: flattenCallToolResult(result) || '(no tool output)', - durationMs: Date.now() - startedAt, - } - } catch (err) { - const errorType = classifyExecutionError(err) - return { - actionId, - tool: action.name, - status: errorType, - errorType, - success: false, - observation: `Tool execution failed: ${(err as Error).message}`, - durationMs: Date.now() - startedAt, - } - } - } - - async executeActions(actions: ToolAction[], options: ToolExecutionOptions = {}): Promise { - const executionMode = options.executionMode ?? 'sequential' - const failurePolicy = options.failurePolicy ?? (options.stopOnRejection === false ? 'collect_all' : 'fail_fast') - - let results: ToolActionResult[] = [] - - if (executionMode === 'parallel') { - const parallelResults = await Promise.all(actions.map((action) => this.executeAction(action, options))) - if (failurePolicy === 'fail_fast') { - const firstRejected = parallelResults.findIndex((result) => result.rejected) - results = firstRejected >= 0 ? parallelResults.slice(0, firstRejected + 1) : parallelResults - } else { - results = parallelResults - } - } else { - for (const action of actions) { - const result = await this.executeAction(action, options) - results.push(result) - if (result.rejected && failurePolicy === 'fail_fast') { - break - } - } - } - - const hasRejection = results.some((result) => result.rejected) - const combinedObservation = results.map((result) => `[${result.tool}]: ${result.observation}`).join('\n\n') - - return { - results, - combinedObservation, - hasRejection, - executionMode, - failurePolicy, - } - } - - clearOnceApprovals(): void { - this.approvalManager.clearOnceApprovals() - } - - dispose(): void { - this.approvalManager.dispose() - } -} - -export function createToolOrchestrator(config: ToolOrchestratorConfig): ToolOrchestrator { - return new ToolOrchestratorImpl(config) -} - +/** @file Orchestrator types (execution moved into the agent sdk_tools wrapper). */ export type { ToolAction, ToolActionResult, diff --git a/packages/core/src/tools/orchestrator/types.ts b/packages/core/src/tools/orchestrator/types.ts index c6b9460..460969c 100644 --- a/packages/core/src/tools/orchestrator/types.ts +++ b/packages/core/src/tools/orchestrator/types.ts @@ -1,4 +1,4 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import type { ApprovalDecision, ApprovalManager, @@ -13,7 +13,7 @@ export type OrchestratorTool = { supportsParallelToolCalls?: boolean isMutating?: boolean validateInput?: (input: unknown) => ToolValidateResult - execute: (input: unknown) => Promise + execute: (input: unknown) => Promise } export type OrchestratorToolRegistry = Record diff --git a/packages/core/src/tools/router/index.test.ts b/packages/core/src/tools/router/index.test.ts index ff871d9..7410637 100644 --- a/packages/core/src/tools/router/index.test.ts +++ b/packages/core/src/tools/router/index.test.ts @@ -61,7 +61,7 @@ describe('ToolRouter', () => { description: 'Test tool', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) expect(router.hasTool('test_tool')).toBe(true) @@ -76,14 +76,14 @@ describe('ToolRouter', () => { description: 'Tool 1', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }, { name: 'tool2', description: 'Tool 2', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }, ]) @@ -98,7 +98,7 @@ describe('ToolRouter', () => { description: 'Native', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) const tools = router.getAllTools() @@ -113,7 +113,7 @@ describe('ToolRouter', () => { description: 'Test', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) const registry = router.toRegistry() @@ -127,7 +127,7 @@ describe('ToolRouter', () => { description: 'Test', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) const counts = router.getToolCount() @@ -147,13 +147,11 @@ describe('ToolRouter', () => { description: 'Echo input', source: 'native', inputSchema: { type: 'object' }, - execute: async (input) => ({ - content: [{ type: 'text', text: JSON.stringify(input) }], - }), + execute: async (input) => ({ type: 'text', value: JSON.stringify(input) }), }) const result = await router.execute('echo', { test: true }) - expect(result.content[0]).toEqual({ type: 'text', text: '{"test":true}' }) + expect(result).toEqual({ type: 'text', value: '{"test":true}' }) }) test('generateToolDefinitions returns all tools', () => { @@ -163,7 +161,7 @@ describe('ToolRouter', () => { description: 'Test tool', source: 'native', inputSchema: { type: 'object', properties: { foo: { type: 'string' } } }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) const defs = router.generateToolDefinitions() @@ -185,7 +183,7 @@ describe('ToolRouter', () => { description: 'My tool description', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) const desc = router.generateToolDescriptions() @@ -202,7 +200,7 @@ describe('ToolRouter', () => { description: 'Test', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) const descs = router.getToolDescriptions() @@ -219,7 +217,7 @@ describe('ToolRouter', () => { description: 'Custom', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }, ], }) diff --git a/packages/core/src/tools/router/index.ts b/packages/core/src/tools/router/index.ts index 4768bcc..aa6e089 100644 --- a/packages/core/src/tools/router/index.ts +++ b/packages/core/src/tools/router/index.ts @@ -6,8 +6,7 @@ * 3. 提供统一的工具查询和执行接口 * 4. 生成工具描述(用于 Prompt) */ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types' -import type { Tool, ToolRegistry, MCPServerConfig, ToolDescription } from './types' +import type { MemoToolOutput, Tool, ToolRegistry, MCPServerConfig, ToolDescription } from './types' import { NativeToolRegistry } from './native' import { McpToolRegistry } from './mcp' import type { McpOAuthSettings } from './mcp/oauth' @@ -89,7 +88,7 @@ export class ToolRouter { * @returns 工具执行结果 * @throws 如果工具不存在 */ - async execute(name: string, input: unknown): Promise { + async execute(name: string, input: unknown): Promise { const tool = this.getTool(name) if (!tool) { throw new Error(`Tool '${name}' not found`) diff --git a/packages/core/src/tools/router/mcp/index.ts b/packages/core/src/tools/router/mcp/index.ts index d7d42f0..05fc1d8 100644 --- a/packages/core/src/tools/router/mcp/index.ts +++ b/packages/core/src/tools/router/mcp/index.ts @@ -1,6 +1,5 @@ /** @file MCP tool registry */ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types' -import type { McpTool, ToolRegistry, MCPServerConfig } from '../types' +import type { McpTool, MemoToolOutput, ToolRegistry, MCPServerConfig } from '../types' import { McpClientPool } from './pool' import { getGlobalMcpCacheStore, type CachedMcpToolDescriptor } from './cache_store' import { setActiveMcpCacheStore, setActiveMcpPool } from './context' @@ -30,12 +29,21 @@ export class McpToolRegistry { serverName, originalName: descriptor.originalName, inputSchema: (descriptor.inputSchema as any) ?? {}, - execute: async (input: unknown): Promise => { + execute: async (input: unknown): Promise => { const connection = await this.pool.connect(serverName, config) - return connection.client.callTool({ + const result = await connection.client.callTool({ name: descriptor.originalName, arguments: input as Record, - }) as Promise + }) + const callResult = result as unknown as { + content?: Array<{ type?: string; text?: string }> + isError?: boolean + } + const texts = + callResult.content?.flatMap((item) => (item.type === 'text' ? [item.text ?? ''] : [])) ?? [] + return result.isError + ? { type: 'error-text', value: texts.join('\n') || `Tool ${descriptor.originalName} failed` } + : { type: 'text', value: texts.join('\n') } }, } } diff --git a/packages/core/src/tools/router/mcp/pool.ts b/packages/core/src/tools/router/mcp/pool.ts index f9490c8..274db1f 100644 --- a/packages/core/src/tools/router/mcp/pool.ts +++ b/packages/core/src/tools/router/mcp/pool.ts @@ -174,7 +174,7 @@ export class McpClientPool { originalName: t.name, inputSchema: t.inputSchema as any, // execute 会在 registry 中绑定 - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), })), } diff --git a/packages/core/src/tools/router/native/index.test.ts b/packages/core/src/tools/router/native/index.test.ts index df35a3d..baaabed 100644 --- a/packages/core/src/tools/router/native/index.test.ts +++ b/packages/core/src/tools/router/native/index.test.ts @@ -9,7 +9,7 @@ describe('NativeToolRegistry', () => { description: 'Test', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) expect(registry.has('test_tool')).toBe(true) @@ -24,14 +24,14 @@ describe('NativeToolRegistry', () => { description: '1', source: 'native', inputSchema: {}, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }, { name: 'tool2', description: '2', source: 'native', inputSchema: {}, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }, ]) @@ -46,7 +46,7 @@ describe('NativeToolRegistry', () => { description: 'Test', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) expect(registry.getAll().length).toBe(1) @@ -59,7 +59,7 @@ describe('NativeToolRegistry', () => { description: 'Test', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) const reg = registry.toRegistry() @@ -75,7 +75,7 @@ describe('NativeToolRegistry', () => { description: 'Test', source: 'native', inputSchema: { type: 'object' }, - execute: async () => ({ content: [] }), + execute: async () => ({ type: 'text', value: '' }), }) expect(registry.size).toBe(1) diff --git a/packages/core/src/tools/router/types.ts b/packages/core/src/tools/router/types.ts index 95e4a08..8df308b 100644 --- a/packages/core/src/tools/router/types.ts +++ b/packages/core/src/tools/router/types.ts @@ -1,5 +1,4 @@ /** @file ToolRouter unified type definitions */ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types' /** Tool source type */ export type ToolSource = 'native' | 'mcp' @@ -13,6 +12,13 @@ export interface JSONSchema { [key: string]: unknown } +/** Tool execution output (AI SDK ToolResultOutput-compatible subset). */ +export type MemoToolOutput = + | { type: 'text'; value: string } + | { type: 'json'; value: unknown } + | { type: 'error-text'; value: string } + | { type: 'execution-denied'; reason?: string } + /** Unified tool interface */ export interface Tool { /** Unique tool name (MCP tools have serverName_ prefix) */ @@ -30,7 +36,7 @@ export interface Tool { /** Optional input validator (usually provided by native/zod adapter layer) */ validateInput?: (input: unknown) => { ok: true; data: unknown } | { ok: false; error: string } /** Execute tool */ - execute: (input: unknown) => Promise + execute: (input: unknown, options?: { abortSignal?: AbortSignal }) => Promise } /** Built-in tool */ diff --git a/packages/core/src/tools/tools/apply_patch.test.ts b/packages/core/src/tools/tools/apply_patch.test.ts index d78443d..245464b 100644 --- a/packages/core/src/tools/tools/apply_patch.test.ts +++ b/packages/core/src/tools/tools/apply_patch.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -24,21 +25,18 @@ async function readText(path: string) { } } -function textPayload(result: { content?: Array<{ type: string; text?: string }> }) { - const first = result.content?.find((item) => item.type === 'text') - return first?.text ?? '' +function textPayload(result: MemoToolOutput) { + if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' + return '' } -function assertPatchOk(result: { isError?: boolean; content?: Array<{ type: string; text?: string }> }) { +function assertPatchOk(result: MemoToolOutput) { const payload = textPayload(result) - assert.ok(!result.isError, payload) + assert.ok(result.type !== 'error-text', payload) } -function assertPatchError( - result: { isError?: boolean; content?: Array<{ type: string; text?: string }> }, - includes?: string, -) { - assert.strictEqual(result.isError, true) +function assertPatchError(result: MemoToolOutput, includes?: string) { + assert.strictEqual(result.type, 'error-text') if (includes) { assert.ok(textPayload(result).includes(includes), textPayload(result)) } diff --git a/packages/core/src/tools/tools/codex_tools.test.ts b/packages/core/src/tools/tools/codex_tools.test.ts index c425f87..52d6c91 100644 --- a/packages/core/src/tools/tools/codex_tools.test.ts +++ b/packages/core/src/tools/tools/codex_tools.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -34,9 +35,9 @@ async function readText(path: string) { } } -function textPayload(result: { content?: Array<{ type: string; text?: string }> }) { - const first = result.content?.find((item) => item.type === 'text') - return first?.text ?? '' +function textPayload(result: MemoToolOutput) { + if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' + return '' } function outputPayload(text: string) { @@ -181,7 +182,7 @@ describe('codex shell family', () => { results.push(await execCommandTool.execute({ cmd: 'sleep 2', yield_time_ms: 0 })) } const overflow = results.find( - (result) => result.isError && textPayload(result).includes('too many active sessions'), + (result) => result.type === 'error-text' && textPayload(result).includes('too many active sessions'), ) assert.ok(overflow, 'expected active-session cap error') @@ -207,7 +208,7 @@ describe('codex file/search family', () => { ].join('\n'), }), ) - assert.ok(!singleRes.isError) + assert.ok(singleRes.type === 'text') assert.strictEqual(await readText(target), 'A beta alpha\n') const batchRes = await runWithRuntimeContext({ cwd: tempDir }, () => @@ -222,13 +223,13 @@ describe('codex file/search family', () => { ].join('\n'), }), ) - assert.ok(!batchRes.isError) + assert.ok(batchRes.type === 'text') assert.strictEqual(await readText(target), 'A B A\n') }) test('read_text_file requires valid path in allowed roots', async () => { const result = await readTextFileTool.execute({ path: '/tmp/not-allowed.txt' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('Access denied')) }) @@ -277,7 +278,7 @@ describe('codex workflow/context tools', () => { { step: 'b', status: 'in_progress' }, ], }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('in_progress')) }) diff --git a/packages/core/src/tools/tools/collab.test.ts b/packages/core/src/tools/tools/collab.test.ts index a3f0a52..8f315e1 100644 --- a/packages/core/src/tools/tools/collab.test.ts +++ b/packages/core/src/tools/tools/collab.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -22,9 +23,9 @@ async function makeTempDir(prefix: string) { return dir } -function textPayload(result: { content?: Array<{ type: string; text?: string }> }) { - const first = result.content?.find((item) => item.type === 'text') - return first?.text ?? '' +function textPayload(result: MemoToolOutput) { + if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' + return '' } beforeAll(async () => { @@ -94,13 +95,13 @@ afterAll(async () => { describe('collab tools', () => { test('spawn + wait reaches completed status and returns final map payload', async () => { const spawnResult = await spawnAgentTool.execute({ message: 'echo:hello' }) - assert.strictEqual(spawnResult.isError, false) + assert.strictEqual(spawnResult.type, 'text') const spawned = JSON.parse(textPayload(spawnResult)) assert.strictEqual(spawned.status, 'running') assert.ok(typeof spawned.agent_id === 'string' && spawned.agent_id.length > 0) const waitResult = await waitTool.execute({ ids: [spawned.agent_id], timeout_ms: 10_000 }) - assert.strictEqual(waitResult.isError, false) + assert.strictEqual(waitResult.type, 'text') const waited = JSON.parse(textPayload(waitResult)) assert.strictEqual(waited.timed_out, false) assert.strictEqual(waited.status[spawned.agent_id], 'completed') @@ -126,7 +127,7 @@ describe('collab tools', () => { id: agentId, message: 'echo:second', }) - assert.strictEqual(sendWhileClosed.isError, true) + assert.strictEqual(sendWhileClosed.type, 'error-text') assert.ok(textPayload(sendWhileClosed).includes('resume_agent')) const resumeResult = await resumeAgentTool.execute({ id: agentId }) @@ -137,7 +138,7 @@ describe('collab tools', () => { id: agentId, message: 'echo:second', }) - assert.strictEqual(sendAfterResume.isError, false) + assert.strictEqual(sendAfterResume.type, 'text') }) test('wait returns not_found immediately for unknown agents', async () => { @@ -155,26 +156,26 @@ describe('collab tools', () => { test('spawn_agent respects MEMO_SUBAGENT_MAX_AGENTS limit', async () => { process.env.MEMO_SUBAGENT_MAX_AGENTS = '1' const first = await spawnAgentTool.execute({ message: 'sleep:5000' }) - assert.strictEqual(first.isError, false) + assert.strictEqual(first.type, 'text') const second = await spawnAgentTool.execute({ message: 'echo:blocked' }) - assert.strictEqual(second.isError, true) + assert.strictEqual(second.type, 'error-text') assert.ok(textPayload(second).includes('concurrency limit')) }) test('wait validates timeout and mutating tools report missing agents', async () => { const invalidTimeout = await waitTool.execute({ ids: ['missing'], timeout_ms: 0 }) - assert.strictEqual(invalidTimeout.isError, true) + assert.strictEqual(invalidTimeout.type, 'error-text') assert.ok(textPayload(invalidTimeout).includes('timeout_ms')) const sendResult = await sendInputTool.execute({ id: 'missing', message: 'x' }) - assert.strictEqual(sendResult.isError, true) + assert.strictEqual(sendResult.type, 'error-text') assert.ok(textPayload(sendResult).includes('agent not found')) const closeResult = await closeAgentTool.execute({ id: 'missing' }) - assert.strictEqual(closeResult.isError, true) + assert.strictEqual(closeResult.type, 'error-text') const resumeResult = await resumeAgentTool.execute({ id: 'missing' }) - assert.strictEqual(resumeResult.isError, true) + assert.strictEqual(resumeResult.type, 'error-text') }) }) diff --git a/packages/core/src/tools/tools/exec_command.test.ts b/packages/core/src/tools/tools/exec_command.test.ts index 247537b..9914f8e 100644 --- a/packages/core/src/tools/tools/exec_command.test.ts +++ b/packages/core/src/tools/tools/exec_command.test.ts @@ -28,7 +28,7 @@ describe('exec_command tool', () => { const result = await execCommandTool.execute({ cmd: 'echo hello' }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.strictEqual(flattenText(result), 'test output') expect(startExecSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -44,7 +44,7 @@ describe('exec_command tool', () => { const result = await execCommandTool.execute({ cmd: 'printf "Line 1\nLine 2\nLine 3"' }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.strictEqual(flattenText(result), multiLineOutput) }) @@ -53,7 +53,7 @@ describe('exec_command tool', () => { const result = await execCommandTool.execute({ cmd: 'true' }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.strictEqual(flattenText(result), '') }) @@ -63,7 +63,7 @@ describe('exec_command tool', () => { const result = await execCommandTool.execute({ cmd: 'echo "你好世界 🌍 Привет мир"' }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.strictEqual(flattenText(result), unicodeOutput) }) }) @@ -199,7 +199,7 @@ describe('exec_command tool', () => { const result = await execCommandTool.execute({ cmd: 'invalid-command' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('exec_command failed')) assert.ok(flattenText(result).includes('command failed')) }) @@ -209,7 +209,7 @@ describe('exec_command tool', () => { const result = await execCommandTool.execute({ cmd: 'definitely-not-a-command' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('exec_command failed')) }) @@ -218,7 +218,7 @@ describe('exec_command tool', () => { const result = await execCommandTool.execute({ cmd: '/root/protected' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('exec_command failed')) }) @@ -227,7 +227,7 @@ describe('exec_command tool', () => { const result = await execCommandTool.execute({ cmd: 'sleep 60', timeout_ms: 1000 }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('exec_command failed')) }) }) diff --git a/packages/core/src/tools/tools/filesystem_tools.test.ts b/packages/core/src/tools/tools/filesystem_tools.test.ts index c08f15a..8c530ef 100644 --- a/packages/core/src/tools/tools/filesystem_tools.test.ts +++ b/packages/core/src/tools/tools/filesystem_tools.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { basename, join } from 'node:path' import { tmpdir } from 'node:os' @@ -13,9 +14,9 @@ import { searchFilesTool } from '@memo/core/tools/tools/search_files' type ToolResult = { content?: Array<{ type: string; text?: string }>; isError?: boolean } -function textPayload(result: ToolResult): string { - const first = result.content?.find((item) => item.type === 'text') - return first?.text ?? '' +function textPayload(result: MemoToolOutput): string { + if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' + return '' } let rootDir = '' @@ -54,19 +55,19 @@ describe('filesystem tools', () => { await writeFile(filePath, 'line1\nline2\nline3\n', 'utf8') const full = await readTextFileTool.execute({ path: filePath }) - assert.strictEqual(full.isError, false) + assert.strictEqual(full.type, 'text') assert.strictEqual(textPayload(full), 'line1\nline2\nline3\n') const head = await readTextFileTool.execute({ path: filePath, head: 2 }) - assert.strictEqual(head.isError, false) + assert.strictEqual(head.type, 'text') assert.strictEqual(textPayload(head), 'line1\nline2') const tail = await readTextFileTool.execute({ path: filePath, tail: 2 }) - assert.strictEqual(tail.isError, false) + assert.strictEqual(tail.type, 'text') assert.strictEqual(textPayload(tail), 'line3\n') const invalid = await readTextFileTool.execute({ path: filePath, head: 1, tail: 1 }) - assert.strictEqual(invalid.isError, true) + assert.strictEqual(invalid.type, 'error-text') assert.ok(textPayload(invalid).includes('Cannot specify both head and tail')) }) @@ -75,7 +76,7 @@ describe('filesystem tools', () => { await writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) const result = await readMediaFileTool.execute({ path: filePath }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') const payload = JSON.parse(textPayload(result)) as { type: string @@ -96,7 +97,7 @@ describe('filesystem tools', () => { await writeFile(second, 'two', 'utf8') const result = await readFilesTool.execute({ paths: [first, missing, second] }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') const text = textPayload(result) assert.ok(text.includes(`${first}:\none`)) assert.ok(text.includes(`${missing}: Error -`)) @@ -107,19 +108,19 @@ describe('filesystem tools', () => { const filePath = join(rootDir, 'write.txt') const first = await writeFileTool.execute({ path: filePath, content: 'alpha' }) - assert.strictEqual(first.isError, false) + assert.strictEqual(first.type, 'text') assert.ok(textPayload(first).includes('Successfully wrote')) assert.strictEqual(await readFile(filePath, 'utf8'), 'alpha') const second = await writeFileTool.execute({ path: filePath, content: 'beta' }) - assert.strictEqual(second.isError, false) + assert.strictEqual(second.type, 'text') assert.strictEqual(await readFile(filePath, 'utf8'), 'beta') }) test('write_file fails when parent directory is missing', async () => { const missingParentPath = join(rootDir, 'missing', 'write.txt') const result = await writeFileTool.execute({ path: missingParentPath, content: 'alpha' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('Parent directory does not exist')) }) @@ -132,7 +133,7 @@ describe('filesystem tools', () => { dryRun: true, edits: [{ oldText: ' b', newText: ' bb' }], }) - assert.strictEqual(dryRun.isError, false) + assert.strictEqual(dryRun.type, 'text') const dryText = textPayload(dryRun) assert.ok(dryText.includes('```diff')) assert.strictEqual(await readFile(filePath, 'utf8'), 'a\n b\n c\n') @@ -144,7 +145,7 @@ describe('filesystem tools', () => { { oldText: ' c', newText: ' cc' }, ], }) - assert.strictEqual(applied.isError, false) + assert.strictEqual(applied.type, 'text') assert.strictEqual(await readFile(filePath, 'utf8'), 'aa\n b\n cc\n') }) @@ -156,7 +157,7 @@ describe('filesystem tools', () => { path: filePath, edits: [{ oldText: 'missing', newText: 'value' }], }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('Could not find exact match for edit')) }) @@ -168,7 +169,7 @@ describe('filesystem tools', () => { path: filePath, edits: [{ oldText: 'line1\nline2', newText: 'line1\nline2-updated' }], }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.ok(textPayload(result).includes('```diff')) assert.strictEqual(await readFile(filePath, 'utf8'), 'line1\nline2-updated\nline3\n') }) @@ -179,7 +180,7 @@ describe('filesystem tools', () => { await writeFile(join(rootDir, 'file.txt'), 'x', 'utf8') const result = await listDirectoryTool.execute({ path: rootDir }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') const text = textPayload(result) assert.ok(text.includes('[DIR] nested')) @@ -198,13 +199,13 @@ describe('filesystem tools', () => { pattern: '**/*.txt', excludePatterns: ['src/**'], }) - assert.strictEqual(matched.isError, false) + assert.strictEqual(matched.type, 'text') const text = textPayload(matched) assert.ok(text.includes(join(rootDir, 'keep.txt'))) assert.ok(!text.includes(join(srcDir, 'inside.txt'))) const none = await searchFilesTool.execute({ path: rootDir, pattern: '**/*.md' }) - assert.strictEqual(none.isError, false) + assert.strictEqual(none.type, 'text') assert.strictEqual(textPayload(none), 'No matches found') }) @@ -213,7 +214,7 @@ describe('filesystem tools', () => { await writeFile(outsideFile, 'outside', 'utf8') const result = await readTextFileTool.execute({ path: outsideFile }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('Access denied - path outside allowed directories')) }) @@ -224,7 +225,7 @@ describe('filesystem tools', () => { const traversalPath = join('..', basename(outsideDir), 'outside-traversal.txt') const result = await readTextFileTool.execute({ path: traversalPath }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('Access denied - path outside allowed directories')) }) @@ -244,7 +245,7 @@ describe('filesystem tools', () => { } const result = await readTextFileTool.execute({ path: linkedPath }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('Access denied - symlink target outside allowed directories')) }) }) diff --git a/packages/core/src/tools/tools/get_memory.test.ts b/packages/core/src/tools/tools/get_memory.test.ts index 6d75c87..c9d98cc 100644 --- a/packages/core/src/tools/tools/get_memory.test.ts +++ b/packages/core/src/tools/tools/get_memory.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -14,9 +15,9 @@ async function makeTempDir(prefix: string) { return dir } -function textPayload(result: { content?: Array<{ type: string; text?: string }> }) { - const first = result.content?.find((item) => item.type === 'text') - return first?.text ?? '' +function textPayload(result: MemoToolOutput) { + if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' + return '' } beforeAll(async () => { @@ -37,7 +38,7 @@ afterAll(async () => { describe('get_memory tool', () => { test('returns missing error when Agents.md does not exist', async () => { const result = await getMemoryTool.execute({ memory_id: 'missing-thread' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('memory not found')) }) @@ -46,7 +47,7 @@ describe('get_memory tool', () => { await writeFile(memoryPath, '## Memo Added Memories\n\n- prefers concise output\n', 'utf8') const result = await getMemoryTool.execute({ memory_id: 'thread-1' }) - assert.ok(!result.isError) + assert.ok(result.type === 'text') const parsed = JSON.parse(textPayload(result)) assert.strictEqual(parsed.memory_id, 'thread-1') diff --git a/packages/core/src/tools/tools/mcp.test.ts b/packages/core/src/tools/tools/mcp.test.ts index be6d36a..4388374 100644 --- a/packages/core/src/tools/tools/mcp.test.ts +++ b/packages/core/src/tools/tools/mcp.test.ts @@ -1,51 +1,70 @@ import assert from 'node:assert' import { describe, test } from 'vitest' import { textResult, flattenText } from './mcp' +import type { MemoToolOutput } from '@memo/core/tools/router/types' describe('mcp helpers', () => { describe('textResult', () => { test('creates successful text result', () => { const result = textResult('hello world') - assert.deepStrictEqual(result.content, [{ type: 'text', text: 'hello world' }]) - assert.strictEqual(result.isError, false) + if (result.type === 'text') { + if (result.type === 'text') { + assert.strictEqual(result.value, 'hello world') + } + } + assert.strictEqual(result.type, 'text') }) test('creates error text result', () => { const result = textResult('error message', true) - assert.deepStrictEqual(result.content, [{ type: 'text', text: 'error message' }]) - assert.strictEqual(result.isError, true) + if (result.type === 'error-text') { + assert.strictEqual(result.value, 'error message') + } + assert.strictEqual(result.type, 'error-text') }) test('handles empty string', () => { const result = textResult('') - assert.deepStrictEqual(result.content, [{ type: 'text', text: '' }]) - assert.strictEqual(result.isError, false) + if (result.type === 'text') { + assert.strictEqual(result.value, '') + } + assert.strictEqual(result.type, 'text') }) test('handles unicode content', () => { const result = textResult('你好世界 🌍 Привет') - assert.strictEqual(result.content[0].text, '你好世界 🌍 Привет') + if (result.type === 'text') { + assert.strictEqual(result.value, '你好世界 🌍 Привет') + } }) test('handles multi-line content', () => { const result = textResult('line1\nline2\nline3') - assert.strictEqual(result.content[0].text, 'line1\nline2\nline3') + if (result.type === 'text') { + assert.strictEqual(result.value, 'line1\nline2\nline3') + } }) test('handles special characters', () => { const result = textResult('\n') - assert.strictEqual(result.content[0].text, '\n') + if (result.type === 'text') { + assert.strictEqual(result.value, '\n') + } }) test('handles very long content', () => { const longContent = 'x'.repeat(100000) const result = textResult(longContent) - assert.strictEqual(result.content[0].text.length, 100000) + if (result.type === 'text') { + assert.strictEqual(result.value.length, 100000) + } }) test('handles JSON-like content', () => { const result = textResult('{"key": "value", "nested": {"a": 1}}') - assert.ok(result.content[0].text.includes('"key"')) + if (result.type === 'text') { + assert.ok(result.value.includes('"key"')) + } }) }) @@ -56,70 +75,37 @@ describe('mcp helpers', () => { }) test('joins multiple text content items', () => { - const result: Parameters[0] = { - content: [ - { type: 'text', text: 'line1' }, - { type: 'text', text: 'line2' }, - ], - isError: false, - } + const result: MemoToolOutput = { type: 'text', value: 'line1\nline2' } assert.strictEqual(flattenText(result), 'line1\nline2') }) test('ignores non-text content', () => { - const result: Parameters[0] = { - content: [ - { type: 'text', text: 'visible' }, - { type: 'image', data: 'base64data' }, - { type: 'text', text: 'also visible' }, - ], - isError: false, - } + const result: MemoToolOutput = { type: 'text', value: 'visible\nalso visible' } assert.strictEqual(flattenText(result), 'visible\nalso visible') }) test('handles empty result', () => { - const result: Parameters[0] = { content: [], isError: false } + const result: MemoToolOutput = { type: 'text', value: '' } assert.strictEqual(flattenText(result), '') }) - test('handles undefined content', () => { - const result: Parameters[0] = { content: undefined, isError: false } + test('handles empty text', () => { + const result: MemoToolOutput = { type: 'text', value: '' } assert.strictEqual(flattenText(result), '') }) - test('handles content with only non-text items', () => { - const result: Parameters[0] = { - content: [ - { type: 'image', data: 'base64' }, - { type: 'resource', resource: { uri: 'file:///test' } }, - ], - isError: false, - } - assert.strictEqual(flattenText(result), '') + test('handles json output', () => { + const result: MemoToolOutput = { type: 'json', value: { a: 1 } } + assert.strictEqual(flattenText(result), '{"a":1}') }) - test('handles mixed empty and non-empty text', () => { - const result: Parameters[0] = { - content: [ - { type: 'text', text: 'first' }, - { type: 'text', text: '' }, - { type: 'text', text: 'last' }, - ], - isError: false, - } - assert.strictEqual(flattenText(result), 'first\n\nlast') + test('handles execution-denied output', () => { + const result: MemoToolOutput = { type: 'execution-denied', reason: 'denied' } + assert.strictEqual(flattenText(result), 'denied') }) test('preserves exact text including whitespace', () => { - const result: Parameters[0] = { - content: [ - { type: 'text', text: ' leading spaces' }, - { type: 'text', text: 'trailing spaces ' }, - { type: 'text', text: '\ttab\t' }, - ], - isError: false, - } + const result: MemoToolOutput = { type: 'text', value: ' leading spaces\ntrailing spaces \n\ttab\t' } const output = flattenText(result) assert.ok(output.includes(' leading spaces')) assert.ok(output.includes('trailing spaces ')) @@ -128,17 +114,17 @@ describe('mcp helpers', () => { test('handles isError flag correctly', () => { const errorResult = textResult('error message', true) - assert.strictEqual(errorResult.isError, true) + assert.strictEqual(errorResult.type, 'error-text') const successResult = textResult('success message', false) - assert.strictEqual(successResult.isError, false) + assert.strictEqual(successResult.type, 'text') }) test('handles many content items', () => { - const content = Array(100) - .fill(null) - .map((_, i) => ({ type: 'text' as const, text: `line${i}` })) - const result: Parameters[0] = { content, isError: false } + const result: MemoToolOutput = { + type: 'text', + value: Array.from({ length: 100 }, (_, i) => `line${i}`).join('\n'), + } const output = flattenText(result) assert.ok(output.includes('line0')) assert.ok(output.includes('line99')) diff --git a/packages/core/src/tools/tools/mcp.ts b/packages/core/src/tools/tools/mcp.ts index 1e816a5..8bfe63c 100644 --- a/packages/core/src/tools/tools/mcp.ts +++ b/packages/core/src/tools/tools/mcp.ts @@ -1,16 +1,13 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types' +import type { MemoToolOutput } from '@memo/core/tools/router/types' -/** Quick constructor for text-based CallToolResult. */ -export function textResult(text: string, isError = false): CallToolResult { - return { content: [{ type: 'text', text }], isError } +/** Quick constructor for text-based tool output. */ +export function textResult(text: string, isError = false): MemoToolOutput { + return isError ? { type: 'error-text', value: text } : { type: 'text', value: text } } -/** Flatten CallToolResult text content to string for observation. */ -export function flattenText(result: CallToolResult): string { - const texts = - result.content?.flatMap((item) => { - if (item.type === 'text') return [item.text] - return [] - }) ?? [] - return texts.join('\n') +/** Flatten tool output to string for observation display. */ +export function flattenText(result: MemoToolOutput): string { + if (result.type === 'text' || result.type === 'error-text') return result.value + if (result.type === 'json') return JSON.stringify(result.value) + return result.reason ?? '' } diff --git a/packages/core/src/tools/tools/mcp_resources.test.ts b/packages/core/src/tools/tools/mcp_resources.test.ts index 3382289..d6a2d53 100644 --- a/packages/core/src/tools/tools/mcp_resources.test.ts +++ b/packages/core/src/tools/tools/mcp_resources.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtemp, readFile, rm } from 'node:fs/promises' @@ -11,9 +12,9 @@ import { readMcpResourceTool, } from '@memo/core/tools/tools/mcp_resources' -function textPayload(result: { content?: Array<{ type: string; text?: string }> }) { - const first = result.content?.find((item) => item.type === 'text') - return first?.text ?? '' +function textPayload(result: MemoToolOutput) { + if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' + return '' } afterEach(() => { @@ -27,7 +28,7 @@ describe('mcp resource tools', () => { test('returns error when MCP pool is missing', async () => { setActiveMcpPool(null) const result = await listMcpResourcesTool.execute({}) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('not initialized')) }) @@ -57,7 +58,7 @@ describe('mcp resource tools', () => { const first = await listMcpResourcesTool.execute({ server: 'alpha', cursor: 'c1' }) const second = await listMcpResourcesTool.execute({ server: 'alpha', cursor: 'c1' }) - assert.ok(!first.isError) + assert.ok(first.type === 'text') assert.deepStrictEqual(capturedCursor, { cursor: 'c1' }) assert.strictEqual(callCount, 1) @@ -192,7 +193,7 @@ describe('mcp resource tools', () => { setActiveMcpPool(pool as any) const result = await listMcpResourcesTool.execute({}) - assert.ok(!result.isError) + assert.ok(result.type === 'text') const parsed = JSON.parse(textPayload(result)) assert.strictEqual(parsed.resources.length, 1) @@ -209,10 +210,10 @@ describe('mcp resource tools', () => { setActiveMcpPool(pool as any) const resourcesResult = await listMcpResourcesTool.execute({ cursor: 'x' }) - assert.strictEqual(resourcesResult.isError, true) + assert.strictEqual(resourcesResult.type, 'error-text') const templatesResult = await listMcpResourceTemplatesTool.execute({ cursor: 'x' }) - assert.strictEqual(templatesResult.isError, true) + assert.strictEqual(templatesResult.type, 'error-text') }) test('caches list resource templates for same key', async () => { @@ -264,7 +265,7 @@ describe('mcp resource tools', () => { const first = await readMcpResourceTool.execute({ server: 'alpha', uri: 'memo://a' }) const second = await readMcpResourceTool.execute({ server: 'alpha', uri: 'memo://a' }) - assert.ok(!first.isError) + assert.ok(first.type === 'text') assert.strictEqual(callCount, 1) assert.deepStrictEqual(capturedUri, { uri: 'memo://a' }) const parsed = JSON.parse(textPayload(first)) @@ -281,7 +282,7 @@ describe('mcp resource tools', () => { setActiveMcpPool(pool as any) const result = await readMcpResourceTool.execute({ server: 'none', uri: 'memo://x' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('MCP server not found')) }) diff --git a/packages/core/src/tools/tools/shell.test.ts b/packages/core/src/tools/tools/shell.test.ts index eedd41f..35e2ef6 100644 --- a/packages/core/src/tools/tools/shell.test.ts +++ b/packages/core/src/tools/tools/shell.test.ts @@ -28,7 +28,7 @@ describe('shell tool', () => { const result = await shellTool.execute({ command: ['echo', 'hello'] }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.strictEqual(flattenText(result), 'test output') expect(startExecSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -268,7 +268,7 @@ describe('shell tool', () => { const result = await shellTool.execute({ command: ['invalid-command'] }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('shell failed')) }) @@ -277,7 +277,7 @@ describe('shell tool', () => { const result = await shellTool.execute({ command: ['definitely-not-real'] }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('shell failed')) }) @@ -286,7 +286,7 @@ describe('shell tool', () => { const result = await shellTool.execute({ command: ['/protected/path'] }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('shell failed')) }) @@ -295,7 +295,7 @@ describe('shell tool', () => { const result = await shellTool.execute({ command: ['sleep', '100'], timeout_ms: 100 }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('shell failed')) }) }) diff --git a/packages/core/src/tools/tools/shell_command.test.ts b/packages/core/src/tools/tools/shell_command.test.ts index 3fe0ca7..bdefa71 100644 --- a/packages/core/src/tools/tools/shell_command.test.ts +++ b/packages/core/src/tools/tools/shell_command.test.ts @@ -28,7 +28,7 @@ describe('shell_command tool', () => { const result = await shellCommandTool.execute({ command: 'echo hello' }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.strictEqual(flattenText(result), 'test output') expect(startExecSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -46,7 +46,7 @@ describe('shell_command tool', () => { command: 'printf "line1\nline2\nline3"', }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.strictEqual(flattenText(result), multiLineOutput) }) @@ -55,7 +55,7 @@ describe('shell_command tool', () => { const result = await shellCommandTool.execute({ command: 'true' }) - assert.strictEqual(result.isError, false) + assert.strictEqual(result.type, 'text') assert.strictEqual(flattenText(result), '') }) }) @@ -125,7 +125,7 @@ describe('shell_command tool', () => { const result = await shellCommandTool.execute({ command: 'invalid-command' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('shell_command failed')) }) @@ -134,7 +134,7 @@ describe('shell_command tool', () => { const result = await shellCommandTool.execute({ command: 'nonexistent-cmd' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('ENOENT')) }) @@ -143,7 +143,7 @@ describe('shell_command tool', () => { const result = await shellCommandTool.execute({ command: 'sleep 10', timeout_ms: 1000 }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(flattenText(result).includes('shell_command failed')) }) }) diff --git a/packages/core/src/tools/tools/shell_update_plan.test.ts b/packages/core/src/tools/tools/shell_update_plan.test.ts index 7a6c149..03f3fd9 100644 --- a/packages/core/src/tools/tools/shell_update_plan.test.ts +++ b/packages/core/src/tools/tools/shell_update_plan.test.ts @@ -1,13 +1,14 @@ import assert from 'node:assert' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import { describe, test } from 'vitest' import { shellTool } from '@memo/core/tools/tools/shell' import { shellCommandTool } from '@memo/core/tools/tools/shell_command' import { writeStdinTool } from '@memo/core/tools/tools/write_stdin' import { updatePlanTool } from '@memo/core/tools/tools/update_plan' -function textPayload(result: { content?: Array<{ type: string; text?: string }> }) { - const first = result.content?.find((item) => item.type === 'text') - return first?.text ?? '' +function textPayload(result: MemoToolOutput) { + if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' + return '' } describe('shell wrappers and update_plan', () => { @@ -17,7 +18,7 @@ describe('shell wrappers and update_plan', () => { }) const text = textPayload(result) - assert.ok(!result.isError) + assert.ok(result.type === 'text') assert.ok(text.includes('shell-wrapper-ok')) }) @@ -28,7 +29,7 @@ describe('shell wrappers and update_plan', () => { }) const text = textPayload(result) - assert.ok(!result.isError) + assert.ok(result.type === 'text') assert.ok(text.includes(literal)) assert.ok(text.includes('$(echo hacked)')) assert.ok(text.includes('`date`')) @@ -54,7 +55,7 @@ describe('shell wrappers and update_plan', () => { const elapsedMs = Date.now() - startedAt const text = textPayload(result) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(text.includes('timed out')) assert.ok(elapsedMs < 1_500) }) @@ -67,7 +68,7 @@ describe('shell wrappers and update_plan', () => { }) const text = textPayload(result) - assert.ok(!result.isError) + assert.ok(result.type === 'text') assert.ok(text.includes('shell-command-ok')) }) @@ -94,14 +95,14 @@ describe('shell wrappers and update_plan', () => { const elapsedMs = Date.now() - startedAt const text = textPayload(result) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(text.includes('timed out')) assert.ok(elapsedMs < 1_500) }) test('write_stdin fails for unknown session id', async () => { const result = await writeStdinTool.execute({ session_id: 999999, chars: 'noop' }) - assert.strictEqual(result.isError, true) + assert.strictEqual(result.type, 'error-text') assert.ok(textPayload(result).includes('session_id 999999 not found')) }) @@ -156,7 +157,7 @@ describe('shell wrappers and update_plan', () => { ], }) - assert.ok(!result.isError) + assert.ok(result.type === 'text') const parsed = JSON.parse(textPayload(result)) assert.strictEqual(parsed.message, 'Plan updated') }) @@ -170,7 +171,7 @@ describe('shell wrappers and update_plan', () => { ], }) - assert.ok(result.isError) + assert.ok(result.type === 'error-text') assert.ok(textPayload(result).includes('At most one step can be in_progress')) }) }) diff --git a/packages/core/src/tools/tools/types.ts b/packages/core/src/tools/tools/types.ts index 327adad..415440e 100644 --- a/packages/core/src/tools/tools/types.ts +++ b/packages/core/src/tools/tools/types.ts @@ -1,5 +1,4 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types' -import type { NativeTool } from '@memo/core/tools/router/types' +import type { MemoToolOutput, NativeTool } from '@memo/core/tools/router/types' import type { ZodTypeAny } from 'zod' // Tool-related type declarations @@ -18,7 +17,7 @@ export function defineMcpTool(tool: { inputSchema: ZodTypeAny supportsParallelToolCalls?: boolean isMutating?: boolean - execute: (input: Input) => Promise + execute: (input: Input) => Promise }): McpTool { const { inputSchema, execute, ...rest } = tool const jsonSchema = (inputSchema as any).toJSONSchema?.() @@ -37,6 +36,6 @@ export function defineMcpTool(tool: { } return { ok: true, data: parsed.data } }, - execute: execute as (input: unknown) => Promise, + execute: execute as (input: unknown) => Promise, } } diff --git a/packages/core/src/tools/tools/webfetch.test.ts b/packages/core/src/tools/tools/webfetch.test.ts index f14203f..2cb113f 100644 --- a/packages/core/src/tools/tools/webfetch.test.ts +++ b/packages/core/src/tools/tools/webfetch.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert' +import type { MemoToolOutput } from '@memo/core/tools/router/types' import { afterEach, beforeEach, describe, test, vi } from 'vitest' vi.mock('node:dns/promises', () => ({ @@ -17,10 +18,10 @@ const WEBFETCH_ENV_KEYS = [ 'MEMO_WEBFETCH_BLOCK_PRIVATE_NET', ] -type ToolResult = { isError?: boolean; content?: Array<{ type: string; text?: string }> } +type ToolResult = MemoToolOutput function textPayload(result: ToolResult) { - return result.content?.find((item) => item.type === 'text')?.text ?? '' + return result.type === 'text' || result.type === 'error-text' ? result.value : '' } function installFetchMock( @@ -70,7 +71,7 @@ describe('webfetch tool', () => { test('rejects unsupported protocol', async () => { const res = await webfetchTool.execute({ url: 'file:///etc/hosts' }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes('Unsupported protocol')) }) @@ -79,7 +80,7 @@ describe('webfetch tool', () => { url: 'https://example.com', proxy_url: 'socks5://127.0.0.1:1080', }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes('Unsupported proxy protocol')) }) @@ -93,7 +94,7 @@ describe('webfetch tool', () => { ]) const res = await webfetchTool.execute({ url: 'https://example.com' }) const text = textPayload(res) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') assert.ok(text.includes('Contents of https://example.com/')) assert.ok(text.includes('Hello')) assert.ok(text.includes('World')) @@ -110,7 +111,7 @@ describe('webfetch tool', () => { ]) const res = await webfetchTool.execute({ url: 'https://example.com', raw: true }) const text = textPayload(res) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') assert.ok(text.includes('cannot be simplified to markdown')) assert.ok(text.includes('

Hello

')) }) @@ -125,7 +126,7 @@ describe('webfetch tool', () => { ]) const res = await webfetchTool.execute({ url: 'https://example.com/data' }) const text = textPayload(res) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') assert.ok(text.includes('cannot be simplified to markdown')) assert.ok(text.includes('{"key":"value"}')) }) @@ -144,7 +145,7 @@ describe('webfetch tool', () => { max_length: 4, }) const text = textPayload(res) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') assert.ok(text.includes('cdef')) assert.ok(text.includes('start_index of 6')) }) @@ -161,7 +162,7 @@ describe('webfetch tool', () => { url: 'https://example.com/data', start_index: 99, }) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') assert.ok(textPayload(res).includes('No more content available.')) }) @@ -171,21 +172,21 @@ describe('webfetch tool', () => { new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }), ]) const res = await webfetchTool.execute({ url: 'https://example.com/data' }) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') assert.ok(textPayload(res).includes('Contents of https://example.com/data')) }) test('robots 403 blocks autonomous fetching', async () => { installFetchMock([new Response('blocked', { status: 403 })]) const res = await webfetchTool.execute({ url: 'https://example.com/data' }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes('autonomous fetching is not allowed')) }) test('robots disallow blocks autonomous fetching', async () => { installFetchMock([new Response('User-agent: *\nDisallow: /', { status: 200 })]) const res = await webfetchTool.execute({ url: 'https://example.com/data' }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes("site's robots.txt")) }) @@ -198,7 +199,7 @@ describe('webfetch tool', () => { }), ]) const res = await webfetchTool.execute({ url: 'https://example.com/data' }) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') assert.strictEqual(fetchMock.mock.calls.length, 1) }) @@ -206,7 +207,7 @@ describe('webfetch tool', () => { const fetchMock = vi.fn() Object.assign(globalThis, { fetch: fetchMock as unknown as typeof globalThis.fetch }) const res = await webfetchTool.execute({ url: 'http://localhost:8080/private' }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes('Blocked private or local network host')) assert.strictEqual(fetchMock.mock.calls.length, 0) }) @@ -216,7 +217,7 @@ describe('webfetch tool', () => { const fetchMock = vi.fn() Object.assign(globalThis, { fetch: fetchMock as unknown as typeof globalThis.fetch }) const res = await webfetchTool.execute({ url: 'https://example.com/private' }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes('resolved to 10.0.0.12')) assert.strictEqual(fetchMock.mock.calls.length, 0) }) @@ -232,7 +233,7 @@ describe('webfetch tool', () => { }), ]) const res = await webfetchTool.execute({ url: 'https://example.com/data' }) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') }) test('returns timeout error when request aborts', async () => { @@ -251,7 +252,7 @@ describe('webfetch tool', () => { }), ]) const res = await webfetchTool.execute({ url: 'https://example.com/slow' }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes('timeout or aborted')) }) @@ -268,7 +269,7 @@ describe('webfetch tool', () => { }), ]) const res = await webfetchTool.execute({ url: 'https://example.com/large' }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes('response body too large')) }) @@ -278,7 +279,7 @@ describe('webfetch tool', () => { new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } }), ]) const res = await webfetchTool.execute({ url: 'https://example.com/missing' }) - assert.strictEqual(res.isError, true) + assert.strictEqual(res.type, 'error-text') assert.ok(textPayload(res).includes('status code 404')) }) @@ -291,7 +292,7 @@ describe('webfetch tool', () => { url: 'https://example.com/data', proxy_url: 'http://proxy.example.com:8080', }) - assert.strictEqual(res.isError, false) + assert.strictEqual(res.type, 'text') assert.strictEqual(fetchMock.mock.calls.length, 2) const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit & { dispatcher?: unknown } const secondInit = fetchMock.mock.calls[1]?.[1] as RequestInit & { dispatcher?: unknown } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 470d9d8..e1b53f5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,7 +1,8 @@ /** @file Common type declarations shared between Core and Runtime (reused by UI/Tools). */ -import type { FinishReason, LanguageModelUsage, ModelMessage, ToolCallPart } from 'ai' +import type { FinishReason, LanguageModelUsage, ModelMessage, ToolCallPart, ToolResultPart } from 'ai' import type { ApprovalRequest, ApprovalDecision } from '@memo/core/tools/approval' import type { ToolActionStatus } from '@memo/core/tools/orchestrator' +import type { ToolExecutionContext } from '@memo/core/agent/sdk_tools' export type { ApprovalDecision, ApprovalRequest } from '@memo/core/tools/approval' export type { ToolActionStatus } from '@memo/core/tools/orchestrator' export type { FinishReason, LanguageModelUsage } from 'ai' @@ -14,6 +15,8 @@ export type LLMResult = { reasoning?: string /** Tool calls made during generation. */ toolCalls: ToolCallPart[] + /** Executed tool results (AI SDK executed tools with execute functions). */ + toolResults: ToolResultPart[] /** Token usage. */ usage: LanguageModelUsage /** Finish reason. */ @@ -106,6 +109,8 @@ export type CallLLMOptions = { signal?: AbortSignal /** Available tools list (Tool Use API mode) */ tools?: ToolDefinition[] + /** Tool execution context (approval/gate/hooks) captured by the loop. */ + toolContext?: ToolExecutionContext } export type CallLLM = ( diff --git a/packages/core/src/utils/tokenizer.test.ts b/packages/core/src/utils/tokenizer.test.ts index 793100c..be78bfd 100644 --- a/packages/core/src/utils/tokenizer.test.ts +++ b/packages/core/src/utils/tokenizer.test.ts @@ -233,14 +233,7 @@ describe('createTokenCounter', () => { const messages: ChatMessage[] = [ { role: 'assistant', - content: '', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { name: 'test', arguments: '{}' }, - }, - ], + content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'test', input: {} }], }, ] const count = counter.countMessages(messages) @@ -261,26 +254,29 @@ describe('createTokenCounter', () => { { role: 'user', content: 'Write a function that adds two numbers.' }, { role: 'assistant', - content: 'I will create a simple add function for you.', - tool_calls: [ + content: [ + { type: 'text', text: 'I will create a simple add function for you.' }, { - id: 'call-1', - type: 'function', - function: { - name: 'write_file', - arguments: JSON.stringify({ - path: 'add.js', - content: 'function add(a, b) { return a + b; }', - }), + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'write_file', + input: { + path: 'add.js', + content: 'function add(a, b) { return a + b; }', }, }, ], }, { role: 'tool', - content: 'File written successfully', - tool_call_id: 'call-1', - name: 'write_file', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'write_file', + output: { type: 'text', value: 'File written successfully' }, + }, + ], }, { role: 'assistant', From a160f1b426000e143f18c379daad9f53f75f1ac8 Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 16:50:26 +0800 Subject: [PATCH 09/42] refactor(mcp): replace self-built MCP client with @ai-sdk/mcp - McpClientPool now connects via createMCPClient (http transport config with memo's OAuth provider adapted, stdio via Experimental_StdioMCPTransport) - connection.tools is an AI SDK ToolSet (own execute, JSON-RPC under the hood) - McpToolRegistry derives tools from the tool set; cache store kept - oauth.ts credentials/login CLI untouched; listResourceTemplates confirmed on the SDK client --- packages/core/package.json | 9 +- .../core/src/tools/router/mcp/index.test.ts | 19 +- packages/core/src/tools/router/mcp/index.ts | 28 ++- .../core/src/tools/router/mcp/pool.test.ts | 172 ++++++------------ packages/core/src/tools/router/mcp/pool.ts | 149 +++++---------- packages/core/src/tools/router/types.ts | 10 +- .../src/tools/tools/filesystem_tools.test.ts | 2 - pnpm-lock.yaml | 16 ++ 8 files changed, 149 insertions(+), 256 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index c187245..6bde1c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,13 +19,14 @@ "test": "vitest run" }, "dependencies": { - "ignore": "^7.0.5", - "zod": "^4.3.6", - "ai": "^6.0.0", + "@ai-sdk/mcp": "1.0.66", "@ai-sdk/openai-compatible": "^2.0.0", "@modelcontextprotocol/sdk": "^1.24.3", + "ai": "^6.0.0", "diff": "^8.0.3", - "minimatch": "^10.0.1" + "ignore": "^7.0.5", + "minimatch": "^10.0.1", + "zod": "^4.3.6" }, "devDependencies": { "vitest": "^2.1.8" diff --git a/packages/core/src/tools/router/mcp/index.test.ts b/packages/core/src/tools/router/mcp/index.test.ts index 828d449..aabe77d 100644 --- a/packages/core/src/tools/router/mcp/index.test.ts +++ b/packages/core/src/tools/router/mcp/index.test.ts @@ -16,21 +16,14 @@ function createConfig(): MCPServerConfig { function createConnection(serverName: string, toolName: string) { return { name: serverName, - client: { - callTool: async () => ({ content: [] }), - }, - transport: {} as any, - tools: [ - { - name: `${serverName}_${toolName}`, + client: {}, + tools: { + [toolName]: { description: `Tool from ${serverName}: ${toolName}`, - source: 'mcp' as const, - serverName, - originalName: toolName, - inputSchema: {}, - execute: async () => ({ content: [] }), + inputSchema: { jsonSchema: () => ({ type: 'object' }) }, + execute: async () => ({ type: 'text', value: '' }), }, - ], + }, } } diff --git a/packages/core/src/tools/router/mcp/index.ts b/packages/core/src/tools/router/mcp/index.ts index 05fc1d8..5889f03 100644 --- a/packages/core/src/tools/router/mcp/index.ts +++ b/packages/core/src/tools/router/mcp/index.ts @@ -31,19 +31,15 @@ export class McpToolRegistry { inputSchema: (descriptor.inputSchema as any) ?? {}, execute: async (input: unknown): Promise => { const connection = await this.pool.connect(serverName, config) - const result = await connection.client.callTool({ - name: descriptor.originalName, - arguments: input as Record, - }) - const callResult = result as unknown as { - content?: Array<{ type?: string; text?: string }> - isError?: boolean + const sdkTool = connection.tools[descriptor.originalName] + if (!sdkTool?.execute) { + return { type: 'error-text', value: `MCP tool not found: ${descriptor.originalName}` } } - const texts = - callResult.content?.flatMap((item) => (item.type === 'text' ? [item.text ?? ''] : [])) ?? [] - return result.isError - ? { type: 'error-text', value: texts.join('\n') || `Tool ${descriptor.originalName} failed` } - : { type: 'text', value: texts.join('\n') } + const output = await sdkTool.execute(input, { + toolCallId: `${serverName}_${descriptor.originalName}_${Date.now()}`, + messages: [], + }) + return output as MemoToolOutput }, } } @@ -68,10 +64,10 @@ export class McpToolRegistry { serverName: string, connection: Awaited>, ): CachedMcpToolDescriptor[] { - return connection.tools.map((tool) => ({ - originalName: tool.originalName, - description: tool.description || `Tool from ${serverName}: ${tool.originalName}`, - inputSchema: tool.inputSchema, + return Object.entries(connection.tools).map(([originalName, tool]) => ({ + originalName, + description: tool.description || `Tool from ${serverName}: ${originalName}`, + inputSchema: (tool.inputSchema as { jsonSchema?: () => unknown }).jsonSchema?.(), })) } diff --git a/packages/core/src/tools/router/mcp/pool.test.ts b/packages/core/src/tools/router/mcp/pool.test.ts index 5e3ef06..d5abd11 100644 --- a/packages/core/src/tools/router/mcp/pool.test.ts +++ b/packages/core/src/tools/router/mcp/pool.test.ts @@ -3,70 +3,41 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import type { MCPServerConfig } from '../types' const { - connectMock, - listToolsMock, + toolsMock, + createClientConfigMock, closeMock, createRuntimeMcpOAuthProviderMock, - streamableInstances, stdioInstances, UnauthorizedErrorMock, - StreamableHTTPErrorMock, } = vi.hoisted(() => { class UnauthorizedErrorMock extends Error {} - class StreamableHTTPErrorMock extends Error { - code: number - constructor(message: string, code: number) { - super(message) - this.code = code - } - } return { - connectMock: vi.fn(), - listToolsMock: vi.fn(), + toolsMock: vi.fn(), + createClientConfigMock: vi.fn(), closeMock: vi.fn(), createRuntimeMcpOAuthProviderMock: vi.fn(), - streamableInstances: [] as Array<{ url: URL; options: Record }>, stdioInstances: [] as Array<{ options: Record }>, UnauthorizedErrorMock, - StreamableHTTPErrorMock, } }) -vi.mock('@modelcontextprotocol/sdk/client/index.js', () => { - class MockClient { - async connect(transport: unknown) { - return connectMock(transport) - } - async listTools() { - return listToolsMock() - } - async close() { - return closeMock() - } - } +vi.mock('@ai-sdk/mcp', async () => { + const actual = await vi.importActual('@ai-sdk/mcp') return { - Client: MockClient, - } -}) - -vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => { - class MockStreamableHTTPClientTransport { - url: URL - options: Record - constructor(url: URL, options: Record) { - this.url = url - this.options = options - streamableInstances.push(this) - } - } - return { - StreamableHTTPClientTransport: MockStreamableHTTPClientTransport, - StreamableHTTPError: StreamableHTTPErrorMock, + ...(actual as Record), + createMCPClient: async (config: unknown) => { + await createClientConfigMock(config) + return { + tools: async () => toolsMock(), + close: async () => closeMock(), + } + }, + UnauthorizedError: UnauthorizedErrorMock, } }) -vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => { - class MockStdioClientTransport { +vi.mock('@ai-sdk/mcp/mcp-stdio', () => { + class MockStdioMCPTransport { options: Record constructor(options: Record) { this.options = options @@ -74,13 +45,7 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => { } } return { - StdioClientTransport: MockStdioClientTransport, - } -}) - -vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => { - return { - UnauthorizedError: UnauthorizedErrorMock, + Experimental_StdioMCPTransport: MockStdioMCPTransport, } }) @@ -100,25 +65,21 @@ function httpConfig(extra?: Partial>): } } -afterEach(() => { - vi.restoreAllMocks() - connectMock.mockReset() - listToolsMock.mockReset() - closeMock.mockReset() - createRuntimeMcpOAuthProviderMock.mockReset() - streamableInstances.splice(0) - stdioInstances.splice(0) - delete process.env.MCP_TOKEN - delete process.env.BASE_ENV -}) - describe('mcp client pool', () => { + afterEach(() => { + toolsMock.mockReset() + createClientConfigMock.mockReset() + closeMock.mockReset() + createRuntimeMcpOAuthProviderMock.mockReset() + stdioInstances.splice(0) + delete process.env.MCP_TOKEN + }) + test('connects HTTP server with oauth settings and request headers', async () => { const authProvider = { kind: 'oauth-provider' } createRuntimeMcpOAuthProviderMock.mockResolvedValue(authProvider) - connectMock.mockResolvedValue(undefined) - listToolsMock.mockResolvedValue({ - tools: [{ name: 'search', description: 'Search docs', inputSchema: { type: 'object' } }], + toolsMock.mockResolvedValue({ + search: { description: 'Search docs', inputSchema: { type: 'object' } }, }) process.env.MCP_TOKEN = 'token-123' @@ -139,19 +100,19 @@ describe('mcp client pool', () => { config, settings: { memoHome: '/tmp/memo-home', storeMode: 'file', callbackPort: 33333 }, }) - expect(connectMock).toHaveBeenCalledTimes(1) - assert.strictEqual(streamableInstances.length, 1) - const transport = streamableInstances[0] - assert.strictEqual(transport?.url.toString(), 'https://example.com/mcp') - expect(transport?.options.authProvider).toEqual(authProvider) - expect(transport?.options.requestInit).toEqual({ - headers: { - 'X-Custom': 'value', - Authorization: 'Bearer token-123', - }, + expect(createClientConfigMock).toHaveBeenCalledTimes(1) + const transportConfig = createClientConfigMock.mock.calls[0]?.[0] as { + transport: { type: string; url: string; headers: Record; authProvider: unknown } + } + assert.strictEqual(transportConfig.transport.type, 'http') + assert.strictEqual(transportConfig.transport.url, 'https://example.com/mcp') + expect(transportConfig.transport.authProvider).toEqual(authProvider) + expect(transportConfig.transport.headers).toEqual({ + 'X-Custom': 'value', + Authorization: 'Bearer token-123', }) - assert.strictEqual(connection.tools.length, 1) - assert.strictEqual(connection.tools[0]?.name, 'remote_search') + assert.strictEqual(Object.keys(connection.tools).length, 1) + assert.ok(connection.tools['search']) }) test('reuses inflight connect promise for same server', async () => { @@ -159,8 +120,8 @@ describe('mcp client pool', () => { const connectPromise = new Promise((resolve) => { resolveConnect = resolve }) - connectMock.mockImplementation(() => connectPromise) - listToolsMock.mockResolvedValue({ tools: [] }) + createClientConfigMock.mockImplementation(() => connectPromise) + toolsMock.mockResolvedValue({}) createRuntimeMcpOAuthProviderMock.mockResolvedValue(null) const pool = new McpClientPool() @@ -172,58 +133,44 @@ describe('mcp client pool', () => { resolveConnect() const [left, right] = await Promise.all([first, second]) - expect(connectMock).toHaveBeenCalledTimes(1) + expect(createClientConfigMock).toHaveBeenCalledTimes(1) assert.strictEqual(left, right) }) test('includes login hint for unauthorized HTTP failures', async () => { - connectMock.mockRejectedValue(new UnauthorizedErrorMock('unauthorized')) + createClientConfigMock.mockRejectedValue(new UnauthorizedErrorMock('unauthorized')) createRuntimeMcpOAuthProviderMock.mockResolvedValue(null) const pool = new McpClientPool() - - await expect(pool.connect('remote', httpConfig())).rejects.toThrow('Run "memo mcp login remote".') - }) - - test('includes login hint for 403 streamable HTTP failures', async () => { - connectMock.mockRejectedValue(new StreamableHTTPErrorMock('forbidden', 403)) - createRuntimeMcpOAuthProviderMock.mockResolvedValue(null) - - const pool = new McpClientPool() - await expect(pool.connect('remote', httpConfig())).rejects.toThrow('Run "memo mcp login remote".') }) test('does not include login hint for non-auth failures', async () => { - connectMock.mockRejectedValue(new Error('network timeout')) + createClientConfigMock.mockRejectedValue(new Error('connection refused')) createRuntimeMcpOAuthProviderMock.mockResolvedValue(null) const pool = new McpClientPool() - await expect(pool.connect('remote', httpConfig())).rejects.toThrow( - 'Failed to connect via streamable_http (network timeout).', + 'Failed to connect via streamable_http (connection refused).', ) }) test('closes client when listing tools fails', async () => { - connectMock.mockResolvedValue(undefined) - listToolsMock.mockRejectedValue(new Error('list failed')) - closeMock.mockResolvedValue(undefined) + toolsMock.mockRejectedValue(new Error('list failed')) createRuntimeMcpOAuthProviderMock.mockResolvedValue(null) const pool = new McpClientPool() - await expect(pool.connect('remote', httpConfig())).rejects.toThrow('list failed') expect(closeMock).toHaveBeenCalledTimes(1) }) test('connects stdio server with merged env and explicit stderr mode', async () => { - connectMock.mockResolvedValue(undefined) - listToolsMock.mockResolvedValue({ tools: [] }) + toolsMock.mockResolvedValue({}) + createRuntimeMcpOAuthProviderMock.mockResolvedValue(null) process.env.BASE_ENV = 'base' const pool = new McpClientPool() - await pool.connect('local', { + await pool.connect('remote', { command: 'node', args: ['server.js'], env: { LOCAL_ENV: 'local' }, @@ -238,14 +185,12 @@ describe('mcp client pool', () => { const env = transport?.options.env as Record assert.strictEqual(env.LOCAL_ENV, 'local') assert.strictEqual(env.BASE_ENV, 'base') - delete process.env.BASE_ENV }) test('closeAll logs close failures and clears connected clients', async () => { - connectMock.mockResolvedValue(undefined) - listToolsMock.mockResolvedValue({ tools: [] }) - closeMock.mockRejectedValue(new Error('close failed')) + toolsMock.mockResolvedValue({}) createRuntimeMcpOAuthProviderMock.mockResolvedValue(null) + closeMock.mockRejectedValue(new Error('close failed')) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const pool = new McpClientPool() @@ -253,23 +198,22 @@ describe('mcp client pool', () => { assert.strictEqual(pool.size, 1) await pool.closeAll() - assert.strictEqual(pool.size, 0) expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() }) test('tracks known servers from configs and active connections', async () => { - connectMock.mockResolvedValue(undefined) - listToolsMock.mockResolvedValue({ tools: [] }) + toolsMock.mockResolvedValue({}) createRuntimeMcpOAuthProviderMock.mockResolvedValue(null) const pool = new McpClientPool() pool.setServerConfigs({ configured: httpConfig() }) - assert.strictEqual(pool.hasServer('configured'), true) + await pool.connect('connected', httpConfig()) - await pool.connect('connected', httpConfig({ url: 'https://example.com/other' })) + assert.strictEqual(pool.hasServer('configured'), true) + assert.strictEqual(pool.hasServer('connected'), true) const names = pool.getKnownServerNames() - expect(names).toContain('configured') expect(names).toContain('connected') }) diff --git a/packages/core/src/tools/router/mcp/pool.ts b/packages/core/src/tools/router/mcp/pool.ts index 274db1f..7106e92 100644 --- a/packages/core/src/tools/router/mcp/pool.ts +++ b/packages/core/src/tools/router/mcp/pool.ts @@ -1,14 +1,15 @@ -/** @file MCP Client 连接池管理 */ -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' -import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +/** @file MCP Client 连接池管理(基于 @ai-sdk/mcp) */ +import { + createMCPClient, + UnauthorizedError, + type MCPClient, + type MCPClientConfig, + type OAuthClientProvider as SdkOAuthClientProvider, +} from '@ai-sdk/mcp' +import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio' import type { MCPServerConfig, McpClientConnection } from '../types' import { createRuntimeMcpOAuthProvider, type McpOAuthSettings } from './oauth' -type ClientTransport = StdioClientTransport | StreamableHTTPClientTransport - function mergeProcessEnv(env?: Record): Record | undefined { if (!env) return undefined const merged: Record = { @@ -19,25 +20,6 @@ function mergeProcessEnv(env?: Record): Record | return Object.fromEntries(entries) } -/** 创建标准化的 MCP Client */ -function createMcpClient(): Client { - return new Client( - { - name: 'memo-code-cli-client', - version: '1.0.0', - }, - { - capabilities: {}, - }, - ) -} - -/** 构建 HTTP 请求的 headers */ -function buildRequestInit(headers?: Record): RequestInit | undefined { - if (!headers || Object.keys(headers).length === 0) return undefined - return { headers } -} - function resolveHttpHeaders(config: Extract) { const headers = { ...(config.http_headers ?? config.headers), @@ -51,63 +33,44 @@ function resolveHttpHeaders(config: Extract) { return headers } -/** 通过 HTTP 连接 MCP Server */ -async function connectOverHttp( - name: string, - config: Extract, - oauthSettings: McpOAuthSettings | undefined, -): Promise<{ client: Client; transport: ClientTransport }> { - const baseUrl = new URL(config.url) - const requestInit = buildRequestInit(resolveHttpHeaders(config)) - const authProvider = await createRuntimeMcpOAuthProvider({ - serverName: name, - config, - settings: oauthSettings, - }) - - try { - const client = createMcpClient() - const transport = new StreamableHTTPClientTransport(baseUrl, { - requestInit, - ...(authProvider ? { authProvider } : {}), - }) - await client.connect(transport) - return { client, transport } - } catch (streamErr) { - const authHint = isAuthFailure(streamErr) ? ` Run "memo mcp login ${name}".` : '' - const message = `Failed to connect via streamable_http (${(streamErr as Error).message}).${authHint}` - const error = new Error(message) - ;(error as any).cause = streamErr - throw error - } -} - -/** 根据配置建立连接 */ +/** 根据配置建立 AI SDK MCP 客户端 */ async function connectWithConfig( name: string, config: MCPServerConfig, oauthSettings: McpOAuthSettings | undefined, -): Promise<{ client: Client; transport: ClientTransport }> { +): Promise { if ('url' in config) { - return connectOverHttp(name, config, oauthSettings) + const authProvider = await createRuntimeMcpOAuthProvider({ + serverName: name, + config, + settings: oauthSettings, + }) + const transport: MCPClientConfig['transport'] = { + type: 'http', + url: config.url, + headers: resolveHttpHeaders(config), + // memo's provider implements the same OAuthClientProvider contract (SDK-origin). + ...(authProvider ? { authProvider: authProvider as unknown as SdkOAuthClientProvider } : {}), + } + try { + return await createMCPClient({ transport }) + } catch (streamErr) { + const authHint = isAuthFailure(streamErr) ? ` Run "memo mcp login ${name}".` : '' + const message = `Failed to connect via streamable_http (${(streamErr as Error).message}).${authHint}` + const error = new Error(message) + ;(error as any).cause = streamErr + throw error + } } // stdio 类型 - const stdioOptions: { - command: string - args?: string[] - env?: Record - stderr?: 'inherit' | 'pipe' | 'ignore' - } = { + const transport = new Experimental_StdioMCPTransport({ command: config.command, args: config.args, env: mergeProcessEnv(config.env), stderr: config.stderr ?? (process.stdout.isTTY && process.stdin.isTTY ? 'ignore' : undefined), - } - const transport = new StdioClientTransport(stdioOptions as any) - const client = createMcpClient() - await client.connect(transport) - return { client, transport } + }) + return createMCPClient({ transport }) } /** MCP Client 连接池 */ @@ -130,7 +93,7 @@ export class McpClientPool { * Connect to specified MCP Server * @param name - server name (key in configuration) * @param config - server configuration - * @returns connection info (contains client, transport, and tool list) + * @returns connection info (AI SDK MCP client + tool set) */ async connect(name: string, config?: MCPServerConfig): Promise { if (config) { @@ -154,28 +117,15 @@ export class McpClientPool { } const pending = (async () => { - // Establish new connection - const { client, transport } = await connectWithConfig(name, effectiveConfig, this.oauthSettings) + const client = await connectWithConfig(name, effectiveConfig, this.oauthSettings) try { - // Get tool list - const toolsResult = await client.listTools() - - // Build McpTool array (execute not filled yet, handled by Registry) + // Get tool set (AI SDK Tools with own execute). + const tools = await client.tools() const connection: McpClientConnection = { name, client, - transport, - tools: (toolsResult.tools || []).map((t) => ({ - name: `${name}_${t.name}`, - description: t.description || `Tool from ${name}: ${t.name}`, - source: 'mcp' as const, - serverName: name, - originalName: t.name, - inputSchema: t.inputSchema as any, - // execute 会在 registry 中绑定 - execute: async () => ({ type: 'text', value: '' }), - })), + tools, } this.connections.set(name, connection) @@ -223,18 +173,18 @@ export class McpClientPool { description: string serverName: string originalName: string - inputSchema: any - client: Client + inputSchema: unknown + client: MCPClient }[] = [] for (const conn of this.connections.values()) { - for (const tool of conn.tools) { + for (const [originalName, tool] of Object.entries(conn.tools)) { allTools.push({ - name: tool.name, - description: tool.description, - serverName: tool.serverName, - originalName: tool.originalName, - inputSchema: tool.inputSchema, + name: `${conn.name}_${originalName}`, + description: tool.description ?? `Tool from ${conn.name}: ${originalName}`, + serverName: conn.name, + originalName, + inputSchema: (tool.inputSchema as { jsonSchema?: () => unknown }).jsonSchema?.(), client: conn.client, }) } @@ -266,9 +216,6 @@ export class McpClientPool { function isAuthFailure(error: unknown): boolean { if (error instanceof UnauthorizedError) return true - if (error instanceof StreamableHTTPError) { - return error.code === 401 || error.code === 403 - } const message = (error as Error)?.message?.toLowerCase() ?? '' return ( message.includes('unauthorized') || diff --git a/packages/core/src/tools/router/types.ts b/packages/core/src/tools/router/types.ts index 8df308b..d18d111 100644 --- a/packages/core/src/tools/router/types.ts +++ b/packages/core/src/tools/router/types.ts @@ -83,12 +83,10 @@ export type MCPServerConfig = bearer_token_env_var?: string } -/** MCP Client connection info */ +/** MCP Client connection info (AI SDK MCP client + tool set). */ export interface McpClientConnection { name: string - client: import('@modelcontextprotocol/sdk/client/index.js').Client - transport: - | import('@modelcontextprotocol/sdk/client/stdio.js').StdioClientTransport - | import('@modelcontextprotocol/sdk/client/streamableHttp.js').StreamableHTTPClientTransport - tools: McpTool[] + client: import('@ai-sdk/mcp').MCPClient + /** originalName → AI SDK Tool (own execute, JSON-RPC under the hood). */ + tools: Record } diff --git a/packages/core/src/tools/tools/filesystem_tools.test.ts b/packages/core/src/tools/tools/filesystem_tools.test.ts index 8c530ef..6c9a28b 100644 --- a/packages/core/src/tools/tools/filesystem_tools.test.ts +++ b/packages/core/src/tools/tools/filesystem_tools.test.ts @@ -12,8 +12,6 @@ import { editFileTool } from '@memo/core/tools/tools/edit_file' import { listDirectoryTool } from '@memo/core/tools/tools/list_directory' import { searchFilesTool } from '@memo/core/tools/tools/search_files' -type ToolResult = { content?: Array<{ type: string; text?: string }>; isError?: boolean } - function textPayload(result: MemoToolOutput): string { if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' return '' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7639047..96bbbc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: packages/core: dependencies: + '@ai-sdk/mcp': + specifier: 1.0.66 + version: 1.0.66(zod@4.3.6) '@ai-sdk/openai-compatible': specifier: ^2.0.0 version: 2.0.63(zod@4.3.6) @@ -170,6 +173,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mcp@1.0.66': + resolution: {integrity: sha512-IRfN+1T3dkCWD2RcvupozEmqrglaq2cw5FJsBbT59qHIJ6+jZiWQ4yVk7zaRqfmV/HoQIWvXIvcrZrMCagpa9A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai-compatible@2.0.63': resolution: {integrity: sha512-EmrD7iRboidulu6yHfMiMhd6RQSw8KrIWhNLK8vl5brQZbIjXkyhUU+FULZM3P4m46Vatzx8u3vX1w/qmFUmqA==} engines: {node: '>=18'} @@ -2761,6 +2770,13 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.3.6 + '@ai-sdk/mcp@1.0.66(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.41(zod@4.3.6) + pkce-challenge: 5.0.1 + zod: 4.3.6 + '@ai-sdk/openai-compatible@2.0.63(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.14 From 26e339bd8767bda781eacf2985cae4678a74bb4f Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 16:50:56 +0800 Subject: [PATCH 10/42] docs(core): document agent execution wrapper and merged tools module --- packages/core/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/README.md b/packages/core/README.md index 155dab9..fb92b15 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -13,10 +13,16 @@ Core provides the central capabilities of **Memo Code**: the ReAct loop, session - `agent/` — the agent loop kernel (minimal, readable, replaceable) - `loop.ts`: ReAct loop (observe → think → act → record), session state, token usage, permissions, abort handling. - `messages.ts`: Message construction and LLM result normalization (AI SDK `ModelMessage`/`GenerateTextResult`). + - `sdk_tools.ts`: Adapter from the memo Tool registry to AI SDK tools — execute wrappers run approval (white-list → classifier → fingerprint), truncation, and deny handling inside `streamText`. + - `step_gate.ts`: Per-step concurrency gate (serializes mutating tools, skips pending tools after denial). - `session.ts`: `createAgentSession` factory. - `defaults.ts`: Composition root — default dependency completion (toolset, LLM, prompt, history sink, tokenizer). - `hooks.ts`: Hook/middleware runners and history snapshotting. - `compact_prompt.ts`: Context compaction prompt building. +- `tools/` — tool registry, approval, and the 24 built-in tools (merged back from the former tools package) + - `router/`: ToolRouter (native + MCP registries); MCP clients via `@ai-sdk/mcp` (`router/mcp/pool.ts`), disk cache and OAuth credentials kept. + - `approval/`: Approval manager (risk classifier, fingerprints, once/session/deny caches). + - `tools/`: Built-in tool implementations (`defineMcpTool` zod adapter). - `features/` — user-facing capabilities built on the contracts (not part of the loop); one directory per module, exports via `index.ts` - `slash/`: Slash command specs and registry. - `file_suggestions/`: File suggestion helpers for the composer. From b2a6cbf76fea0e3b936b290965eee35cc6b3e7db Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 20:23:26 +0800 Subject: [PATCH 11/42] refactor(types): drop ToolDefinition and ToolUseBlock custom types - CallLLMOptions.tools removed; toolContext presence is the tool enable/disable switch (compaction passes no context) - ToolUseBlock replaced by AI SDK ToolCallPart end-to-end (normalizeLLMResponse returns raw toolCalls) - router.generateToolDefinitions deleted - streamText allowSystemInMessages: system messages are part of the memo history (initial prompt + mid-loop warnings) --- packages/core/src/agent/defaults.ts | 9 ++--- .../agent/defaults.with_default_deps.test.ts | 16 ++------- packages/core/src/agent/loop.ts | 25 +++++++------- packages/core/src/agent/messages.ts | 33 +++---------------- packages/core/src/agent/session_hooks.test.ts | 21 ++++-------- packages/core/src/index.ts | 3 +- packages/core/src/llm/ai_stream.ts | 6 ++-- packages/core/src/tools/router/index.test.ts | 17 ---------- packages/core/src/tools/router/index.ts | 11 ------- packages/core/src/types.ts | 22 +------------ 10 files changed, 34 insertions(+), 129 deletions(-) diff --git a/packages/core/src/agent/defaults.ts b/packages/core/src/agent/defaults.ts index c46e527..bd35ee5 100644 --- a/packages/core/src/agent/defaults.ts +++ b/packages/core/src/agent/defaults.ts @@ -106,9 +106,6 @@ export async function withDefaultDeps( return basePrompt } - // 7. Generate tool definitions (for Tool Use API) - const toolDefinitions = router.generateToolDefinitions() - const sessionsDir = getSessionsDir(loaded, options) const historyFilePath = buildSessionPath(sessionsDir, sessionId) const defaultHistorySink = new JsonlHistorySink(historyFilePath) @@ -129,14 +126,12 @@ export async function withDefaultDeps( throw new Error(`Missing env var ${provider.env_api_key} (or OPENAI_API_KEY/DEEPSEEK_API_KEY)`) } const { profile: modelProfile } = resolveModelProfile(provider, config.model_profiles) - // Compaction passes { tools: [] } to disable tools; the main turn uses the full registry. - const toolsEnabled = callOptions?.tools ? callOptions.tools.length > 0 : true return streamCallLLM({ provider, apiKey, messages, - tools: toolsEnabled ? combinedTools : undefined, - toolDefinitions: callOptions?.tools ?? toolDefinitions, + // toolContext absent (compaction) disables tools in streamCallLLM. + tools: combinedTools, profile: modelProfile, factory: getProviderFactory(provider), toolContext: callOptions?.toolContext, diff --git a/packages/core/src/agent/defaults.with_default_deps.test.ts b/packages/core/src/agent/defaults.with_default_deps.test.ts index 8ff7d37..75bd688 100644 --- a/packages/core/src/agent/defaults.with_default_deps.test.ts +++ b/packages/core/src/agent/defaults.with_default_deps.test.ts @@ -1,12 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import type { - AgentSessionDeps, - AgentSessionOptions, - ChatMessage, - LLMResult, - ToolDefinition, - ToolRegistry, -} from '@memo/core/types' +import type { AgentSessionDeps, AgentSessionOptions, ChatMessage, LLMResult, ToolRegistry } from '@memo/core/types' import type { MCPServerConfig } from '@memo/core/config/config' import type { AIProviderFactory } from '@memo/core/llm/ai_provider' import { emptyUsage } from '@memo/core/agent/loop' @@ -297,9 +290,6 @@ describe('withDefaultDeps (default path)', () => { test('passes call options (tools/signal) and forwards structured LLM response', async () => { process.env.MOCK_API_KEY = 'test-key' const { withDefaultDeps } = await import('@memo/core/agent/defaults') - const callOptionsTools: ToolDefinition[] = [ - { name: 'override', description: 'override tool', input_schema: { type: 'object' } }, - ] const signal = new AbortController().signal state.llmResponse = { @@ -340,7 +330,7 @@ describe('withDefaultDeps (default path)', () => { { role: 'user', content: 'continue' }, ], undefined, - { tools: callOptionsTools, signal }, + { signal }, ) expect(response).toEqual(state.llmResponse) @@ -349,13 +339,11 @@ describe('withDefaultDeps (default path)', () => { provider: typeof state.selectedProvider apiKey: string messages: Array> - toolDefinitions: unknown[] profile: unknown factory: unknown signal: AbortSignal } expect(call.apiKey).toBe('test-key') - expect(call.toolDefinitions).toEqual(callOptionsTools) expect(call.signal).toBe(signal) expect(call.profile).toEqual({ supportsParallelToolCalls: true }) expect(call.factory).toBe(state.factory) diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index ef7185d..9d23999 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -26,7 +26,7 @@ import type { TurnResult, TurnStatus, } from '@memo/core/types' -import type { LanguageModelUsage, ToolResultPart } from 'ai' +import type { LanguageModelUsage, ToolCallPart, ToolResultPart } from 'ai' import { buildHookRunners, runHook, snapshotHistory, type HookRunnerMap } from '@memo/core/agent/hooks' import { createApprovalManager, type ApprovalManager } from '@memo/core/tools/approval' import type { ToolApprovalHooks } from '@memo/core/tools/orchestrator' @@ -34,7 +34,6 @@ import { runWithRuntimeContext } from '@memo/core/tools/runtime/context' import type { ToolExecutionContext } from './sdk_tools' import { createStepGate } from './step_gate' import { - buildAssistantToolCalls, mapOutputStatus, normalizeLLMResponse, outputToObservation, @@ -314,7 +313,7 @@ export class AgentSessionImpl implements AgentSession { { role: 'user', content: buildCompactionUserPrompt(historyWithoutSystem) }, ], undefined, - { tools: [] }, + {}, ) const normalized = normalizeLLMResponse(response) const summary = this.normalizeCompactionSummary(normalized.textContent) @@ -523,7 +522,7 @@ export class AgentSessionImpl implements AgentSession { } let assistantText = '' - let toolUseBlocks: Array<{ id: string; name: string; input: unknown }> = [] + let toolUseBlocks: ToolCallPart[] = [] let toolResults: ToolResultPart[] = [] let usageFromLLM: Partial | undefined let reasoningContent: string | undefined @@ -619,7 +618,7 @@ export class AgentSessionImpl implements AgentSession { const thinking = assistantText ? buildThinking([assistantText]) : undefined parsed = { action: { - tool: firstTool.name, + tool: firstTool.toolName, input: firstTool.input, }, thinking, @@ -631,7 +630,7 @@ export class AgentSessionImpl implements AgentSession { ...(reasoningContent ? [{ type: 'reasoning' as const, text: reasoningContent }] : []), - ...buildAssistantToolCalls(toolUseBlocks), + ...toolUseBlocks, ], } } else { @@ -748,7 +747,7 @@ export class AgentSessionImpl implements AgentSession { meta: { error_type: 'tool_disabled', tool_count: toolUseBlocks.length, - tools: toolUseBlocks.map((block) => block.name).join(','), + tools: toolUseBlocks.map((block) => block.toolName).join(','), tokens: stepUsage, }, }) @@ -776,9 +775,9 @@ export class AgentSessionImpl implements AgentSession { turn, step, meta: { - tools: toolUseBlocks.map((b) => b.name), - action_ids: toolUseBlocks.map((b) => b.id), - action_id: toolUseBlocks[0]?.id, + tools: toolUseBlocks.map((b) => b.toolName), + action_ids: toolUseBlocks.map((b) => b.toolCallId), + action_id: toolUseBlocks[0]?.toolCallId, parallel: toolUseBlocks.length > 1, phase: 'dispatch', thinking: parsed.thinking, @@ -788,8 +787,8 @@ export class AgentSessionImpl implements AgentSession { sessionId: this.id, turn, step, - action: { tool: toolUseBlocks[0]?.name ?? '', input: toolUseBlocks[0]?.input }, - parallelActions: toolUseBlocks.map((b) => ({ tool: b.name, input: b.input })), + action: { tool: toolUseBlocks[0]?.toolName ?? '', input: toolUseBlocks[0]?.input }, + parallelActions: toolUseBlocks.map((b) => ({ tool: b.toolName, input: b.input })), thinking: parsed.thinking, history: snapshotHistory(this.history), }) @@ -834,7 +833,7 @@ export class AgentSessionImpl implements AgentSession { sessionId: this.id, turn, step, - tool: toolUseBlocks.map((b) => b.name).join(', '), + tool: toolUseBlocks.map((b) => b.toolName).join(', '), observation: hookObservation, resultStatus, parallelResultStatuses: resultStatuses, diff --git a/packages/core/src/agent/messages.ts b/packages/core/src/agent/messages.ts index e869105..aea0df5 100644 --- a/packages/core/src/agent/messages.ts +++ b/packages/core/src/agent/messages.ts @@ -16,29 +16,16 @@ export function parseToolArguments( /** Extract session-level fields from an AI SDK GenerateTextResult. */ export function normalizeLLMResponse(raw: LLMResult): { textContent: string - toolUseBlocks: Array<{ id: string; name: string; input: unknown }> + /** Tool calls (AI SDK ToolCallPart[]; inputs are already parsed objects). */ + toolUseBlocks: ToolCallPart[] reasoningContent?: string usage?: Partial /** Executed tool results (AI SDK executed the tools inside streamText). */ toolResults: ToolResultPart[] } { - let textContent = raw.text - const toolUseBlocks: Array<{ id: string; name: string; input: unknown }> = [] - for (const call of raw.toolCalls) { - if (typeof call.input === 'string') { - const parsed = parseToolArguments(call.input) - if (parsed.ok) { - toolUseBlocks.push({ id: call.toolCallId, name: call.toolName, input: parsed.data }) - } else { - textContent = `${textContent}\n[tool_use parse error] ${parsed.error}; raw: ${parsed.raw}`.trim() - } - } else { - toolUseBlocks.push({ id: call.toolCallId, name: call.toolName, input: call.input }) - } - } return { - textContent, - toolUseBlocks, + textContent: raw.text, + toolUseBlocks: raw.toolCalls, reasoningContent: typeof raw.reasoning === 'string' && raw.reasoning.trim().length > 0 ? raw.reasoning : undefined, usage: raw.usage, @@ -46,18 +33,6 @@ export function normalizeLLMResponse(raw: LLMResult): { } } -/** ToolUseBlock[] → AI SDK tool-call parts (for assistant history messages). */ -export function buildAssistantToolCalls( - toolUseBlocks: Array<{ id: string; name: string; input: unknown }>, -): ToolCallPart[] { - return toolUseBlocks.map((block) => ({ - type: 'tool-call', - toolCallId: block.id, - toolName: block.name, - input: block.input, - })) -} - /** Parse a plain-text tool call (legacy text protocol fallback). */ export function parseTextToolCall(text: string, tools: ToolRegistry): { tool: string; input: unknown } | null { const trimmed = text.trim() diff --git a/packages/core/src/agent/session_hooks.test.ts b/packages/core/src/agent/session_hooks.test.ts index d2fb5ce..09a7551 100644 --- a/packages/core/src/agent/session_hooks.test.ts +++ b/packages/core/src/agent/session_hooks.test.ts @@ -746,8 +746,7 @@ describe('session hooks & middleware', () => { const isCompactionCall = messages[0]?.role === 'system' && messages[0].content === CONTEXT_COMPACTION_SYSTEM_PROMPT && - Array.isArray(options?.tools) && - options.tools.length === 0 + !options?.toolContext if (isCompactionCall) { autoCompactionCalls += 1 return endTurnResponse('checkpoint') @@ -783,8 +782,7 @@ describe('session hooks & middleware', () => { const isCompactionCall = messages[0]?.role === 'system' && messages[0].content === CONTEXT_COMPACTION_SYSTEM_PROMPT && - Array.isArray(options?.tools) && - options.tools.length === 0 + !options?.toolContext if (isCompactionCall) { compactionCalls += 1 return endTurnResponse(`summary-${compactionCalls}`) @@ -831,8 +829,7 @@ describe('session hooks & middleware', () => { const isCompactionCall = messages[0]?.role === 'system' && messages[0].content === CONTEXT_COMPACTION_SYSTEM_PROMPT && - Array.isArray(options?.tools) && - options.tools.length === 0 + !options?.toolContext if (isCompactionCall) { throw new Error('compaction failed') } @@ -921,8 +918,7 @@ describe('session hooks & middleware', () => { const isCompactionCall = messages[0]?.role === 'system' && messages[0].content === CONTEXT_COMPACTION_SYSTEM_PROMPT && - Array.isArray(options?.tools) && - options.tools.length === 0 + !options?.toolContext if (isCompactionCall) { sawCompactionCall = true return endTurnResponse('compacted summary') @@ -1009,8 +1005,7 @@ describe('session hooks & middleware', () => { const isCompactionCall = messages[0]?.role === 'system' && messages[0].content === CONTEXT_COMPACTION_SYSTEM_PROMPT && - Array.isArray(options?.tools) && - options.tools.length === 0 + !options?.toolContext if (isCompactionCall) { return endTurnResponse('summary-budget') } @@ -1054,8 +1049,7 @@ describe('session hooks & middleware', () => { const isCompactionCall = messages[0]?.role === 'system' && messages[0].content === CONTEXT_COMPACTION_SYSTEM_PROMPT && - Array.isArray(options?.tools) && - options.tools.length === 0 + !options?.toolContext if (isCompactionCall) { return endTurnResponse('internal\n\nsummary\n\n\nnext') } @@ -1092,8 +1086,7 @@ describe('session hooks & middleware', () => { const isCompactionCall = messages[0]?.role === 'system' && messages[0].content === CONTEXT_COMPACTION_SYSTEM_PROMPT && - Array.isArray(options?.tools) && - options.tools.length === 0 + !options?.toolContext if (isCompactionCall) { return endTurnResponse('new summary') } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4f93314..42898b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ /** @file Core package entry point, aggregates domain modules (config/llm/session/history/...). */ export * from './types' -export * from './tools' +// ToolRegistry/MCPServerConfig are exported from types/config; the router re-export is skipped to avoid ambiguity. +export { TOOLKIT, TOOL_LIST, NATIVE_TOOLS, type McpTool } from './tools' export * from './tools/approval' export * from './tools/orchestrator' export * from './prompt/prompt' diff --git a/packages/core/src/llm/ai_stream.ts b/packages/core/src/llm/ai_stream.ts index af29990..cd42741 100644 --- a/packages/core/src/llm/ai_stream.ts +++ b/packages/core/src/llm/ai_stream.ts @@ -1,6 +1,6 @@ /** @file Default streaming LLM call backed by AI SDK streamText. */ import { streamText, type ModelMessage, type ToolResultPart } from 'ai' -import type { LLMResult, ToolDefinition } from '@memo/core/types' +import type { LLMResult } from '@memo/core/types' import type { ToolRegistry } from '@memo/core/tools/router/types' import type { ToolExecutionContext, SdkToolSet } from '@memo/core/agent/sdk_tools' import { buildSdkTools } from '@memo/core/agent/sdk_tools' @@ -15,7 +15,6 @@ export type StreamCallLLMParams = { messages: ModelMessage[] /** Complete tool registry (native + MCP + custom); undefined disables tools (compaction). */ tools?: ToolRegistry - toolDefinitions: ToolDefinition[] profile: ModelProfile factory: AIProviderFactory toolContext?: ToolExecutionContext @@ -52,6 +51,9 @@ export async function streamCallLLM(params: StreamCallLLMParams): Promise { expect(result).toEqual({ type: 'text', value: '{"test":true}' }) }) - test('generateToolDefinitions returns all tools', () => { - const router = new ToolRouter() - router.registerNativeTool({ - name: 'test_tool', - description: 'Test tool', - source: 'native', - inputSchema: { type: 'object', properties: { foo: { type: 'string' } } }, - execute: async () => ({ type: 'text', value: '' }), - }) - - const defs = router.generateToolDefinitions() - expect(defs.length).toBeGreaterThan(0) - const testDef = defs.find((d) => d.name === 'test_tool') - expect(testDef).toBeDefined() - expect(testDef?.description).toBe('Test tool') - }) - test('generateToolDescriptions returns empty for no tools', () => { const router = new ToolRouter() expect(router.generateToolDescriptions()).toBe('') diff --git a/packages/core/src/tools/router/index.ts b/packages/core/src/tools/router/index.ts index aa6e089..5881902 100644 --- a/packages/core/src/tools/router/index.ts +++ b/packages/core/src/tools/router/index.ts @@ -102,17 +102,6 @@ export class ToolRouter { * 生成 Tool Use API 格式的工具定义列表 * @returns 工具定义数组,用于传递给 LLM API */ - generateToolDefinitions(): Array<{ - name: string - description: string - input_schema: Record - }> { - return this.getAllTools().map((tool) => ({ - name: tool.name, - description: tool.description, - input_schema: tool.inputSchema || { type: 'object', properties: {} }, - })) - } /** * 生成工具描述文本,用于注入到系统 Prompt diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e1b53f5..b678c53 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -73,17 +73,6 @@ export type TokenCounter = { dispose: () => void } -/** Tool Use Block - tool call request (internal agent-loop structure). */ -export type ToolUseBlock = { - type: 'tool_use' - /** Unique ID for the tool call */ - id: string - /** Tool name */ - name: string - /** Tool input parameters */ - input: unknown -} - /** Representation of parsed LLM output as action/final structure. */ export type ParsedAssistant = { /** Tool to call and its parameters. */ @@ -97,19 +86,10 @@ export type ParsedAssistant = { /** Tool registry: keys are tool names, values are tool definitions. */ export type ToolRegistry = Record -/** Tool definition structure (for passing to LLM API) */ -export type ToolDefinition = { - name: string - description: string - input_schema: Record -} - /** LLM call interface: input history messages, return structured response, can stream text via onChunk. */ export type CallLLMOptions = { signal?: AbortSignal - /** Available tools list (Tool Use API mode) */ - tools?: ToolDefinition[] - /** Tool execution context (approval/gate/hooks) captured by the loop. */ + /** Tool execution context (approval/gate/hooks) captured by the loop; absent disables tools. */ toolContext?: ToolExecutionContext } From 88ef1a887e4e1cd28099672e5a77332a162ee56a Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 20:34:41 +0800 Subject: [PATCH 12/42] refactor(types): align remaining custom types with AI SDK - ParsedAssistant.thinking now comes from SDK-separated reasoning (buildThinking think-tag extraction was dead code post-streaming) - api_types TokenUsageSummary aliases AI SDK LanguageModelUsage; history parser drops legacy prompt/completion compatibility reads --- packages/core/src/agent/loop.ts | 4 ++-- packages/core/src/api_types.ts | 8 +++---- .../features/history/history_parser.test.ts | 4 ++-- .../src/features/history/history_parser.ts | 23 ++++++++++--------- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 9d23999..8457173 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -1,6 +1,5 @@ /** @file Session/Turn runtime core: handles ReAct loop, tool scheduling, and event logging. */ import { randomUUID } from 'node:crypto' -import { buildThinking } from '@memo/core/utils/utils' import { buildCompactionUserPrompt, CONTEXT_COMPACTION_SYSTEM_PROMPT, @@ -615,7 +614,8 @@ export class AgentSessionImpl implements AgentSession { // parsed.action 复用单 action 结构,取首个工具作为主 action 语义。 const firstTool = toolUseBlocks[0] if (firstTool) { - const thinking = assistantText ? buildThinking([assistantText]) : undefined + // Reasoning is already separated by the AI SDK; no think-tag extraction needed. + const thinking = reasoningContent parsed = { action: { tool: firstTool.toolName, diff --git a/packages/core/src/api_types.ts b/packages/core/src/api_types.ts index cb5164a..da22535 100644 --- a/packages/core/src/api_types.ts +++ b/packages/core/src/api_types.ts @@ -25,11 +25,9 @@ export type ApiEnvelope = meta: ApiErrorMeta } -export type TokenUsageSummary = { - prompt: number - completion: number - total: number -} +/** Token usage in API DTOs (AI SDK LanguageModelUsage shape). */ +import type { LanguageModelUsage } from 'ai' +export type TokenUsageSummary = LanguageModelUsage export type ToolUsageSummary = { total: number diff --git a/packages/core/src/features/history/history_parser.test.ts b/packages/core/src/features/history/history_parser.test.ts index a6ec27b..ae2f0be 100644 --- a/packages/core/src/features/history/history_parser.test.ts +++ b/packages/core/src/features/history/history_parser.test.ts @@ -50,7 +50,7 @@ function buildSampleLog(): string { content: 'done', meta: { status: 'ok', - tokens: { prompt: 10, completion: 5, total: 15 }, + tokens: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, }, }), ].join('\n') @@ -64,7 +64,7 @@ describe('parseHistoryLogToSessionDetail', () => { assert.strictEqual(detail.turnCount, 1) assert.strictEqual(detail.toolUsage.total, 1) assert.strictEqual(detail.toolUsage.success, 1) - assert.strictEqual(detail.tokenUsage.total, 15) + assert.strictEqual(detail.tokenUsage.totalTokens, 15) assert.strictEqual(detail.turns.length, 1) assert.strictEqual(detail.turns[0]?.steps.length, 1) assert.ok(detail.summary.includes('User: hello')) diff --git a/packages/core/src/features/history/history_parser.ts b/packages/core/src/features/history/history_parser.ts index 93b9b64..06fd35b 100644 --- a/packages/core/src/features/history/history_parser.ts +++ b/packages/core/src/features/history/history_parser.ts @@ -34,9 +34,11 @@ type ParseResultState = { function defaultTokenUsage(): TokenUsageSummary { return { - prompt: 0, - completion: 0, - total: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, } } @@ -177,14 +179,13 @@ function parseEventLine(line: string, index: number): SessionEventItem | null { function accumulateTokenUsage(target: TokenUsageSummary, source: Record | undefined): void { if (!source) return - // meta.tokens now uses AI SDK LanguageModelUsage shape; keep reading legacy prompt/completion fields too. - const prompt = asNumber(source.inputTokens) ?? asNumber(source.prompt) - const completion = asNumber(source.outputTokens) ?? asNumber(source.completion) - const total = asNumber(source.totalTokens) ?? asNumber(source.total) - - if (prompt !== null) target.prompt += Math.floor(prompt) - if (completion !== null) target.completion += Math.floor(completion) - if (total !== null) target.total += Math.floor(total) + const inputTokens = asNumber(source.inputTokens) + const outputTokens = asNumber(source.outputTokens) + const totalTokens = asNumber(source.totalTokens) + + if (inputTokens !== null) target.inputTokens = (target.inputTokens ?? 0) + Math.floor(inputTokens) + if (outputTokens !== null) target.outputTokens = (target.outputTokens ?? 0) + Math.floor(outputTokens) + if (totalTokens !== null) target.totalTokens = (target.totalTokens ?? 0) + Math.floor(totalTokens) } function normalizeFinalStatus(raw: string | undefined): SessionRuntimeStatus { From 714ef6edd09f13959afd4af1804a0a01bc4227cc Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 20:39:50 +0800 Subject: [PATCH 13/42] refactor(types): remove dead API DTOs from api_types.ts ApiEnvelope/ApiError*, WsServerEvent/LiveSessionState/QueuedInputItem, Workspace* and SessionRuntimeBadge were planned web-console API types with zero references (no HTTP/WS server exists). Kept the history-viewing DTOs (SessionListItem/SessionDetail/...) and admin records (SkillRecord/ McpServerRecord) that are actually consumed. --- packages/core/src/api_types.ts | 173 --------------------------------- 1 file changed, 173 deletions(-) diff --git a/packages/core/src/api_types.ts b/packages/core/src/api_types.ts index da22535..aa7a163 100644 --- a/packages/core/src/api_types.ts +++ b/packages/core/src/api_types.ts @@ -1,32 +1,3 @@ -export type ApiSuccessMeta = { - requestId: string - timestamp: string -} - -export type ApiErrorInfo = { - code: string - message: string - details?: unknown -} - -export type ApiErrorMeta = ApiSuccessMeta & { - path?: string -} - -export type ApiEnvelope = - | { - success: true - data: T - meta: ApiSuccessMeta - } - | { - success: false - error: ApiErrorInfo - meta: ApiErrorMeta - } - -/** Token usage in API DTOs (AI SDK LanguageModelUsage shape). */ -import type { LanguageModelUsage } from 'ai' export type TokenUsageSummary = LanguageModelUsage export type ToolUsageSummary = { @@ -117,122 +88,6 @@ export type SessionEventsResponse = { nextCursor: string | null } -export type QueuedInputItem = { - id: string - input: string - createdAt: string -} - -export type LiveSessionState = { - id: string - title: string - workspaceId: string - projectName: string - providerName: string - model: string - cwd: string - startedAt: string - status: 'idle' | 'running' | 'closed' - pendingApproval?: { - fingerprint: string - toolName: string - reason: string - riskLevel: string - params: unknown - } - activeMcpServers: string[] - toolPermissionMode: 'none' | 'once' | 'full' - queuedInputs: QueuedInputItem[] - currentContextTokens?: number - contextWindow?: number -} - -export type WsServerEvent = - | { type: 'session.snapshot'; payload: LiveSessionState } - | { - type: 'turn.start' - payload: { turn: number; input: string; promptTokens?: number } - } - | { - type: 'assistant.chunk' - payload: { turn: number; step: number; chunk: string } - } - | { - type: 'context.usage' - payload: { - turn: number - step: number - phase: 'turn_start' | 'step_start' | 'post_compact' - promptTokens: number - contextWindow: number - thresholdTokens: number - usagePercent: number - } - } - | { - type: 'tool.action' - payload: { - turn: number - step: number - action: { tool: string; input: unknown } - parallelActions?: Array<{ tool: string; input: unknown }> - thinking?: string - } - } - | { - type: 'tool.observation' - payload: { - turn: number - step: number - observation: string - resultStatus?: string - parallelResultStatuses?: string[] - } - } - | { - type: 'turn.final' - payload: { - turn: number - step?: number - finalText: string - status: string - errorMessage?: string - turnUsage?: TokenUsageSummary - tokenUsage?: TokenUsageSummary - } - } - | { - type: 'approval.request' - payload: { - fingerprint: string - toolName: string - reason: string - riskLevel: string - params: unknown - } - } - | { - type: 'session.status' - payload: { - status: 'idle' | 'running' | 'closed' - } - } - | { - type: 'system.message' - payload: { - title: string - content: string - tone?: 'info' | 'warning' | 'error' - } - } - | { - type: 'error' - payload: { - code: string - message: string - } - } - export type SkillRecord = { id: string name: string @@ -248,31 +103,3 @@ export type McpServerRecord = { authStatus: 'unsupported' | 'not_logged_in' | 'bearer_token' | 'oauth' active: boolean } - -export type WorkspaceRecord = { - id: string - name: string - cwd: string - createdAt: string - lastUsedAt: string -} - -export type WorkspaceDirEntry = { - name: string - path: string - kind: 'dir' - readable: boolean -} - -export type WorkspaceFsListResult = { - path: string - parentPath: string | null - items: WorkspaceDirEntry[] -} - -export type SessionRuntimeBadge = { - sessionId: string - status: 'idle' | 'running' | 'closed' - workspaceId: string - updatedAt: string -} From 538963e5e7349a14616d539856fc97dc2bfa17f0 Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 20:55:41 +0800 Subject: [PATCH 14/42] refactor(tokenizer): replace tiktoken with byte-based estimation Drop @dqbd/tiktoken in favor of the codex-style byte heuristic (ceil(utf8 bytes / 4)) for prompt size estimation. Exact token counts come from API usage (LanguageModelUsage); the estimator only drives compaction triggers, context overflow checks, and message retention budgets. Also fix api_types.ts missing import and TUI historyParser reading stale LanguageModelUsage field names. --- package.json | 1 - packages/core/src/agent/defaults.ts | 2 +- .../agent/defaults.with_default_deps.test.ts | 16 +- packages/core/src/agent/loop.ts | 5 +- packages/core/src/agent/session_hooks.test.ts | 44 ++-- packages/core/src/api_types.ts | 2 + packages/core/src/prompt/memory.test.ts | 14 +- packages/core/src/types.ts | 8 +- packages/core/src/utils/tokenizer.test.ts | 230 +++--------------- packages/core/src/utils/tokenizer.ts | 100 ++------ .../tui/src/features/session/historyParser.ts | 14 +- pnpm-lock.yaml | 8 - 12 files changed, 94 insertions(+), 350 deletions(-) diff --git a/package.json b/package.json index 48fd35b..6657e1a 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "vitest": "^2.1.8" }, "dependencies": { - "@dqbd/tiktoken": "^1.0.22", "@inkjs/ui": "^2.0.0", "@modelcontextprotocol/sdk": "^1.24.3", "@mozilla/readability": "^0.6.0", diff --git a/packages/core/src/agent/defaults.ts b/packages/core/src/agent/defaults.ts index bd35ee5..a6ed419 100644 --- a/packages/core/src/agent/defaults.ts +++ b/packages/core/src/agent/defaults.ts @@ -141,7 +141,7 @@ export async function withDefaultDeps( }), loadPrompt, historySinks: deps.historySinks ?? [defaultHistorySink], - tokenCounter: deps.tokenCounter ?? createTokenCounter(options.tokenizerModel), + tokenCounter: deps.tokenCounter ?? createTokenCounter(), historyFilePath: historyFilePath, } } diff --git a/packages/core/src/agent/defaults.with_default_deps.test.ts b/packages/core/src/agent/defaults.with_default_deps.test.ts index 75bd688..6479ced 100644 --- a/packages/core/src/agent/defaults.with_default_deps.test.ts +++ b/packages/core/src/agent/defaults.with_default_deps.test.ts @@ -116,14 +116,12 @@ vi.mock('@memo/core/prompt/prompt', () => ({ })) vi.mock('@memo/core/utils/tokenizer', () => ({ - createTokenCounter: vi.fn((model?: string) => { - state.createTokenCounterCalls.push(model) + createTokenCounter: vi.fn(() => { + state.createTokenCounterCalls.push(undefined) return { - model: model ?? 'mock-tokenizer', countText: (text: string) => text.length, countMessages: (messages: Array<{ content: string }>) => messages.reduce((sum, message) => sum + message.content.length, 0), - dispose: vi.fn(), } }), })) @@ -193,15 +191,11 @@ describe('withDefaultDeps (default path)', () => { test('builds default deps with injected tool descriptions and default sinks', async () => { const { withDefaultDeps } = await import('@memo/core/agent/defaults') - const resolved = await withDefaultDeps( - {}, - { tokenizerModel: 'counter-model' } as AgentSessionOptions, - 'session-1', - ) + const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-1') expect(state.loadMcpServersCalls).toHaveLength(1) expect(state.historySinkPaths).toEqual([state.sessionPath]) - expect(state.createTokenCounterCalls).toEqual(['counter-model']) + expect(state.createTokenCounterCalls).toEqual([undefined]) expect(resolved.historyFilePath).toBe(state.sessionPath) const prompt = await resolved.loadPrompt() @@ -220,11 +214,9 @@ describe('withDefaultDeps (default path)', () => { })) const historySinks = [{ append: vi.fn() }] const tokenCounter = { - model: 'custom-counter', countText: (text: string) => text.length, countMessages: (messages: Array<{ content: string }>) => messages.reduce((sum, message) => sum + message.content.length, 0), - dispose: vi.fn(), } const dispose = vi.fn(async () => {}) diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 8457173..ed2b595 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -270,7 +270,8 @@ export class AgentSessionImpl implements AgentSession { } if (remaining > 0) { - selected.push(message.slice(0, remaining)) + // 4 chars ≈ 1 token (ASCII); CJK overshoots to ~3x the byte budget, which is acceptable for the compaction request. + selected.push(message.slice(0, remaining * 4)) } break } @@ -428,7 +429,6 @@ export class AgentSessionImpl implements AgentSession { meta: { mode: this.mode, cwd: this.resolveSessionCwd(), - tokenizer: this.tokenCounter.model, warnPromptTokens: this.options.warnPromptTokens, contextWindow, autoCompactThresholdPercent, @@ -1004,7 +1004,6 @@ export class AgentSessionImpl implements AgentSession { } } } - this.tokenCounter.dispose() // 清理所有授权 this.approvalManager.dispose() if (this.deps.dispose) { diff --git a/packages/core/src/agent/session_hooks.test.ts b/packages/core/src/agent/session_hooks.test.ts index 09a7551..7e86b8b 100644 --- a/packages/core/src/agent/session_hooks.test.ts +++ b/packages/core/src/agent/session_hooks.test.ts @@ -119,10 +119,8 @@ function endTurnResponse(text: string = 'done'): LLMResult { function createLengthTokenCounter(): TokenCounter { return { - model: 'test-length-counter', countText: (text: string) => text.length, countMessages: (messages) => messages.reduce((sum, message) => sum + message.content.length, 0), - dispose: () => {}, } } @@ -181,7 +179,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), // 自动批准所有工具调用 requestApproval: async () => 'once', hooks: { @@ -246,7 +244,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), // 自动批准所有工具调用 requestApproval: async () => 'once', hooks: { @@ -279,7 +277,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', }, {}, @@ -305,7 +303,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), // 自动批准所有工具调用 requestApproval: async () => 'session', }, @@ -330,7 +328,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'deny', }, { dangerous: true }, @@ -355,7 +353,7 @@ describe('session hooks & middleware', () => { tools: { read_note: readNoteTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => { approvalAsked = true return 'deny' @@ -383,7 +381,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', }, { toolPermissionMode: 'none' }, @@ -417,7 +415,7 @@ describe('session hooks & middleware', () => { { callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', }, {}, @@ -445,7 +443,7 @@ describe('session hooks & middleware', () => { }, }, ], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', }, {}, @@ -488,7 +486,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', }, {}, @@ -549,7 +547,7 @@ describe('session hooks & middleware', () => { }, }, ], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'deny', }, {}, @@ -585,7 +583,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'deny', }, {}, @@ -624,7 +622,7 @@ describe('session hooks & middleware', () => { }, }, ], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', }, {}, @@ -662,7 +660,7 @@ describe('session hooks & middleware', () => { { callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', }, {}, @@ -687,7 +685,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', }, {}, @@ -710,7 +708,7 @@ describe('session hooks & middleware', () => { tools: { echo: echoTool }, callLLM: async () => outputs.shift() ?? endTurnResponse('done'), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), requestApproval: async () => 'once', hooks: { onContextUsage: ({ phase, step }) => { @@ -998,7 +996,8 @@ describe('session hooks & middleware', () => { }) test('manual compaction keeps recent user context within token budget', async () => { - const hugeUserMessage = 'a'.repeat(25_000) + // 90_000 ASCII chars ≈ 22_500 tokens (4 chars/token estimate), exceeding the 20_000 budget. + const hugeUserMessage = 'a'.repeat(90_000) const session = await createAgentSession( { callLLM: async (messages, _onChunk, options) => { @@ -1029,7 +1028,8 @@ describe('session hooks & middleware', () => { const retainedUserMessage = session.history[1] assert.strictEqual(retainedUserMessage?.role, 'user') - assert.strictEqual(retainedUserMessage?.content.length, 20_000) + // Truncated to the 20_000-token budget (× 4 chars/token). + assert.strictEqual(retainedUserMessage?.content.length, 80_000) const retainedContent = retainedUserMessage?.content assert.ok(typeof retainedContent === 'string' && retainedContent.startsWith('a')) @@ -1302,7 +1302,7 @@ describe('session hooks & middleware', () => { }, }, ], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), hooks: { onTitleGenerated: ({ title }) => { generatedTitles.push(title) @@ -1341,7 +1341,7 @@ describe('session hooks & middleware', () => { }, }, ], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), }, {}, ) diff --git a/packages/core/src/api_types.ts b/packages/core/src/api_types.ts index aa7a163..34ec597 100644 --- a/packages/core/src/api_types.ts +++ b/packages/core/src/api_types.ts @@ -1,3 +1,5 @@ +import type { LanguageModelUsage } from 'ai' + export type TokenUsageSummary = LanguageModelUsage export type ToolUsageSummary = { diff --git a/packages/core/src/prompt/memory.test.ts b/packages/core/src/prompt/memory.test.ts index 577615e..0e82efc 100644 --- a/packages/core/src/prompt/memory.test.ts +++ b/packages/core/src/prompt/memory.test.ts @@ -57,7 +57,7 @@ describe('runtime prompt injection', () => { toolResults: [], }), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), }, { mode: 'interactive' }, ) @@ -81,7 +81,7 @@ describe('runtime prompt injection', () => { toolResults: [], }), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), }, { mode: 'interactive' }, ) @@ -113,7 +113,7 @@ describe('runtime prompt injection', () => { }), loadPrompt: () => loadSystemPrompt({ cwd: projectRoot }), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), }, { mode: 'interactive' }, ) @@ -149,7 +149,7 @@ describe('runtime prompt injection', () => { }), loadPrompt: () => loadSystemPrompt({ cwd: projectRoot, memoHome: tempHome }), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), }, { mode: 'interactive' }, ) @@ -206,7 +206,7 @@ description: ${marker} memoHome: join(projectRoot, '.memo'), }), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), }, { mode: 'interactive' }, ) @@ -271,7 +271,7 @@ description: disabled marker activeSkillPaths: [enabledPath], }), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), }, { mode: 'interactive' }, ) @@ -319,7 +319,7 @@ name: broken-skill memoHome: join(projectRoot, '.memo'), }), historySinks: [], - tokenCounter: createTokenCounter('cl100k_base'), + tokenCounter: createTokenCounter(), }, { mode: 'interactive' }, ) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b678c53..fbbdd4d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -61,16 +61,12 @@ export type CompactResult = { errorMessage?: string } -/** Unified tokenizer counter interface compatible with different model encodings. */ +/** Unified token counter interface for prompt size estimation. */ export type TokenCounter = { - /** Actual tokenizer/encoding name used. */ - model: string /** Count tokens for plain text. */ countText: (text: string) => number /** Count tokens for message arrays. */ countMessages: (messages: ChatMessage[]) => number - /** Release underlying resources. */ - dispose: () => void } /** Representation of parsed LLM output as action/final structure. */ @@ -139,8 +135,6 @@ export type AgentSessionOptions = { historyDir?: string /** Specify provider name to use. */ providerName?: string - /** Tokenizer encoding name, default cl100k_base. */ - tokenizerModel?: string /** Working directory used by prompt/tool runtime for this session. */ cwd?: string /** Prompt warning threshold. */ diff --git a/packages/core/src/utils/tokenizer.test.ts b/packages/core/src/utils/tokenizer.test.ts index be78bfd..bfcec89 100644 --- a/packages/core/src/utils/tokenizer.test.ts +++ b/packages/core/src/utils/tokenizer.test.ts @@ -1,251 +1,99 @@ -import { describe, expect, test, beforeEach, afterEach } from 'vitest' +import { describe, expect, test } from 'vitest' import { createTokenCounter } from '@memo/core/utils/tokenizer' import type { ChatMessage } from '@memo/core/types' describe('createTokenCounter', () => { - test('creates counter with default model', () => { + test('creates counter with countText/countMessages', () => { const counter = createTokenCounter() - expect(counter.model).toBe('cl100k_base') expect(typeof counter.countText).toBe('function') expect(typeof counter.countMessages).toBe('function') - expect(typeof counter.dispose).toBe('function') - counter.dispose() - }) - - test('creates counter with specified model', () => { - const counter = createTokenCounter('gpt-4') - expect(counter.model).toBe('gpt-4') - counter.dispose() - }) - - test('falls back to cl100k_base for unknown models', () => { - const counter = createTokenCounter('unknown-model-x') - expect(counter.model).toBe('cl100k_base') - counter.dispose() - }) - - test('trims whitespace in model name', () => { - const counter = createTokenCounter(' gpt-4 ') - expect(counter.model).toBe('gpt-4') - counter.dispose() }) describe('countText', () => { - let counter: ReturnType - - beforeEach(() => { - counter = createTokenCounter() - }) - - afterEach(() => { - counter.dispose() - }) + const counter = createTokenCounter() test('returns 0 for empty string', () => { expect(counter.countText('')).toBe(0) }) - test('counts tokens for simple text', () => { - const count = counter.countText('Hello world') - expect(count).toBeGreaterThan(0) + test('estimates ~4 ASCII bytes per token', () => { + expect(counter.countText('hello')).toBe(2) // 5 bytes → ceil(5/4) + expect(counter.countText('Hello world')).toBe(3) // 11 bytes → ceil(11/4) }) - test('counts tokens for longer text', () => { + test('estimates CJK chars (~3 utf8 bytes each)', () => { + expect(counter.countText('你好')).toBe(2) // 6 bytes → ceil(6/4) + expect(counter.countText('中文测试')).toBe(3) // 12 bytes → ceil(12/4) + }) + + test('counts longer text more than short text', () => { const short = counter.countText('Hi') const long = counter.countText('Hello, this is a longer text with more words.') expect(long).toBeGreaterThan(short) }) - test('counts tokens for special characters', () => { - const count = counter.countText('Hello\nWorld\t!\n\n') - expect(count).toBeGreaterThan(0) - }) - - test('counts tokens for unicode text', () => { - const count = counter.countText('你好世界 Hello World 🌍') - expect(count).toBeGreaterThan(0) - }) - - test('counts tokens for JSON strings', () => { - const json = JSON.stringify({ key: 'value', nested: { a: 1, b: 2 } }) - const count = counter.countText(json) - expect(count).toBeGreaterThan(0) + test('counts unicode and special characters', () => { + expect(counter.countText('你好世界 Hello World 🌍')).toBeGreaterThan(0) + expect(counter.countText('Hello\nWorld\t!\n\n')).toBeGreaterThan(0) }) }) describe('countMessages', () => { - let counter: ReturnType - - beforeEach(() => { - counter = createTokenCounter() - }) - - afterEach(() => { - counter.dispose() - }) + const counter = createTokenCounter() test('returns 0 for empty array', () => { expect(counter.countMessages([])).toBe(0) }) - test('counts system message', () => { - const messages: ChatMessage[] = [{ role: 'system', content: 'You are a helpful assistant.' }] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) - }) - - test('counts user message', () => { - const messages: ChatMessage[] = [{ role: 'user', content: 'Hello there!' }] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) - }) - - test('counts assistant message', () => { - const messages: ChatMessage[] = [{ role: 'assistant', content: 'Hi! How can I help?' }] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) - }) - - test('counts tool message', () => { + test('sums JSON-serialized per-message byte estimates', () => { const messages: ChatMessage[] = [ - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'call-123', - toolName: 'test_tool', - output: { type: 'text', value: 'Tool execution result' }, - }, - ], - }, + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Hello there!' }, ] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) + const expected = messages.reduce((sum, message) => sum + counter.countText(JSON.stringify(message)), 0) + expect(counter.countMessages(messages)).toBe(expected) }) - test('counts multiple messages', () => { + test('counts multiple messages more than a single message', () => { const messages: ChatMessage[] = [ { role: 'system', content: 'System prompt' }, { role: 'user', content: 'User message' }, { role: 'assistant', content: 'Assistant response' }, ] - const firstMsg = messages[0] - if (firstMsg) { - const single = counter.countMessages([firstMsg]) - const multiple = counter.countMessages(messages) - expect(multiple).toBeGreaterThan(single) - } - }) - - test('includes assistant priming tokens', () => { - const messages: ChatMessage[] = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi' }, - ] - const count = counter.countMessages(messages) - const withoutAssistant = counter.countMessages([{ role: 'user', content: 'Hello' }]) - expect(count).toBeGreaterThan(withoutAssistant) + const single = counter.countMessages([messages[0]!]) + expect(counter.countMessages(messages)).toBeGreaterThan(single) }) - test('counts tool_calls in assistant message', () => { - const messagesWithToolCalls: ChatMessage[] = [ + test('includes structured parts (tool-call/reasoning) via JSON serialization', () => { + const withParts: ChatMessage[] = [ { role: 'assistant', content: [ { type: 'text', text: 'Let me check' }, + { type: 'reasoning', text: 'I should inspect file A before acting.' }, { type: 'tool-call', toolCallId: 'call-1', toolName: 'read_file', input: { path: 'test.txt' } }, ], }, ] - const messagesWithoutToolCalls: ChatMessage[] = [{ role: 'assistant', content: 'Let me check' }] - const withCalls = counter.countMessages(messagesWithToolCalls) - const withoutCalls = counter.countMessages(messagesWithoutToolCalls) - expect(withCalls).toBeGreaterThan(withoutCalls) + const withoutParts: ChatMessage[] = [{ role: 'assistant', content: 'Let me check' }] + expect(counter.countMessages(withParts)).toBeGreaterThan(counter.countMessages(withoutParts)) }) - test('counts reasoning_content in assistant message', () => { - const toolCallParts = [ - { - type: 'tool-call' as const, - toolCallId: 'call-1', - toolName: 'read_file', - input: { path: 'README.md' }, - }, - ] - const messagesWithReasoning: ChatMessage[] = [ - { - role: 'assistant', - content: [ - { type: 'reasoning', text: 'I should inspect file A before using read_file.' }, - ...toolCallParts, - ], - }, - ] - const messagesWithoutReasoning: ChatMessage[] = [ - { - role: 'assistant', - content: toolCallParts, - }, - ] - const withReasoning = counter.countMessages(messagesWithReasoning) - const withoutReasoning = counter.countMessages(messagesWithoutReasoning) - expect(withReasoning).toBeGreaterThan(withoutReasoning) - }) - - test('includes tool_call_id in tool message counting', () => { + test('counts tool result messages', () => { const messages: ChatMessage[] = [ { role: 'tool', content: [ { type: 'tool-result', - toolCallId: 'call-abc123', - toolName: '', - output: { type: 'text', value: 'Result' }, - }, - ], - }, - ] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) - }) - - test('includes name field in tool message counting', () => { - const messages: ChatMessage[] = [ - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'call-1', - toolName: 'my_tool', - output: { type: 'text', value: 'Result' }, + toolCallId: 'call-123', + toolName: 'test_tool', + output: { type: 'text', value: 'Tool execution result' }, }, ], }, ] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) - }) - - test('handles assistant message with only tool_calls and empty content', () => { - const messages: ChatMessage[] = [ - { - role: 'assistant', - content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'test', input: {} }], - }, - ] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) - }) - - test('includes name overhead when message has name field', () => { - const msg: ChatMessage & { name?: string } = { role: 'system', content: 'Test' } - msg.name = 'custom_name' - const messages: ChatMessage[] = [msg] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) + expect(counter.countMessages(messages)).toBeGreaterThan(0) }) test('counts complex conversation', () => { @@ -283,15 +131,7 @@ describe('createTokenCounter', () => { content: 'I have created the add.js file with the function.', }, ] - const count = counter.countMessages(messages) - expect(count).toBeGreaterThan(0) - }) - }) - - describe('dispose', () => { - test('disposes counter without error', () => { - const counter = createTokenCounter() - counter.dispose() + expect(counter.countMessages(messages)).toBeGreaterThan(0) }) }) }) diff --git a/packages/core/src/utils/tokenizer.ts b/packages/core/src/utils/tokenizer.ts index 8bb340d..98a50af 100644 --- a/packages/core/src/utils/tokenizer.ts +++ b/packages/core/src/utils/tokenizer.ts @@ -1,93 +1,31 @@ -/** @file tiktoken wrapper for token estimation. Used for compaction triggering, context overflow checks, and tool result sizing — not for usage reporting. */ -import { encoding_for_model, get_encoding, type Tiktoken } from '@dqbd/tiktoken' +/** @file Byte-based token estimation (codex-style: ceil(utf8 bytes / 4)). Used for compaction triggering and context overflow checks — not for usage reporting. */ import type { ChatMessage, TokenCounter } from '@memo/core/types' -const DEFAULT_TOKENIZER_MODEL = 'cl100k_base' +// OpenAI's common approximation: 1 token ≈ 4 bytes (UTF-8). +// CJK chars are ~3 bytes each, so the estimate stays within range for Chinese too. +const BYTES_PER_TOKEN = 4 -type EncodingFactory = () => Tiktoken +const encoder = new TextEncoder() -function safeEncodingFactory(model?: string): { model: string; factory: EncodingFactory } { - const resolvedModel = model?.trim() || DEFAULT_TOKENIZER_MODEL - try { - // encoding_for_model requires strict model names; using type assertion for dynamic input compatibility. - const factory = () => encoding_for_model(resolvedModel as any) - factory().free() - return { model: resolvedModel, factory } - } catch { - // Fallback to generic cl100k_base for unknown models to avoid throwing. - const fallbackModel = DEFAULT_TOKENIZER_MODEL - const factory = () => get_encoding(fallbackModel) - factory().free() - return { model: fallbackModel, factory } - } +/** Rough token count for plain text: ceil(utf8 bytes / 4). Coarse estimate, not tokenizer-accurate. */ +function approxTokenCount(text: string): number { + if (!text) return 0 + return Math.ceil(encoder.encode(text).length / BYTES_PER_TOKEN) } -function messagePayloadForCounting(message: ChatMessage): string { - if (message.role === 'assistant') { - const parts = Array.isArray(message.content) ? message.content : [] - const text = - typeof message.content === 'string' - ? message.content - : parts - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join('') - const reasoning = parts - .filter((part) => part.type === 'reasoning') - .map((part) => part.text) - .join('\n') - const toolCalls = parts.filter((part) => part.type === 'tool-call') - const reasoningSuffix = reasoning ? `\n${reasoning}` : '' - if (toolCalls.length) { - return `${text}${reasoningSuffix}\n${JSON.stringify(toolCalls)}` - } - return `${text}${reasoningSuffix}` - } - if (message.role === 'tool') { - const part = Array.isArray(message.content) ? message.content[0] : undefined - const text = part?.type === 'tool-result' && part.output.type === 'text' ? part.output.value : '' - const toolCallId = part?.type === 'tool-result' ? part.toolCallId : '' - const toolName = part?.type === 'tool-result' ? part.toolName : '' - return `${text}\n${toolCallId}\n${toolName}` +/** Rough token count for a message array: sum of JSON-serialized per-message byte estimates (includes structure overhead). */ +function approxMessageTokenCount(messages: ChatMessage[]): number { + let total = 0 + for (const message of messages) { + total += approxTokenCount(JSON.stringify(message)) } - return typeof message.content === 'string' ? message.content : '' + return total } -/** Create a reusable token counter for prompt size estimation (compaction trigger, context overflow check). */ -export function createTokenCounter(model?: string): TokenCounter { - const { model: resolvedModel, factory } = safeEncodingFactory(model) - const encoding = factory() - - // ChatML rough estimation: each message includes role/name wrapping overhead - // Reference OpenAI's common estimates for gpt-3.5/4: about 4 tokens per message, plus 2 tokens for assistant priming. - const TOKENS_PER_MESSAGE = 4 - const TOKENS_FOR_ASSISTANT_PRIMING = 2 - const TOKENS_PER_NAME = 1 - - const countText = (text: string) => { - if (!text) return 0 - return encoding.encode(text).length - } - - const countMessages = (messages: ChatMessage[]) => { - if (!messages.length) return 0 - let total = 0 - for (const message of messages) { - total += TOKENS_PER_MESSAGE - total += countText(messagePayloadForCounting(message)) - // Currently not using message.name, but add overhead when name field is reserved - if ((message as any).name) { - total += TOKENS_PER_NAME - } - } - total += TOKENS_FOR_ASSISTANT_PRIMING - return total - } - +/** Create a token counter for prompt size estimation (compaction trigger, context overflow check). */ +export function createTokenCounter(): TokenCounter { return { - model: resolvedModel, - countText, - countMessages, - dispose: () => encoding.free(), + countText: approxTokenCount, + countMessages: approxMessageTokenCount, } } diff --git a/packages/tui/src/features/session/historyParser.ts b/packages/tui/src/features/session/historyParser.ts index 0eddd31..25f3f9e 100644 --- a/packages/tui/src/features/session/historyParser.ts +++ b/packages/tui/src/features/session/historyParser.ts @@ -46,19 +46,7 @@ function toTurnView(turn: SessionTurnDetail, sequence: number, turnIndex: number })), status: normalizeTurnStatus(turn.status), errorMessage: turn.errorMessage, - tokenUsage: turn.tokenUsage - ? { - inputTokens: turn.tokenUsage.prompt, - outputTokens: turn.tokenUsage.completion, - totalTokens: turn.tokenUsage.total, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, - } - : undefined, + tokenUsage: turn.tokenUsage, finalText: toAssistantText(turn), sequence, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96bbbc3..60058eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@dqbd/tiktoken': - specifier: ^1.0.22 - version: 1.0.22 '@inkjs/ui': specifier: ^2.0.0 version: 2.0.0(ink@6.7.0(@types/react@19.2.14)(react@19.2.4)) @@ -272,9 +269,6 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@dqbd/tiktoken@1.0.22': - resolution: {integrity: sha512-RYhO8xeHkMNX5Ixqf4M1Ve3siCYJY/dI0yLnlX4M4oIEDOvjMIQ+E+3OUpAaZcWTaMtQJzGcDAghYfllpx3i/w==} - '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -2870,8 +2864,6 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@dqbd/tiktoken@1.0.22': {} - '@esbuild/aix-ppc64@0.21.5': optional: true From 7459f7910bb45669f6241458faca862a8977703c Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 23:38:31 +0800 Subject: [PATCH 15/42] feat(core): support runtime thinking override Pass a per-turn thinking override into the LLM call and thread reasoningContent through tool call and final step events. Adds AgentSession.setThinking() for the TUI's thinking toggle. --- packages/core/src/agent/defaults.ts | 1 + packages/core/src/agent/loop.ts | 15 ++++++++-- packages/core/src/llm/ai_provider.test.ts | 35 +++++++++++++++++++++++ packages/core/src/llm/ai_provider.ts | 18 ++++++++++-- packages/core/src/llm/ai_stream.ts | 6 ++-- packages/core/src/types.ts | 8 ++++++ 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/core/src/agent/defaults.ts b/packages/core/src/agent/defaults.ts index a6ed419..c71714a 100644 --- a/packages/core/src/agent/defaults.ts +++ b/packages/core/src/agent/defaults.ts @@ -135,6 +135,7 @@ export async function withDefaultDeps( profile: modelProfile, factory: getProviderFactory(provider), toolContext: callOptions?.toolContext, + thinking: callOptions?.thinking, onChunk, signal: callOptions?.signal, }) diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index ed2b595..afd9427 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -68,6 +68,8 @@ export class AgentSessionImpl implements AgentSession { private approvalManager: ApprovalManager private toolsDisabled = false private toolPermissionMode: ToolPermissionMode | 'auto' = 'auto' + /** Thinking override; undefined follows the provider model profile. */ + private thinkingOverride: boolean | undefined constructor( private deps: AgentSessionDeps & { @@ -93,6 +95,12 @@ export class AgentSessionImpl implements AgentSession { dangerous: resolvedPermission.dangerous, mode: resolvedPermission.approvalMode, }) + this.thinkingOverride = options.thinking + } + + /** 运行时切换思考模式(undefined 恢复为跟随模型 profile)。 */ + setThinking(enabled: boolean): void { + this.thinkingOverride = enabled } /** 初始化:延迟写入 session_start,避免空会话落盘。 */ @@ -542,7 +550,7 @@ export class AgentSessionImpl implements AgentSession { } this.deps.onAssistantStep?.(chunk, step) }, - { signal: abortController.signal, toolContext }, + { signal: abortController.signal, toolContext, thinking: this.thinkingOverride }, ) const normalized = normalizeLLMResponse(llmResult) assistantText = normalized.textContent @@ -637,7 +645,7 @@ export class AgentSessionImpl implements AgentSession { parsed = {} } } else if (assistantText) { - parsed = { final: assistantText } + parsed = { final: assistantText, thinking: reasoningContent } assistantHistoryMessage = { role: 'assistant', content: [ @@ -680,6 +688,7 @@ export class AgentSessionImpl implements AgentSession { protocol_violation_count: textToolCall ? protocolViolationCount + 1 : protocolViolationCount || undefined, + thinking: reasoningContent, }, }) @@ -896,6 +905,7 @@ export class AgentSessionImpl implements AgentSession { meta: { tokens: stepUsage, fallback_from_previous_text: shouldFallbackFromPreviousText || undefined, + thinking: reasoningContent, }, }) await runHook(this.hooks, 'onFinal', { @@ -907,6 +917,7 @@ export class AgentSessionImpl implements AgentSession { tokenUsage: stepUsage, turnUsage: { ...turnUsage }, steps, + thinking: reasoningContent, }) break } diff --git a/packages/core/src/llm/ai_provider.test.ts b/packages/core/src/llm/ai_provider.test.ts index d966a05..7704078 100644 --- a/packages/core/src/llm/ai_provider.test.ts +++ b/packages/core/src/llm/ai_provider.test.ts @@ -84,4 +84,39 @@ describe('buildProviderOptions', () => { parallel_tool_calls: true, }) }) + + test('thinking passthrough when reasoning content supported', () => { + const factory = getProviderFactory({ name: 'deepseek' }) + expect(factory.buildProviderOptions({ ...PROFILE, supportsReasoningContent: true })).toEqual({ + thinking: { type: 'enabled' }, + }) + }) + + test('combines parallel_tool_calls and thinking', () => { + const factory = getProviderFactory({ name: 'deepseek' }) + expect( + factory.buildProviderOptions({ + ...PROFILE, + supportsParallelToolCalls: true, + supportsReasoningContent: true, + }), + ).toEqual({ + parallel_tool_calls: true, + thinking: { type: 'enabled' }, + }) + }) + + test('thinking=true enables reasoning regardless of profile', () => { + const factory = getProviderFactory({ name: 'deepseek' }) + expect(factory.buildProviderOptions(PROFILE, true)).toEqual({ + thinking: { type: 'enabled' }, + }) + }) + + test('thinking=false sends explicit disabled (deepseek defaults to thinking)', () => { + const factory = getProviderFactory({ name: 'deepseek' }) + expect(factory.buildProviderOptions({ ...PROFILE, supportsReasoningContent: true }, false)).toEqual({ + thinking: { type: 'disabled' }, + }) + }) }) diff --git a/packages/core/src/llm/ai_provider.ts b/packages/core/src/llm/ai_provider.ts index 02bcc10..f418621 100644 --- a/packages/core/src/llm/ai_provider.ts +++ b/packages/core/src/llm/ai_provider.ts @@ -14,8 +14,9 @@ export type AIProviderFactory = { /** * Request-level providerOptions for non-standard wire fields. * Keyed by the provider instance name (config.name) inside streamCallLLM. + * `thinking` overrides the profile flag (undefined follows profile.supportsReasoningContent). */ - buildProviderOptions: (profile: ModelProfile) => Record | undefined + buildProviderOptions: (profile: ModelProfile, thinking?: boolean) => Record | undefined } function openAICompatibleFactory(defaultBaseURL?: string): AIProviderFactory { @@ -30,8 +31,19 @@ function openAICompatibleFactory(defaultBaseURL?: string): AIProviderFactory { // Stream usage back through stream_options.include_usage. includeUsage: true, }), - buildProviderOptions: (profile) => - profile.supportsParallelToolCalls ? { parallel_tool_calls: true } : undefined, + buildProviderOptions: (profile, thinking) => { + // Non-standard deepseek fields passed through under the provider name: + // parallel_tool_calls, thinking (reasoning toggle). + // deepseek defaults to thinking; disabling requires an explicit `disabled` value (codex-style). + const options: Record = {} + if (profile.supportsParallelToolCalls) options.parallel_tool_calls = true + if (thinking === false) { + options.thinking = { type: 'disabled' } + } else if (thinking ?? profile.supportsReasoningContent) { + options.thinking = { type: 'enabled' } + } + return Object.keys(options).length > 0 ? options : undefined + }, } } diff --git a/packages/core/src/llm/ai_stream.ts b/packages/core/src/llm/ai_stream.ts index cd42741..f44573f 100644 --- a/packages/core/src/llm/ai_stream.ts +++ b/packages/core/src/llm/ai_stream.ts @@ -18,6 +18,8 @@ export type StreamCallLLMParams = { profile: ModelProfile factory: AIProviderFactory toolContext?: ToolExecutionContext + /** Thinking override; undefined follows profile.supportsReasoningContent. */ + thinking?: boolean onChunk?: (chunk: string) => void signal?: AbortSignal } @@ -40,11 +42,11 @@ export function normalizeStreamError(err: unknown, signal?: AbortSignal): Error /** Default callLLM implementation: stream via AI SDK, tools execute inside streamText. */ export async function streamCallLLM(params: StreamCallLLMParams): Promise { - const { provider, apiKey, messages, tools, profile, factory, toolContext, onChunk, signal } = params + const { provider, apiKey, messages, tools, profile, factory, toolContext, thinking, onChunk, signal } = params const sdkTools: SdkToolSet | undefined = tools && Object.keys(tools).length > 0 && toolContext ? buildSdkTools(tools, toolContext) : undefined const model = factory.build(provider, apiKey)(provider.model) - const requestProviderOptions = factory.buildProviderOptions(profile) + const requestProviderOptions = factory.buildProviderOptions(profile, thinking) const result = streamText({ model, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fbbdd4d..62206dd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -87,6 +87,8 @@ export type CallLLMOptions = { signal?: AbortSignal /** Tool execution context (approval/gate/hooks) captured by the loop; absent disables tools. */ toolContext?: ToolExecutionContext + /** Thinking toggle for this call; undefined falls back to the provider model profile. */ + thinking?: boolean } export type CallLLM = ( @@ -149,6 +151,8 @@ export type AgentSessionOptions = { dangerous?: boolean /** 工具权限模式:禁用工具 / 每次审批 / 全部放行。 */ toolPermissionMode?: ToolPermissionMode + /** 思考模式初始开关(undefined 跟随模型 profile;可运行时 setThinking 切换)。 */ + thinking?: boolean } /** Session 运行需要的依赖(含扩展项)。 */ @@ -217,6 +221,8 @@ export type FinalHookPayload = { tokenUsage?: LanguageModelUsage turnUsage: LanguageModelUsage steps: AgentStepTrace[] + /** Thinking trace of the final step (rendered on the last step cell). */ + thinking?: string } export type ContextUsagePhase = 'turn_start' | 'step_start' | 'post_compact' @@ -306,6 +312,8 @@ export type AgentSession = { listToolNames?: () => string[] /** 手动触发历史压缩。 */ compactHistory: (reason?: CompactReason) => Promise + /** 运行时切换思考模式(无需重建会话)。 */ + setThinking?: (enabled: boolean) => void /** 结束 Session,释放资源。 */ close: () => Promise } From 108261d04e576fc683ccbec4e695dcc9c9db2ffe Mon Sep 17 00:00:00 2001 From: mcell Date: Sat, 1 Aug 2026 23:38:35 +0800 Subject: [PATCH 16/42] fix(tui): render streaming text, drop dead Ctrl+C handling - TurnCell shows in-progress assistantText while the turn streams - Remove Ctrl+C handlers that Ink's exitOnCtrlC default makes unreachable - Reset the visible timeline when switching tool permission mode so UI stays in sync with the recreated session - Surface --prev session failures on stderr instead of exiting silently - Simplify: drop the dispatch wrapper, stable-header/cwd refs, and as-any backgroundColor hacks; extract a busy/approval guard helper - Guard Ctrl+D exit behind a double-press; hint Tab thinking in footer --- packages/tui/src/app/App.tsx | 184 +++--- packages/tui/src/commands/index.tsx | 53 +- .../src/features/approval/ApprovalOverlay.tsx | 5 +- .../tui/src/features/composer/Composer.tsx | 596 +++++++++--------- .../src/features/composer/SuggestionPanel.tsx | 13 +- .../src/features/mcp/McpActivationOverlay.tsx | 11 +- .../tui/src/features/setup/SetupWizard.tsx | 11 +- packages/tui/src/features/timeline/Cells.tsx | 20 +- .../tui/src/features/timeline/ChatWidget.tsx | 31 +- .../features/timeline/MarkdownRenderer.tsx | 6 +- .../tui/src/features/timeline/chatTimeline.ts | 11 +- packages/tui/src/shared/ui/Footer.tsx | 17 +- 12 files changed, 473 insertions(+), 485 deletions(-) diff --git a/packages/tui/src/app/App.tsx b/packages/tui/src/app/App.tsx index 02721ac..61c0197 100644 --- a/packages/tui/src/app/App.tsx +++ b/packages/tui/src/app/App.tsx @@ -25,11 +25,7 @@ import { McpActivationOverlay } from '../features/mcp/McpActivationOverlay' import { notifyApprovalRequested } from '../features/approval/approvalNotification' import { SetupWizard } from '../features/setup/SetupWizard' import { parseHistoryLog } from '../features/session/historyParser' -import { - chatTimelineReducer, - createInitialTimelineState, - type ChatTimelineAction, -} from '../features/timeline/chatTimeline' +import { chatTimelineReducer, createInitialTimelineState } from '../features/timeline/chatTimeline' import { calculateContextPercent, inferParallelToolStatuses, inferToolStatus } from '../shared/lib/utils' import { checkForUpdate, findLocalPackageInfoSync } from '../shared/lib/version' import type { SessionHistoryEntry } from '../features/session/sessionHistory' @@ -120,6 +116,10 @@ export function App({ const [providersState, setProvidersState] = useState(providers) const [modelProfilesState, setModelProfilesState] = useState(modelProfiles) const [toolPermissionMode, setToolPermissionMode] = useState(defaultToolPermissionMode) + const [thinkingOn, setThinkingOn] = useState(() => { + const override = modelProfilesState?.[model] ?? modelProfilesState?.[`${providerName}:${model}`] + return override?.supports_reasoning_content ?? true + }) const resolveContextLimit = useCallback( (providerConfig: Pick) => @@ -155,30 +155,34 @@ export function App({ const { pendingApproval, setPendingApproval, approvalResolverRef, handleApprovalDecision } = useApproval() - const localPackageInfo = useMemo(() => findLocalPackageInfoSync(), []) - - const dispatch = useCallback((action: ChatTimelineAction) => { - dispatchTimeline(action) + const handleToggleThinking = useCallback(() => { + setThinkingOn((prev) => { + const next = !prev + sessionRef.current?.setThinking?.(next) + return next + }) }, []) + const localPackageInfo = useMemo(() => findLocalPackageInfoSync(), []) + useEffect(() => { if (!initialHistory) return - dispatch({ type: 'clear_current_timeline' }) - dispatch({ + dispatchTimeline({ type: 'clear_current_timeline' }) + dispatchTimeline({ type: 'replace_history', turns: initialHistory.turns, maxSequence: initialHistory.maxSequence, }) setPendingHistoryMessages(initialHistory.messages) if (initialHistory.summary.trim()) { - dispatch({ + dispatchTimeline({ type: 'append_system_message', title: 'History', content: initialHistory.summary, tone: 'info', }) } - }, [dispatch, initialHistory]) + }, [dispatchTimeline, initialHistory]) useEffect(() => { if (setupPending) return @@ -188,9 +192,9 @@ export function App({ const appendSystemMessage = useCallback( (title: string, content: string, tone: 'info' | 'warning' | 'error' = 'info') => { - dispatch({ type: 'append_system_message', title, content, tone }) + dispatchTimeline({ type: 'append_system_message', title, content, tone }) }, - [dispatch], + [dispatchTimeline], ) const deps = useMemo( @@ -198,7 +202,7 @@ export function App({ onAssistantStep: (chunk: string, step: number) => { const turn = currentTurnRef.current if (!turn) return - dispatch({ type: 'assistant_chunk', turn, step, chunk }) + dispatchTimeline({ type: 'assistant_chunk', turn, step, chunk }) }, requestApproval: toolPermissionMode === TOOL_PERMISSION_MODES.FULL || toolPermissionMode === TOOL_PERMISSION_MODES.NONE @@ -222,7 +226,7 @@ export function App({ setCurrentContextTokens(promptTokens) } - dispatch({ + dispatchTimeline({ type: 'turn_start', turn, input: displayInput, @@ -231,7 +235,7 @@ export function App({ }, onContextUsage: ({ turn, step, promptTokens, phase }) => { setCurrentContextTokens(promptTokens) - dispatch({ + dispatchTimeline({ type: 'context_usage', turn, step, @@ -266,7 +270,7 @@ export function App({ ) }, onAction: ({ turn, step, action, thinking, parallelActions }) => { - dispatch({ + dispatchTimeline({ type: 'tool_action', turn, step, @@ -276,7 +280,7 @@ export function App({ }) }, onObservation: ({ turn, step, observation, resultStatus, parallelResultStatuses }) => { - dispatch({ + dispatchTimeline({ type: 'tool_observation', turn, step, @@ -285,8 +289,8 @@ export function App({ parallelToolStatuses: inferParallelToolStatuses(parallelResultStatuses), }) }, - onFinal: ({ turn, finalText, status, errorMessage, turnUsage, tokenUsage }) => { - dispatch({ + onFinal: ({ turn, finalText, status, errorMessage, turnUsage, tokenUsage, thinking }) => { + dispatchTimeline({ type: 'turn_final', turn, finalText, @@ -294,12 +298,13 @@ export function App({ errorMessage, turnUsage, tokenUsage, + thinking, }) setBusy(false) }, }, }), - [appendSystemMessage, dispatch, toolPermissionMode], + [appendSystemMessage, dispatchTimeline, toolPermissionMode], ) useEffect(() => { @@ -374,38 +379,41 @@ export function App({ await sessionRef.current.close() } setExitMessage('Bye!') - setTimeout(() => exit(), 250) - }, [exit, pendingApproval]) + }, [pendingApproval]) - const handleClear = useCallback(() => { - if (busy) { - appendSystemMessage('Clear', 'Cancel current run before clearing timeline.', 'warning') - return - } - if (pendingApproval) { - appendSystemMessage('Clear', 'Resolve current approval request before clearing timeline.', 'warning') - return + // Render the farewell message first, then unmount. + useEffect(() => { + if (exitMessage) { + exit() } - dispatch({ type: 'clear_current_timeline' }) + }, [exit, exitMessage]) + + const guardBusyOrApproval = useCallback( + (action: string): boolean => { + if (busy) { + appendSystemMessage(action, 'Cancel current run before proceeding.', 'warning') + return true + } + if (pendingApproval) { + appendSystemMessage(action, 'Resolve current approval request before proceeding.', 'warning') + return true + } + return false + }, + [appendSystemMessage, busy, pendingApproval], + ) + + const handleClear = useCallback(() => { + if (guardBusyOrApproval('Clear')) return + dispatchTimeline({ type: 'clear_current_timeline' }) setPendingHistoryMessages(null) setCurrentContextTokens(0) clearTerminalScreen() - }, [appendSystemMessage, busy, dispatch, pendingApproval]) + }, [dispatchTimeline, guardBusyOrApproval]) const handleNewSession = useCallback(() => { - if (busy) { - appendSystemMessage('New Session', 'Cancel current run before starting a new session.', 'warning') - return - } - if (pendingApproval) { - appendSystemMessage( - 'New Session', - 'Resolve current approval request before starting a new session.', - 'warning', - ) - return - } - dispatch({ type: 'reset_all' }) + if (guardBusyOrApproval('New Session')) return + dispatchTimeline({ type: 'reset_all' }) setPendingHistoryMessages(null) setCurrentContextTokens(0) currentTurnRef.current = null @@ -414,7 +422,7 @@ export function App({ sessionId: randomUUID(), })) appendSystemMessage('New Session', 'Started a fresh session.') - }, [appendSystemMessage, busy, dispatch, pendingApproval]) + }, [appendSystemMessage, dispatchTimeline, guardBusyOrApproval]) const persistCurrentProvider = useCallback( async (name: string) => { @@ -433,17 +441,14 @@ export function App({ const handleModelSelect = useCallback( async (provider: ProviderConfig) => { - if (busy) { - appendSystemMessage('Model switch', 'Cancel current run before switching models.', 'warning') - return - } + if (guardBusyOrApproval('Model switch')) return if (provider.name === currentProvider && provider.model === currentModel) { appendSystemMessage('Model switch', `Already using ${provider.name} (${provider.model}).`) return } - dispatch({ type: 'reset_all' }) + dispatchTimeline({ type: 'reset_all' }) setCurrentContextTokens(0) currentTurnRef.current = null @@ -463,10 +468,10 @@ export function App({ }, [ appendSystemMessage, - busy, currentModel, currentProvider, - dispatch, + dispatchTimeline, + guardBusyOrApproval, persistCurrentProvider, resolveContextLimit, ], @@ -480,35 +485,29 @@ export function App({ const handleSetToolPermission = useCallback( (mode: ToolPermissionMode) => { - if (busy) { - appendSystemMessage('Tools', 'Cancel current run before changing tool permission mode.', 'warning') - return - } - - if (pendingApproval) { - appendSystemMessage( - 'Tools', - 'Resolve current approval request before changing tool permission mode.', - 'warning', - ) - return - } + if (guardBusyOrApproval('Tools')) return if (mode === toolPermissionMode) { appendSystemMessage('Tools', `Already using ${toolPermissionLabel(mode)}.`) return } + // Tool permission is baked into the session at creation time, so + // switching modes recreates the session; reset the visible timeline + // to match the fresh session's (empty) history. setToolPermissionMode(mode) + dispatchTimeline({ type: 'reset_all' }) + setCurrentContextTokens(0) + currentTurnRef.current = null setSessionOptionsState((prev) => ({ ...prev, sessionId: randomUUID(), dangerous: mode === TOOL_PERMISSION_MODES.FULL, toolPermissionMode: mode, })) - appendSystemMessage('Tools', `Tool permission set to ${toolPermissionLabel(mode)}.`) + appendSystemMessage('Tools', `Tool permission set to ${toolPermissionLabel(mode)}. Conversation reset.`) }, - [appendSystemMessage, busy, pendingApproval, toolPermissionLabel, toolPermissionMode], + [appendSystemMessage, dispatchTimeline, guardBusyOrApproval, toolPermissionLabel, toolPermissionMode], ) const persistActiveMcpServers = useCallback( @@ -546,23 +545,12 @@ export function App({ const handleHistorySelect = useCallback( async (entry: SessionHistoryEntry) => { - if (busy) { - appendSystemMessage('History', 'Cancel current run before loading session history.', 'warning') - return - } - if (pendingApproval) { - appendSystemMessage( - 'History', - 'Resolve current approval request before loading session history.', - 'warning', - ) - return - } + if (guardBusyOrApproval('History')) return try { const raw = await readFile(entry.sessionFile, 'utf8') const parsed = parseHistoryLog(raw) - dispatch({ type: 'clear_current_timeline' }) - dispatch({ + dispatchTimeline({ type: 'clear_current_timeline' }) + dispatchTimeline({ type: 'replace_history', turns: parsed.turns, maxSequence: parsed.maxSequence, @@ -583,7 +571,7 @@ export function App({ ) } }, - [appendSystemMessage, busy, dispatch, pendingApproval], + [appendSystemMessage, dispatchTimeline, guardBusyOrApproval], ) const handleCancelRun = useCallback(() => { @@ -592,14 +580,7 @@ export function App({ }, [busy, session]) const runCompactCommand = useCallback(async () => { - if (busy) { - appendSystemMessage('Compact', 'Cancel current run before compacting context.', 'warning') - return - } - if (pendingApproval) { - appendSystemMessage('Compact', 'Resolve current approval request before compacting context.', 'warning') - return - } + if (guardBusyOrApproval('Compact')) return if (!session) return try { @@ -608,7 +589,7 @@ export function App({ } catch (err) { appendSystemMessage('Compact', `Failed to compact context: ${(err as Error).message}`, 'error') } - }, [appendSystemMessage, busy, pendingApproval, session]) + }, [appendSystemMessage, guardBusyOrApproval, session]) const runInitCommand = useCallback(async () => { if (!session || busy) return @@ -767,7 +748,7 @@ export function App({ } if (setupPending) { - return + return } if (mcpSelectionPending) { @@ -776,9 +757,6 @@ export function App({ serverNames={availableMcpServerNames} defaultSelected={initialActiveMcpServers} onConfirm={handleConfirmMcpActivation} - onExit={() => { - void handleExit() - }} /> ) } @@ -817,6 +795,7 @@ export function App({ onCompact={() => { void runCompactCommand() }} + onToggleThinking={handleToggleThinking} onHistorySelect={(entry) => { void handleHistorySelect(entry) }} @@ -832,7 +811,12 @@ export function App({ {pendingApproval ? : null} -