diff --git a/archives/code/dead-code-batch-2/README.md b/archives/code/dead-code-batch-2/README.md new file mode 100644 index 000000000..c6cd9131b --- /dev/null +++ b/archives/code/dead-code-batch-2/README.md @@ -0,0 +1,36 @@ +# Dead Code Batch 2 + +- Purpose: archive dead code that no longer participates in the active renderer or main runtime. +- Archived at: 2026-03-15 +- Rationale: static inspection confirmed these files have no live code references and are kept in + source form for precise rollback only. + +## Archived Paths + +- `src/renderer/src/components/NewThreadMock.vue` +- `src/renderer/src/components/mock/MockChatPage.vue` +- `src/renderer/src/components/mock/MockInputBox.vue` +- `src/renderer/src/components/mock/MockInputToolbar.vue` +- `src/renderer/src/components/mock/MockMessageList.vue` +- `src/renderer/src/components/mock/MockStatusBar.vue` +- `src/renderer/src/components/mock/MockTopBar.vue` +- `src/renderer/src/components/mock/MockWelcomePage.vue` +- `src/renderer/src/composables/useMockViewState.ts` +- `src/main/presenter/agentPresenter/tools/questionTool.ts` +- `src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts` +- `src/main/presenter/agentPresenter/events.ts` +- `src/main/presenter/agentPresenter/message/index.ts` +- `src/main/presenter/agentPresenter/permission/index.ts` +- `src/main/presenter/agentPresenter/session/index.ts` +- `src/main/presenter/agentPresenter/streaming/index.ts` +- `src/main/presenter/agentPresenter/tool/index.ts` +- `src/main/presenter/agentPresenter/utility/index.ts` +- `src/main/presenter/searchPrompts/index.ts` +- `src/main/presenter/sessionPresenter/persistence/index.ts` +- `src/main/presenter/sessionPresenter/tab/index.ts` + +## Notes + +- This directory is not part of the runtime, build, typecheck, or test target set. +- Restore by moving files back to their original paths if a later audit proves they are still + needed. diff --git a/src/main/presenter/agentPresenter/events.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/events.ts similarity index 100% rename from src/main/presenter/agentPresenter/events.ts rename to archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/events.ts diff --git a/src/main/presenter/agentPresenter/message/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/message/index.ts similarity index 100% rename from src/main/presenter/agentPresenter/message/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/message/index.ts diff --git a/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts new file mode 100644 index 000000000..c39ec9ffb --- /dev/null +++ b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts @@ -0,0 +1,5 @@ +export { + buildRuntimeCapabilitiesPrompt, + buildSystemEnvPrompt, + type BuildSystemEnvPromptOptions +} from '../../../lib/agentRuntime/systemEnvPromptBuilder' diff --git a/src/main/presenter/agentPresenter/permission/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/permission/index.ts similarity index 100% rename from src/main/presenter/agentPresenter/permission/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/permission/index.ts diff --git a/src/main/presenter/agentPresenter/session/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/session/index.ts similarity index 100% rename from src/main/presenter/agentPresenter/session/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/session/index.ts diff --git a/src/main/presenter/agentPresenter/streaming/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/streaming/index.ts similarity index 100% rename from src/main/presenter/agentPresenter/streaming/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/streaming/index.ts diff --git a/src/main/presenter/agentPresenter/tool/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/tool/index.ts similarity index 100% rename from src/main/presenter/agentPresenter/tool/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/tool/index.ts diff --git a/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/tools/questionTool.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/tools/questionTool.ts new file mode 100644 index 000000000..1979f7342 --- /dev/null +++ b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/tools/questionTool.ts @@ -0,0 +1,6 @@ +export { + parseQuestionToolArgs, + QUESTION_TOOL_NAME, + questionToolSchema, + type QuestionToolInput +} from '../../../lib/agentRuntime/questionTool' diff --git a/src/main/presenter/agentPresenter/utility/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/utility/index.ts similarity index 100% rename from src/main/presenter/agentPresenter/utility/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/agentPresenter/utility/index.ts diff --git a/src/main/presenter/searchPrompts/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/searchPrompts/index.ts similarity index 100% rename from src/main/presenter/searchPrompts/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/searchPrompts/index.ts diff --git a/src/main/presenter/sessionPresenter/persistence/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/sessionPresenter/persistence/index.ts similarity index 100% rename from src/main/presenter/sessionPresenter/persistence/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/sessionPresenter/persistence/index.ts diff --git a/src/main/presenter/sessionPresenter/tab/index.ts b/archives/code/dead-code-batch-2/src/main/presenter/sessionPresenter/tab/index.ts similarity index 100% rename from src/main/presenter/sessionPresenter/tab/index.ts rename to archives/code/dead-code-batch-2/src/main/presenter/sessionPresenter/tab/index.ts diff --git a/src/renderer/src/components/NewThreadMock.vue b/archives/code/dead-code-batch-2/src/renderer/src/components/NewThreadMock.vue similarity index 100% rename from src/renderer/src/components/NewThreadMock.vue rename to archives/code/dead-code-batch-2/src/renderer/src/components/NewThreadMock.vue diff --git a/src/renderer/src/components/mock/MockChatPage.vue b/archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockChatPage.vue similarity index 100% rename from src/renderer/src/components/mock/MockChatPage.vue rename to archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockChatPage.vue diff --git a/src/renderer/src/components/mock/MockInputBox.vue b/archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockInputBox.vue similarity index 100% rename from src/renderer/src/components/mock/MockInputBox.vue rename to archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockInputBox.vue diff --git a/src/renderer/src/components/mock/MockInputToolbar.vue b/archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockInputToolbar.vue similarity index 100% rename from src/renderer/src/components/mock/MockInputToolbar.vue rename to archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockInputToolbar.vue diff --git a/src/renderer/src/components/mock/MockMessageList.vue b/archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockMessageList.vue similarity index 100% rename from src/renderer/src/components/mock/MockMessageList.vue rename to archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockMessageList.vue diff --git a/src/renderer/src/components/mock/MockStatusBar.vue b/archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockStatusBar.vue similarity index 100% rename from src/renderer/src/components/mock/MockStatusBar.vue rename to archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockStatusBar.vue diff --git a/src/renderer/src/components/mock/MockTopBar.vue b/archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockTopBar.vue similarity index 100% rename from src/renderer/src/components/mock/MockTopBar.vue rename to archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockTopBar.vue diff --git a/src/renderer/src/components/mock/MockWelcomePage.vue b/archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockWelcomePage.vue similarity index 100% rename from src/renderer/src/components/mock/MockWelcomePage.vue rename to archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockWelcomePage.vue diff --git a/src/renderer/src/composables/useMockViewState.ts b/archives/code/dead-code-batch-2/src/renderer/src/composables/useMockViewState.ts similarity index 100% rename from src/renderer/src/composables/useMockViewState.ts rename to archives/code/dead-code-batch-2/src/renderer/src/composables/useMockViewState.ts diff --git a/archives/code/dead-renderer-batch-1/README.md b/archives/code/dead-renderer-batch-1/README.md new file mode 100644 index 000000000..c7d250859 --- /dev/null +++ b/archives/code/dead-renderer-batch-1/README.md @@ -0,0 +1,17 @@ +# Dead Renderer Batch 1 + +- Purpose: archive renderer dead code that is no longer on the active chat path. +- Archived at: 2026-03-15 +- Rationale: static inspection confirmed there are no active references in `src/`, `test/`, or live docs. Files are kept in source form for precise rollback only. + +## Archived Paths + +- `src/renderer/src/components/message/MessageMinimap.vue` +- `src/renderer/src/composables/message/useMessageMinimap.ts` +- `src/renderer/src/components/MessageNavigationSidebar.vue` +- `src/renderer/src/lib/messageRuntimeCache.ts` + +## Notes + +- This directory is not part of the runtime, build, typecheck, or test target set. +- Restore by moving files back to their original paths if a later audit proves they are still needed. diff --git a/src/renderer/src/components/MessageNavigationSidebar.vue b/archives/code/dead-renderer-batch-1/src/renderer/src/components/MessageNavigationSidebar.vue similarity index 100% rename from src/renderer/src/components/MessageNavigationSidebar.vue rename to archives/code/dead-renderer-batch-1/src/renderer/src/components/MessageNavigationSidebar.vue diff --git a/src/renderer/src/components/message/MessageMinimap.vue b/archives/code/dead-renderer-batch-1/src/renderer/src/components/message/MessageMinimap.vue similarity index 100% rename from src/renderer/src/components/message/MessageMinimap.vue rename to archives/code/dead-renderer-batch-1/src/renderer/src/components/message/MessageMinimap.vue diff --git a/src/renderer/src/composables/message/useMessageMinimap.ts b/archives/code/dead-renderer-batch-1/src/renderer/src/composables/message/useMessageMinimap.ts similarity index 100% rename from src/renderer/src/composables/message/useMessageMinimap.ts rename to archives/code/dead-renderer-batch-1/src/renderer/src/composables/message/useMessageMinimap.ts diff --git a/src/renderer/src/lib/messageRuntimeCache.ts b/archives/code/dead-renderer-batch-1/src/renderer/src/lib/messageRuntimeCache.ts similarity index 100% rename from src/renderer/src/lib/messageRuntimeCache.ts rename to archives/code/dead-renderer-batch-1/src/renderer/src/lib/messageRuntimeCache.ts diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 11f80e95b..a5f1ff201 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -138,8 +138,7 @@ sequenceDiagram participant LLM as LLMProviderPresenter (Main) participant LLMH as LLMEventHandler (Main) participant Sched as StreamUpdateScheduler (Main) - participant Cache as messageRuntimeCache (Renderer) - participant List as MessageList/Minimap (Renderer) + participant List as MessageList (Renderer) UI->>Store: send(message) Store->>IPC: presenter:call(agentPresenter.sendMessage) @@ -149,8 +148,7 @@ sequenceDiagram LLM-->>LLMH: stream chunks LLMH->>Sched: enqueueDelta(content/tool_call/usage) Sched-->>Store: STREAM_EVENTS.RESPONSE (init/delta) - Store->>Cache: cacheMessage/ensureMessageId - Cache-->>List: messageItems/minimapMessages + Store-->>List: update messageItems LLMH-->>Sched: flushAll(final) Sched-->>Store: STREAM_EVENTS.RESPONSE (final) LLMH-->>Store: STREAM_EVENTS.END/ERROR @@ -162,7 +160,7 @@ sequenceDiagram - AgentPresenter.sendMessage: `src/main/presenter/agentPresenter/index.ts` - StreamGenerationHandler.startStreamCompletion: `src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts` - LLMEventHandler + StreamUpdateScheduler: `src/main/presenter/agentPresenter/streaming/llmEventHandler.ts`, `src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts` -- MessageList/Minimap: `src/renderer/src/components/message/MessageList.vue`, `src/renderer/src/components/message/MessageMinimap.vue` +- MessageList: `src/renderer/src/components/chat/MessageList.vue` ## 3. Agent Loop 详细流程 diff --git a/docs/architecture/new-ui-implementation-plan.md b/docs/architecture/new-ui-implementation-plan.md index 595b4cfa3..bab6f5058 100644 --- a/docs/architecture/new-ui-implementation-plan.md +++ b/docs/architecture/new-ui-implementation-plan.md @@ -556,8 +556,8 @@ sessionStore.selectSession(id) - `chat.ts`: Remove session management logic, keep message logic **Deprecated**: -- `components/mock/*.vue`: Mock components can be deleted after completion -- `composables/useMockViewState.ts`: Mock state management can be deleted +- `components/mock/*.vue`: Archived in `archives/code/dead-code-batch-2/` +- `composables/useMockViewState.ts`: Archived in `archives/code/dead-code-batch-2/` --- diff --git a/docs/specs/agent-cleanup/plan.md b/docs/specs/agent-cleanup/plan.md new file mode 100644 index 000000000..66a52e830 --- /dev/null +++ b/docs/specs/agent-cleanup/plan.md @@ -0,0 +1,39 @@ +# Agent Cleanup Checkpoint + +## Summary + +This workstream is paused after the main cleanup milestones were completed and dead code was +archived. + +Done: + +- shared helper ownership moved to `src/main/lib/agentRuntime` +- active renderer chat path moved off legacy message protocol +- renderer dead code archived in `archives/code/dead-renderer-batch-1/` +- renderer mock/orphan dead code archived in `archives/code/dead-code-batch-2/` +- new-session skill state moved to `new_sessions.active_skills` +- legacy `agentPresenter/**` removed from global presenter access +- provider-layer MCP global access removed + +## Keep For Now + +- `LegacyChatImportService` +- legacy import hook / status tracking +- old `conversations/messages` tables as import-only sources +- `scripts/agent-cleanup-guard.mjs` as anti-regression protection + +## Resume Order Later + +When cleanup resumes, use this order: + +1. clear the remaining export-only / non-active-path type coupling +2. inventory and reduce adjacent provider globals +3. run a final retirement audit on old presenter runtime wiring +4. only then consider deleting old legacy folders or old import tables + +## Default Rules + +1. One cleanup slice per PR. +2. Do not mix event-contract changes with runtime decoupling. +3. Do not remove import-only compatibility during routine refactors. +4. Prefer archiving dead code before hard deletion. diff --git a/docs/specs/agent-cleanup/spec.md b/docs/specs/agent-cleanup/spec.md new file mode 100644 index 000000000..461c582df --- /dev/null +++ b/docs/specs/agent-cleanup/spec.md @@ -0,0 +1,56 @@ +# Agent Cleanup + +## Summary + +This cleanup paused at a stable checkpoint on March 15, 2026. + +Current primary flow: + +- renderer active chat pages/stores/components +- `newAgentPresenter` +- `deepchatAgentPresenter` + +Current state: + +- active renderer chat path no longer depends on `@shared/chat` +- dead renderer and mock code has been archived under `archives/code/` +- new-session skills live in `new_sessions.active_skills` +- imported `legacy-session-*` skills are repaired back into `new_sessions.active_skills` on first + access +- legacy `agentPresenter/**` no longer reads global `presenter.*` directly +- provider-layer MCP conversion no longer reads `presenter.mcpPresenter` + +## Compatibility Boundary + +The supported compatibility boundary is now: + +- keep `LegacyChatImportService` +- keep legacy import hook / status tracking +- keep old `conversations/messages` tables as import-only sources + +The new primary flow should not regain runtime ownership from old `agentPresenter` / +`sessionPresenter` code. + +## Guardrails + +`scripts/agent-cleanup-guard.mjs` is still intentionally kept. + +It now acts as a pure anti-regression guard with zero baseline: + +1. new main-path modules must not import legacy `agentPresenter/sessionPresenter` +2. active renderer chat path must not reintroduce `@shared/chat` +3. legacy `agentPresenter/**` must not regain global `presenter.*` access +4. provider-layer code must not reintroduce `presenter.mcpPresenter` +5. `SkillPresenter` and MCP gating must not regain retired legacy fallbacks + +This is low maintenance now because there is no allowlist left to manage. + +## Remaining Work + +The cleanup is intentionally paused here. Remaining backlog is small and can wait for future +feature work: + +- export-only `@shared/chat` coupling in `newAgentPresenter` +- non-active renderer residual import in `PromptEditorSheet` +- adjacent provider globals such as `devicePresenter` / `oauthPresenter` +- final runtime retirement audit for old presenter folders diff --git a/docs/specs/agent-cleanup/tasks.md b/docs/specs/agent-cleanup/tasks.md new file mode 100644 index 000000000..41339f95d --- /dev/null +++ b/docs/specs/agent-cleanup/tasks.md @@ -0,0 +1,36 @@ +# Agent Cleanup Checkpoint Tasks + +## Completed + +- [x] Added cleanup docs and static guardrails +- [x] Moved shared runtime helpers out of legacy presenter folders +- [x] Moved active renderer chat path off `@shared/chat` +- [x] Archived dead renderer path code in `archives/code/dead-renderer-batch-1/` +- [x] Archived renderer mock/orphan dead code in `archives/code/dead-code-batch-2/` +- [x] Persisted new-session skills in `new_sessions.active_skills` +- [x] Retired old-session skill fallback to legacy conversation settings +- [x] Removed global `presenter.*` access from `agentPresenter/**` +- [x] Removed provider-layer `presenter.mcpPresenter` access +- [x] Reduced startup/runtime legacy wiring on the new primary path + +## Kept Intentionally + +- [x] `LegacyChatImportService` +- [x] legacy import hook / status tracking +- [x] old `conversations/messages` tables as import-only sources +- [x] `scripts/agent-cleanup-guard.mjs` + +## Remaining Backlog + +- [ ] `src/main/presenter/newAgentPresenter/index.ts` still has export-only `@shared/chat` coupling +- [ ] `src/renderer/settings/components/prompt/PromptEditorSheet.vue` still imports `MessageFile` + from `@shared/chat` outside the active chat path +- [ ] adjacent provider globals remain for later review: + - `presenter.devicePresenter` in OpenAI providers + - `presenter.oauthPresenter` in Anthropic +- [ ] final retirement audit for old runtime folders and wiring + +## Archive Batches + +- [x] `archives/code/dead-renderer-batch-1/` +- [x] `archives/code/dead-code-batch-2/` diff --git a/docs/specs/agent-tooling-v2/plan.md b/docs/specs/agent-tooling-v2/plan.md index 4bd1ea682..d088d2790 100644 --- a/docs/specs/agent-tooling-v2/plan.md +++ b/docs/specs/agent-tooling-v2/plan.md @@ -40,14 +40,14 @@ 5. `src/main/presenter/toolPresenter/index.ts`(tool prompt 与路由提示) 6. `src/main/presenter/agentPresenter/message/messageBuilder.ts`(system prompt 拼接) 7. `src/main/presenter/agentPresenter/message/skillsPromptBuilder.ts`(skills allowedTools 接入) -8. `src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts`(新增 env prompt 生成) +8. `src/main/lib/agentRuntime/systemEnvPromptBuilder.ts`(env prompt 生成) 9. 与上述模块直接相关的测试与文档 明确不改动: 1. `src/main/presenter/browser/**` 2. `src/main/presenter/agentPresenter/acp/chatSettingsTools.ts` -3. `src/main/presenter/agentPresenter/tools/questionTool.ts` +3. `src/main/lib/agentRuntime/questionTool.ts` 4. `src/main/presenter/mcpPresenter/**` ## 2. 设计决策 @@ -287,7 +287,7 @@ canonical: - 保证 system prompt 固定顺序拼接。 3. `src/main/presenter/agentPresenter/message/skillsPromptBuilder.ts` - `getSkillsAllowedTools()` 使用 canonicalized allowed tools。 -4. `src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts`(新增) +4. `src/main/lib/agentRuntime/systemEnvPromptBuilder.ts` - 统一生成 env prompt(模型、系统、仓库、AGENTS.md)。 拼接顺序(固定): diff --git a/docs/specs/agent-tooling-v2/tasks.md b/docs/specs/agent-tooling-v2/tasks.md index a87f549ae..ade6f5e4e 100644 --- a/docs/specs/agent-tooling-v2/tasks.md +++ b/docs/specs/agent-tooling-v2/tasks.md @@ -15,7 +15,7 @@ ## T2 新增统一 Env Prompt Builder -- [ ] 新增 `src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts`。 +- [ ] 新增 `src/main/lib/agentRuntime/systemEnvPromptBuilder.ts`。 - [ ] 实现 `buildSystemEnvPrompt(...)`,输出固定格式: - 模型名 + 模型 ID - ``:workdir、git repo、platform、date diff --git a/docs/specs/new-ui-chat-components/spec.md b/docs/specs/new-ui-chat-components/spec.md index d7dc84c5c..79fd0587d 100644 --- a/docs/specs/new-ui-chat-components/spec.md +++ b/docs/specs/new-ui-chat-components/spec.md @@ -4,15 +4,15 @@ Chat components handle message display, input, and status configuration during active sessions. Each component's visual design must match its mock counterpart exactly. -## Reference Files +## Archived Reference Files -| Component | Mock File | +| Component | Archived Mock File | |-----------|-----------| -| ChatTopBar | `components/mock/MockTopBar.vue` | -| MessageList | `components/mock/MockMessageList.vue` | -| InputBox | `components/mock/MockInputBox.vue` | -| InputToolbar | `components/mock/MockInputToolbar.vue` | -| StatusBar | `components/mock/MockStatusBar.vue` | +| ChatTopBar | `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockTopBar.vue` | +| MessageList | `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockMessageList.vue` | +| InputBox | `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockInputBox.vue` | +| InputToolbar | `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockInputToolbar.vue` | +| StatusBar | `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockStatusBar.vue` | ## File Locations @@ -29,7 +29,7 @@ src/renderer/src/components/chat/ ## 1. ChatTopBar -**Mock reference**: `MockTopBar.vue` (copy layout and classes exactly) +**Archived mock reference**: `dead-code-batch-2/.../MockTopBar.vue` **Layout**: ``` @@ -57,7 +57,7 @@ interface Props { ## 2. MessageList -**Mock reference**: `MockMessageList.vue` (copy layout and classes exactly) +**Archived mock reference**: `dead-code-batch-2/.../MockMessageList.vue` **Layout**: ``` @@ -96,7 +96,7 @@ Note: The existing `useChatStore` already handles message fetching and caching v ## 3. ChatInputBox -**Mock reference**: `MockInputBox.vue` (copy layout and classes exactly) +**Archived mock reference**: `dead-code-batch-2/.../MockInputBox.vue` **Layout**: ``` @@ -133,7 +133,7 @@ interface Emits { ## 4. ChatInputToolbar -**Mock reference**: `MockInputToolbar.vue` (copy layout and classes exactly) +**Archived mock reference**: `dead-code-batch-2/.../MockInputToolbar.vue` **Layout**: ``` @@ -159,7 +159,7 @@ interface Emits { ## 5. ChatStatusBar -**Mock reference**: `MockStatusBar.vue` (copy layout and classes exactly) +**Archived mock reference**: `dead-code-batch-2/.../MockStatusBar.vue` **Layout**: ``` diff --git a/docs/specs/new-ui-implementation/todo.md b/docs/specs/new-ui-implementation/todo.md index f6a512e2b..02e7e6c67 100644 --- a/docs/specs/new-ui-implementation/todo.md +++ b/docs/specs/new-ui-implementation/todo.md @@ -6,20 +6,20 @@ This document tracks the development progress of new UI feature implementation. **Architecture Design**: [new-ui-implementation-plan.md](../../architecture/new-ui-implementation-plan.md) -## Mock Reference File List +## Archived Reference File List -| Mock File | Purpose | Target Replacement | +| Archived Mock File | Purpose | Target Replacement | |-----------|---------|-------------------| -| `components/mock/MockWelcomePage.vue` | Welcome page | `pages/WelcomePage.vue` | -| `components/NewThreadMock.vue` | NewThread page | `pages/NewThreadPage.vue` | -| `components/mock/MockChatPage.vue` | Chat page | `pages/ChatPage.vue` | -| `components/mock/MockTopBar.vue` | Top bar | `components/chat/ChatTopBar.vue` | -| `components/mock/MockMessageList.vue` | Message list | `components/chat/MessageList.vue` | -| `components/mock/MockInputBox.vue` | Input box | `components/chat/ChatInputBox.vue` | -| `components/mock/MockInputToolbar.vue` | Input toolbar | `components/chat/ChatInputToolbar.vue` | -| `components/mock/MockStatusBar.vue` | Status bar | `components/chat/ChatStatusBar.vue` | +| `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockWelcomePage.vue` | Welcome page | `pages/WelcomePage.vue` | +| `archives/code/dead-code-batch-2/src/renderer/src/components/NewThreadMock.vue` | NewThread page | `pages/NewThreadPage.vue` | +| `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockChatPage.vue` | Chat page | `pages/ChatPage.vue` | +| `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockTopBar.vue` | Top bar | `components/chat/ChatTopBar.vue` | +| `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockMessageList.vue` | Message list | `components/chat/MessageList.vue` | +| `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockInputBox.vue` | Input box | `components/chat/ChatInputBox.vue` | +| `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockInputToolbar.vue` | Input toolbar | `components/chat/ChatInputToolbar.vue` | +| `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockStatusBar.vue` | Status bar | `components/chat/ChatStatusBar.vue` | | `components/WindowSideBar.vue` | Sidebar | (refactored in place) | -| `composables/useMockViewState.ts` | State management | Replaced by stores | +| `archives/code/dead-code-batch-2/src/renderer/src/composables/useMockViewState.ts` | State management | Replaced by stores | --- @@ -240,15 +240,15 @@ This document tracks the development progress of new UI feature implementation. ### 5.5 Cleanup -- [ ] Delete `components/mock/MockWelcomePage.vue` -- [ ] Delete `components/mock/MockChatPage.vue` -- [ ] Delete `components/mock/MockTopBar.vue` -- [ ] Delete `components/mock/MockMessageList.vue` -- [ ] Delete `components/mock/MockInputBox.vue` -- [ ] Delete `components/mock/MockInputToolbar.vue` -- [ ] Delete `components/mock/MockStatusBar.vue` -- [ ] Delete `components/NewThreadMock.vue` -- [ ] Delete `composables/useMockViewState.ts` +- [x] Archive `components/mock/MockWelcomePage.vue` -> `archives/code/dead-code-batch-2/` +- [x] Archive `components/mock/MockChatPage.vue` -> `archives/code/dead-code-batch-2/` +- [x] Archive `components/mock/MockTopBar.vue` -> `archives/code/dead-code-batch-2/` +- [x] Archive `components/mock/MockMessageList.vue` -> `archives/code/dead-code-batch-2/` +- [x] Archive `components/mock/MockInputBox.vue` -> `archives/code/dead-code-batch-2/` +- [x] Archive `components/mock/MockInputToolbar.vue` -> `archives/code/dead-code-batch-2/` +- [x] Archive `components/mock/MockStatusBar.vue` -> `archives/code/dead-code-batch-2/` +- [x] Archive `components/NewThreadMock.vue` -> `archives/code/dead-code-batch-2/` +- [x] Archive `composables/useMockViewState.ts` -> `archives/code/dead-code-batch-2/` --- diff --git a/docs/specs/new-ui-pages/spec.md b/docs/specs/new-ui-pages/spec.md index c9b6f937d..c6f1fcbce 100644 --- a/docs/specs/new-ui-pages/spec.md +++ b/docs/specs/new-ui-pages/spec.md @@ -4,13 +4,13 @@ Three page components driven by the Page Router. No fallback to old ChatView — this is a full replacement. -## Reference Files +## Archived Reference Files -| Page | Mock File | +| Page | Archived Mock File | |------|-----------| -| WelcomePage | `components/mock/MockWelcomePage.vue` | -| NewThreadPage | `components/NewThreadMock.vue` | -| ChatPage | `components/mock/MockChatPage.vue` | +| WelcomePage | `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockWelcomePage.vue` | +| NewThreadPage | `archives/code/dead-code-batch-2/src/renderer/src/components/NewThreadMock.vue` | +| ChatPage | `archives/code/dead-code-batch-2/src/renderer/src/components/mock/MockChatPage.vue` | ## File Locations @@ -84,7 +84,7 @@ onMounted(async () => { ## 2. WelcomePage -**Mock reference**: `MockWelcomePage.vue` (copy layout and classes exactly) +**Archived mock reference**: `dead-code-batch-2/.../MockWelcomePage.vue` **Layout**: ``` @@ -133,7 +133,7 @@ onMounted(async () => { ## 3. NewThreadPage -**Mock reference**: `NewThreadMock.vue` (copy layout and classes exactly) +**Archived mock reference**: `dead-code-batch-2/.../NewThreadMock.vue` **Layout**: ``` @@ -188,7 +188,7 @@ const handleSubmit = (message: string) => { ## 4. ChatPage -**Mock reference**: `MockChatPage.vue` (copy layout and classes exactly) +**Archived mock reference**: `dead-code-batch-2/.../MockChatPage.vue` **Props**: ```typescript diff --git a/package.json b/package.json index 7613b8f9c..b2a63fe56 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "test:ui": "vitest --ui", "format:check": "prettier --check .", "format": "prettier --cache --write .", - "lint": "oxlint .", + "lint": "pnpm run lint:agent-cleanup && oxlint .", + "lint:agent-cleanup": "node scripts/agent-cleanup-guard.mjs", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "vue-tsgo --project tsconfig.app.tsgo.json", "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", diff --git a/scripts/agent-cleanup-guard.mjs b/scripts/agent-cleanup-guard.mjs new file mode 100644 index 000000000..5a5789f02 --- /dev/null +++ b/scripts/agent-cleanup-guard.mjs @@ -0,0 +1,251 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +const ROOT = process.cwd() + +const SOURCE_EXTENSIONS = new Set([ + '.js', + '.jsx', + '.mjs', + '.cjs', + '.ts', + '.tsx', + '.mts', + '.cts', + '.vue' +]) + +const LEGACY_MAIN_DIRS = [ + path.join(ROOT, 'src/main/presenter/agentPresenter'), + path.join(ROOT, 'src/main/presenter/sessionPresenter') +] + +const PRIMARY_MAIN_GUARD_PATHS = [ + path.join(ROOT, 'src/main/presenter/newAgentPresenter'), + path.join(ROOT, 'src/main/presenter/deepchatAgentPresenter'), + path.join(ROOT, 'src/main/presenter/skillPresenter'), + path.join(ROOT, 'src/main/presenter/mcpPresenter/toolManager.ts'), + path.join(ROOT, 'src/main/presenter/syncPresenter/index.ts') +] + +const RENDERER_CHAT_GUARD_PATHS = [ + path.join(ROOT, 'src/renderer/src/pages/ChatPage.vue'), + path.join(ROOT, 'src/renderer/src/pages/NewThreadPage.vue'), + path.join(ROOT, 'src/renderer/src/stores/ui'), + path.join(ROOT, 'src/renderer/src/components/chat'), + path.join(ROOT, 'src/renderer/src/components/message'), + path.join(ROOT, 'src/renderer/src/composables/useArtifacts.ts'), + path.join(ROOT, 'src/renderer/src/components/sidepanel/WorkspacePanel.vue') +] + +const LEGACY_AGENT_RUNTIME_DIR = path.join(ROOT, 'src/main/presenter/agentPresenter') +const PROVIDER_LAYER_DIR = path.join(ROOT, 'src/main/presenter/llmProviderPresenter/providers') +const SKILL_PRESENTER_DIR = path.join(ROOT, 'src/main/presenter/skillPresenter') +const MCP_TOOL_MANAGER_FILE = path.join(ROOT, 'src/main/presenter/mcpPresenter/toolManager.ts') + +const LEGACY_AGENT_RUNTIME_GLOBALS = [ + 'sessionManager', + 'toolPresenter', + 'mcpPresenter', + 'configPresenter', + 'skillPresenter', + 'filePermissionService', + 'settingsPermissionService', + 'newAgentPresenter', + 'sessionPresenter', + 'yoBrowserPresenter', + 'filePresenter', + 'llmproviderPresenter', + 'windowPresenter' +] + +function toPosix(value) { + return value.split(path.sep).join('/') +} + +function relativePath(filePath) { + return toPosix(path.relative(ROOT, filePath)) +} + +function isSourceFile(filePath) { + return SOURCE_EXTENSIONS.has(path.extname(filePath)) +} + +function isUnder(targetPath, parentPath) { + const normalizedTarget = path.resolve(targetPath) + const normalizedParent = path.resolve(parentPath) + return ( + normalizedTarget === normalizedParent || + normalizedTarget.startsWith(`${normalizedParent}${path.sep}`) + ) +} + +function isProtectedPath(filePath, protectedPaths) { + return protectedPaths.some((entry) => isUnder(filePath, entry)) +} + +function extractModuleSpecifiers(source) { + const specifiers = new Set() + const patterns = [ + /\bimport\s+(?:type\s+)?[\s\S]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\bexport\s+[\s\S]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g + ] + + for (const pattern of patterns) { + let match + while ((match = pattern.exec(source)) !== null) { + specifiers.add(match[1]) + } + } + + return specifiers +} + +async function collectFiles(entryPath) { + const stats = await fs.stat(entryPath) + if (stats.isFile()) { + return isSourceFile(entryPath) ? [entryPath] : [] + } + + const entries = await fs.readdir(entryPath, { withFileTypes: true }) + const files = [] + for (const entry of entries) { + const nextPath = path.join(entryPath, entry.name) + if (entry.isDirectory()) { + files.push(...(await collectFiles(nextPath))) + continue + } + if (entry.isFile() && isSourceFile(nextPath)) { + files.push(nextPath) + } + } + return files +} + +function isLegacyMainImport(filePath, specifier) { + if (!isProtectedPath(filePath, PRIMARY_MAIN_GUARD_PATHS)) { + return false + } + + if (specifier.startsWith('.')) { + const resolved = path.resolve(path.dirname(filePath), specifier) + return LEGACY_MAIN_DIRS.some((legacyDir) => isUnder(resolved, legacyDir)) + } + + return ( + specifier === '@/presenter/agentPresenter' || + specifier.startsWith('@/presenter/agentPresenter/') || + specifier === '@/presenter/sessionPresenter' || + specifier.startsWith('@/presenter/sessionPresenter/') + ) +} + +function buildViolation(kind, filePath, specifier) { + return { + kind, + file: relativePath(filePath), + specifier + } +} + +async function findViolations() { + const scanRoots = [ + path.join(ROOT, 'src/main/presenter/newAgentPresenter'), + path.join(ROOT, 'src/main/presenter/deepchatAgentPresenter'), + path.join(ROOT, 'src/main/presenter/skillPresenter'), + path.join(ROOT, 'src/main/presenter/mcpPresenter/toolManager.ts'), + path.join(ROOT, 'src/main/presenter/syncPresenter/index.ts'), + path.join(ROOT, 'src/main/presenter/llmProviderPresenter/providers'), + path.join(ROOT, 'src/main/presenter/agentPresenter'), + path.join(ROOT, 'src/renderer/src/pages/ChatPage.vue'), + path.join(ROOT, 'src/renderer/src/pages/NewThreadPage.vue'), + path.join(ROOT, 'src/renderer/src/stores/ui'), + path.join(ROOT, 'src/renderer/src/components/chat'), + path.join(ROOT, 'src/renderer/src/components/message'), + path.join(ROOT, 'src/renderer/src/composables/useArtifacts.ts'), + path.join(ROOT, 'src/renderer/src/components/sidepanel/WorkspacePanel.vue') + ] + + const fileSet = new Set() + for (const entry of scanRoots) { + for (const file of await collectFiles(entry)) { + fileSet.add(file) + } + } + + const violations = [] + for (const filePath of [...fileSet].sort()) { + const source = await fs.readFile(filePath, 'utf8') + + for (const specifier of extractModuleSpecifiers(source)) { + if (isLegacyMainImport(filePath, specifier)) { + violations.push(buildViolation('legacy-main-import', filePath, specifier)) + } + + if ( + isProtectedPath(filePath, RENDERER_CHAT_GUARD_PATHS) && + (specifier === '@shared/chat' || specifier.startsWith('@shared/chat/')) + ) { + violations.push(buildViolation('legacy-chat-import', filePath, specifier)) + } + } + + if (filePath === MCP_TOOL_MANAGER_FILE && source.includes('input_chatMode')) { + violations.push(buildViolation('global-chat-mode', filePath, 'input_chatMode')) + } + + if ( + isProtectedPath(filePath, PRIMARY_MAIN_GUARD_PATHS) && + source.includes('presenter.sessionPresenter.') + ) { + violations.push(buildViolation('legacy-session-access', filePath, 'presenter.sessionPresenter')) + } + + if (isProtectedPath(filePath, [SKILL_PRESENTER_DIR]) && /\bpresenter\./.test(source)) { + violations.push(buildViolation('skill-global-presenter', filePath, 'presenter.*')) + } + + if ( + isProtectedPath(filePath, [SKILL_PRESENTER_DIR]) && + (source.includes('getLegacyConversation') || source.includes('updateLegacyConversationSettings')) + ) { + violations.push(buildViolation('skill-legacy-fallback', filePath, 'legacy conversation skills')) + } + + if (isProtectedPath(filePath, [LEGACY_AGENT_RUNTIME_DIR])) { + for (const legacyGlobal of LEGACY_AGENT_RUNTIME_GLOBALS) { + if (source.includes(`presenter.${legacyGlobal}`)) { + violations.push( + buildViolation(`agent-global-${legacyGlobal}`, filePath, `presenter.${legacyGlobal}`) + ) + } + } + } + + if (isProtectedPath(filePath, [PROVIDER_LAYER_DIR]) && source.includes('presenter.mcpPresenter')) { + violations.push(buildViolation('provider-global-mcp', filePath, 'presenter.mcpPresenter')) + } + } + + return violations +} + +async function main() { + const violations = await findViolations() + if (violations.length > 0) { + console.error('Agent cleanup guard failed.') + for (const violation of violations) { + console.error(`- [${violation.kind}] ${violation.file} -> ${violation.specifier}`) + } + process.exit(1) + } + + console.log('Agent cleanup guard passed. Baseline violations tracked: 0.') +} + +main().catch((error) => { + console.error('Agent cleanup guard failed to run:', error) + process.exit(1) +}) diff --git a/src/main/index.ts b/src/main/index.ts index b1de5206b..a1daf4683 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -115,6 +115,15 @@ registerCoreHooks(lifecycleManager) // Initialize presenter after ready let presenter: Presenter + +function clearPresenterPermissionCaches(activePresenter?: Presenter): void { + if (!activePresenter) return + + activePresenter.commandPermissionService.clearAll() + activePresenter.filePermissionService.clearAll() + activePresenter.settingsPermissionService.clearAll() +} + // Start the lifecycle management system instead of using app.whenReady() app.whenReady().then(async () => { // Set app user model id for windows @@ -135,14 +144,13 @@ app.whenReady().then(async () => { }) app.on('before-quit', () => { - if (!presenter) return - presenter.sessionPresenter.clearCommandPermissionCache() + clearPresenterPermissionCaches(presenter) }) // Handle window-all-closed event app.on('window-all-closed', () => { + clearPresenterPermissionCaches(presenter) if (!presenter) return - presenter.sessionPresenter.clearCommandPermissionCache() // Check if there are any non-floating-button windows const mainWindows = presenter.windowPresenter.getAllWindows() diff --git a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts new file mode 100644 index 000000000..9c8eec4e6 --- /dev/null +++ b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts @@ -0,0 +1,772 @@ +import { spawn, type ChildProcess } from 'child_process' +import fs from 'fs' +import path from 'path' +import { nanoid } from 'nanoid' +import logger from '@shared/logger' +import { getShellEnvironment, getUserShell } from './shellEnvHelper' +import { resolveSessionDir } from './sessionPaths' + +// Configuration with environment variable support +const getConfig = () => ({ + backgroundMs: parseInt(process.env.PI_BASH_YIELD_MS || '10000', 10), + timeoutSec: parseInt(process.env.PI_BASH_TIMEOUT_SEC || '1800', 10), + cleanupMs: parseInt(process.env.PI_BASH_JOB_TTL_MS || '1800000', 10), + maxOutputChars: + parseInt( + process.env.OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS || + process.env.PI_BASH_MAX_OUTPUT_CHARS || + '500', + 10 + ) || 500, + offloadThresholdChars: 10000 // Offload to file when output exceeds this +}) + +export interface SessionMeta { + sessionId: string + command: string + status: 'running' | 'done' | 'error' | 'killed' + createdAt: number + lastAccessedAt: number + pid?: number + exitCode?: number + outputLength: number + offloaded: boolean +} + +interface BackgroundSession { + sessionId: string + conversationId: string + command: string + child: ChildProcess + status: 'running' | 'done' | 'error' | 'killed' + exitCode?: number + errorMessage?: string + createdAt: number + lastAccessedAt: number + outputBuffer: string + outputFilePath: string | null + outputWriteQueue: Promise + totalOutputLength: number + offloadDisabled: boolean + stdoutEof: boolean + stderrEof: boolean + closePromise: Promise + resolveClose: () => void + closeSettled: boolean + killTimeoutId?: NodeJS.Timeout +} + +interface StartSessionResult { + sessionId: string + status: 'running' +} + +interface PollResult { + status: 'running' | 'done' | 'error' | 'killed' + output: string + exitCode?: number + offloaded?: boolean + outputFilePath?: string +} + +interface LogResult { + status: 'running' | 'done' | 'error' | 'killed' + output: string + totalLength: number + exitCode?: number + offloaded?: boolean + outputFilePath?: string +} + +export class BackgroundExecSessionManager { + private sessions = new Map>() + private cleanupIntervalId?: NodeJS.Timeout + + constructor() { + this.startCleanupTimer() + } + + async start( + conversationId: string, + command: string, + cwd: string, + options?: { + timeout?: number + env?: Record + } + ): Promise { + const config = getConfig() + const sessionId = `bg_${nanoid(12)}` + const { shell, args } = getUserShell() + const shellEnv = await getShellEnvironment() + + const sessionDir = resolveSessionDir(conversationId) + if (sessionDir) { + fs.mkdirSync(sessionDir, { recursive: true }) + } + + const outputFilePath = sessionDir ? path.join(sessionDir, `bgexec_${sessionId}.log`) : null + + const child = spawn(shell, [...args, command], { + cwd, + env: { + ...process.env, + ...shellEnv, + ...options?.env + }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + + let resolveClose = () => {} + const closePromise = new Promise((resolve) => { + resolveClose = resolve + }) + + const now = Date.now() + const session: BackgroundSession = { + sessionId, + conversationId, + command, + child, + status: 'running', + createdAt: now, + lastAccessedAt: now, + outputBuffer: '', + outputFilePath, + outputWriteQueue: Promise.resolve(), + totalOutputLength: 0, + offloadDisabled: false, + stdoutEof: false, + stderrEof: false, + closePromise, + resolveClose, + closeSettled: false + } + + this.setupOutputHandling(session, config) + this.setupProcessLifecycle(session) + + const timeout = options?.timeout ?? config.timeoutSec * 1000 + if (timeout > 0) { + session.killTimeoutId = setTimeout(() => { + void this.killInternal(session, 'timeout') + }, timeout) + } + + if (!this.sessions.has(conversationId)) { + this.sessions.set(conversationId, new Map()) + } + this.sessions.get(conversationId)!.set(sessionId, session) + + logger.info(`[BackgroundExec] Started session ${sessionId} for conversation ${conversationId}`) + + return { sessionId, status: 'running' } + } + + list(conversationId: string): SessionMeta[] { + const conversationSessions = this.sessions.get(conversationId) + if (!conversationSessions) return [] + + return Array.from(conversationSessions.values()).map((session) => ({ + sessionId: session.sessionId, + command: session.command, + status: session.status, + createdAt: session.createdAt, + lastAccessedAt: session.lastAccessedAt, + pid: session.child.pid, + exitCode: session.exitCode, + outputLength: session.totalOutputLength, + offloaded: this.hasPersistedOutput(session, getConfig()) + })) + } + + async poll(conversationId: string, sessionId: string): Promise { + const session = this.getSession(conversationId, sessionId) + session.lastAccessedAt = Date.now() + await this.waitForSessionDrain(session) + + const config = getConfig() + const isOffloaded = this.hasPersistedOutput(session, config) + + if (isOffloaded && session.outputFilePath) { + const output = this.getRecentOutputFromSession(session, config.maxOutputChars) + return { + status: session.status, + output, + exitCode: session.exitCode, + offloaded: true, + outputFilePath: session.outputFilePath + } + } + + const output = this.getRecentOutput(session.outputBuffer, config.maxOutputChars) + return { + status: session.status, + output, + exitCode: session.exitCode, + offloaded: false + } + } + + async log( + conversationId: string, + sessionId: string, + offset = 0, + limit = 1000 + ): Promise { + const session = this.getSession(conversationId, sessionId) + session.lastAccessedAt = Date.now() + await this.waitForSessionDrain(session) + + const config = getConfig() + const isOffloaded = this.hasPersistedOutput(session, config) + + let output: string + if (isOffloaded && session.outputFilePath) { + output = this.readOutputFromSession(session, offset, limit, config) + } else { + output = session.outputBuffer.slice(offset, offset + limit) + } + + return { + status: session.status, + output, + totalLength: session.totalOutputLength, + exitCode: session.exitCode, + offloaded: isOffloaded, + outputFilePath: session.outputFilePath || undefined + } + } + + write(conversationId: string, sessionId: string, data: string, eof = false): void { + const session = this.getSession(conversationId, sessionId) + + if (session.status !== 'running') { + throw new Error(`Session ${sessionId} is not running`) + } + + if (!session.child.stdin || session.child.stdin.destroyed) { + throw new Error(`Session ${sessionId} stdin is not available`) + } + + session.child.stdin.write(data) + if (eof) { + session.child.stdin.end() + } + + session.lastAccessedAt = Date.now() + } + + async kill(conversationId: string, sessionId: string): Promise { + const session = this.getSession(conversationId, sessionId) + await this.killInternal(session, 'user') + } + + clear(conversationId: string, sessionId: string): void { + const session = this.getSession(conversationId, sessionId) + + session.outputBuffer = '' + session.totalOutputLength = 0 + + if (session.outputFilePath) { + this.queueOutputWrite(session, '', 'truncate') + } + + session.lastAccessedAt = Date.now() + } + + async remove(conversationId: string, sessionId: string): Promise { + const conversationSessions = this.sessions.get(conversationId) + if (!conversationSessions) { + throw new Error(`No sessions found for conversation ${conversationId}`) + } + + const session = conversationSessions.get(sessionId) + if (!session) { + throw new Error(`Session ${sessionId} not found`) + } + + if (session.status === 'running') { + await this.killInternal(session, 'remove') + } else { + await session.closePromise + } + + await session.outputWriteQueue.catch((error) => { + logger.warn('[BackgroundExec] Failed while draining output write queue:', error) + }) + + if (session.outputFilePath && fs.existsSync(session.outputFilePath)) { + try { + fs.unlinkSync(session.outputFilePath) + } catch (error) { + logger.warn( + `[BackgroundExec] Failed to remove output file ${session.outputFilePath}:`, + error + ) + } + } + + if (session.killTimeoutId) { + clearTimeout(session.killTimeoutId) + } + + conversationSessions.delete(sessionId) + if (conversationSessions.size === 0) { + this.sessions.delete(conversationId) + } + + logger.info(`[BackgroundExec] Removed session ${sessionId}`) + } + + async cleanupConversation(conversationId: string): Promise { + const conversationSessions = this.sessions.get(conversationId) + if (!conversationSessions) return + + const sessionIds = Array.from(conversationSessions.keys()) + await Promise.all(sessionIds.map((id) => this.remove(conversationId, id).catch(() => {}))) + } + + async shutdown(): Promise { + if (this.cleanupIntervalId) { + clearInterval(this.cleanupIntervalId) + } + + const allSessions: Array<{ conversationId: string; sessionId: string }> = [] + for (const [conversationId, sessions] of this.sessions) { + for (const sessionId of sessions.keys()) { + allSessions.push({ conversationId, sessionId }) + } + } + + await Promise.all( + allSessions.map(({ conversationId, sessionId }) => + this.remove(conversationId, sessionId).catch(() => {}) + ) + ) + } + + private getSession(conversationId: string, sessionId: string): BackgroundSession { + const conversationSessions = this.sessions.get(conversationId) + if (!conversationSessions) { + throw new Error(`No sessions found for conversation ${conversationId}`) + } + + const session = conversationSessions.get(sessionId) + if (!session) { + throw new Error(`Session ${sessionId} not found`) + } + + return session + } + + private setupOutputHandling( + session: BackgroundSession, + config: ReturnType + ): void { + const stdoutHandler = (data: Buffer) => { + this.appendOutput(session, data.toString('utf-8'), config) + } + + const stderrHandler = (data: Buffer) => { + this.appendOutput(session, data.toString('utf-8'), config) + } + + session.child.stdout?.on('data', stdoutHandler) + session.child.stderr?.on('data', stderrHandler) + + session.child.stdout?.on('end', () => { + session.stdoutEof = true + }) + + session.child.stderr?.on('end', () => { + session.stderrEof = true + }) + } + + private appendOutput( + session: BackgroundSession, + data: string, + config: ReturnType + ): void { + session.totalOutputLength += data.length + + const shouldOffload = + !session.offloadDisabled && + session.outputFilePath !== null && + session.totalOutputLength > config.offloadThresholdChars + + if (shouldOffload) { + const chunk = session.outputBuffer + data + session.outputBuffer = '' + this.queueOutputWrite(session, chunk, 'append') + } else { + session.outputBuffer += data + } + } + + private setupProcessLifecycle(session: BackgroundSession): void { + session.child.on('error', (error) => { + if (session.status === 'running') { + session.status = 'error' + } + session.errorMessage = error.message + logger.error(`[BackgroundExec] Session ${session.sessionId} error:`, error) + queueMicrotask(() => { + if (!session.closeSettled && session.exitCode === undefined) { + void this.finalizeSession(session, null, null) + } + }) + }) + + session.child.on('close', (code, signal) => { + if (session.killTimeoutId) { + clearTimeout(session.killTimeoutId) + } + + if (signal === 'SIGTERM' || signal === 'SIGKILL') { + session.status = 'killed' + } else if (code !== 0 && code !== null) { + session.status = 'error' + } else { + session.status = 'done' + } + + session.exitCode = code ?? undefined + void this.finalizeSession(session, code, signal) + }) + } + + private async killInternal(session: BackgroundSession, reason: string): Promise { + if (session.status !== 'running') return + + logger.info(`[BackgroundExec] Killing session ${session.sessionId} (reason: ${reason})`) + + if (session.killTimeoutId) { + clearTimeout(session.killTimeoutId) + } + + const gracefulKill = new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve() + }, 2000) + + session.child.once('close', () => { + clearTimeout(timeout) + resolve() + }) + + try { + session.child.kill('SIGTERM') + } catch { + resolve() + } + }) + + await gracefulKill + + if (session.status === 'running') { + try { + session.child.kill('SIGKILL') + } catch (error) { + logger.warn(`[BackgroundExec] Failed to force kill session ${session.sessionId}:`, error) + } + } + + await session.closePromise + } + + private getRecentOutput(buffer: string, maxChars: number): string { + if (buffer.length <= maxChars) return buffer + return buffer.slice(-maxChars) + } + + private hasPersistedOutput( + session: BackgroundSession, + config: ReturnType + ): boolean { + return ( + session.outputFilePath !== null && session.totalOutputLength > config.offloadThresholdChars + ) + } + + private getPersistedOutputLength( + session: BackgroundSession, + config: ReturnType + ): number { + if (!this.hasPersistedOutput(session, config)) { + return 0 + } + + return Math.max(0, session.totalOutputLength - session.outputBuffer.length) + } + + private getRecentOutputFromSession(session: BackgroundSession, maxChars: number): string { + if (!session.outputFilePath) { + return this.getRecentOutput(session.outputBuffer, maxChars) + } + + const filePreview = this.readLastCharsFromFile(session.outputFilePath, maxChars) + if (!session.outputBuffer) { + return filePreview + } + + return this.getRecentOutput(filePreview + session.outputBuffer, maxChars) + } + + private readOutputFromSession( + session: BackgroundSession, + offset: number, + limit: number, + config: ReturnType + ): string { + if (!session.outputFilePath) { + return session.outputBuffer.slice(offset, offset + limit) + } + + const persistedLength = this.getPersistedOutputLength(session, config) + if (persistedLength <= 0) { + return session.outputBuffer.slice(offset, offset + limit) + } + + if (offset >= persistedLength) { + const bufferOffset = offset - persistedLength + return session.outputBuffer.slice(bufferOffset, bufferOffset + limit) + } + + const fileLimit = Math.min(limit, persistedLength - offset) + const persistedOutput = this.readFromFile(session.outputFilePath, offset, fileLimit) + if (persistedOutput.length >= limit) { + return persistedOutput + } + + const remaining = limit - persistedOutput.length + return persistedOutput + session.outputBuffer.slice(0, remaining) + } + + private readLastCharsFromFile(filePath: string, maxChars: number): string { + try { + const stats = fs.statSync(filePath) + const fileSize = stats.size + const bytesToRead = Math.min(maxChars * 4, fileSize) + const startPosition = Math.max(0, fileSize - bytesToRead) + + const fd = fs.openSync(filePath, 'r') + try { + const buffer = Buffer.alloc(bytesToRead) + fs.readSync(fd, buffer, 0, bytesToRead, startPosition) + const content = buffer.toString('utf-8') + if (startPosition > 0 && content.length > 0) { + const firstNewline = content.indexOf('\n') + if (firstNewline > 0) { + return content.slice(firstNewline + 1) + } + } + return content + } finally { + fs.closeSync(fd) + } + } catch (error) { + logger.warn('[BackgroundExec] Failed to read from output file:', error) + return '' + } + } + + private readFromFile(filePath: string, offset: number, limit: number): string { + try { + const safeOffset = Math.max(0, Math.floor(offset)) + const safeLimit = Math.max(0, Math.floor(limit)) + if (safeLimit === 0) { + return '' + } + + const fd = fs.openSync(filePath, 'r') + try { + const fileSize = fs.fstatSync(fd).size + if (fileSize === 0) { + return '' + } + + const { startByte, endByte } = this.resolveUtf8ByteRange( + fd, + fileSize, + safeOffset, + safeLimit + ) + if (endByte <= startByte) { + return '' + } + + const bytesToRead = endByte - startByte + const buffer = Buffer.alloc(bytesToRead) + const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, startByte) + if (bytesRead <= 0) { + return '' + } + return buffer.subarray(0, bytesRead).toString('utf-8') + } finally { + fs.closeSync(fd) + } + } catch (error) { + logger.warn('[BackgroundExec] Failed to read from output file:', error) + return '' + } + } + + private queueOutputWrite( + session: BackgroundSession, + data: string, + mode: 'append' | 'truncate' + ): void { + if (!session.outputFilePath) { + if (mode === 'append' && data) { + session.outputBuffer += data + } + return + } + + if (mode === 'append' && session.offloadDisabled) { + if (data) { + session.outputBuffer += data + } + return + } + + const outputFilePath = session.outputFilePath + session.outputWriteQueue = session.outputWriteQueue + .then(async () => { + if (mode === 'truncate') { + await fs.promises.writeFile(outputFilePath, data, 'utf-8') + return + } + if (data.length === 0) { + return + } + await fs.promises.appendFile(outputFilePath, data, 'utf-8') + }) + .catch((error) => { + logger.warn(`[BackgroundExec] Failed to write output file (${mode}):`, error) + if (mode === 'append' && data.length > 0) { + session.offloadDisabled = true + session.outputBuffer += data + } + }) + } + + private async waitForSessionDrain(session: BackgroundSession): Promise { + if (session.status === 'running') { + return + } + + await session.closePromise + } + + private async finalizeSession( + session: BackgroundSession, + code: number | null, + signal: NodeJS.Signals | null + ): Promise { + try { + await session.outputWriteQueue.catch((error) => { + logger.warn('[BackgroundExec] Failed while draining output queue:', error) + }) + } finally { + if (!session.closeSettled) { + session.closeSettled = true + session.resolveClose() + } + } + + logger.info( + `[BackgroundExec] Session ${session.sessionId} closed with code ${code}, signal ${signal}` + ) + } + + private resolveUtf8ByteRange( + fd: number, + fileSize: number, + offset: number, + limit: number + ): { startByte: number; endByte: number } { + const targetStart = offset + const targetEnd = offset + limit + let startByte = targetStart === 0 ? 0 : -1 + let endByte = -1 + let charCount = 0 + let currentBytePos = 0 + + const chunkSize = 64 * 1024 + const chunkBuffer = Buffer.alloc(chunkSize) + + while (currentBytePos < fileSize && endByte === -1) { + const bytesToRead = Math.min(chunkSize, fileSize - currentBytePos) + const bytesRead = fs.readSync(fd, chunkBuffer, 0, bytesToRead, currentBytePos) + if (bytesRead <= 0) { + break + } + + for (let i = 0; i < bytesRead; i++) { + const byte = chunkBuffer[i] + if ((byte & 0xc0) !== 0x80) { + const absoluteBytePos = currentBytePos + i + if (startByte === -1 && charCount === targetStart) { + startByte = absoluteBytePos + } + if (charCount === targetEnd) { + endByte = absoluteBytePos + break + } + charCount++ + } + } + + currentBytePos += bytesRead + } + + if (startByte === -1) { + startByte = fileSize + } + if (endByte === -1) { + endByte = fileSize + } + if (endByte < startByte) { + endByte = startByte + } + + return { startByte, endByte } + } + + private startCleanupTimer(): void { + this.cleanupIntervalId = setInterval( + () => { + this.runCleanup() + }, + 5 * 60 * 1000 + ) + } + + private runCleanup(): void { + const config = getConfig() + const now = Date.now() + const expiredSessions: Array<{ conversationId: string; sessionId: string }> = [] + + for (const [conversationId, sessions] of this.sessions) { + for (const [sessionId, session] of sessions) { + if (now - session.lastAccessedAt > config.cleanupMs) { + expiredSessions.push({ conversationId, sessionId }) + } else if (session.status !== 'running' && now - session.lastAccessedAt > 5 * 60 * 1000) { + expiredSessions.push({ conversationId, sessionId }) + } + } + } + + for (const { conversationId, sessionId } of expiredSessions) { + logger.info(`[BackgroundExec] Auto-removing expired session ${sessionId}`) + void this.remove(conversationId, sessionId).catch((error) => { + logger.warn('[BackgroundExec] Failed to remove expired session:', error) + }) + } + } +} + +export const backgroundExecSessionManager = new BackgroundExecSessionManager() diff --git a/src/main/presenter/agentPresenter/tools/questionTool.ts b/src/main/lib/agentRuntime/questionTool.ts similarity index 100% rename from src/main/presenter/agentPresenter/tools/questionTool.ts rename to src/main/lib/agentRuntime/questionTool.ts diff --git a/src/main/lib/agentRuntime/sessionPaths.ts b/src/main/lib/agentRuntime/sessionPaths.ts new file mode 100644 index 000000000..96563a22d --- /dev/null +++ b/src/main/lib/agentRuntime/sessionPaths.ts @@ -0,0 +1,43 @@ +import { app } from 'electron' +import path from 'path' + +export function getSessionsRoot(): string { + return path.resolve(app.getPath('home'), '.deepchat', 'sessions') +} + +export function resolveSessionDir(conversationId: string): string | null { + if (!conversationId.trim()) { + return null + } + + const sessionsRoot = getSessionsRoot() + const resolvedSessionDir = path.resolve(sessionsRoot, conversationId) + const rootWithSeparator = sessionsRoot.endsWith(path.sep) + ? sessionsRoot + : `${sessionsRoot}${path.sep}` + + if (resolvedSessionDir !== sessionsRoot && !resolvedSessionDir.startsWith(rootWithSeparator)) { + return null + } + + return resolvedSessionDir +} + +export function resolveToolOffloadPath(conversationId: string, toolCallId: string): string | null { + const sessionDir = resolveSessionDir(conversationId) + if (!sessionDir) { + return null + } + + const safeToolCallId = toolCallId.replace(/[\\/]/g, '_') + return path.join(sessionDir, `tool_${safeToolCallId}.offload`) +} + +export function resolveToolOffloadTemplatePath(conversationId: string): string | null { + const sessionDir = resolveSessionDir(conversationId) + if (!sessionDir) { + return null + } + + return path.join(sessionDir, 'tool_.offload') +} diff --git a/src/main/lib/agentRuntime/shellEnvHelper.ts b/src/main/lib/agentRuntime/shellEnvHelper.ts new file mode 100644 index 000000000..a8a502a25 --- /dev/null +++ b/src/main/lib/agentRuntime/shellEnvHelper.ts @@ -0,0 +1,213 @@ +import { spawn } from 'child_process' +import * as path from 'path' + +// Memory cache for shell environment variables +let cachedShellEnv: Record | null = null + +const TIMEOUT_MS = 3000 // 3 seconds timeout + +/** + * Get user's default shell + */ +export function getUserShell(): { shell: string; args: string[] } { + const platform = process.platform + + if (platform === 'win32') { + // Windows: use PowerShell or cmd.exe + const powershell = process.env.PSModulePath ? 'powershell.exe' : null + if (powershell) { + return { shell: powershell, args: ['-NoProfile', '-Command'] } + } + return { shell: 'cmd.exe', args: ['/c'] } + } + + // Unix-like: use SHELL env var or default to bash + const shell = process.env.SHELL || '/bin/bash' + if (shell.includes('bash')) { + return { shell, args: ['-c'] } + } + if (shell.includes('zsh')) { + return { shell, args: ['-c'] } + } + if (shell.includes('fish')) { + return { shell, args: ['-c'] } + } + return { shell, args: ['-c'] } +} + +/** + * Execute shell command to get environment variables + * This will source shell initialization files to get nvm/n/fnm/volta paths + */ +async function executeShellEnvCommand(): Promise> { + const { shell, args } = getUserShell() + const platform = process.platform + + let envCommand: string + + if (platform === 'win32') { + envCommand = 'Get-ChildItem Env: | ForEach-Object { "$($_.Name)=$($_.Value)" }' + } else { + const shellName = path.basename(shell) + + if (shellName === 'bash') { + envCommand = ` + [ -f ~/.bashrc ] && source ~/.bashrc + [ -f ~/.bash_profile ] && source ~/.bash_profile + [ -f ~/.profile ] && source ~/.profile + env + `.trim() + } else if (shellName === 'zsh') { + envCommand = ` + [ -f ~/.zshrc ] && source ~/.zshrc + env + `.trim() + } else { + envCommand = 'env' + } + } + + return await new Promise>((resolve, reject) => { + const child = spawn(shell, [...args, envCommand], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env } + }) + + let stdout = '' + let stderr = '' + let timeoutId: NodeJS.Timeout | null = null + + timeoutId = setTimeout(() => { + child.kill() + reject(new Error(`Shell environment command timed out after ${TIMEOUT_MS}ms`)) + }, TIMEOUT_MS) + + child.stdout?.on('data', (data: Buffer) => { + stdout += data.toString() + }) + + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString() + }) + + child.on('error', (error) => { + if (timeoutId) clearTimeout(timeoutId) + reject(error) + }) + + child.on('exit', (code, signal) => { + if (timeoutId) clearTimeout(timeoutId) + + if (code !== 0 && signal === null) { + console.warn( + `[ACP] Shell environment command exited with code ${code}, stderr: ${stderr.substring(0, 200)}` + ) + resolve({}) + return + } + + if (signal) { + console.warn(`[ACP] Shell environment command killed by signal: ${signal}`) + resolve({}) + return + } + + const env: Record = {} + const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + for (const line of lines) { + const match = line.match(/^([^=]+)=(.*)$/) + if (match) { + const [, key, value] = match + env[key.trim()] = value.trim() + } + } + + resolve(env) + }) + }) +} + +/** + * Get shell environment variables with caching + * This will source shell initialization files to get nvm/n/fnm/volta paths + */ +export async function getShellEnvironment(): Promise> { + if (cachedShellEnv !== null) { + console.log('[ACP] Using cached shell environment variables') + return cachedShellEnv + } + + console.log('[ACP] Fetching shell environment variables (this may take a moment)...') + + try { + const shellEnv = await executeShellEnvCommand() + const filteredEnv: Record = {} + + if (shellEnv.PATH) { + filteredEnv.PATH = shellEnv.PATH + } + if (shellEnv.Path) { + filteredEnv.Path = shellEnv.Path + } + + const nodeEnvVars = [ + 'NVM_DIR', + 'NVM_CD_FLAGS', + 'NVM_BIN', + 'NODE_PATH', + 'NODE_VERSION', + 'FNM_DIR', + 'VOLTA_HOME', + 'N_PREFIX' + ] + + for (const key of nodeEnvVars) { + if (shellEnv[key]) { + filteredEnv[key] = shellEnv[key] + } + } + + const npmEnvVars = [ + 'npm_config_registry', + 'npm_config_cache', + 'npm_config_prefix', + 'npm_config_tmp', + 'NPM_CONFIG_REGISTRY', + 'NPM_CONFIG_CACHE', + 'NPM_CONFIG_PREFIX', + 'NPM_CONFIG_TMP' + ] + + for (const key of npmEnvVars) { + if (shellEnv[key]) { + filteredEnv[key] = shellEnv[key] + } + } + + cachedShellEnv = filteredEnv + + console.log('[ACP] Shell environment variables fetched and cached:', { + pathLength: filteredEnv.PATH?.length || filteredEnv.Path?.length || 0, + hasNvm: !!filteredEnv.NVM_DIR, + hasFnm: !!filteredEnv.FNM_DIR, + hasVolta: !!filteredEnv.VOLTA_HOME, + hasN: !!filteredEnv.N_PREFIX, + envVarCount: Object.keys(filteredEnv).length + }) + + return filteredEnv + } catch (error) { + console.warn('[ACP] Failed to get shell environment variables:', error) + cachedShellEnv = {} + return {} + } +} + +/** + * Clear the shell environment cache + * Should be called when ACP configuration changes (e.g., useBuiltinRuntime) + */ +export function clearShellEnvironmentCache(): void { + cachedShellEnv = null + console.log('[ACP] Shell environment cache cleared') +} diff --git a/src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts b/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts similarity index 100% rename from src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts rename to src/main/lib/agentRuntime/systemEnvPromptBuilder.ts diff --git a/src/main/presenter/agentPresenter/acp/agentToolManager.ts b/src/main/presenter/agentPresenter/acp/agentToolManager.ts index e830bf3bd..72eaadac4 100644 --- a/src/main/presenter/agentPresenter/acp/agentToolManager.ts +++ b/src/main/presenter/agentPresenter/acp/agentToolManager.ts @@ -6,19 +6,19 @@ import path from 'path' import { app, nativeImage } from 'electron' import logger from '@shared/logger' import type { ChatMessage } from '@shared/types/core/chat-message' -import { presenter } from '@/presenter' import { buildBinaryReadGuidance, shouldRejectAgentBinaryRead } from '@/lib/binaryReadGuard' import { AgentFileSystemHandler } from './agentFileSystemHandler' import { AgentBashHandler } from './agentBashHandler' import { SkillTools } from '../../skillPresenter/skillTools' import { SkillExecutionService } from '../../skillPresenter/skillExecutionService' -import { questionToolSchema, QUESTION_TOOL_NAME } from '../tools/questionTool' +import { questionToolSchema, QUESTION_TOOL_NAME } from '@/lib/agentRuntime/questionTool' import { ChatSettingsToolHandler, buildChatSettingsToolDefinitions, CHAT_SETTINGS_SKILL_NAME, CHAT_SETTINGS_TOOL_NAMES } from './chatSettingsTools' +import type { AgentToolRuntimePort } from '../runtimePorts' // Consider moving to a shared handlers location in future refactoring import { @@ -59,6 +59,7 @@ interface AgentToolManagerOptions { agentWorkspacePath: string | null configPresenter: IConfigPresenter commandPermissionHandler?: CommandPermissionService + runtimePort: AgentToolRuntimePort } export class AgentToolManager { @@ -67,6 +68,7 @@ export class AgentToolManager { private bashHandler: AgentBashHandler | null = null private readonly commandPermissionHandler?: CommandPermissionService private readonly configPresenter: IConfigPresenter + private readonly runtimePort: AgentToolRuntimePort private skillTools: SkillTools | null = null private skillExecutionService: SkillExecutionService | null = null private chatSettingsHandler: ChatSettingsToolHandler | null = null @@ -237,6 +239,7 @@ export class AgentToolManager { this.agentWorkspacePath = options.agentWorkspacePath this.configPresenter = options.configPresenter this.commandPermissionHandler = options.commandPermissionHandler + this.runtimePort = options.runtimePort if (this.agentWorkspacePath) { this.fileSystemHandler = new AgentFileSystemHandler([this.agentWorkspacePath]) this.bashHandler = new AgentBashHandler( @@ -298,9 +301,9 @@ export class AgentToolManager { // 4. DeepChat settings tools (agent mode only, skill gated) if (isAgentMode && this.isSkillsEnabled() && context.conversationId) { try { - const activeSkills = await presenter.skillPresenter.getActiveSkills(context.conversationId) + const activeSkills = await this.getSkillPresenter().getActiveSkills(context.conversationId) if (activeSkills.includes(CHAT_SETTINGS_SKILL_NAME)) { - const allowedTools = await presenter.skillPresenter.getActiveSkillsAllowedTools( + const allowedTools = await this.getSkillPresenter().getActiveSkillsAllowedTools( context.conversationId ) const requiredSettingsTools = Object.values(CHAT_SETTINGS_TOOL_NAMES) @@ -325,7 +328,7 @@ export class AgentToolManager { // 5. YoBrowser CDP tools (agent mode only) if (isAgentMode) { try { - defs.push(...presenter.yoBrowserPresenter.toolHandler.getToolDefinitions()) + defs.push(...this.getYoBrowserToolHandler().getToolDefinitions()) } catch (error) { logger.warn('[AgentToolManager] Failed to load YoBrowser tools', { error }) } @@ -386,7 +389,7 @@ export class AgentToolManager { // Route to YoBrowser CDP tools if (toolName.startsWith('yo_browser_')) { - const response = await presenter.yoBrowserPresenter.toolHandler.callTool(toolName, args) + const response = await this.getYoBrowserToolHandler().callTool(toolName, args) return { content: response } @@ -397,40 +400,10 @@ export class AgentToolManager { private async getWorkdirForConversation(conversationId: string): Promise { try { - const session = await presenter?.newAgentPresenter?.getSession(conversationId) - const normalized = session?.projectDir?.trim() - if (normalized) { - return normalized - } - } catch (error) { - logger.warn('[AgentToolManager] Failed to resolve new session workdir:', { - conversationId, - error - }) - } - - try { - const session = await presenter?.sessionManager?.getSession(conversationId) - if (!session?.resolved) { - return null - } - - const resolved = session.resolved - - if (resolved.chatMode === 'acp agent') { - const modelId = resolved.modelId - const map = resolved.acpWorkdirMap - return modelId && map ? (map[modelId] ?? null) : null - } - - if (resolved.chatMode === 'agent') { - return resolved.agentWorkspacePath ?? null - } - - return null + return await this.runtimePort.resolveConversationWorkdir(conversationId) } catch (error) { if (!this.isConversationNotFoundError(error)) { - logger.warn('[AgentToolManager] Failed to resolve legacy conversation workdir:', { + logger.warn('[AgentToolManager] Failed to resolve conversation workdir:', { conversationId, error }) @@ -783,7 +756,7 @@ export class AgentToolManager { readArgs.path, baseDirectory ) - const mimeType = await presenter.filePresenter.getMimeType(validPath) + const mimeType = await this.getFilePresenter().getMimeType(validPath) if (await shouldRejectAgentBinaryRead(validPath, mimeType)) { return { @@ -810,7 +783,7 @@ export class AgentToolManager { } } - const prepared = await presenter.filePresenter.prepareFileCompletely( + const prepared = await this.getFilePresenter().prepareFileCompletely( validPath, mimeType, 'llm-friendly' @@ -1016,7 +989,7 @@ export class AgentToolManager { addPath(app.getPath('temp')) if (conversationId) { - const approved = presenter.filePermissionService?.getApprovedPaths(conversationId) ?? [] + const approved = this.runtimePort.getApprovedFilePaths?.(conversationId) ?? [] for (const approvedPath of approved) { addPath(approvedPath) } @@ -1162,7 +1135,7 @@ export class AgentToolManager { defaultVisionModel.modelId, defaultVisionModel.providerId ) - const response = await presenter.llmproviderPresenter.generateCompletionStandalone( + const response = await this.getLlmProviderPresenter().generateCompletionStandalone( defaultVisionModel.providerId, messages, defaultVisionModel.modelId, @@ -1242,17 +1215,33 @@ export class AgentToolManager { return this.configPresenter.getSkillsEnabled() } + private getSkillPresenter() { + return this.runtimePort.getSkillPresenter() + } + + private getYoBrowserToolHandler() { + return this.runtimePort.getYoBrowserToolHandler() + } + + private getFilePresenter() { + return this.runtimePort.getFilePresenter() + } + + private getLlmProviderPresenter() { + return this.runtimePort.getLlmProviderPresenter() + } + private async isChatSettingsSkillActive(conversationId?: string): Promise { if (!conversationId || !this.isSkillsEnabled()) { return false } - const activeSkills = await presenter.skillPresenter.getActiveSkills(conversationId) + const activeSkills = await this.getSkillPresenter().getActiveSkills(conversationId) return activeSkills.includes(CHAT_SETTINGS_SKILL_NAME) } private getSkillTools(): SkillTools { if (!this.skillTools) { - this.skillTools = new SkillTools(presenter.skillPresenter) + this.skillTools = new SkillTools(this.getSkillPresenter()) } return this.skillTools } @@ -1261,9 +1250,12 @@ export class AgentToolManager { if (!this.chatSettingsHandler) { this.chatSettingsHandler = new ChatSettingsToolHandler({ configPresenter: this.configPresenter, - skillPresenter: presenter.skillPresenter, - sessionPresenter: presenter.sessionPresenter, - windowPresenter: presenter.windowPresenter + skillPresenter: this.getSkillPresenter(), + windowRuntime: { + createSettingsWindow: () => this.runtimePort.createSettingsWindow(), + sendToWindow: (windowId, channel, ...args) => + this.runtimePort.sendToWindow(windowId, channel, ...args) + } }) } return this.chatSettingsHandler @@ -1272,7 +1264,7 @@ export class AgentToolManager { private getSkillExecutionService(): SkillExecutionService { if (!this.skillExecutionService) { this.skillExecutionService = new SkillExecutionService( - presenter.skillPresenter, + this.getSkillPresenter(), this.configPresenter ) } @@ -1352,9 +1344,9 @@ export class AgentToolManager { private async hasRunnableSkillScripts(conversationId: string): Promise { try { - const activeSkills = await presenter.skillPresenter.getActiveSkills(conversationId) + const activeSkills = await this.getSkillPresenter().getActiveSkills(conversationId) for (const skillName of activeSkills) { - const scripts = await presenter.skillPresenter.listSkillScripts(skillName) + const scripts = await this.getSkillPresenter().listSkillScripts(skillName) if (scripts.some((script) => script.enabled)) { return true } @@ -1601,7 +1593,7 @@ export class AgentToolManager { const shouldCheckPermission = await this.isChatSettingsSkillActive(conversationId) if (shouldCheckPermission && conversationId) { const approved = - presenter.settingsPermissionService?.consumeApproval(conversationId, toolName) ?? false + this.runtimePort.consumeSettingsApproval?.(conversationId, toolName) ?? false if (!approved) { const responseContent = 'components.messageBlockPermissionRequest.description.write' return { diff --git a/src/main/presenter/agentPresenter/acp/backgroundExecSessionManager.ts b/src/main/presenter/agentPresenter/acp/backgroundExecSessionManager.ts index c1d9ed4d7..c4dc8700c 100644 --- a/src/main/presenter/agentPresenter/acp/backgroundExecSessionManager.ts +++ b/src/main/presenter/agentPresenter/acp/backgroundExecSessionManager.ts @@ -1,829 +1,5 @@ -import { spawn, type ChildProcess } from 'child_process' -import fs from 'fs' -import path from 'path' -import { nanoid } from 'nanoid' -import logger from '@shared/logger' -import { getShellEnvironment, getUserShell } from './shellEnvHelper' -import { resolveSessionDir } from '../../sessionPresenter/sessionPaths' - -// Configuration with environment variable support -const getConfig = () => ({ - backgroundMs: parseInt(process.env.PI_BASH_YIELD_MS || '10000', 10), - timeoutSec: parseInt(process.env.PI_BASH_TIMEOUT_SEC || '1800', 10), - cleanupMs: parseInt(process.env.PI_BASH_JOB_TTL_MS || '1800000', 10), - maxOutputChars: - parseInt( - process.env.OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS || - process.env.PI_BASH_MAX_OUTPUT_CHARS || - '500', - 10 - ) || 500, - offloadThresholdChars: 10000 // Offload to file when output exceeds this -}) - -export interface SessionMeta { - sessionId: string - command: string - status: 'running' | 'done' | 'error' | 'killed' - createdAt: number - lastAccessedAt: number - pid?: number - exitCode?: number - outputLength: number - offloaded: boolean -} - -interface BackgroundSession { - sessionId: string - conversationId: string - command: string - child: ChildProcess - status: 'running' | 'done' | 'error' | 'killed' - exitCode?: number - errorMessage?: string - createdAt: number - lastAccessedAt: number - outputBuffer: string - outputFilePath: string | null - outputWriteQueue: Promise - totalOutputLength: number - offloadDisabled: boolean - stdoutEof: boolean - stderrEof: boolean - closePromise: Promise - resolveClose: () => void - closeSettled: boolean - killTimeoutId?: NodeJS.Timeout -} - -interface StartSessionResult { - sessionId: string - status: 'running' -} - -interface PollResult { - status: 'running' | 'done' | 'error' | 'killed' - output: string - exitCode?: number - offloaded?: boolean - outputFilePath?: string -} - -interface LogResult { - status: 'running' | 'done' | 'error' | 'killed' - output: string - totalLength: number - exitCode?: number - offloaded?: boolean - outputFilePath?: string -} - -export class BackgroundExecSessionManager { - private sessions = new Map>() - private cleanupIntervalId?: NodeJS.Timeout - - constructor() { - this.startCleanupTimer() - } - - /** - * Start a new background exec session - */ - async start( - conversationId: string, - command: string, - cwd: string, - options?: { - timeout?: number - env?: Record - } - ): Promise { - const config = getConfig() - const sessionId = `bg_${nanoid(12)}` - const { shell, args } = getUserShell() - const shellEnv = await getShellEnvironment() - - // Ensure session directory exists for offload - const sessionDir = resolveSessionDir(conversationId) - if (sessionDir) { - fs.mkdirSync(sessionDir, { recursive: true }) - } - - const outputFilePath = sessionDir ? path.join(sessionDir, `bgexec_${sessionId}.log`) : null - - const child = spawn(shell, [...args, command], { - cwd, - env: { - ...process.env, - ...shellEnv, - ...options?.env - }, - stdio: ['pipe', 'pipe', 'pipe'] - }) - - let resolveClose = () => {} - const closePromise = new Promise((resolve) => { - resolveClose = resolve - }) - - const now = Date.now() - const session: BackgroundSession = { - sessionId, - conversationId, - command, - child, - status: 'running', - createdAt: now, - lastAccessedAt: now, - outputBuffer: '', - outputFilePath, - outputWriteQueue: Promise.resolve(), - totalOutputLength: 0, - offloadDisabled: false, - stdoutEof: false, - stderrEof: false, - closePromise, - resolveClose, - closeSettled: false - } - - // Set up output handling - this.setupOutputHandling(session, config) - - // Set up process lifecycle handling - this.setupProcessLifecycle(session) - - // Set up timeout - const timeout = options?.timeout ?? config.timeoutSec * 1000 - if (timeout > 0) { - session.killTimeoutId = setTimeout(() => { - this.killInternal(session, 'timeout') - }, timeout) - } - - // Store session - if (!this.sessions.has(conversationId)) { - this.sessions.set(conversationId, new Map()) - } - this.sessions.get(conversationId)!.set(sessionId, session) - - logger.info(`[BackgroundExec] Started session ${sessionId} for conversation ${conversationId}`) - - return { sessionId, status: 'running' } - } - - /** - * List all sessions for a conversation - */ - list(conversationId: string): SessionMeta[] { - const conversationSessions = this.sessions.get(conversationId) - if (!conversationSessions) return [] - - return Array.from(conversationSessions.values()).map((session) => ({ - sessionId: session.sessionId, - command: session.command, - status: session.status, - createdAt: session.createdAt, - lastAccessedAt: session.lastAccessedAt, - pid: session.child.pid, - exitCode: session.exitCode, - outputLength: session.totalOutputLength, - offloaded: this.hasPersistedOutput(session, getConfig()) - })) - } - - /** - * Poll for new output (returns recent output only) - */ - async poll(conversationId: string, sessionId: string): Promise { - const session = this.getSession(conversationId, sessionId) - session.lastAccessedAt = Date.now() - await this.waitForSessionDrain(session) - - const config = getConfig() - const isOffloaded = this.hasPersistedOutput(session, config) - - if (isOffloaded && session.outputFilePath) { - const output = this.getRecentOutputFromSession(session, config.maxOutputChars) - return { - status: session.status, - output, - exitCode: session.exitCode, - offloaded: true, - outputFilePath: session.outputFilePath - } - } - - // Return recent output from buffer - const output = this.getRecentOutput(session.outputBuffer, config.maxOutputChars) - return { - status: session.status, - output, - exitCode: session.exitCode, - offloaded: false - } - } - - /** - * Get full output log with pagination - */ - async log( - conversationId: string, - sessionId: string, - offset = 0, - limit = 1000 - ): Promise { - const session = this.getSession(conversationId, sessionId) - session.lastAccessedAt = Date.now() - await this.waitForSessionDrain(session) - - const config = getConfig() - const isOffloaded = this.hasPersistedOutput(session, config) - - let output: string - if (isOffloaded && session.outputFilePath) { - output = this.readOutputFromSession(session, offset, limit, config) - } else { - output = session.outputBuffer.slice(offset, offset + limit) - } - - return { - status: session.status, - output, - totalLength: session.totalOutputLength, - exitCode: session.exitCode, - offloaded: isOffloaded, - outputFilePath: session.outputFilePath || undefined - } - } - - /** - * Write data to session stdin - */ - write(conversationId: string, sessionId: string, data: string, eof = false): void { - const session = this.getSession(conversationId, sessionId) - - if (session.status !== 'running') { - throw new Error(`Session ${sessionId} is not running`) - } - - if (!session.child.stdin || session.child.stdin.destroyed) { - throw new Error(`Session ${sessionId} stdin is not available`) - } - - session.child.stdin.write(data) - if (eof) { - session.child.stdin.end() - } - - session.lastAccessedAt = Date.now() - } - - /** - * Kill a running session - */ - async kill(conversationId: string, sessionId: string): Promise { - const session = this.getSession(conversationId, sessionId) - await this.killInternal(session, 'user') - } - - /** - * Clear output buffer/file - */ - clear(conversationId: string, sessionId: string): void { - const session = this.getSession(conversationId, sessionId) - - session.outputBuffer = '' - session.totalOutputLength = 0 - - if (session.outputFilePath) { - this.queueOutputWrite(session, '', 'truncate') - } - - session.lastAccessedAt = Date.now() - } - - /** - * Remove a session completely - */ - async remove(conversationId: string, sessionId: string): Promise { - const conversationSessions = this.sessions.get(conversationId) - if (!conversationSessions) { - throw new Error(`No sessions found for conversation ${conversationId}`) - } - - const session = conversationSessions.get(sessionId) - if (!session) { - throw new Error(`Session ${sessionId} not found`) - } - - // Kill if still running - if (session.status === 'running') { - await this.killInternal(session, 'remove') - } else { - await session.closePromise - } - - // Ensure queued writes are completed before deleting files. - await session.outputWriteQueue.catch((error) => { - logger.warn(`[BackgroundExec] Failed while draining output write queue:`, error) - }) - - // Clean up output file - if (session.outputFilePath && fs.existsSync(session.outputFilePath)) { - try { - fs.unlinkSync(session.outputFilePath) - } catch (error) { - logger.warn( - `[BackgroundExec] Failed to remove output file ${session.outputFilePath}:`, - error - ) - } - } - - // Clear timeout - if (session.killTimeoutId) { - clearTimeout(session.killTimeoutId) - } - - // Remove from map - conversationSessions.delete(sessionId) - if (conversationSessions.size === 0) { - this.sessions.delete(conversationId) - } - - logger.info(`[BackgroundExec] Removed session ${sessionId}`) - } - - /** - * Clean up all sessions for a conversation - */ - async cleanupConversation(conversationId: string): Promise { - const conversationSessions = this.sessions.get(conversationId) - if (!conversationSessions) return - - const sessionIds = Array.from(conversationSessions.keys()) - await Promise.all(sessionIds.map((id) => this.remove(conversationId, id).catch(() => {}))) - } - - /** - * Shutdown all sessions - */ - async shutdown(): Promise { - if (this.cleanupIntervalId) { - clearInterval(this.cleanupIntervalId) - } - - const allSessions: Array<{ conversationId: string; sessionId: string }> = [] - for (const [conversationId, sessions] of this.sessions) { - for (const sessionId of sessions.keys()) { - allSessions.push({ conversationId, sessionId }) - } - } - - await Promise.all( - allSessions.map(({ conversationId, sessionId }) => - this.remove(conversationId, sessionId).catch(() => {}) - ) - ) - } - - // Private methods - - private getSession(conversationId: string, sessionId: string): BackgroundSession { - const conversationSessions = this.sessions.get(conversationId) - if (!conversationSessions) { - throw new Error(`No sessions found for conversation ${conversationId}`) - } - - const session = conversationSessions.get(sessionId) - if (!session) { - throw new Error(`Session ${sessionId} not found`) - } - - return session - } - - private setupOutputHandling( - session: BackgroundSession, - config: ReturnType - ): void { - const stdoutHandler = (data: Buffer) => { - this.appendOutput(session, data.toString('utf-8'), config) - } - - const stderrHandler = (data: Buffer) => { - this.appendOutput(session, data.toString('utf-8'), config) - } - - session.child.stdout?.on('data', stdoutHandler) - session.child.stderr?.on('data', stderrHandler) - - session.child.stdout?.on('end', () => { - session.stdoutEof = true - }) - - session.child.stderr?.on('end', () => { - session.stderrEof = true - }) - } - - private appendOutput( - session: BackgroundSession, - data: string, - config: ReturnType - ): void { - session.totalOutputLength += data.length - - const shouldOffload = - !session.offloadDisabled && - session.outputFilePath !== null && - session.totalOutputLength > config.offloadThresholdChars - - if (shouldOffload) { - const chunk = session.outputBuffer + data - session.outputBuffer = '' - this.queueOutputWrite(session, chunk, 'append') - } else { - // Keep in buffer - session.outputBuffer += data - } - } - - private setupProcessLifecycle(session: BackgroundSession): void { - session.child.on('error', (error) => { - if (session.status === 'running') { - session.status = 'error' - } - session.errorMessage = error.message - logger.error(`[BackgroundExec] Session ${session.sessionId} error:`, error) - queueMicrotask(() => { - if (!session.closeSettled && session.exitCode === undefined) { - void this.finalizeSession(session, null, null) - } - }) - }) - - session.child.on('close', (code, signal) => { - if (session.killTimeoutId) { - clearTimeout(session.killTimeoutId) - } - - if (signal === 'SIGTERM' || signal === 'SIGKILL') { - session.status = 'killed' - } else if (code !== 0 && code !== null) { - session.status = 'error' - } else { - session.status = 'done' - } - - session.exitCode = code ?? undefined - void this.finalizeSession(session, code, signal) - }) - } - - private async killInternal(session: BackgroundSession, reason: string): Promise { - if (session.status !== 'running') return - - logger.info(`[BackgroundExec] Killing session ${session.sessionId} (reason: ${reason})`) - - // Clear timeout - if (session.killTimeoutId) { - clearTimeout(session.killTimeoutId) - } - - // Try graceful kill first - const gracefulKill = new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve() // Timeout, will force kill - }, 2000) - - session.child.once('close', () => { - clearTimeout(timeout) - resolve() - }) - - try { - session.child.kill('SIGTERM') - } catch { - resolve() - } - }) - - await gracefulKill - - // Force kill if still running - if (session.status === 'running') { - try { - session.child.kill('SIGKILL') - } catch (error) { - logger.warn(`[BackgroundExec] Failed to force kill session ${session.sessionId}:`, error) - } - } - - await session.closePromise - } - - private getRecentOutput(buffer: string, maxChars: number): string { - if (buffer.length <= maxChars) return buffer - return buffer.slice(-maxChars) - } - - private hasPersistedOutput( - session: BackgroundSession, - config: ReturnType - ): boolean { - return ( - session.outputFilePath !== null && session.totalOutputLength > config.offloadThresholdChars - ) - } - - private getPersistedOutputLength( - session: BackgroundSession, - config: ReturnType - ): number { - if (!this.hasPersistedOutput(session, config)) { - return 0 - } - - return Math.max(0, session.totalOutputLength - session.outputBuffer.length) - } - - private getRecentOutputFromSession(session: BackgroundSession, maxChars: number): string { - if (!session.outputFilePath) { - return this.getRecentOutput(session.outputBuffer, maxChars) - } - - const filePreview = this.readLastCharsFromFile(session.outputFilePath, maxChars) - if (!session.outputBuffer) { - return filePreview - } - - return this.getRecentOutput(filePreview + session.outputBuffer, maxChars) - } - - private readOutputFromSession( - session: BackgroundSession, - offset: number, - limit: number, - config: ReturnType - ): string { - if (!session.outputFilePath) { - return session.outputBuffer.slice(offset, offset + limit) - } - - const persistedLength = this.getPersistedOutputLength(session, config) - if (persistedLength <= 0) { - return session.outputBuffer.slice(offset, offset + limit) - } - - if (offset >= persistedLength) { - const bufferOffset = offset - persistedLength - return session.outputBuffer.slice(bufferOffset, bufferOffset + limit) - } - - const fileLimit = Math.min(limit, persistedLength - offset) - const persistedOutput = this.readFromFile(session.outputFilePath, offset, fileLimit) - if (persistedOutput.length >= limit) { - return persistedOutput - } - - const remaining = limit - persistedOutput.length - return persistedOutput + session.outputBuffer.slice(0, remaining) - } - - private readLastCharsFromFile(filePath: string, maxChars: number): string { - try { - const stats = fs.statSync(filePath) - const fileSize = stats.size - - // Estimate bytes needed (assuming UTF-8, worst case 4 bytes per char) - const bytesToRead = Math.min(maxChars * 4, fileSize) - const startPosition = Math.max(0, fileSize - bytesToRead) - - const fd = fs.openSync(filePath, 'r') - try { - const buffer = Buffer.alloc(bytesToRead) - fs.readSync(fd, buffer, 0, bytesToRead, startPosition) - const content = buffer.toString('utf-8') - // If we read from middle of file, find first newline to start clean - if (startPosition > 0 && content.length > 0) { - const firstNewline = content.indexOf('\n') - if (firstNewline > 0) { - return content.slice(firstNewline + 1) - } - } - return content - } finally { - fs.closeSync(fd) - } - } catch (error) { - logger.warn(`[BackgroundExec] Failed to read from output file:`, error) - return '' - } - } - - private readFromFile(filePath: string, offset: number, limit: number): string { - try { - const safeOffset = Math.max(0, Math.floor(offset)) - const safeLimit = Math.max(0, Math.floor(limit)) - if (safeLimit === 0) { - return '' - } - - const fd = fs.openSync(filePath, 'r') - try { - const fileSize = fs.fstatSync(fd).size - if (fileSize === 0) { - return '' - } - - const { startByte, endByte } = this.resolveUtf8ByteRange( - fd, - fileSize, - safeOffset, - safeLimit - ) - if (endByte <= startByte) { - return '' - } - - const bytesToRead = endByte - startByte - const buffer = Buffer.alloc(bytesToRead) - const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, startByte) - if (bytesRead <= 0) { - return '' - } - return buffer.subarray(0, bytesRead).toString('utf-8') - } finally { - fs.closeSync(fd) - } - } catch (error) { - logger.warn(`[BackgroundExec] Failed to read from output file:`, error) - return '' - } - } - - private queueOutputWrite( - session: BackgroundSession, - data: string, - mode: 'append' | 'truncate' - ): void { - if (!session.outputFilePath) { - if (mode === 'append' && data) { - session.outputBuffer += data - } - return - } - - if (mode === 'append' && session.offloadDisabled) { - if (data) { - session.outputBuffer += data - } - return - } - - const outputFilePath = session.outputFilePath - session.outputWriteQueue = session.outputWriteQueue - .then(async () => { - if (mode === 'truncate') { - await fs.promises.writeFile(outputFilePath, data, 'utf-8') - return - } - if (data.length === 0) { - return - } - await fs.promises.appendFile(outputFilePath, data, 'utf-8') - }) - .catch((error) => { - logger.warn(`[BackgroundExec] Failed to write output file (${mode}):`, error) - if (mode === 'append' && data.length > 0) { - session.offloadDisabled = true - session.outputBuffer += data - } - }) - } - - private async waitForSessionDrain(session: BackgroundSession): Promise { - if (session.status === 'running') { - return - } - - await session.closePromise - } - - private async finalizeSession( - session: BackgroundSession, - code: number | null, - signal: NodeJS.Signals | null - ): Promise { - try { - await session.outputWriteQueue.catch((error) => { - logger.warn('[BackgroundExec] Failed while draining output queue:', error) - }) - } finally { - if (!session.closeSettled) { - session.closeSettled = true - session.resolveClose() - } - } - - logger.info( - `[BackgroundExec] Session ${session.sessionId} closed with code ${code}, signal ${signal}` - ) - } - - private resolveUtf8ByteRange( - fd: number, - fileSize: number, - offset: number, - limit: number - ): { startByte: number; endByte: number } { - const targetStart = offset - const targetEnd = offset + limit - let startByte = targetStart === 0 ? 0 : -1 - let endByte = -1 - let charCount = 0 - let currentBytePos = 0 - - const chunkSize = 64 * 1024 - const chunkBuffer = Buffer.alloc(chunkSize) - - while (currentBytePos < fileSize && endByte === -1) { - const bytesToRead = Math.min(chunkSize, fileSize - currentBytePos) - const bytesRead = fs.readSync(fd, chunkBuffer, 0, bytesToRead, currentBytePos) - if (bytesRead <= 0) { - break - } - - for (let i = 0; i < bytesRead; i++) { - const byte = chunkBuffer[i] - // UTF-8 character starts at non-continuation byte. - if ((byte & 0xc0) !== 0x80) { - const absoluteBytePos = currentBytePos + i - if (startByte === -1 && charCount === targetStart) { - startByte = absoluteBytePos - } - if (charCount === targetEnd) { - endByte = absoluteBytePos - break - } - charCount++ - } - } - - currentBytePos += bytesRead - } - - if (startByte === -1) { - startByte = fileSize - } - if (endByte === -1) { - endByte = fileSize - } - if (endByte < startByte) { - endByte = startByte - } - - return { startByte, endByte } - } - - private startCleanupTimer(): void { - // Run cleanup every 5 minutes - this.cleanupIntervalId = setInterval( - () => { - this.runCleanup() - }, - 5 * 60 * 1000 - ) - } - - private runCleanup(): void { - const config = getConfig() - const now = Date.now() - const expiredSessions: Array<{ conversationId: string; sessionId: string }> = [] - - for (const [conversationId, sessions] of this.sessions) { - for (const [sessionId, session] of sessions) { - // Clean up sessions that have been inactive for cleanupMs - if (now - session.lastAccessedAt > config.cleanupMs) { - expiredSessions.push({ conversationId, sessionId }) - } - // Also clean up completed sessions after a shorter period (5 minutes) - else if (session.status !== 'running' && now - session.lastAccessedAt > 5 * 60 * 1000) { - expiredSessions.push({ conversationId, sessionId }) - } - } - } - - for (const { conversationId, sessionId } of expiredSessions) { - logger.info(`[BackgroundExec] Auto-removing expired session ${sessionId}`) - this.remove(conversationId, sessionId).catch((error) => { - logger.warn(`[BackgroundExec] Failed to remove expired session:`, error) - }) - } - } -} - -// Singleton instance -export const backgroundExecSessionManager = new BackgroundExecSessionManager() +export { + BackgroundExecSessionManager, + backgroundExecSessionManager, + type SessionMeta +} from '@/lib/agentRuntime/backgroundExecSessionManager' diff --git a/src/main/presenter/agentPresenter/acp/chatSettingsTools.ts b/src/main/presenter/agentPresenter/acp/chatSettingsTools.ts index ee856bb96..ccab337f5 100644 --- a/src/main/presenter/agentPresenter/acp/chatSettingsTools.ts +++ b/src/main/presenter/agentPresenter/acp/chatSettingsTools.ts @@ -8,11 +8,10 @@ import type { OpenChatSettingsSection, MCPToolDefinition, IConfigPresenter, - ISkillPresenter, - ISessionPresenter, - IWindowPresenter + ISkillPresenter } from '@shared/presenter' import { SETTINGS_EVENTS } from '@/events' +import type { AgentToolRuntimePort } from '../runtimePorts' export const CHAT_SETTINGS_SKILL_NAME = 'deepchat-settings' export const CHAT_SETTINGS_TOOL_NAMES = { @@ -170,8 +169,7 @@ export class ChatSettingsToolHandler { private readonly options: { configPresenter: IConfigPresenter skillPresenter: ISkillPresenter - sessionPresenter: ISessionPresenter - windowPresenter: IWindowPresenter + windowRuntime: Pick } ) {} @@ -366,7 +364,7 @@ export class ChatSettingsToolHandler { const normalizedSection = normalizeSection(section) const routeName = normalizedSection ? SETTINGS_ROUTE_NAMES[normalizedSection] : undefined - const windowId = await this.options.windowPresenter.createSettingsWindow() + const windowId = await this.options.windowRuntime.createSettingsWindow() if (!windowId) { return { ok: false, @@ -376,7 +374,7 @@ export class ChatSettingsToolHandler { } if (routeName) { - this.options.windowPresenter.sendToWindow(windowId, SETTINGS_EVENTS.NAVIGATE, { + this.options.windowRuntime.sendToWindow(windowId, SETTINGS_EVENTS.NAVIGATE, { routeName, section: normalizedSection }) diff --git a/src/main/presenter/agentPresenter/acp/shellEnvHelper.ts b/src/main/presenter/agentPresenter/acp/shellEnvHelper.ts index 83951b0ef..846227653 100644 --- a/src/main/presenter/agentPresenter/acp/shellEnvHelper.ts +++ b/src/main/presenter/agentPresenter/acp/shellEnvHelper.ts @@ -1,249 +1,5 @@ -import { spawn } from 'child_process' -import * as path from 'path' - -// Memory cache for shell environment variables -let cachedShellEnv: Record | null = null - -const TIMEOUT_MS = 3000 // 3 seconds timeout - -/** - * Get user's default shell - */ -export function getUserShell(): { shell: string; args: string[] } { - const platform = process.platform - - if (platform === 'win32') { - // Windows: use PowerShell or cmd.exe - const powershell = process.env.PSModulePath ? 'powershell.exe' : null - if (powershell) { - return { shell: powershell, args: ['-NoProfile', '-Command'] } - } - return { shell: 'cmd.exe', args: ['/c'] } - } else { - // Unix-like: use SHELL env var or default to bash - const shell = process.env.SHELL || '/bin/bash' - // For interactive shells, use -c to execute command - // For bash/zsh, we need to source profile files to get nvm/etc - if (shell.includes('bash')) { - return { shell, args: ['-c'] } - } else if (shell.includes('zsh')) { - return { shell, args: ['-c'] } - } else if (shell.includes('fish')) { - return { shell, args: ['-c'] } - } else { - return { shell, args: ['-c'] } - } - } -} - -/** - * Execute shell command to get environment variables - * This will source shell initialization files to get nvm/n/fnm/volta paths - */ -async function executeShellEnvCommand(): Promise> { - const { shell, args } = getUserShell() - const platform = process.platform - - // Build command to get environment variables - // For bash/zsh, we need to source common profile files - let envCommand: string - - if (platform === 'win32') { - // Windows: PowerShell command to get all env vars - envCommand = 'Get-ChildItem Env: | ForEach-Object { "$($_.Name)=$($_.Value)" }' - } else { - // Unix-like: source common profile files and then print env - // This ensures nvm/n/fnm/volta initialization scripts are loaded - const shellName = path.basename(shell) - - if (shellName === 'bash') { - // Source .bashrc, .bash_profile, .profile in order - envCommand = ` - [ -f ~/.bashrc ] && source ~/.bashrc - [ -f ~/.bash_profile ] && source ~/.bash_profile - [ -f ~/.profile ] && source ~/.profile - env - `.trim() - } else if (shellName === 'zsh') { - // Source .zshrc - envCommand = ` - [ -f ~/.zshrc ] && source ~/.zshrc - env - `.trim() - } else { - // For other shells, just run env - envCommand = 'env' - } - } - - return new Promise>((resolve, reject) => { - const child = spawn(shell, [...args, envCommand], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env } - }) - - let stdout = '' - let stderr = '' - let timeoutId: NodeJS.Timeout | null = null - - // Set timeout - timeoutId = setTimeout(() => { - child.kill() - reject(new Error(`Shell environment command timed out after ${TIMEOUT_MS}ms`)) - }, TIMEOUT_MS) - - child.stdout?.on('data', (data: Buffer) => { - stdout += data.toString() - }) - - child.stderr?.on('data', (data: Buffer) => { - stderr += data.toString() - }) - - child.on('error', (error) => { - if (timeoutId) clearTimeout(timeoutId) - reject(error) - }) - - child.on('exit', (code, signal) => { - if (timeoutId) clearTimeout(timeoutId) - - if (code !== 0 && signal === null) { - console.warn( - `[ACP] Shell environment command exited with code ${code}, stderr: ${stderr.substring(0, 200)}` - ) - // Don't reject, return empty object as fallback - resolve({}) - return - } - - if (signal) { - console.warn(`[ACP] Shell environment command killed by signal: ${signal}`) - resolve({}) - return - } - - // Parse environment variables from output - const env: Record = {} - - if (platform === 'win32') { - // PowerShell output format: KEY=VALUE - const lines = stdout.split('\n').filter((line) => line.trim().length > 0) - for (const line of lines) { - const match = line.match(/^([^=]+)=(.*)$/) - if (match) { - const [, key, value] = match - env[key.trim()] = value.trim() - } - } - } else { - // Unix env output format: KEY=VALUE - const lines = stdout.split('\n').filter((line) => line.trim().length > 0) - for (const line of lines) { - const match = line.match(/^([^=]+)=(.*)$/) - if (match) { - const [, key, value] = match - env[key.trim()] = value.trim() - } - } - } - - resolve(env) - }) - }) -} - -/** - * Get shell environment variables with caching - * This will source shell initialization files to get nvm/n/fnm/volta paths - */ -export async function getShellEnvironment(): Promise> { - // Check cache first - if (cachedShellEnv !== null) { - console.log('[ACP] Using cached shell environment variables') - return cachedShellEnv - } - - console.log('[ACP] Fetching shell environment variables (this may take a moment)...') - - try { - const shellEnv = await executeShellEnvCommand() - - // Filter and keep only relevant environment variables - // Focus on PATH and Node.js related variables - const filteredEnv: Record = {} - - // Always include PATH (most important for nvm/n/fnm/volta) - if (shellEnv.PATH) { - filteredEnv.PATH = shellEnv.PATH - } - if (shellEnv.Path) { - // Windows uses 'Path' instead of 'PATH' - filteredEnv.Path = shellEnv.Path - } - - // Include Node.js version manager related variables - const nodeEnvVars = [ - 'NVM_DIR', - 'NVM_CD_FLAGS', - 'NVM_BIN', - 'NODE_PATH', - 'NODE_VERSION', - 'FNM_DIR', - 'VOLTA_HOME', - 'N_PREFIX' - ] - - for (const key of nodeEnvVars) { - if (shellEnv[key]) { - filteredEnv[key] = shellEnv[key] - } - } - - // Include npm-related variables - const npmEnvVars = [ - 'npm_config_registry', - 'npm_config_cache', - 'npm_config_prefix', - 'npm_config_tmp', - 'NPM_CONFIG_REGISTRY', - 'NPM_CONFIG_CACHE', - 'NPM_CONFIG_PREFIX', - 'NPM_CONFIG_TMP' - ] - - for (const key of npmEnvVars) { - if (shellEnv[key]) { - filteredEnv[key] = shellEnv[key] - } - } - - // Cache the result - cachedShellEnv = filteredEnv - - console.log('[ACP] Shell environment variables fetched and cached:', { - pathLength: filteredEnv.PATH?.length || filteredEnv.Path?.length || 0, - hasNvm: !!filteredEnv.NVM_DIR, - hasFnm: !!filteredEnv.FNM_DIR, - hasVolta: !!filteredEnv.VOLTA_HOME, - hasN: !!filteredEnv.N_PREFIX, - envVarCount: Object.keys(filteredEnv).length - }) - - return filteredEnv - } catch (error) { - console.warn('[ACP] Failed to get shell environment variables:', error) - // Cache empty object to avoid repeated failures - cachedShellEnv = {} - return {} - } -} - -/** - * Clear the shell environment cache - * Should be called when ACP configuration changes (e.g., useBuiltinRuntime) - */ -export function clearShellEnvironmentCache(): void { - cachedShellEnv = null - console.log('[ACP] Shell environment cache cleared') -} +export { + clearShellEnvironmentCache, + getShellEnvironment, + getUserShell +} from '@/lib/agentRuntime/shellEnvHelper' diff --git a/src/main/presenter/agentPresenter/index.ts b/src/main/presenter/agentPresenter/index.ts index 73632d82c..03b076f5f 100644 --- a/src/main/presenter/agentPresenter/index.ts +++ b/src/main/presenter/agentPresenter/index.ts @@ -3,16 +3,24 @@ import type { IAgentPresenter, IConfigPresenter, ILlmProviderPresenter, + IMCPPresenter, ISessionPresenter, + ISkillPresenter, ISQLitePresenter, + IToolPresenter, MESSAGE_METADATA } from '@shared/presenter' import type { AssistantMessage, AssistantMessageBlock, UserMessageContent } from '@shared/chat' import { eventBus, SendTarget } from '@/eventbus' import { STREAM_EVENTS } from '@/events' -import { presenter } from '@/presenter' import type { SessionContextResolved } from './session/sessionContext' import type { SessionManager } from './session/sessionManager' +import type { AgentSessionRuntimePort } from './session/sessionRuntimePort' +import type { + AgentMcpRuntimePort, + AgentPermissionRuntimePort, + AgentPromptRuntimePort +} from './runtimePorts' import { MessageManager } from '../sessionPresenter/managers/messageManager' import type { ThreadHandlerContext } from './types/handlerContext' import { CommandPermissionService } from '../permission/commandPermissionService' @@ -31,7 +39,11 @@ type AgentPresenterDependencies = { sqlitePresenter: ISQLitePresenter llmProviderPresenter: ILlmProviderPresenter configPresenter: IConfigPresenter + mcpPresenter: IMCPPresenter + skillPresenter: ISkillPresenter + toolPresenter: IToolPresenter commandPermissionService: CommandPermissionService + permissionRuntime: AgentPermissionRuntimePort messageManager?: MessageManager } @@ -51,6 +63,10 @@ export class AgentPresenter implements IAgentPresenter { private permissionHandler: PermissionHandler private utilityHandler: UtilityHandler private streamUpdateScheduler: StreamUpdateScheduler + private readonly sessionRuntime: AgentSessionRuntimePort + private readonly mcpRuntime: AgentMcpRuntimePort + private readonly promptRuntime: AgentPromptRuntimePort + private readonly permissionRuntime: AgentPermissionRuntimePort constructor(options: AgentPresenterDependencies) { this.sessionPresenter = options.sessionPresenter @@ -60,6 +76,49 @@ export class AgentPresenter implements IAgentPresenter { this.configPresenter = options.configPresenter this.messageManager = options.messageManager ?? new MessageManager(options.sqlitePresenter) this.commandPermissionService = options.commandPermissionService + this.permissionRuntime = options.permissionRuntime + this.sessionRuntime = { + getSession: (agentId) => this.sessionManager.getSession(agentId), + getSessionSync: (agentId) => this.sessionManager.getSessionSync(agentId), + resolveWorkspaceContext: (conversationId, modelId) => + this.sessionManager.resolveWorkspaceContext(conversationId, modelId), + startLoop: (agentId, messageId, runtimeOptions) => + this.sessionManager.startLoop(agentId, messageId, runtimeOptions), + setStatus: (agentId, status) => this.sessionManager.setStatus(agentId, status), + getStatus: (agentId) => this.sessionManager.getStatus(agentId), + updateRuntime: (agentId, updates) => this.sessionManager.updateRuntime(agentId, updates), + incrementToolCallCount: (agentId) => this.sessionManager.incrementToolCallCount(agentId), + clearPendingPermission: (agentId) => this.sessionManager.clearPendingPermission(agentId), + clearPendingQuestion: (agentId) => this.sessionManager.clearPendingQuestion(agentId), + addPendingPermission: (agentId, permission) => + this.sessionManager.addPendingPermission(agentId, permission), + removePendingPermission: (agentId, messageId, toolCallId) => + this.sessionManager.removePendingPermission(agentId, messageId, toolCallId), + getPendingPermissions: (agentId) => this.sessionManager.getPendingPermissions(agentId), + hasPendingPermissions: (agentId, messageId) => + this.sessionManager.hasPendingPermissions(agentId, messageId), + acquirePermissionResumeLock: (agentId, messageId) => + this.sessionManager.acquirePermissionResumeLock(agentId, messageId), + releasePermissionResumeLock: (agentId) => + this.sessionManager.releasePermissionResumeLock(agentId), + getPermissionResumeLock: (agentId) => this.sessionManager.getPermissionResumeLock(agentId) + } + this.mcpRuntime = { + callTool: (request) => options.mcpPresenter.callTool(request), + grantPermission: (serverName, permissionType, remember, conversationId) => + options.mcpPresenter.grantPermission(serverName, permissionType, remember, conversationId), + isServerRunning: (serverName) => options.mcpPresenter.isServerRunning(serverName) + } + this.promptRuntime = { + getInputChatMode: async () => + options.configPresenter.getSetting('input_chatMode') ?? undefined, + getSkillsEnabled: () => options.configPresenter.getSkillsEnabled(), + getActiveSkills: (conversationId) => options.skillPresenter.getActiveSkills(conversationId), + loadSkillContent: (name) => options.skillPresenter.loadSkillContent(name), + getMetadataPrompt: () => options.skillPresenter.getMetadataPrompt(), + getActiveSkillsAllowedTools: (conversationId) => + options.skillPresenter.getActiveSkillsAllowedTools(conversationId) + } this.streamUpdateScheduler = new StreamUpdateScheduler({ messageManager: this.messageManager @@ -69,7 +128,12 @@ export class AgentPresenter implements IAgentPresenter { sqlitePresenter: this.sqlitePresenter, messageManager: this.messageManager, llmProviderPresenter: this.llmProviderPresenter, - configPresenter: this.configPresenter + configPresenter: this.configPresenter, + sessionRuntime: this.sessionRuntime, + toolPresenter: options.toolPresenter, + mcpRuntime: this.mcpRuntime, + promptRuntime: this.promptRuntime, + permissionRuntime: this.permissionRuntime } this.contentBufferHandler = new ContentBufferHandler({ @@ -89,6 +153,7 @@ export class AgentPresenter implements IAgentPresenter { contentBufferHandler: this.contentBufferHandler, toolCallHandler: this.toolCallHandler, streamUpdateScheduler: this.streamUpdateScheduler, + sessionRuntime: this.sessionRuntime, onConversationUpdated: (state) => this.handleConversationUpdates(state) }) @@ -99,9 +164,6 @@ export class AgentPresenter implements IAgentPresenter { this.permissionHandler = new PermissionHandler(handlerContext, { generatingMessages: this.generatingMessages, - llmProviderPresenter: this.llmProviderPresenter, - getMcpPresenter: () => presenter.mcpPresenter, - getToolPresenter: () => presenter.toolPresenter, streamGenerationHandler: this.streamGenerationHandler, llmEventHandler: this.llmEventHandler, commandPermissionHandler: this.commandPermissionService @@ -393,8 +455,8 @@ export class AgentPresenter implements IAgentPresenter { if (message.status === 'pending') { await this.messageManager.updateMessageStatus(messageId, 'sent') } - presenter.sessionManager.clearPendingQuestion(message.conversationId) - presenter.sessionManager.setStatus(message.conversationId, 'idle') + this.sessionRuntime.clearPendingQuestion(message.conversationId) + this.sessionRuntime.setStatus(message.conversationId, 'idle') } private async resolvePendingQuestionIfNeeded( diff --git a/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts b/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts index febed8214..1bba58533 100644 --- a/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts +++ b/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts @@ -1,13 +1,18 @@ -import { ChatMessage, IConfigPresenter, LLMAgentEvent, MCPToolCall } from '@shared/presenter' -import { presenter } from '@/presenter' +import { + ChatMessage, + IConfigPresenter, + IToolPresenter, + LLMAgentEvent, + MCPToolCall +} from '@shared/presenter' import { eventBus, SendTarget } from '@/eventbus' import { WORKSPACE_EVENTS } from '@/events' import { BaseLLMProvider } from '@/presenter/llmProviderPresenter/baseProvider' import { StreamState } from './loopState' import { RateLimitManager } from '@/presenter/llmProviderPresenter/managers/rateLimitManager' import { ToolCallProcessor } from './toolCallProcessor' -import { ToolPresenter } from '../../toolPresenter' import { getAgentFilteredTools } from '../../mcpPresenter/agentMcpFilter' +import type { AgentSessionRuntimePort } from '../session/sessionRuntimePort' interface AgentLoopHandlerOptions { configPresenter: IConfigPresenter @@ -15,11 +20,12 @@ interface AgentLoopHandlerOptions { activeStreams: Map canStartNewStream: () => boolean rateLimitManager: RateLimitManager + sessionRuntime: Pick + getToolPresenter: () => IToolPresenter } export class AgentLoopHandler { private readonly toolCallProcessor: ToolCallProcessor - private toolPresenter: ToolPresenter | null = null private currentSupportsVision = false constructor(private readonly options: AgentLoopHandlerOptions) { @@ -29,7 +35,7 @@ export class AgentLoopHandler { let modelId: string | undefined if (context.conversationId) { try { - const session = await presenter.sessionManager.getSession(context.conversationId) + const session = await this.options.sessionRuntime.getSession(context.conversationId) modelId = session?.resolved.modelId } catch { // Ignore errors, modelId will be undefined @@ -55,7 +61,11 @@ export class AgentLoopHandler { return await this.getToolPresenter().callTool(request) }, preCheckToolPermission: async (request: MCPToolCall) => { - return await this.getToolPresenter().preCheckToolPermission(request) + const toolPresenter = this.getToolPresenter() + if (!toolPresenter.preCheckToolPermission) { + return null + } + return await toolPresenter.preCheckToolPermission(request) }, onToolCallFinished: ({ toolServerName, conversationId }) => { if (toolServerName !== 'agent-filesystem') return @@ -64,30 +74,12 @@ export class AgentLoopHandler { }) } - /** - * Lazy initialization of ToolPresenter - * This is needed because ToolPresenter depends on mcpPresenter and yoBrowserPresenter - * which are created after LLMProviderPresenter in the Presenter initialization order - */ - private getToolPresenter(): ToolPresenter { - if (!this.toolPresenter) { - if (presenter.toolPresenter) { - this.toolPresenter = presenter.toolPresenter as ToolPresenter - return this.toolPresenter - } - // Check if presenter is fully initialized - if (!presenter.mcpPresenter || !presenter.yoBrowserPresenter) { - throw new Error( - 'ToolPresenter dependencies not initialized. mcpPresenter and yoBrowserPresenter must be initialized first.' - ) - } - this.toolPresenter = new ToolPresenter({ - mcpPresenter: presenter.mcpPresenter, - yoBrowserPresenter: presenter.yoBrowserPresenter, - configPresenter: this.options.configPresenter - }) + private getToolPresenter(): IToolPresenter { + const toolPresenter = this.options.getToolPresenter() + if (!toolPresenter) { + throw new Error('ToolPresenter is unavailable') } - return this.toolPresenter + return toolPresenter } /** @@ -100,7 +92,7 @@ export class AgentLoopHandler { conversationId?: string, modelId?: string ): Promise<{ chatMode: 'agent' | 'acp agent'; agentWorkspacePath: string | null }> { - return presenter.sessionManager.resolveWorkspaceContext(conversationId, modelId) + return this.options.sessionRuntime.resolveWorkspaceContext(conversationId, modelId) } private notifyWorkspaceFilesChanged(conversationId?: string): void { @@ -125,10 +117,10 @@ export class AgentLoopHandler { } private async filterToolsForChatMode( - tools: Awaited>, + tools: Awaited>, chatMode: 'agent' | 'acp agent', agentId?: string - ): Promise>> { + ): Promise>> { if (chatMode !== 'acp agent') return tools if (!agentId) return [] diff --git a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts index 9f3f533ae..e3ffc0bb2 100644 --- a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts +++ b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts @@ -10,7 +10,7 @@ import fs from 'fs/promises' import path from 'path' import { isNonRetryableError } from './errorClassification' import { resolveToolOffloadPath } from '../../sessionPresenter/sessionPaths' -import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../tools/questionTool' +import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '@/lib/agentRuntime/questionTool' interface ToolCallProcessorOptions { getAllToolDefinitions: (context: ToolCallExecutionContext) => Promise diff --git a/src/main/presenter/agentPresenter/message/messageBuilder.ts b/src/main/presenter/agentPresenter/message/messageBuilder.ts index 7822da68d..8599fe871 100644 --- a/src/main/presenter/agentPresenter/message/messageBuilder.ts +++ b/src/main/presenter/agentPresenter/message/messageBuilder.ts @@ -1,8 +1,13 @@ import { approximateTokenSize } from 'tokenx' -import { presenter } from '@/presenter' import { AssistantMessage, Message, MessageFile, UserMessageContent } from '@shared/chat' import { ModelType } from '@shared/model' -import { CONVERSATION, ModelConfig, SearchResult, ChatMessage } from '@shared/presenter' +import { + CONVERSATION, + IToolPresenter, + ModelConfig, + SearchResult, + ChatMessage +} from '@shared/presenter' import type { MCPToolDefinition } from '@shared/presenter' import { modelCapabilities } from '../../configPresenter/modelCapabilities' @@ -22,7 +27,11 @@ import { buildSkillsPrompt, getSkillsAllowedTools } from './skillsPromptBuilder' -import { buildRuntimeCapabilitiesPrompt, buildSystemEnvPrompt } from './systemEnvPromptBuilder' +import { + buildRuntimeCapabilitiesPrompt, + buildSystemEnvPrompt +} from '@/lib/agentRuntime/systemEnvPromptBuilder' +import type { AgentPromptRuntimePort } from '../runtimePorts' export type PendingToolCall = { id: string @@ -43,6 +52,8 @@ export interface PreparePromptContentParams { imageFiles: MessageFile[] supportsFunctionCall: boolean modelType?: ModelType + toolPresenter: IToolPresenter + promptRuntime: AgentPromptRuntimePort } export interface ContinueToolCallContextParams { @@ -111,7 +122,9 @@ export async function preparePromptContent({ vision, imageFiles, supportsFunctionCall, - modelType + modelType, + toolPresenter, + promptRuntime }: PreparePromptContentParams): Promise<{ finalContent: ChatMessage[] promptTokens: number @@ -124,7 +137,7 @@ export async function preparePromptContent({ } const rawChatMode = conversation.settings.chatMode - const rawFallback = await presenter.configPresenter.getSetting('input_chatMode') + const rawFallback = await promptRuntime.getInputChatMode() const chatMode: 'agent' | 'acp agent' = normalizeChatMode(rawChatMode) ?? normalizeChatMode(rawFallback) ?? 'agent' @@ -137,12 +150,12 @@ export async function preparePromptContent({ const { providerId, modelId } = conversation.settings const supportsVision = modelCapabilities.supportsVision(providerId, modelId) - const toolCallCenter = new ToolCallCenter(presenter.toolPresenter) + const toolCallCenter = new ToolCallCenter(toolPresenter) let toolDefinitions: MCPToolDefinition[] = [] let effectiveEnabledMcpTools = enabledMcpTools if (!isImageGeneration && chatMode === 'agent') { - const skillsAllowedTools = await getSkillsAllowedTools(conversation.id) + const skillsAllowedTools = await getSkillsAllowedTools(promptRuntime, conversation.id) effectiveEnabledMcpTools = mergeToolSelections(enabledMcpTools, skillsAllowedTools) } @@ -170,8 +183,8 @@ export async function preparePromptContent({ if (!isImageGeneration && chatMode === 'agent') { runtimePrompt = buildRuntimeCapabilitiesPrompt() try { - skillsMetadataPrompt = await buildSkillsMetadataPrompt() - skillsPrompt = await buildSkillsPrompt(conversation.id) + skillsMetadataPrompt = await buildSkillsMetadataPrompt(promptRuntime) + skillsPrompt = await buildSkillsPrompt(promptRuntime, conversation.id) } catch (error) { console.warn('AgentPresenter: Failed to build skills prompt', error) } diff --git a/src/main/presenter/agentPresenter/message/skillsPromptBuilder.ts b/src/main/presenter/agentPresenter/message/skillsPromptBuilder.ts index f9569ed46..0f868c42f 100644 --- a/src/main/presenter/agentPresenter/message/skillsPromptBuilder.ts +++ b/src/main/presenter/agentPresenter/message/skillsPromptBuilder.ts @@ -1,9 +1,4 @@ -import { presenter } from '@/presenter' -import { SkillPresenter } from '../../skillPresenter' - -function isSkillsEnabled(): boolean { - return presenter.configPresenter.getSkillsEnabled() -} +import type { AgentPromptRuntimePort } from '../runtimePorts' /** * Build the skills prompt section for the system prompt. @@ -12,14 +7,16 @@ function isSkillsEnabled(): boolean { * @param conversationId - The conversation ID to get active skills for * @returns A formatted string containing all active skill contents, or empty string if no skills active */ -export async function buildSkillsPrompt(conversationId: string): Promise { +export async function buildSkillsPrompt( + promptRuntime: AgentPromptRuntimePort, + conversationId: string +): Promise { try { - if (!isSkillsEnabled()) { + if (!promptRuntime.getSkillsEnabled()) { return '' } - const skillPresenter = presenter.skillPresenter as SkillPresenter - const activeSkills = await skillPresenter.getActiveSkills(conversationId) + const activeSkills = await promptRuntime.getActiveSkills(conversationId) if (activeSkills.length === 0) { return '' @@ -28,7 +25,7 @@ export async function buildSkillsPrompt(conversationId: string): Promise const skillContents: string[] = [] for (const skillName of activeSkills) { - const skillContent = await skillPresenter.loadSkillContent(skillName) + const skillContent = await promptRuntime.loadSkillContent(skillName) if (skillContent && skillContent.content) { skillContents.push(`## Skill: ${skillName}\n\n${skillContent.content}`) } @@ -50,14 +47,15 @@ export async function buildSkillsPrompt(conversationId: string): Promise * Lists available skills and how to activate them. * Delegates to skillPresenter.getMetadataPrompt() to avoid code duplication. */ -export async function buildSkillsMetadataPrompt(): Promise { +export async function buildSkillsMetadataPrompt( + promptRuntime: AgentPromptRuntimePort +): Promise { try { - if (!isSkillsEnabled()) { + if (!promptRuntime.getSkillsEnabled()) { return '' } - const skillPresenter = presenter.skillPresenter as SkillPresenter - return await skillPresenter.getMetadataPrompt() + return await promptRuntime.getMetadataPrompt() } catch (error) { console.warn('[SkillsPromptBuilder] Failed to build skills metadata prompt:', error) return '' @@ -71,14 +69,16 @@ export async function buildSkillsMetadataPrompt(): Promise { * @param conversationId - The conversation ID * @returns Array of allowed tool names from active skills */ -export async function getSkillsAllowedTools(conversationId: string): Promise { +export async function getSkillsAllowedTools( + promptRuntime: AgentPromptRuntimePort, + conversationId: string +): Promise { try { - if (!isSkillsEnabled()) { + if (!promptRuntime.getSkillsEnabled()) { return [] } - const skillPresenter = presenter.skillPresenter as SkillPresenter - return await skillPresenter.getActiveSkillsAllowedTools(conversationId) + return await promptRuntime.getActiveSkillsAllowedTools(conversationId) } catch (error) { console.warn('[SkillsPromptBuilder] Failed to get skills allowed tools:', error) return [] diff --git a/src/main/presenter/agentPresenter/permission/permissionHandler.ts b/src/main/presenter/agentPresenter/permission/permissionHandler.ts index 2fa880fbc..5fd0ed4b2 100644 --- a/src/main/presenter/agentPresenter/permission/permissionHandler.ts +++ b/src/main/presenter/agentPresenter/permission/permissionHandler.ts @@ -1,12 +1,5 @@ -import { presenter } from '@/presenter' import type { AssistantMessage, AssistantMessageBlock } from '@shared/chat' -import type { - ILlmProviderPresenter, - IMCPPresenter, - IToolPresenter, - MCPToolDefinition, - MCPToolResponse -} from '@shared/presenter' +import type { MCPToolDefinition, MCPToolResponse } from '@shared/presenter' import { buildPostToolExecutionContext, type PendingToolCall } from '../message/messageBuilder' import type { GeneratingMessageState } from '../streaming/types' import type { StreamGenerationHandler } from '../streaming/streamGenerationHandler' @@ -71,8 +64,6 @@ function canBatchUpdate( export class PermissionHandler extends BaseHandler { private readonly generatingMessages: Map - private readonly getMcpPresenter: () => IMCPPresenter - private readonly getToolPresenter: () => IToolPresenter private readonly streamGenerationHandler: StreamGenerationHandler private readonly llmEventHandler: LLMEventHandler private readonly commandPermissionHandler: CommandPermissionService @@ -81,9 +72,6 @@ export class PermissionHandler extends BaseHandler { context: ThreadHandlerContext, options: { generatingMessages: Map - llmProviderPresenter: ILlmProviderPresenter - getMcpPresenter: () => IMCPPresenter - getToolPresenter: () => IToolPresenter streamGenerationHandler: StreamGenerationHandler llmEventHandler: LLMEventHandler commandPermissionHandler: CommandPermissionService @@ -91,8 +79,6 @@ export class PermissionHandler extends BaseHandler { ) { super(context) this.generatingMessages = options.generatingMessages - this.getMcpPresenter = options.getMcpPresenter - this.getToolPresenter = options.getToolPresenter this.streamGenerationHandler = options.streamGenerationHandler this.llmEventHandler = options.llmEventHandler this.commandPermissionHandler = options.commandPermissionHandler @@ -101,8 +87,6 @@ export class PermissionHandler extends BaseHandler { private assertDependencies(): void { void this.generatingMessages - void this.getMcpPresenter - void this.getToolPresenter void this.streamGenerationHandler void this.llmEventHandler void this.commandPermissionHandler @@ -163,7 +147,7 @@ export class PermissionHandler extends BaseHandler { // Step 2: Remove this permission from pending list (only if we actually updated something) if (updatedCount > 0) { - presenter.sessionManager.removePendingPermission(conversationId, messageId, toolCallId) + this.sessionRuntime.removePendingPermission(conversationId, messageId, toolCallId) this.notifyFrontendPermissionUpdate(conversationId, messageId) } else { console.warn( @@ -189,8 +173,8 @@ export class PermissionHandler extends BaseHandler { parsedPermissionRequest, granted ) - presenter.sessionManager.clearPendingPermission(conversationId) - presenter.sessionManager.setStatus(conversationId, 'generating') + this.sessionRuntime.clearPendingPermission(conversationId) + this.sessionRuntime.setStatus(conversationId, 'generating') return } } @@ -220,7 +204,7 @@ export class PermissionHandler extends BaseHandler { // Mark as denied and continue await this.updatePermissionBlocks(messageId, toolCallId, false, permissionType) } else { - presenter.filePermissionService?.approve(conversationId, paths, remember) + this.permissionRuntime.approveFileAccess?.(conversationId, paths, remember) } } else if (serverName === 'deepchat-settings') { const parsedPermissionRequest = this.parsePermissionRequest(targetPermissionBlock) @@ -234,12 +218,12 @@ export class PermissionHandler extends BaseHandler { console.warn('[PermissionHandler] Missing tool name in settings permission request') await this.updatePermissionBlocks(messageId, toolCallId, false, permissionType) } else { - presenter.settingsPermissionService?.approve(conversationId, toolName, remember) + this.permissionRuntime.approveSettingsAccess?.(conversationId, toolName, remember) } } else { // MCP server permission try { - await this.getMcpPresenter().grantPermission( + await this.mcpRuntime.grantPermission( serverName, permissionType, remember, @@ -265,7 +249,7 @@ export class PermissionHandler extends BaseHandler { } // Step 6: All permissions resolved - try to acquire resume lock and execute - const lockAcquired = presenter.sessionManager.acquirePermissionResumeLock( + const lockAcquired = this.sessionRuntime.acquirePermissionResumeLock( conversationId, messageId ) @@ -285,7 +269,7 @@ export class PermissionHandler extends BaseHandler { try { const conversationId = await this.getConversationIdFromMessage(messageId) if (conversationId) { - presenter.sessionManager.releasePermissionResumeLock(conversationId) + this.sessionRuntime.releasePermissionResumeLock(conversationId) } } catch (lockError) { console.warn('[PermissionHandler] Failed to release lock during error handling:', lockError) @@ -453,15 +437,15 @@ export class PermissionHandler extends BaseHandler { // CRITICAL SECTION: Lock must be held throughout this entire method // Early-exit checks: Validate session state before proceeding - const session = presenter.sessionManager.getSessionSync(conversationId) + const session = this.sessionRuntime.getSessionSync(conversationId) if (!session) { console.warn('[PermissionHandler] Session not found, skipping resume:', conversationId) - presenter.sessionManager.releasePermissionResumeLock(conversationId) + this.sessionRuntime.releasePermissionResumeLock(conversationId) return } // Verify the lock is still valid (same message) - const currentLock = presenter.sessionManager.getPermissionResumeLock(conversationId) + const currentLock = this.sessionRuntime.getPermissionResumeLock(conversationId) if (!currentLock || currentLock.messageId !== messageId) { console.warn( '[PermissionHandler] Lock mismatch or expired, skipping resume. Expected:', @@ -471,38 +455,38 @@ export class PermissionHandler extends BaseHandler { ) // CRITICAL: Always release lock if we don't proceed - it was acquired in handlePermissionResponse if (currentLock) { - presenter.sessionManager.releasePermissionResumeLock(conversationId) + this.sessionRuntime.releasePermissionResumeLock(conversationId) } return } // Ensure status is appropriate for tool execution // Transition from waiting_permission to generating since we're resuming - const currentStatus = presenter.sessionManager.getStatus(conversationId) + const currentStatus = this.sessionRuntime.getStatus(conversationId) if (currentStatus === 'waiting_permission') { console.log('[PermissionHandler] Transitioning session from waiting_permission to generating') - presenter.sessionManager.setStatus(conversationId, 'generating') + this.sessionRuntime.setStatus(conversationId, 'generating') } else if ( currentStatus === 'idle' && - presenter.sessionManager.hasPendingPermissions(conversationId, messageId) + this.sessionRuntime.hasPendingPermissions(conversationId, messageId) ) { console.warn( '[PermissionHandler] Session was idle during permission resume, forcing generating status' ) - presenter.sessionManager.setStatus(conversationId, 'generating') + this.sessionRuntime.setStatus(conversationId, 'generating') } else if (currentStatus !== 'generating') { console.warn( '[PermissionHandler] Session status not suitable for resume. Status:', currentStatus ) - presenter.sessionManager.releasePermissionResumeLock(conversationId) + this.sessionRuntime.releasePermissionResumeLock(conversationId) return } try { // Step 1: Start the agent loop with pending permissions preservation // skipLockAcquisition: PermissionHandler already holds the lock - await presenter.sessionManager.startLoop(conversationId, messageId, { + await this.sessionRuntime.startLoop(conversationId, messageId, { preservePendingPermissions: true, skipLockAcquisition: true }) @@ -525,7 +509,7 @@ export class PermissionHandler extends BaseHandler { ) await this.resumeStreamCompletion(conversationId, messageId) // SINGLE EXIT POINT: Release lock - presenter.sessionManager.releasePermissionResumeLock(conversationId) + this.sessionRuntime.releasePermissionResumeLock(conversationId) return } @@ -575,7 +559,7 @@ export class PermissionHandler extends BaseHandler { '[PermissionHandler] Tool(s) executed but more permissions pending, releasing lock and waiting' ) // SINGLE EXIT POINT: Release lock - presenter.sessionManager.releasePermissionResumeLock(conversationId) + this.sessionRuntime.releasePermissionResumeLock(conversationId) this.notifyFrontendPermissionUpdate(conversationId, messageId) return } @@ -583,7 +567,7 @@ export class PermissionHandler extends BaseHandler { // Step 8: All permissions resolved, continue with stream completion await this.continueAfterToolsExecuted(state, conversationId, messageId) // SINGLE EXIT POINT: Release lock after successful completion - presenter.sessionManager.releasePermissionResumeLock(conversationId) + this.sessionRuntime.releasePermissionResumeLock(conversationId) } catch (error) { console.error('[PermissionHandler] Failed to resume tool execution:', error) this.generatingMessages.delete(messageId) @@ -598,7 +582,7 @@ export class PermissionHandler extends BaseHandler { } // SINGLE EXIT POINT: Ensure lock is released on error - presenter.sessionManager.releasePermissionResumeLock(conversationId) + this.sessionRuntime.releasePermissionResumeLock(conversationId) throw error } } @@ -733,12 +717,11 @@ export class PermissionHandler extends BaseHandler { let toolDef: MCPToolDefinition | undefined try { - const { chatMode, agentWorkspacePath } = - await presenter.sessionManager.resolveWorkspaceContext( - conversationId, - conversation.settings.modelId - ) - const toolDefinitions = await this.getToolPresenter().getAllToolDefinitions({ + const { chatMode, agentWorkspacePath } = await this.sessionRuntime.resolveWorkspaceContext( + conversationId, + conversation.settings.modelId + ) + const toolDefinitions = await this.toolPresenter.getAllToolDefinitions({ enabledMcpTools: conversation.settings.enabledMcpTools, chatMode, supportsVision: false, @@ -779,7 +762,7 @@ export class PermissionHandler extends BaseHandler { let toolContent = '' let toolRawData: MCPToolResponse | null = null try { - const toolCallResult = await this.getToolPresenter().callTool({ + const toolCallResult = await this.toolPresenter.callTool({ id: toolCall.id, type: 'function', function: { @@ -813,7 +796,7 @@ export class PermissionHandler extends BaseHandler { // Check if permission is required again if (toolRawData?.requiresPermission) { // Add this permission to pending list and set session status - presenter.sessionManager.addPendingPermission(conversationId, { + this.sessionRuntime.addPendingPermission(conversationId, { messageId: state.message.id, toolCallId: toolCall.id, permissionType: @@ -824,7 +807,7 @@ export class PermissionHandler extends BaseHandler { | 'command') || 'read', payload: toolRawData.permissionRequest ?? {} }) - presenter.sessionManager.setStatus(conversationId, 'waiting_permission') + this.sessionRuntime.setStatus(conversationId, 'waiting_permission') await this.llmEventHandler.handleLLMAgentResponse({ eventId: state.message.id, @@ -949,7 +932,7 @@ export class PermissionHandler extends BaseHandler { const conversationId = message.conversationId // Permission denied flow: skip lock acquisition (not part of permission resume critical section) - await presenter.sessionManager.startLoop(conversationId, messageId, { + await this.sessionRuntime.startLoop(conversationId, messageId, { preservePendingPermissions: true, skipLockAcquisition: true }) @@ -1079,7 +1062,7 @@ export class PermissionHandler extends BaseHandler { return new Promise((resolve) => { const checkReady = async () => { try { - const isRunning = await this.getMcpPresenter().isServerRunning(serverName) + const isRunning = await this.mcpRuntime.isServerRunning(serverName) if (isRunning) { setTimeout(() => resolve(), 200) return @@ -1215,8 +1198,7 @@ export class PermissionHandler extends BaseHandler { */ private notifyFrontendPermissionUpdate(conversationId: string, messageId: string): void { try { - const pendingPermissions = - presenter.sessionManager.getPendingPermissions(conversationId) ?? [] + const pendingPermissions = this.sessionRuntime.getPendingPermissions(conversationId) ?? [] const nextPermission = pendingPermissions[0] console.log('[PermissionHandler] Notifying frontend of permission update:', { conversationId, diff --git a/src/main/presenter/agentPresenter/runtimePorts.ts b/src/main/presenter/agentPresenter/runtimePorts.ts new file mode 100644 index 000000000..f10baf47d --- /dev/null +++ b/src/main/presenter/agentPresenter/runtimePorts.ts @@ -0,0 +1,46 @@ +import type { + IFilePresenter, + ILlmProviderPresenter, + IMCPPresenter, + IWindowPresenter, + IYoBrowserPresenter +} from '@shared/presenter' +import type { ISkillPresenter, SkillContent } from '@shared/types/skill' + +export interface AgentMcpRuntimePort { + callTool: IMCPPresenter['callTool'] + grantPermission: IMCPPresenter['grantPermission'] + isServerRunning: IMCPPresenter['isServerRunning'] +} + +export interface AgentPromptRuntimePort { + getInputChatMode(): Promise + getSkillsEnabled(): boolean + getActiveSkills(conversationId: string): Promise + loadSkillContent(name: string): Promise + getMetadataPrompt(): Promise + getActiveSkillsAllowedTools(conversationId: string): Promise +} + +export interface AgentPermissionRuntimePort { + approveFileAccess?(conversationId: string, paths: string[], remember: boolean): void + getApprovedFilePaths?(conversationId: string): string[] + approveSettingsAccess?(conversationId: string, toolName: string, remember: boolean): void + consumeSettingsApproval?(conversationId: string, toolName: string): boolean +} + +export interface AgentToolRuntimePort { + resolveConversationWorkdir(conversationId: string): Promise + getSkillPresenter(): ISkillPresenter + getYoBrowserToolHandler(): IYoBrowserPresenter['toolHandler'] + getFilePresenter(): Pick + getLlmProviderPresenter(): Pick + createSettingsWindow(): ReturnType + sendToWindow( + windowId: number, + channel: string, + ...args: unknown[] + ): ReturnType + getApprovedFilePaths?(conversationId: string): string[] + consumeSettingsApproval?(conversationId: string, toolName: string): boolean +} diff --git a/src/main/presenter/agentPresenter/session/sessionRuntimePort.ts b/src/main/presenter/agentPresenter/session/sessionRuntimePort.ts new file mode 100644 index 000000000..41c054308 --- /dev/null +++ b/src/main/presenter/agentPresenter/session/sessionRuntimePort.ts @@ -0,0 +1,39 @@ +import type { + PendingPermission, + PermissionResumeLock, + SessionContext, + SessionStatus +} from './sessionContext' + +export type SessionRuntimeLoopOptions = { + preservePendingPermissions?: boolean + skipLockAcquisition?: boolean +} + +export type SessionRuntimeWorkspaceContext = { + chatMode: 'agent' | 'acp agent' + agentWorkspacePath: string | null +} + +export interface AgentSessionRuntimePort { + getSession(agentId: string): Promise + getSessionSync(agentId: string): SessionContext | null + resolveWorkspaceContext( + conversationId?: string, + modelId?: string + ): Promise + startLoop(agentId: string, messageId: string, options?: SessionRuntimeLoopOptions): Promise + setStatus(agentId: string, status: SessionStatus): void + getStatus(agentId: string): SessionStatus | null + updateRuntime(agentId: string, updates: Partial): void + incrementToolCallCount(agentId: string): void + clearPendingPermission(agentId: string): void + clearPendingQuestion(agentId: string): void + addPendingPermission(agentId: string, permission: PendingPermission): void + removePendingPermission(agentId: string, messageId: string, toolCallId: string): void + getPendingPermissions(agentId: string): PendingPermission[] | undefined + hasPendingPermissions(agentId: string, messageId?: string): boolean + acquirePermissionResumeLock(agentId: string, messageId: string): boolean + releasePermissionResumeLock(agentId: string): void + getPermissionResumeLock(agentId: string): PermissionResumeLock | undefined +} diff --git a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts index 60e67125a..404d8b85a 100644 --- a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts @@ -4,12 +4,12 @@ import type { AssistantMessageBlock } from '@shared/chat' import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks' import type { LLMAgentEventData, MESSAGE_METADATA } from '@shared/presenter' import { approximateTokenSize } from 'tokenx' -import { presenter } from '@/presenter' import type { MessageManager } from '../../sessionPresenter/managers/messageManager' import type { GeneratingMessageState } from './types' import type { ContentBufferHandler } from './contentBufferHandler' import type { ToolCallHandler } from '../loop/toolCallHandler' import type { StreamUpdateScheduler } from './streamUpdateScheduler' +import type { AgentSessionRuntimePort } from '../session/sessionRuntimePort' type ConversationUpdateHandler = (state: GeneratingMessageState) => Promise @@ -27,6 +27,7 @@ export class LLMEventHandler { private readonly contentBufferHandler: ContentBufferHandler private readonly toolCallHandler: ToolCallHandler private readonly streamUpdateScheduler: StreamUpdateScheduler + private readonly sessionRuntime: AgentSessionRuntimePort private readonly onConversationUpdated?: ConversationUpdateHandler private readonly errorByEventId: Map = new Map() @@ -36,6 +37,7 @@ export class LLMEventHandler { contentBufferHandler: ContentBufferHandler toolCallHandler: ToolCallHandler streamUpdateScheduler: StreamUpdateScheduler + sessionRuntime: AgentSessionRuntimePort onConversationUpdated?: ConversationUpdateHandler }) { this.generatingMessages = options.generatingMessages @@ -43,6 +45,7 @@ export class LLMEventHandler { this.contentBufferHandler = options.contentBufferHandler this.toolCallHandler = options.toolCallHandler this.streamUpdateScheduler = options.streamUpdateScheduler + this.sessionRuntime = options.sessionRuntime this.onConversationUpdated = options.onConversationUpdated } @@ -156,7 +159,7 @@ export class LLMEventHandler { switch (tool_call) { case 'start': - presenter.sessionManager.incrementToolCallCount(state.conversationId) + this.sessionRuntime.incrementToolCallCount(state.conversationId) await this.toolCallHandler.processToolCallStart(state, msg, currentTime) break case 'update': @@ -164,7 +167,7 @@ export class LLMEventHandler { await this.toolCallHandler.processToolCallUpdate(state, msg) break case 'permission-required': - presenter.sessionManager.addPendingPermission(state.conversationId, { + this.sessionRuntime.addPendingPermission(state.conversationId, { messageId: eventId, toolCallId: tool_call_id || '', permissionType: @@ -172,17 +175,17 @@ export class LLMEventHandler { 'read', payload: msg.permission_request ?? {} }) - presenter.sessionManager.setStatus(state.conversationId, 'waiting_permission') + this.sessionRuntime.setStatus(state.conversationId, 'waiting_permission') await this.toolCallHandler.processToolCallPermission(state, msg, currentTime) break case 'question-required': - presenter.sessionManager.updateRuntime(state.conversationId, { + this.sessionRuntime.updateRuntime(state.conversationId, { pendingQuestion: { messageId: eventId, toolCallId: tool_call_id || '' } }) - presenter.sessionManager.setStatus(state.conversationId, 'waiting_question') + this.sessionRuntime.setStatus(state.conversationId, 'waiting_question') await this.toolCallHandler.processQuestionRequest(state, msg, currentTime) break case 'permission-granted': @@ -367,15 +370,15 @@ export class LLMEventHandler { if (state) { this.generatingMessages.delete(eventId) - presenter.sessionManager.setStatus(state.conversationId, 'error') - presenter.sessionManager.clearPendingPermission(state.conversationId) - presenter.sessionManager.clearPendingQuestion(state.conversationId) + this.sessionRuntime.setStatus(state.conversationId, 'error') + this.sessionRuntime.clearPendingPermission(state.conversationId) + this.sessionRuntime.clearPendingQuestion(state.conversationId) } else { const message = await this.messageManager.getMessage(eventId) if (message) { - presenter.sessionManager.setStatus(message.conversationId, 'error') - presenter.sessionManager.clearPendingPermission(message.conversationId) - presenter.sessionManager.clearPendingQuestion(message.conversationId) + this.sessionRuntime.setStatus(message.conversationId, 'error') + this.sessionRuntime.clearPendingPermission(message.conversationId) + this.sessionRuntime.clearPendingQuestion(message.conversationId) } } @@ -433,9 +436,9 @@ export class LLMEventHandler { // Question tool ends the assistant message even when waiting for user input. await this.messageManager.updateMessageStatus(eventId, 'sent') } - presenter.sessionManager.setStatus(state.conversationId, 'waiting_permission') + this.sessionRuntime.setStatus(state.conversationId, 'waiting_permission') if (!hasPendingPermissions) { - presenter.sessionManager.setStatus(state.conversationId, 'waiting_question') + this.sessionRuntime.setStatus(state.conversationId, 'waiting_question') } await this.streamUpdateScheduler.flushAll(eventId, 'final') this.generatingMessages.delete(eventId) @@ -445,9 +448,9 @@ export class LLMEventHandler { } await this.finalizeMessage(state, eventId, Boolean(userStop)) - presenter.sessionManager.setStatus(state.conversationId, 'idle') - presenter.sessionManager.clearPendingPermission(state.conversationId) - presenter.sessionManager.clearPendingQuestion(state.conversationId) + this.sessionRuntime.setStatus(state.conversationId, 'idle') + this.sessionRuntime.clearPendingPermission(state.conversationId) + this.sessionRuntime.clearPendingQuestion(state.conversationId) } try { diff --git a/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts b/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts index 6dbf443d4..c91f30339 100644 --- a/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts @@ -12,7 +12,6 @@ import type { CONVERSATION, MCPToolResponse } from '@shared/presenter' import { buildUserMessageContext, formatUserMessageContent } from '../message/messageFormatter' import { preparePromptContent } from '../message/messageBuilder' import type { GeneratingMessageState } from './types' -import { presenter } from '@/presenter' import { BaseHandler, type ThreadHandlerContext } from '../types/handlerContext' import type { LLMEventHandler } from './llmEventHandler' import { LoopOrchestrator } from '../loop/loopOrchestrator' @@ -55,7 +54,7 @@ export class StreamGenerationHandler extends BaseHandler { try { state.isCancelled = false // Normal flow: skip lock acquisition (lock is only for permission resume) - await presenter.sessionManager.startLoop(conversationId, state.message.id, { + await this.sessionRuntime.startLoop(conversationId, state.message.id, { skipLockAcquisition: true }) @@ -65,11 +64,10 @@ export class StreamGenerationHandler extends BaseHandler { selectedVariantsMap ) - const { chatMode, agentWorkspacePath } = - await presenter.sessionManager.resolveWorkspaceContext( - conversationId, - conversation.settings.modelId - ) + const { chatMode, agentWorkspacePath } = await this.sessionRuntime.resolveWorkspaceContext( + conversationId, + conversation.settings.modelId + ) if (chatMode === 'agent' && agentWorkspacePath) { conversation.settings.agentWorkspacePath = agentWorkspacePath } @@ -97,7 +95,9 @@ export class StreamGenerationHandler extends BaseHandler { vision: Boolean(modelConfig?.vision), imageFiles: modelConfig?.vision ? imageFiles : [], supportsFunctionCall: modelConfig.functionCall, - modelType: modelConfig.type + modelType: modelConfig.type, + toolPresenter: this.toolPresenter, + promptRuntime: this.promptRuntime }) this.throwIfCancelled(state.message.id) @@ -159,7 +159,7 @@ export class StreamGenerationHandler extends BaseHandler { try { state.isCancelled = false // Normal flow: skip lock acquisition (lock is only for permission resume) - await presenter.sessionManager.startLoop(conversationId, state.message.id, { + await this.sessionRuntime.startLoop(conversationId, state.message.id, { skipLockAcquisition: true }) @@ -190,7 +190,7 @@ export class StreamGenerationHandler extends BaseHandler { if (!toolCall.id || !toolCall.name || !toolCall.params) { console.warn('[StreamGenerationHandler] Tool call parameters incomplete') } else { - toolCallResponse = await presenter.mcpPresenter.callTool({ + toolCallResponse = await this.mcpRuntime.callTool({ id: toolCall.id, type: 'function', function: { @@ -240,7 +240,9 @@ export class StreamGenerationHandler extends BaseHandler { vision: false, imageFiles: [], supportsFunctionCall: modelConfig.functionCall, - modelType: modelConfig.type + modelType: modelConfig.type, + toolPresenter: this.toolPresenter, + promptRuntime: this.promptRuntime }) await this.updateGenerationState(state, promptTokens) diff --git a/src/main/presenter/agentPresenter/types/handlerContext.ts b/src/main/presenter/agentPresenter/types/handlerContext.ts index a0a4b0a9f..08e76622a 100644 --- a/src/main/presenter/agentPresenter/types/handlerContext.ts +++ b/src/main/presenter/agentPresenter/types/handlerContext.ts @@ -1,12 +1,28 @@ -import type { IConfigPresenter, ILlmProviderPresenter, ISQLitePresenter } from '@shared/presenter' +import type { + IConfigPresenter, + ILlmProviderPresenter, + ISQLitePresenter, + IToolPresenter +} from '@shared/presenter' import type { CONVERSATION } from '@shared/presenter' import type { MessageManager } from '../../sessionPresenter/managers/messageManager' +import type { AgentSessionRuntimePort } from '../session/sessionRuntimePort' +import type { + AgentMcpRuntimePort, + AgentPermissionRuntimePort, + AgentPromptRuntimePort +} from '../runtimePorts' export type ThreadHandlerContext = { sqlitePresenter: ISQLitePresenter messageManager: MessageManager llmProviderPresenter: ILlmProviderPresenter configPresenter: IConfigPresenter + sessionRuntime: AgentSessionRuntimePort + toolPresenter: IToolPresenter + mcpRuntime: AgentMcpRuntimePort + promptRuntime: AgentPromptRuntimePort + permissionRuntime: AgentPermissionRuntimePort } export class BaseHandler { @@ -32,6 +48,26 @@ export class BaseHandler { return this.ctx.configPresenter } + protected get sessionRuntime(): AgentSessionRuntimePort { + return this.ctx.sessionRuntime + } + + protected get toolPresenter(): IToolPresenter { + return this.ctx.toolPresenter + } + + protected get mcpRuntime(): AgentMcpRuntimePort { + return this.ctx.mcpRuntime + } + + protected get promptRuntime(): AgentPromptRuntimePort { + return this.ctx.promptRuntime + } + + protected get permissionRuntime(): AgentPermissionRuntimePort { + return this.ctx.permissionRuntime + } + protected async getMessage(messageId: string) { return this.messageManager.getMessage(messageId) } diff --git a/src/main/presenter/deepchatAgentPresenter/dispatch.ts b/src/main/presenter/deepchatAgentPresenter/dispatch.ts index 79db30b22..1b19ff86b 100644 --- a/src/main/presenter/deepchatAgentPresenter/dispatch.ts +++ b/src/main/presenter/deepchatAgentPresenter/dispatch.ts @@ -1,11 +1,12 @@ import { eventBus, SendTarget } from '@/eventbus' import { STREAM_EVENTS } from '@/events' import { presenter } from '@/presenter' -import type { MCPToolDefinition, SearchResult } from '@shared/presenter' import type { MCPToolCall, MCPContentItem, MCPResourceContent } from '@shared/types/core/mcp' +import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { SearchResult } from '@shared/types/core/search' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import type { AssistantMessageBlock, PermissionMode } from '@shared/types/agent-interface' -import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../agentPresenter/tools/questionTool' +import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../../lib/agentRuntime/questionTool' import type { IoParams, PendingToolInteraction, ProcessHooks, StreamState } from './types' import type { ChatMessage } from '@shared/types/core/chat-message' import { nanoid } from 'nanoid' diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index 0be94e914..0b3a3a4a2 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -14,22 +14,18 @@ import type { } from '@shared/types/agent-interface' import type { MCPToolCall, MCPToolResponse } from '@shared/types/core/mcp' import type { ChatMessage } from '@shared/types/core/chat-message' -import type { - IConfigPresenter, - ILlmProviderPresenter, - MCPToolDefinition, - ModelConfig -} from '@shared/presenter' +import type { IConfigPresenter, ILlmProviderPresenter, ModelConfig } from '@shared/presenter' +import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import { nanoid } from 'nanoid' import type { SQLitePresenter } from '../sqlitePresenter' import { eventBus, SendTarget } from '@/eventbus' import { SESSION_EVENTS, STREAM_EVENTS } from '@/events' -import { presenter } from '@/presenter' import { buildRuntimeCapabilitiesPrompt, buildSystemEnvPrompt -} from '../agentPresenter/message/systemEnvPromptBuilder' +} from '@/lib/agentRuntime/systemEnvPromptBuilder' +import { presenter } from '@/presenter' import { buildContext, buildResumeContext } from './contextBuilder' import { appendSummarySection, CompactionService, type CompactionIntent } from './compactionService' import { buildPersistableMessageTracePayload } from './messageTracePayload' @@ -1018,7 +1014,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { modelConfig: ModelConfig, temperature: number, maxTokens: number, - tools: import('@shared/presenter').MCPToolDefinition[] + tools: import('@shared/types/core/mcp').MCPToolDefinition[] ) => AsyncGenerator } } diff --git a/src/main/presenter/deepchatAgentPresenter/messageStore.ts b/src/main/presenter/deepchatAgentPresenter/messageStore.ts index 93e16197e..a9ad90b4c 100644 --- a/src/main/presenter/deepchatAgentPresenter/messageStore.ts +++ b/src/main/presenter/deepchatAgentPresenter/messageStore.ts @@ -7,7 +7,7 @@ import type { AssistantMessageBlock, MessageMetadata } from '@shared/types/agent-interface' -import type { SearchResult } from '@shared/presenter' +import type { SearchResult } from '@shared/types/core/search' import type { DeepChatMessageRow } from '../sqlitePresenter/tables/deepchatMessages' export class DeepChatMessageStore { diff --git a/src/main/presenter/deepchatAgentPresenter/toolOutputGuard.ts b/src/main/presenter/deepchatAgentPresenter/toolOutputGuard.ts index bd69c69e5..c8c603885 100644 --- a/src/main/presenter/deepchatAgentPresenter/toolOutputGuard.ts +++ b/src/main/presenter/deepchatAgentPresenter/toolOutputGuard.ts @@ -1,10 +1,10 @@ import fs from 'fs/promises' import path from 'path' import { approximateTokenSize } from 'tokenx' -import type { MCPToolDefinition } from '@shared/presenter' import type { ChatMessage } from '@shared/types/core/chat-message' +import type { MCPToolDefinition } from '@shared/types/core/mcp' +import { resolveToolOffloadPath } from '@/lib/agentRuntime/sessionPaths' import { estimateMessagesTokens } from './contextBuilder' -import { resolveToolOffloadPath } from '../sessionPresenter/sessionPaths' const TOOL_OUTPUT_OFFLOAD_THRESHOLD = 5000 const TOOL_OUTPUT_PREVIEW_LENGTH = 1024 diff --git a/src/main/presenter/deepchatAgentPresenter/types.ts b/src/main/presenter/deepchatAgentPresenter/types.ts index a3da6dac3..3bb7d2171 100644 --- a/src/main/presenter/deepchatAgentPresenter/types.ts +++ b/src/main/presenter/deepchatAgentPresenter/types.ts @@ -6,7 +6,8 @@ import type { } from '@shared/types/agent-interface' import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' import type { ChatMessage } from '@shared/types/core/chat-message' -import type { MCPToolDefinition, ModelConfig } from '@shared/presenter' +import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { ModelConfig } from '@shared/presenter' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import type { DeepChatMessageStore } from './messageStore' import type { ToolOutputGuard } from './toolOutputGuard' diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 87c266966..1a56b55a2 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -4,6 +4,8 @@ import { BrowserWindow, ipcMain, IpcMainInvokeEvent, app } from 'electron' import { WindowPresenter } from './windowPresenter' import { ShortcutPresenter } from './shortcutPresenter' import { + CONVERSATION, + CONVERSATION_SETTINGS, IConfigPresenter, IDeeplinkPresenter, IDevicePresenter, @@ -59,9 +61,15 @@ import { } from './permission' import { AgentPresenter } from './agentPresenter' import { SessionManager } from './agentPresenter/session/sessionManager' +import type { SessionContext } from './agentPresenter/session/sessionContext' +import type { + AgentPermissionRuntimePort, + AgentToolRuntimePort +} from './agentPresenter/runtimePorts' import { ConversationExporterService } from './exporter' import { SkillPresenter } from './skillPresenter' +import type { SkillSessionStatePort } from './skillPresenter' import { SkillSyncPresenter } from './skillSyncPresenter' import { HooksNotificationsService } from './hooksNotifications' import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge' @@ -90,11 +98,8 @@ export class Presenter implements IPresenter { sqlitePresenter: ISQLitePresenter llmproviderPresenter: ILlmProviderPresenter configPresenter: IConfigPresenter - sessionPresenter: ISessionPresenter exporter: IConversationExporter - agentPresenter: IAgentPresenter & ISessionPresenter - sessionManager: SessionManager devicePresenter: IDevicePresenter upgradePresenter: IUpgradePresenter shortcutPresenter: IShortcutPresenter @@ -121,6 +126,11 @@ export class Presenter implements IPresenter { commandPermissionService: CommandPermissionService filePermissionService: FilePermissionService settingsPermissionService: SettingsPermissionService + private readonly legacyPermissionRuntime: AgentPermissionRuntimePort + private legacyMessageManager: MessageManager + private legacySessionPresenter?: SessionPresenter + private legacyAgentPresenter?: IAgentPresenter & ISessionPresenter + private legacySessionManager?: SessionManager private constructor(lifecycleManager: ILifecycleManager) { // Store lifecycle manager reference for component access @@ -133,38 +143,45 @@ export class Presenter implements IPresenter { // 初始化各个 Presenter 实例及其依赖 this.windowPresenter = new WindowPresenter(this.configPresenter) this.tabPresenter = new TabPresenter(this.windowPresenter) - this.llmproviderPresenter = new LLMProviderPresenter(this.configPresenter, this.sqlitePresenter) + this.llmproviderPresenter = new LLMProviderPresenter( + this.configPresenter, + this.sqlitePresenter, + () => this.sessionManager, + () => this.toolPresenter, + { + mcpToolsToAnthropicTools: (mcpTools, serverName) => + this.mcpPresenter.mcpToolsToAnthropicTools(mcpTools, serverName), + mcpToolsToGeminiTools: (mcpTools, serverName) => + this.mcpPresenter.mcpToolsToGeminiTools(mcpTools, serverName), + mcpToolsToOpenAITools: (mcpTools, serverName) => + this.mcpPresenter.mcpToolsToOpenAITools(mcpTools, serverName), + mcpToolsToOpenAIResponsesTools: (mcpTools, serverName) => + this.mcpPresenter.mcpToolsToOpenAIResponsesTools(mcpTools, serverName), + getNpmRegistry: () => this.mcpPresenter.getNpmRegistry?.() ?? null, + getUvRegistry: () => this.mcpPresenter.getUvRegistry?.() ?? null + } + ) const commandPermissionHandler = new CommandPermissionService() this.commandPermissionService = commandPermissionHandler this.filePermissionService = new FilePermissionService() this.settingsPermissionService = new SettingsPermissionService() + this.legacyPermissionRuntime = { + approveFileAccess: (conversationId, paths, remember) => + this.filePermissionService.approve(conversationId, paths, remember), + getApprovedFilePaths: (conversationId) => + this.filePermissionService.getApprovedPaths(conversationId), + approveSettingsAccess: (conversationId, toolName, remember) => + this.settingsPermissionService.approve(conversationId, toolName, remember), + consumeSettingsApproval: (conversationId, toolName) => + this.settingsPermissionService.consumeApproval(conversationId, toolName) + } const messageManager = new MessageManager(this.sqlitePresenter) + this.legacyMessageManager = messageManager this.devicePresenter = new DevicePresenter() this.exporter = new ConversationExporterService({ sqlitePresenter: this.sqlitePresenter, configPresenter: this.configPresenter }) - this.sessionPresenter = new SessionPresenter({ - messageManager, - sqlitePresenter: this.sqlitePresenter, - llmProviderPresenter: this.llmproviderPresenter, - configPresenter: this.configPresenter, - exporter: this.exporter, - commandPermissionService: commandPermissionHandler - }) - this.sessionManager = new SessionManager({ - configPresenter: this.configPresenter, - sessionPresenter: this.sessionPresenter - }) - this.agentPresenter = new AgentPresenter({ - sessionPresenter: this.sessionPresenter, - sessionManager: this.sessionManager, - sqlitePresenter: this.sqlitePresenter, - llmProviderPresenter: this.llmproviderPresenter, - configPresenter: this.configPresenter, - commandPermissionService: commandPermissionHandler, - messageManager - }) as unknown as IAgentPresenter & ISessionPresenter this.mcpPresenter = new McpPresenter(this.configPresenter) this.upgradePresenter = new UpgradePresenter(this.configPresenter) this.shortcutPresenter = new ShortcutPresenter(this.configPresenter) @@ -189,16 +206,99 @@ export class Presenter implements IPresenter { // Initialize generic Workspace presenter (for all Agent modes) this.workspacePresenter = new WorkspacePresenter(this.filePresenter) + const agentToolRuntime: AgentToolRuntimePort = { + resolveConversationWorkdir: async (conversationId) => { + try { + const session = await this.newAgentPresenter?.getSession(conversationId) + const normalized = session?.projectDir?.trim() + if (normalized) { + return normalized + } + } catch (error) { + console.warn('[Presenter] Failed to resolve new session workdir:', { + conversationId, + error + }) + } + + const legacySession = await this.sessionManager.getSession(conversationId) + if (!legacySession?.resolved) { + return null + } + + const resolved = legacySession.resolved + if (resolved.chatMode === 'acp agent') { + const modelId = resolved.modelId + const map = resolved.acpWorkdirMap + return modelId && map ? (map[modelId] ?? null) : null + } + + if (resolved.chatMode === 'agent') { + return resolved.agentWorkspacePath ?? null + } + + return null + }, + getSkillPresenter: () => this.skillPresenter, + getYoBrowserToolHandler: () => this.yoBrowserPresenter.toolHandler, + getFilePresenter: () => ({ + getMimeType: (filePath) => this.filePresenter.getMimeType(filePath), + prepareFileCompletely: (absPath, typeInfo, contentType) => + this.filePresenter.prepareFileCompletely(absPath, typeInfo, contentType) + }), + getLlmProviderPresenter: () => ({ + generateCompletionStandalone: (providerId, messages, modelId, temperature, maxTokens) => + this.llmproviderPresenter.generateCompletionStandalone( + providerId, + messages, + modelId, + temperature, + maxTokens + ) + }), + createSettingsWindow: () => this.windowPresenter.createSettingsWindow(), + sendToWindow: (windowId, channel, ...args) => + this.windowPresenter.sendToWindow(windowId, channel, ...args), + getApprovedFilePaths: (conversationId) => + this.legacyPermissionRuntime.getApprovedFilePaths?.(conversationId) ?? [], + consumeSettingsApproval: (conversationId, toolName) => + this.legacyPermissionRuntime.consumeSettingsApproval?.(conversationId, toolName) ?? false + } + // Initialize unified Tool presenter (for routing MCP and Agent tools) this.toolPresenter = new ToolPresenter({ mcpPresenter: this.mcpPresenter, - yoBrowserPresenter: this.yoBrowserPresenter, configPresenter: this.configPresenter, - commandPermissionHandler + commandPermissionHandler, + agentToolRuntime }) + const skillSessionStatePort: SkillSessionStatePort = { + hasNewSession: async (conversationId) => { + try { + return Boolean(await this.newAgentPresenter?.getSession(conversationId)) + } catch { + return false + } + }, + getPersistedNewSessionSkills: (conversationId) => + ( + this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter + ).newSessionsTable?.getActiveSkills(conversationId) ?? [], + setPersistedNewSessionSkills: (conversationId, skills) => + ( + this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter + ).newSessionsTable?.updateActiveSkills(conversationId, skills), + repairImportedLegacySessionSkills: async (conversationId) => { + const newAgentPresenter = this.newAgentPresenter as INewAgentPresenter & { + repairImportedLegacySessionSkills?: (sessionId: string) => Promise + } + return (await newAgentPresenter.repairImportedLegacySessionSkills?.(conversationId)) ?? [] + } + } + // Initialize Skill presenter - this.skillPresenter = new SkillPresenter(this.configPresenter) + this.skillPresenter = new SkillPresenter(this.configPresenter, skillSessionStatePort) // Initialize Skill Sync presenter this.skillSyncPresenter = new SkillSyncPresenter(this.skillPresenter, this.configPresenter) @@ -239,6 +339,105 @@ export class Presenter implements IPresenter { this.setupEventBus() // 设置事件总线监听 } + get agentPresenter(): IAgentPresenter & ISessionPresenter { + return this.ensureLegacyAgentRuntime().agentPresenter + } + + get sessionPresenter(): ISessionPresenter { + return this.getLegacySessionPresenter() + } + + get sessionManager(): SessionManager { + return this.ensureLegacyAgentRuntime().sessionManager + } + + getActiveLegacyConversationIdSync(webContentsId: number): string | null { + return this.legacySessionPresenter?.getActiveConversationIdSync(webContentsId) ?? null + } + + getLegacyRuntimeSessionSync(conversationId: string): SessionContext | null { + return this.legacySessionManager?.getSessionSync(conversationId) ?? null + } + + async getLegacyConversation(conversationId: string): Promise { + return await this.getLegacySessionPresenter().getConversation(conversationId) + } + + async updateLegacyConversationSettings( + conversationId: string, + settings: Partial + ): Promise { + await this.getLegacySessionPresenter().updateConversationSettings(conversationId, settings) + } + + async broadcastLegacyThreadListUpdate(): Promise { + await this.getLegacySessionPresenter().broadcastThreadListUpdate() + } + + async cleanupLegacyConversationRuntime(conversationId: string): Promise { + if (this.legacyAgentPresenter) { + await this.legacyAgentPresenter.cleanupConversation(conversationId) + return + } + + this.legacySessionManager?.removeSession(conversationId) + + try { + await this.llmproviderPresenter.clearAcpSession(conversationId) + } catch (error) { + console.warn('[Presenter] Failed to clear legacy ACP session:', error) + } + } + + private ensureLegacyAgentRuntime(): { + agentPresenter: IAgentPresenter & ISessionPresenter + sessionManager: SessionManager + } { + if (!this.legacySessionManager) { + this.legacySessionManager = new SessionManager({ + configPresenter: this.configPresenter, + sessionPresenter: this.getLegacySessionPresenter() + }) + } + + if (!this.legacyAgentPresenter) { + this.legacyAgentPresenter = new AgentPresenter({ + sessionPresenter: this.getLegacySessionPresenter(), + sessionManager: this.legacySessionManager, + sqlitePresenter: this.sqlitePresenter, + llmProviderPresenter: this.llmproviderPresenter, + configPresenter: this.configPresenter, + mcpPresenter: this.mcpPresenter, + skillPresenter: this.skillPresenter, + toolPresenter: this.toolPresenter, + commandPermissionService: this.commandPermissionService, + permissionRuntime: this.legacyPermissionRuntime, + messageManager: this.legacyMessageManager + }) as unknown as IAgentPresenter & ISessionPresenter + } + + return { + agentPresenter: this.legacyAgentPresenter, + sessionManager: this.legacySessionManager + } + } + + private getLegacySessionPresenter(): SessionPresenter { + if (!this.legacySessionPresenter) { + this.legacySessionPresenter = new SessionPresenter({ + messageManager: this.legacyMessageManager, + sqlitePresenter: this.sqlitePresenter, + llmProviderPresenter: this.llmproviderPresenter, + configPresenter: this.configPresenter, + exporter: this.exporter, + commandPermissionService: this.commandPermissionService + }) + } + + this.legacySessionPresenter.initializeLegacyRuntime() + return this.legacySessionPresenter + } + public static getInstance(lifecycleManager: ILifecycleManager): Presenter { if (!Presenter.instance) { // 只能在类内部调用私有构造函数 diff --git a/src/main/presenter/llmProviderPresenter/baseProvider.ts b/src/main/presenter/llmProviderPresenter/baseProvider.ts index 1b8e48d11..1b4e3180e 100644 --- a/src/main/presenter/llmProviderPresenter/baseProvider.ts +++ b/src/main/presenter/llmProviderPresenter/baseProvider.ts @@ -16,6 +16,7 @@ import { eventBus, SendTarget } from '@/eventbus' import { CONFIG_EVENTS } from '@/events' import logger from '@shared/logger' import { resolveRequestTraceContext, type ProviderRequestTracePayload } from './requestTrace' +import type { ProviderMcpRuntimePort } from './runtimePorts' /** * Base LLM Provider Abstract Class @@ -39,15 +40,21 @@ export abstract class BaseLLMProvider { protected customModels: MODEL_META[] = [] protected isInitialized: boolean = false protected configPresenter: IConfigPresenter + protected readonly mcpRuntime?: ProviderMcpRuntimePort protected defaultHeaders: Record = { 'HTTP-Referer': 'https://deepchatai.cn', 'X-Title': 'DeepChat' } - constructor(provider: LLM_PROVIDER, configPresenter: IConfigPresenter) { + constructor( + provider: LLM_PROVIDER, + configPresenter: IConfigPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { this.provider = provider this.configPresenter = configPresenter + this.mcpRuntime = mcpRuntime this.defaultHeaders = DevicePresenter.getDefaultHeaders() // Initialize models and customModels from cached config data diff --git a/src/main/presenter/llmProviderPresenter/index.ts b/src/main/presenter/llmProviderPresenter/index.ts index edd878702..c511e1d4a 100644 --- a/src/main/presenter/llmProviderPresenter/index.ts +++ b/src/main/presenter/llmProviderPresenter/index.ts @@ -12,6 +12,7 @@ import { ModelScopeMcpSyncResult, IConfigPresenter, ISQLitePresenter, + IToolPresenter, AcpWorkdirInfo, AcpDebugRequest, AcpDebugRunResult @@ -32,6 +33,8 @@ import type { OllamaProvider } from './providers/ollamaProvider' import { ShowResponse } from 'ollama' import { AcpSessionPersistence } from '../agentPresenter/acp' import { AcpProvider } from './providers/acpProvider' +import type { AgentSessionRuntimePort } from '../agentPresenter/session/sessionRuntimePort' +import type { ProviderMcpRuntimePort } from './runtimePorts' export class LLMProviderPresenter implements ILlmProviderPresenter { private currentProviderId: string | null = null @@ -48,7 +51,16 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { private readonly modelScopeSyncManager: ModelScopeSyncManager private readonly acpSessionPersistence: AcpSessionPersistence - constructor(configPresenter: IConfigPresenter, sqlitePresenter: ISQLitePresenter) { + constructor( + configPresenter: IConfigPresenter, + sqlitePresenter: ISQLitePresenter, + getSessionRuntime?: () => Pick< + AgentSessionRuntimePort, + 'getSession' | 'resolveWorkspaceContext' + >, + getToolPresenter?: () => IToolPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { this.rateLimitManager = new RateLimitManager(configPresenter) this.acpSessionPersistence = new AcpSessionPersistence(sqlitePresenter) this.providerInstanceManager = new ProviderInstanceManager({ @@ -59,7 +71,8 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { setCurrentProviderId: (providerId) => { this.currentProviderId = providerId }, - acpSessionPersistence: this.acpSessionPersistence + acpSessionPersistence: this.acpSessionPersistence, + mcpRuntime }) this.modelManager = new ModelManager({ configPresenter, @@ -80,7 +93,30 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { getProviderInstance: this.getProviderInstance.bind(this), activeStreams: this.activeStreams, canStartNewStream: this.canStartNewStream.bind(this), - rateLimitManager: this.rateLimitManager + rateLimitManager: this.rateLimitManager, + getToolPresenter: () => { + if (!getToolPresenter) { + throw new Error('ToolPresenter is unavailable') + } + return getToolPresenter() + }, + sessionRuntime: { + getSession: async (agentId) => { + if (!getSessionRuntime) { + throw new Error('Legacy session runtime is unavailable') + } + return await getSessionRuntime().getSession(agentId) + }, + resolveWorkspaceContext: async (conversationId, modelId) => { + if (!getSessionRuntime) { + return { + chatMode: 'agent', + agentWorkspacePath: null + } + } + return await getSessionRuntime().resolveWorkspaceContext(conversationId, modelId) + } + } satisfies Pick }) this.rateLimitManager.initializeProviderRateLimitConfigs() diff --git a/src/main/presenter/llmProviderPresenter/managers/providerInstanceManager.ts b/src/main/presenter/llmProviderPresenter/managers/providerInstanceManager.ts index f15f2a1ee..0843b9333 100644 --- a/src/main/presenter/llmProviderPresenter/managers/providerInstanceManager.ts +++ b/src/main/presenter/llmProviderPresenter/managers/providerInstanceManager.ts @@ -37,6 +37,7 @@ import { VoiceAIProvider } from '../providers/voiceAIProvider' import { RateLimitManager } from './rateLimitManager' import { StreamState } from '../types' import { AcpSessionPersistence } from '../../agentPresenter/acp' +import type { ProviderMcpRuntimePort } from '../runtimePorts' type ProviderConstructor = new ( provider: LLM_PROVIDER, @@ -51,6 +52,7 @@ interface ProviderInstanceManagerOptions { getCurrentProviderId: () => string | null setCurrentProviderId: (providerId: string | null) => void acpSessionPersistence?: AcpSessionPersistence + mcpRuntime?: ProviderMcpRuntimePort } export class ProviderInstanceManager { @@ -408,11 +410,12 @@ export class ProviderInstanceManager { return new AcpProvider( provider, this.options.configPresenter, - this.options.acpSessionPersistence + this.options.acpSessionPersistence, + this.options.mcpRuntime ) } - return new ProviderClass(provider, this.options.configPresenter) + return new ProviderClass(provider, this.options.configPresenter, this.options.mcpRuntime) } catch (error) { console.error(`Failed to create provider instance for ${provider.id}:`, error) return undefined diff --git a/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts b/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts index 97ad5309f..865500f6f 100644 --- a/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts @@ -35,7 +35,7 @@ import { type AcpSessionRecord } from '../../agentPresenter/acp' import { nanoid } from 'nanoid' -import { presenter } from '@/presenter' +import type { ProviderMcpRuntimePort } from '../runtimePorts' type EventQueue = { push: (event: LLMCoreStreamEvent | null) => void @@ -68,9 +68,10 @@ export class AcpProvider extends BaseLLMProvider { constructor( provider: LLM_PROVIDER, configPresenter: IConfigPresenter, - sessionPersistence: AcpSessionPersistence + sessionPersistence: AcpSessionPersistence, + mcpRuntime?: ProviderMcpRuntimePort ) { - super(provider, configPresenter) + super(provider, configPresenter, mcpRuntime) this.sessionPersistence = sessionPersistence this.processManager = new AcpProcessManager({ providerId: provider.id, @@ -78,12 +79,12 @@ export class AcpProvider extends BaseLLMProvider { getNpmRegistry: async () => { // Get npm registry from MCP presenter's server manager // This will use the fastest registry from speed test - return presenter.mcpPresenter.getNpmRegistry?.() ?? null + return this.mcpRuntime?.getNpmRegistry?.() ?? null }, getUvRegistry: async () => { // Get uv registry from MCP presenter's server manager // This will use the fastest registry from speed test - return presenter.mcpPresenter.getUvRegistry?.() ?? null + return this.mcpRuntime?.getUvRegistry?.() ?? null } }) this.sessionManager = new AcpSessionManager({ diff --git a/src/main/presenter/llmProviderPresenter/providers/anthropicProvider.ts b/src/main/presenter/llmProviderPresenter/providers/anthropicProvider.ts index c0284cc6a..5c818109c 100644 --- a/src/main/presenter/llmProviderPresenter/providers/anthropicProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/anthropicProvider.ts @@ -15,6 +15,7 @@ import { presenter } from '@/presenter' import { Usage } from '@anthropic-ai/sdk/resources' import { proxyConfig } from '../../proxyConfig' import { ProxyAgent } from 'undici' +import type { ProviderMcpRuntimePort } from '../runtimePorts' const OAUTH_MODEL_LIST = { data: [ @@ -84,8 +85,12 @@ export class AnthropicProvider extends BaseLLMProvider { private isOAuthMode = false private defaultModel = 'claude-sonnet-4-5-20250929' - constructor(provider: LLM_PROVIDER, configPresenter: IConfigPresenter) { - super(provider, configPresenter) + constructor( + provider: LLM_PROVIDER, + configPresenter: IConfigPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { + super(provider, configPresenter, mcpRuntime) this.init() } @@ -1014,7 +1019,7 @@ ${context} // 将MCP工具转换为Anthropic工具格式 const anthropicTools = mcpTools.length > 0 - ? await presenter.mcpPresenter.mcpToolsToAnthropicTools(mcpTools, this.provider.id) + ? await this.mcpRuntime?.mcpToolsToAnthropicTools(mcpTools, this.provider.id) : undefined // 创建基本请求参数 @@ -1243,7 +1248,7 @@ ${context} // 将MCP工具转换为Anthropic工具格式 const anthropicTools = mcpTools.length > 0 - ? await presenter.mcpPresenter.mcpToolsToAnthropicTools(mcpTools, this.provider.id) + ? await this.mcpRuntime?.mcpToolsToAnthropicTools(mcpTools, this.provider.id) : undefined // 创建基本请求参数 diff --git a/src/main/presenter/llmProviderPresenter/providers/awsBedrockProvider.ts b/src/main/presenter/llmProviderPresenter/providers/awsBedrockProvider.ts index 1c10ab7b6..f11b727e8 100644 --- a/src/main/presenter/llmProviderPresenter/providers/awsBedrockProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/awsBedrockProvider.ts @@ -10,7 +10,6 @@ import { } from '@shared/presenter' import { createStreamEvent } from '@shared/types/core/llm-events' import { BaseLLMProvider, SUMMARY_TITLES_PROMPT } from '../baseProvider' -import { presenter } from '@/presenter' import { BedrockClient, ListFoundationModelsCommand } from '@aws-sdk/client-bedrock' import { BedrockRuntimeClient, @@ -20,14 +19,19 @@ import { } from '@aws-sdk/client-bedrock-runtime' import Anthropic from '@anthropic-ai/sdk' import { Usage } from '@anthropic-ai/sdk/resources/messages' +import type { ProviderMcpRuntimePort } from '../runtimePorts' export class AwsBedrockProvider extends BaseLLMProvider { private bedrock!: BedrockClient private bedrockRuntime!: BedrockRuntimeClient private defaultModel = 'anthropic.claude-3-5-sonnet-20240620-v1:0' - constructor(provider: AWS_BEDROCK_PROVIDER, configPresenter: IConfigPresenter) { - super(provider, configPresenter) + constructor( + provider: AWS_BEDROCK_PROVIDER, + configPresenter: IConfigPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { + super(provider, configPresenter, mcpRuntime) this.init() } @@ -629,7 +633,7 @@ ${text} // 将MCP工具转换为Anthropic工具格式 const anthropicTools = mcpTools.length > 0 - ? await presenter.mcpPresenter.mcpToolsToAnthropicTools(mcpTools, this.provider.id) + ? await this.mcpRuntime?.mcpToolsToAnthropicTools(mcpTools, this.provider.id) : undefined // 创建基本请求参数 diff --git a/src/main/presenter/llmProviderPresenter/providers/geminiProvider.ts b/src/main/presenter/llmProviderPresenter/providers/geminiProvider.ts index 9aac49174..259d650e0 100644 --- a/src/main/presenter/llmProviderPresenter/providers/geminiProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/geminiProvider.ts @@ -1,4 +1,3 @@ -import { presenter } from '@/presenter' import { Content, FunctionCallingConfigMode, @@ -29,6 +28,7 @@ import { BaseLLMProvider, SUMMARY_TITLES_PROMPT } from '../baseProvider' import { modelCapabilities } from '../../configPresenter/modelCapabilities' import { eventBus, SendTarget } from '@/eventbus' import { CONFIG_EVENTS } from '@/events' +import type { ProviderMcpRuntimePort } from '../runtimePorts' // Mapping from simple keys to API HarmCategory constants const keyToHarmCategoryMap: Record = { @@ -52,8 +52,12 @@ const safetySettingKeys = Object.keys(keyToHarmCategoryMap) export class GeminiProvider extends BaseLLMProvider { private genAI: GoogleGenAI - constructor(provider: LLM_PROVIDER, configPresenter: IConfigPresenter) { - super(provider, configPresenter) + constructor( + provider: LLM_PROVIDER, + configPresenter: IConfigPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { + super(provider, configPresenter, mcpRuntime) this.genAI = new GoogleGenAI({ apiKey: this.provider.apiKey, httpOptions: { baseUrl: this.provider.baseUrl } @@ -834,7 +838,7 @@ export class GeminiProvider extends BaseLLMProvider { // Load MCP tools if available if (mcpTools.length > 0) - geminiTools = await presenter.mcpPresenter.mcpToolsToGeminiTools(mcpTools, this.provider.id) + geminiTools = (await this.mcpRuntime?.mcpToolsToGeminiTools(mcpTools, this.provider.id)) ?? [] // 格式化消息为Gemini格式 const formattedParts = this.formatGeminiMessages(messages) diff --git a/src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts b/src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts index f1df831a6..d8400b2ce 100644 --- a/src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts @@ -15,8 +15,8 @@ import { DEFAULT_MODEL_CONTEXT_LENGTH, DEFAULT_MODEL_MAX_TOKENS } from '@shared/ import { createStreamEvent } from '@shared/types/core/llm-events' import { BaseLLMProvider, SUMMARY_TITLES_PROMPT } from '../baseProvider' import { Ollama, Message, ShowResponse } from 'ollama' -import { presenter } from '@/presenter' import { EMBEDDING_TEST_KEY, isNormalized } from '@/utils/vector' +import type { ProviderMcpRuntimePort } from '../runtimePorts' // Define Ollama tool type interface OllamaTool { @@ -40,8 +40,12 @@ interface OllamaTool { export class OllamaProvider extends BaseLLMProvider { private ollama: Ollama - constructor(provider: LLM_PROVIDER, configPresenter: IConfigPresenter) { - super(provider, configPresenter) + constructor( + provider: LLM_PROVIDER, + configPresenter: IConfigPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { + super(provider, configPresenter, mcpRuntime) if (this.provider.apiKey) { this.ollama = new Ollama({ host: this.provider.baseUrl, @@ -437,10 +441,8 @@ export class OllamaProvider extends BaseLLMProvider { // 辅助方法:将 MCP 工具转换为 Ollama 工具格式 private async convertToOllamaTools(mcpTools: MCPToolDefinition[]): Promise { - const openAITools = await presenter.mcpPresenter.mcpToolsToOpenAITools( - mcpTools, - this.provider.id - ) + const openAITools = + (await this.mcpRuntime?.mcpToolsToOpenAITools(mcpTools, this.provider.id)) ?? [] return openAITools.map((rawTool) => { const tool = rawTool as unknown as { function: { diff --git a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts index 5fcb5a2bc..abb3d7043 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts @@ -32,6 +32,7 @@ import sharp from 'sharp' import { proxyConfig } from '../../proxyConfig' import { modelCapabilities } from '../../configPresenter/modelCapabilities' import { ProxyAgent } from 'undici' +import type { ProviderMcpRuntimePort } from '../runtimePorts' const OPENAI_REASONING_MODELS = [ 'o4-mini', @@ -71,8 +72,12 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { // Add blacklist of providers that don't support OpenAI standard interface private static readonly NO_MODELS_API_LIST: string[] = [] - constructor(provider: LLM_PROVIDER, configPresenter: IConfigPresenter) { - super(provider, configPresenter) + constructor( + provider: LLM_PROVIDER, + configPresenter: IConfigPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { + super(provider, configPresenter, mcpRuntime) this.createOpenAIClient() if (OpenAICompatibleProvider.NO_MODELS_API_LIST.includes(this.provider.id.toLowerCase())) { this.isNoModelsApi = true @@ -1036,7 +1041,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { // 如果支持原生函数调用,则转换工具定义为 OpenAI 格式 const apiTools = tools.length > 0 && supportsFunctionCall - ? await presenter.mcpPresenter.mcpToolsToOpenAITools(tools, this.provider.id) + ? await this.mcpRuntime?.mcpToolsToOpenAITools(tools, this.provider.id) : undefined // 构建请求参数 diff --git a/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts b/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts index 8b4cb3c30..63e80ad34 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts @@ -23,6 +23,7 @@ import sharp from 'sharp' import { proxyConfig } from '../../proxyConfig' import { ProxyAgent } from 'undici' import { modelCapabilities } from '../../configPresenter/modelCapabilities' +import type { ProviderMcpRuntimePort } from '../runtimePorts' const OPENAI_REASONING_MODELS = [ 'o4-mini', @@ -62,8 +63,12 @@ export class OpenAIResponsesProvider extends BaseLLMProvider { // 添加不支持 OpenAI 标准接口的供应商黑名单 private static readonly NO_MODELS_API_LIST: string[] = [] - constructor(provider: LLM_PROVIDER, configPresenter: IConfigPresenter) { - super(provider, configPresenter) + constructor( + provider: LLM_PROVIDER, + configPresenter: IConfigPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { + super(provider, configPresenter, mcpRuntime) this.createOpenAIClient() if (OpenAIResponsesProvider.NO_MODELS_API_LIST.includes(this.provider.id.toLowerCase())) { this.isNoModelsApi = true @@ -573,7 +578,7 @@ export class OpenAIResponsesProvider extends BaseLLMProvider { } const apiTools = tools.length > 0 && supportsFunctionCall - ? await presenter.mcpPresenter.mcpToolsToOpenAIResponsesTools(tools, this.provider.id) + ? await this.mcpRuntime?.mcpToolsToOpenAIResponsesTools(tools, this.provider.id) : undefined const requestParams: OpenAI.Responses.ResponseCreateParams = { diff --git a/src/main/presenter/llmProviderPresenter/providers/vertexProvider.ts b/src/main/presenter/llmProviderPresenter/providers/vertexProvider.ts index 7fcb2dda0..7c02e094c 100644 --- a/src/main/presenter/llmProviderPresenter/providers/vertexProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/vertexProvider.ts @@ -1,4 +1,3 @@ -import { presenter } from '@/presenter' import { Content, FunctionCallingConfigMode, @@ -30,6 +29,7 @@ import { BaseLLMProvider, SUMMARY_TITLES_PROMPT } from '../baseProvider' import { modelCapabilities } from '../../configPresenter/modelCapabilities' import { eventBus, SendTarget } from '@/eventbus' import { CONFIG_EVENTS } from '@/events' +import type { ProviderMcpRuntimePort } from '../runtimePorts' // Mapping from simple keys to API HarmCategory constants const keyToHarmCategoryMap: Record = { @@ -53,8 +53,12 @@ const safetySettingKeys = Object.keys(keyToHarmCategoryMap) export class VertexProvider extends BaseLLMProvider { private genAI: GoogleGenAI - constructor(provider: LLM_PROVIDER, configPresenter: IConfigPresenter) { - super(provider, configPresenter) + constructor( + provider: LLM_PROVIDER, + configPresenter: IConfigPresenter, + mcpRuntime?: ProviderMcpRuntimePort + ) { + super(provider, configPresenter, mcpRuntime) this.genAI = this.createGenAIClient() this.init() } @@ -922,7 +926,7 @@ export class VertexProvider extends BaseLLMProvider { // Load MCP tools if available if (mcpTools.length > 0) - geminiTools = await presenter.mcpPresenter.mcpToolsToGeminiTools(mcpTools, this.provider.id) + geminiTools = (await this.mcpRuntime?.mcpToolsToGeminiTools(mcpTools, this.provider.id)) ?? [] // 格式化消息为Gemini格式 const formattedParts = this.formatVertexMessages(messages) diff --git a/src/main/presenter/llmProviderPresenter/runtimePorts.ts b/src/main/presenter/llmProviderPresenter/runtimePorts.ts new file mode 100644 index 000000000..7d780af79 --- /dev/null +++ b/src/main/presenter/llmProviderPresenter/runtimePorts.ts @@ -0,0 +1,10 @@ +import type { IMCPPresenter } from '@shared/presenter' + +export interface ProviderMcpRuntimePort { + mcpToolsToAnthropicTools: IMCPPresenter['mcpToolsToAnthropicTools'] + mcpToolsToGeminiTools: IMCPPresenter['mcpToolsToGeminiTools'] + mcpToolsToOpenAITools: IMCPPresenter['mcpToolsToOpenAITools'] + mcpToolsToOpenAIResponsesTools: IMCPPresenter['mcpToolsToOpenAIResponsesTools'] + getNpmRegistry?: IMCPPresenter['getNpmRegistry'] + getUvRegistry?: IMCPPresenter['getUvRegistry'] +} diff --git a/src/main/presenter/mcpPresenter/toolManager.ts b/src/main/presenter/mcpPresenter/toolManager.ts index 46a4443fb..cbc88ae3a 100644 --- a/src/main/presenter/mcpPresenter/toolManager.ts +++ b/src/main/presenter/mcpPresenter/toolManager.ts @@ -7,7 +7,8 @@ import { MCPContentItem, MCPTextContent, IConfigPresenter, - Resource + Resource, + CONVERSATION } from '@shared/presenter' import { ServerManager } from './serverManager' import { McpClient } from './mcpClient' @@ -270,6 +271,76 @@ export class ToolManager { return 'write' } + private async resolveAcpSessionContext(conversationId?: string): Promise<{ + agentId: string + providerId: string + projectDir: string | null + } | null> { + const sessionId = conversationId?.trim() + if (!sessionId) { + return null + } + + try { + const session = await presenter.newAgentPresenter.getSession(sessionId) + const agentId = session?.agentId?.trim() + const providerId = session?.providerId?.trim() + if (session && providerId === 'acp' && agentId) { + return { + agentId, + providerId, + projectDir: session.projectDir?.trim() || null + } + } + } catch (error) { + console.warn('[ToolManager] Failed to resolve new session MCP context:', error) + } + + try { + const conversation = await presenter.getLegacyConversation(sessionId) + return this.mapLegacyConversationToAcpContext(conversation) + } catch (error) { + console.warn('[ToolManager] Failed to resolve legacy session MCP context:', error) + return null + } + } + + private mapLegacyConversationToAcpContext(conversation: CONVERSATION | null | undefined): { + agentId: string + providerId: string + projectDir: string | null + } | null { + const settings = conversation?.settings + if (!settings) { + return null + } + + const providerId = typeof settings.providerId === 'string' ? settings.providerId.trim() : '' + const chatMode = settings.chatMode + const isAcpConversation = providerId === 'acp' || chatMode === 'acp agent' + if (!isAcpConversation) { + return null + } + + const agentId = typeof settings.modelId === 'string' ? settings.modelId.trim() : '' + if (!agentId) { + return null + } + + const directProjectDir = + typeof settings.agentWorkspacePath === 'string' ? settings.agentWorkspacePath.trim() : '' + const mappedProjectDir = + typeof settings.acpWorkdirMap?.[agentId] === 'string' + ? settings.acpWorkdirMap[agentId]?.trim() + : '' + + return { + agentId, + providerId: providerId || 'acp', + projectDir: directProjectDir || mappedProjectDir || null + } + } + // 检查工具调用权限 private checkToolPermission( originalToolName: string, @@ -419,45 +490,30 @@ export class ToolManager { const { client: targetClient, originalName } = targetInfo const toolServerName = targetClient.serverName - // ACP agent-level MCP access control (only applies in "acp agent" chat mode) + // ACP agent-level MCP access control resolves from session context, not global chat mode. if (toolCall.conversationId) { - const chatMode = this.configPresenter.getSetting<'agent' | 'acp agent'>('input_chatMode') - if (chatMode === 'acp agent') { - try { - let agentId: string | null = null - - const session = await presenter.newAgentPresenter.getSession(toolCall.conversationId) - if (session?.agentId?.trim()) { - agentId = session.agentId.trim() - } else { - const conversation = await presenter.sessionPresenter.getConversation( - toolCall.conversationId + try { + const acpContext = await this.resolveAcpSessionContext(toolCall.conversationId) + if (acpContext?.providerId === 'acp' && acpContext.agentId) { + const acpAgents = await this.configPresenter.getAcpAgents() + if (acpAgents.some((item) => item.id === acpContext.agentId)) { + const selections = await this.configPresenter.getAgentMcpSelections( + acpContext.agentId ) - if (typeof conversation?.settings?.modelId === 'string') { - const normalized = conversation.settings.modelId.trim() - agentId = normalized.length > 0 ? normalized : null - } - } - - if (agentId) { - const acpAgents = await this.configPresenter.getAcpAgents() - if (acpAgents.some((item) => item.id === agentId)) { - const selections = await this.configPresenter.getAgentMcpSelections(agentId) - if (!selections?.length || !selections.includes(toolServerName)) { - return { - toolCallId: toolCall.id, - content: `MCP server '${toolServerName}' is not allowed for ACP agent '${agentId}'. Configure MCP access in ACP settings.`, - isError: true - } + if (!selections?.length || !selections.includes(toolServerName)) { + return { + toolCallId: toolCall.id, + content: `MCP server '${toolServerName}' is not allowed for ACP agent '${acpContext.agentId}'. Configure MCP access in ACP settings.`, + isError: true } } } - } catch (error) { - console.warn( - '[ToolManager] Failed to resolve ACP agent context for MCP access control:', - error - ) } + } catch (error) { + console.warn( + '[ToolManager] Failed to resolve ACP agent context for MCP access control:', + error + ) } } diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index b1b1b91f8..d74af30aa 100644 --- a/src/main/presenter/newAgentPresenter/index.ts +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -18,12 +18,12 @@ import type { ToolInteractionResult } from '@shared/types/agent-interface' import type { Message } from '@shared/chat' +import type { SearchResult } from '@shared/types/core/search' import type { IConfigPresenter, ILlmProviderPresenter, ISkillPresenter, - CONVERSATION, - SearchResult + CONVERSATION } from '@shared/presenter' import type { SQLitePresenter } from '../sqlitePresenter' import type { DeepChatAgentPresenter } from '../deepchatAgentPresenter' @@ -452,6 +452,10 @@ export class NewAgentPresenter { this.legacyImportService.startInBackground(false) } + async repairImportedLegacySessionSkills(sessionId: string): Promise { + return await this.legacyImportService.repairImportedLegacySessionSkills(sessionId) + } + async listMessageTraces(messageId: string): Promise { if (!messageId?.trim()) return [] return this.sqlitePresenter.deepchatMessageTracesTable diff --git a/src/main/presenter/newAgentPresenter/legacyImportService.ts b/src/main/presenter/newAgentPresenter/legacyImportService.ts index 7479bbdc5..744ce9fce 100644 --- a/src/main/presenter/newAgentPresenter/legacyImportService.ts +++ b/src/main/presenter/newAgentPresenter/legacyImportService.ts @@ -8,11 +8,12 @@ import type { LegacyImportStatus, UserMessageContent } from '@shared/types/agent-interface' -import type { SearchResult } from '@shared/presenter' +import type { SearchResult } from '@shared/types/core/search' type LegacyRow = Record const IMPORT_KEY = 'legacy_chat_db_import_v1' +const SKILL_REPAIR_KEY = 'legacy_chat_skill_repair_v1' const DEFAULT_USER_CONTENT: UserMessageContent = { text: '', @@ -26,6 +27,7 @@ export class LegacyChatImportService { private readonly sqlitePresenter: SQLitePresenter private readonly sourceDbPath: string private runningPromise: Promise | null = null + private skillRepairPromise: Promise | null = null constructor(sqlitePresenter: SQLitePresenter, sourceDbPath?: string) { this.sqlitePresenter = sqlitePresenter @@ -54,6 +56,21 @@ export class LegacyChatImportService { return this.start(true) } + async repairImportedLegacySessionSkills(sessionId: string): Promise { + const normalizedSessionId = sessionId?.trim() + if (!normalizedSessionId.startsWith('legacy-session-')) { + return this.sqlitePresenter.newSessionsTable.getActiveSkills(normalizedSessionId) + } + + const currentSkills = this.sqlitePresenter.newSessionsTable.getActiveSkills(normalizedSessionId) + if (currentSkills.length > 0) { + return currentSkills + } + + await this.ensureImportedLegacySkillRepair() + return this.sqlitePresenter.newSessionsTable.getActiveSkills(normalizedSessionId) + } + async importFromSourceDb( sourceDbPath: string, mode: 'increment' | 'overwrite' = 'increment' @@ -347,6 +364,9 @@ export class LegacyChatImportService { const isPinned = this.pickNumber(conversation, ['is_pinned']) === 1 const createdAt = this.pickNumber(conversation, ['created_at']) ?? Date.now() const updatedAt = this.pickNumber(conversation, ['updated_at']) ?? createdAt + const importedActiveSkills = this.parseStringArray( + this.pickString(conversation, ['active_skills']) ?? '' + ) let projectDir = this.pickString(conversation, ['agent_workspace_path']) ?? null if (!projectDir && agentId !== 'deepchat') { @@ -366,6 +386,7 @@ export class LegacyChatImportService { this.sqlitePresenter.newSessionsTable.create(sessionId, agentId, title, projectDir, { isPinned, isDraft: false, + activeSkills: importedActiveSkills, createdAt, updatedAt }) @@ -741,4 +762,131 @@ export class LegacyChatImportService { } return null } + + private parseStringArray(raw: string): string[] { + if (!raw.trim()) { + return [] + } + + try { + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string') + : [] + } catch { + return [] + } + } + + private async ensureImportedLegacySkillRepair(): Promise { + const status = this.sqlitePresenter.legacyImportStatusTable.get(SKILL_REPAIR_KEY) + if (status?.status === 'completed') { + return + } + + if (this.skillRepairPromise) { + await this.skillRepairPromise + return + } + + this.skillRepairPromise = (async () => { + const startedAt = status?.started_at ?? Date.now() + this.sqlitePresenter.legacyImportStatusTable.upsert(SKILL_REPAIR_KEY, { + status: 'running', + sourceDbPath: this.sourceDbPath, + startedAt, + finishedAt: null, + importedSessions: status?.imported_sessions ?? 0, + importedMessages: 0, + importedSearchResults: 0, + error: null, + updatedAt: Date.now() + }) + + try { + const repairedSessions = await this.backfillImportedLegacySessionSkills() + const finishedAt = Date.now() + this.sqlitePresenter.legacyImportStatusTable.upsert(SKILL_REPAIR_KEY, { + status: 'completed', + sourceDbPath: this.sourceDbPath, + startedAt, + finishedAt, + importedSessions: repairedSessions, + importedMessages: 0, + importedSearchResults: 0, + error: null, + updatedAt: finishedAt + }) + } catch (error) { + const finishedAt = Date.now() + this.sqlitePresenter.legacyImportStatusTable.upsert(SKILL_REPAIR_KEY, { + status: 'failed', + sourceDbPath: this.sourceDbPath, + startedAt, + finishedAt, + importedSessions: status?.imported_sessions ?? 0, + importedMessages: 0, + importedSearchResults: 0, + error: error instanceof Error ? error.message : String(error), + updatedAt: finishedAt + }) + throw error + } + })().finally(() => { + this.skillRepairPromise = null + }) + + await this.skillRepairPromise + } + + private async backfillImportedLegacySessionSkills(): Promise { + try { + const total = await this.sqlitePresenter.getConversationCount() + if (total <= 0) { + return 0 + } + + const pageSize = 200 + const totalPages = Math.ceil(total / pageSize) + let repairedSessions = 0 + + for (let page = 1; page <= totalPages; page += 1) { + const { list } = await this.sqlitePresenter.getConversationList(page, pageSize) + await this.sqlitePresenter.runTransaction(() => { + for (const conversation of list) { + const legacySkills = Array.isArray(conversation.settings?.activeSkills) + ? conversation.settings.activeSkills.filter( + (item): item is string => typeof item === 'string' + ) + : [] + if (legacySkills.length === 0) { + continue + } + + const sessionId = this.toLegacySessionId(conversation.id) + const sessionRow = this.sqlitePresenter.newSessionsTable.get(sessionId) + if (!sessionRow) { + continue + } + + const currentSkills = this.sqlitePresenter.newSessionsTable.getActiveSkills(sessionId) + if (currentSkills.length > 0) { + continue + } + + this.sqlitePresenter.newSessionsTable.updateActiveSkills(sessionId, legacySkills) + repairedSessions += 1 + } + }) + } + + return repairedSessions + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('no such table')) { + return 0 + } + throw error + } + } } diff --git a/src/main/presenter/sessionPresenter/index.ts b/src/main/presenter/sessionPresenter/index.ts index 9b1150377..7be1a41b8 100644 --- a/src/main/presenter/sessionPresenter/index.ts +++ b/src/main/presenter/sessionPresenter/index.ts @@ -39,6 +39,7 @@ export class SessionPresenter implements ISessionPresenter { private exporter: IConversationExporter private commandPermissionService: CommandPermissionService private activeConversationBindings: Map = new Map() + private legacyRuntimeInitialized = false constructor(options: { messageManager?: MessageManager @@ -61,11 +62,20 @@ export class SessionPresenter implements ISessionPresenter { messageManager: this.messageManager, activeConversationBindings: this.activeConversationBindings }) + } + + initializeLegacyRuntime(): void { + if (this.legacyRuntimeInitialized) { + return + } + + this.legacyRuntimeInitialized = true + // Clean up conversation bindings when a bound renderer is closed. eventBus.on(TAB_EVENTS.CLOSED, (webContentsId: number) => { const activeConversationId = this.getActiveConversationIdSync(webContentsId) if (activeConversationId) { - void presenter.agentPresenter.cleanupConversation(activeConversationId) + void presenter.cleanupLegacyConversationRuntime(activeConversationId) this.commandPermissionService.clearConversation(activeConversationId) presenter.filePermissionService?.clearConversation(activeConversationId) presenter.settingsPermissionService?.clearConversation(activeConversationId) @@ -91,7 +101,7 @@ export class SessionPresenter implements ISessionPresenter { }) // 初始化时处理所有未完成的消息 - this.messageManager.initializeUnfinishedMessages() + void this.messageManager.initializeUnfinishedMessages() } async createSession(params: CreateSessionParams): Promise { @@ -574,7 +584,7 @@ export class SessionPresenter implements ISessionPresenter { } async deleteConversation(conversationId: string): Promise { - await presenter.agentPresenter.cleanupConversation(conversationId) + await presenter.cleanupLegacyConversationRuntime(conversationId) this.commandPermissionService.clearConversation(conversationId) presenter.filePermissionService?.clearConversation(conversationId) presenter.settingsPermissionService?.clearConversation(conversationId) @@ -1032,8 +1042,8 @@ export class SessionPresenter implements ISessionPresenter { ? 'floating' : 'main' const sessionContext = - typeof presenter?.sessionManager?.getSessionSync === 'function' - ? presenter.sessionManager.getSessionSync(conversation.id) + typeof presenter?.getLegacyRuntimeSessionSync === 'function' + ? presenter.getLegacyRuntimeSessionSync(conversation.id) : null const settings = conversation.settings as unknown as Omit< Session['config'], diff --git a/src/main/presenter/sessionPresenter/sessionPaths.ts b/src/main/presenter/sessionPresenter/sessionPaths.ts index 940255b99..2d6658015 100644 --- a/src/main/presenter/sessionPresenter/sessionPaths.ts +++ b/src/main/presenter/sessionPresenter/sessionPaths.ts @@ -1,32 +1,6 @@ -import { app } from 'electron' -import path from 'path' - -export function getSessionsRoot(): string { - return path.resolve(app.getPath('home'), '.deepchat', 'sessions') -} - -export function resolveSessionDir(conversationId: string): string | null { - if (!conversationId.trim()) return null - const sessionsRoot = getSessionsRoot() - const resolvedSessionDir = path.resolve(sessionsRoot, conversationId) - const rootWithSeparator = sessionsRoot.endsWith(path.sep) - ? sessionsRoot - : `${sessionsRoot}${path.sep}` - if (resolvedSessionDir !== sessionsRoot && !resolvedSessionDir.startsWith(rootWithSeparator)) { - return null - } - return resolvedSessionDir -} - -export function resolveToolOffloadPath(conversationId: string, toolCallId: string): string | null { - const sessionDir = resolveSessionDir(conversationId) - if (!sessionDir) return null - const safeToolCallId = toolCallId.replace(/[\\/]/g, '_') - return path.join(sessionDir, `tool_${safeToolCallId}.offload`) -} - -export function resolveToolOffloadTemplatePath(conversationId: string): string | null { - const sessionDir = resolveSessionDir(conversationId) - if (!sessionDir) return null - return path.join(sessionDir, 'tool_.offload') -} +export { + getSessionsRoot, + resolveSessionDir, + resolveToolOffloadPath, + resolveToolOffloadTemplatePath +} from '@/lib/agentRuntime/sessionPaths' diff --git a/src/main/presenter/skillPresenter/index.ts b/src/main/presenter/skillPresenter/index.ts index a98578dc7..01846f9de 100644 --- a/src/main/presenter/skillPresenter/index.ts +++ b/src/main/presenter/skillPresenter/index.ts @@ -19,7 +19,6 @@ import { } from '@shared/types/skill' import { eventBus, SendTarget } from '@/eventbus' import { SKILL_EVENTS } from '@/events' -import { presenter } from '@/presenter' import logger from '@shared/logger' import { normalizeSkillAllowedTools } from './toolNameMapping' @@ -60,6 +59,13 @@ const DEFAULT_RUNTIME_POLICY: SkillRuntimePolicy = { node: 'auto' } +export interface SkillSessionStatePort { + hasNewSession(conversationId: string): Promise + getPersistedNewSessionSkills(conversationId: string): string[] + setPersistedNewSessionSkills(conversationId: string, skills: string[]): void + repairImportedLegacySessionSkills(conversationId: string): Promise +} + function createDefaultSkillExtensionConfig(): SkillExtensionConfig { return { version: 1, @@ -137,13 +143,16 @@ export class SkillPresenter implements ISkillPresenter { private sidecarDir: string private metadataCache: Map = new Map() private contentCache: Map = new Map() - private newAgentActiveSkills: Map = new Map() private watcher: FSWatcher | null = null private initialized: boolean = false // Prevent concurrent discovery calls (race condition protection) private discoveryPromise: Promise | null = null + private legacySkillRetirementWarnings: Set = new Set() - constructor(private readonly configPresenter: IConfigPresenter) { + constructor( + private readonly configPresenter: IConfigPresenter, + private readonly sessionStatePort: SkillSessionStatePort + ) { // Skills directory: ~/.deepchat/skills/ this.skillsDir = this.resolveSkillsDir() this.sidecarDir = path.join(this.skillsDir, SKILL_CONFIG.SIDECAR_DIR) @@ -979,42 +988,58 @@ export class SkillPresenter implements ISkillPresenter { private async isNewAgentSession(conversationId: string): Promise { try { - const session = await presenter?.newAgentPresenter?.getSession(conversationId) - return Boolean(session) + return await this.sessionStatePort.hasNewSession(conversationId) } catch { return false } } + private isImportedLegacySessionId(conversationId: string): boolean { + return conversationId.startsWith('legacy-session-') + } + + private async loadNewSessionSkills(conversationId: string): Promise { + const persistedSkills = this.getPersistedNewSessionSkills(conversationId) + if (persistedSkills.length > 0 || !this.isImportedLegacySessionId(conversationId)) { + return persistedSkills + } + + try { + return await this.sessionStatePort.repairImportedLegacySessionSkills(conversationId) + } catch (error) { + console.warn( + `[SkillPresenter] Failed to repair imported legacy session skills for ${conversationId}:`, + error + ) + return persistedSkills + } + } + + private warnLegacySkillRetired(conversationId: string): void { + if (this.legacySkillRetirementWarnings.has(conversationId)) { + return + } + + this.legacySkillRetirementWarnings.add(conversationId) + logger.warn('[SkillPresenter] Ignoring skill state update for retired legacy conversation.', { + conversationId + }) + } + /** * Get active skills for a conversation */ async getActiveSkills(conversationId: string): Promise { if (await this.isNewAgentSession(conversationId)) { - const skills = this.newAgentActiveSkills.get(conversationId) ?? [] + const skills = await this.loadNewSessionSkills(conversationId) const validSkills = await this.validateSkillNames(skills) if (validSkills.length !== skills.length) { - this.newAgentActiveSkills.set(conversationId, validSkills) + this.setPersistedNewSessionSkills(conversationId, validSkills) } return validSkills } - try { - const conversation = await presenter.sessionPresenter.getConversation(conversationId) - const activeSkills = conversation?.settings?.activeSkills || [] - const validSkills = await this.validateSkillNames(activeSkills) - - if (validSkills.length !== activeSkills.length) { - await presenter.sessionPresenter.updateConversationSettings(conversationId, { - activeSkills: validSkills - }) - } - - return validSkills - } catch (error) { - console.error(`[SkillPresenter] Error getting active skills for ${conversationId}:`, error) - return [] - } + return [] } /** @@ -1023,20 +1048,18 @@ export class SkillPresenter implements ISkillPresenter { async setActiveSkills(conversationId: string, skills: string[]): Promise { try { const isNewSession = await this.isNewAgentSession(conversationId) - const previousSkills = await this.getActiveSkills(conversationId) - const previousSet = new Set(previousSkills) - // Validate skill names const validSkills = await this.validateSkillNames(skills) + if (!isNewSession) { + this.warnLegacySkillRetired(conversationId) + return + } + + const previousSkills = await this.getActiveSkills(conversationId) + const previousSet = new Set(previousSkills) const validSet = new Set(validSkills) - if (isNewSession) { - this.newAgentActiveSkills.set(conversationId, validSkills) - } else { - await presenter.sessionPresenter.updateConversationSettings(conversationId, { - activeSkills: validSkills - }) - } + this.setPersistedNewSessionSkills(conversationId, validSkills) const activated = validSkills.filter((skill) => !previousSet.has(skill)) const deactivated = previousSkills.filter((skill) => !validSet.has(skill)) @@ -1061,7 +1084,7 @@ export class SkillPresenter implements ISkillPresenter { } async clearNewAgentSessionSkills(conversationId: string): Promise { - this.newAgentActiveSkills.delete(conversationId) + this.setPersistedNewSessionSkills(conversationId, []) } /** @@ -1270,4 +1293,20 @@ export class SkillPresenter implements ISkillPresenter { return acc } + + private getPersistedNewSessionSkills(conversationId: string): string[] { + try { + return this.sessionStatePort.getPersistedNewSessionSkills(conversationId) + } catch (error) { + console.warn( + `[SkillPresenter] Failed to read persisted active skills for ${conversationId}:`, + error + ) + return [] + } + } + + private setPersistedNewSessionSkills(conversationId: string, skills: string[]): void { + this.sessionStatePort.setPersistedNewSessionSkills(conversationId, skills) + } } diff --git a/src/main/presenter/skillPresenter/skillExecutionService.ts b/src/main/presenter/skillPresenter/skillExecutionService.ts index a72dd34e9..7470441fb 100644 --- a/src/main/presenter/skillPresenter/skillExecutionService.ts +++ b/src/main/presenter/skillPresenter/skillExecutionService.ts @@ -9,10 +9,10 @@ import type { SkillRuntimePreference, SkillScriptDescriptor } from '@shared/types/skill' +import { backgroundExecSessionManager } from '@/lib/agentRuntime/backgroundExecSessionManager' +import { getShellEnvironment, getUserShell } from '@/lib/agentRuntime/shellEnvHelper' +import { resolveSessionDir } from '@/lib/agentRuntime/sessionPaths' import { RuntimeHelper } from '@/lib/runtimeHelper' -import { resolveSessionDir } from '../sessionPresenter/sessionPaths' -import { backgroundExecSessionManager } from '../agentPresenter/acp/backgroundExecSessionManager' -import { getShellEnvironment, getUserShell } from '../agentPresenter/acp/shellEnvHelper' const DEFAULT_TIMEOUT_MS = 120000 const FOREGROUND_OFFLOAD_THRESHOLD = 10000 diff --git a/src/main/presenter/sqlitePresenter/tables/newSessions.ts b/src/main/presenter/sqlitePresenter/tables/newSessions.ts index 799994ab8..3a1086394 100644 --- a/src/main/presenter/sqlitePresenter/tables/newSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/newSessions.ts @@ -8,6 +8,7 @@ export interface NewSessionRow { project_dir: string | null is_pinned: number is_draft: number + active_skills: string created_at: number updated_at: number } @@ -37,11 +38,14 @@ export class NewSessionsTable extends BaseTable { if (version === 11) { return `ALTER TABLE new_sessions ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0;` } + if (version === 15) { + return `ALTER TABLE new_sessions ADD COLUMN active_skills TEXT NOT NULL DEFAULT '[]';` + } return null } getLatestVersion(): number { - return 11 + return 15 } create( @@ -52,6 +56,7 @@ export class NewSessionsTable extends BaseTable { options?: { isDraft?: boolean isPinned?: boolean + activeSkills?: string[] createdAt?: number updatedAt?: number } @@ -68,9 +73,10 @@ export class NewSessionsTable extends BaseTable { project_dir, is_pinned, is_draft, + active_skills, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, @@ -79,6 +85,7 @@ export class NewSessionsTable extends BaseTable { projectDir, options?.isPinned ? 1 : 0, options?.isDraft ? 1 : 0, + JSON.stringify(options?.activeSkills ?? []), createdAt, updatedAt ) @@ -114,7 +121,9 @@ export class NewSessionsTable extends BaseTable { update( id: string, - fields: Partial> + fields: Partial< + Pick + > ): void { const setClauses: string[] = [] const params: unknown[] = [] @@ -135,6 +144,10 @@ export class NewSessionsTable extends BaseTable { setClauses.push('is_draft = ?') params.push(fields.is_draft) } + if (fields.active_skills !== undefined) { + setClauses.push('active_skills = ?') + params.push(fields.active_skills) + } if (setClauses.length === 0) return @@ -148,4 +161,31 @@ export class NewSessionsTable extends BaseTable { delete(id: string): void { this.db.prepare('DELETE FROM new_sessions WHERE id = ?').run(id) } + + getActiveSkills(id: string): string[] { + const row = this.db.prepare('SELECT active_skills FROM new_sessions WHERE id = ?').get(id) as + | { active_skills?: string | null } + | undefined + + return this.parseActiveSkills(row?.active_skills) + } + + updateActiveSkills(id: string, activeSkills: string[]): void { + this.update(id, { active_skills: JSON.stringify(activeSkills) }) + } + + private parseActiveSkills(raw: string | null | undefined): string[] { + if (!raw) { + return [] + } + + try { + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string') + : [] + } catch { + return [] + } + } } diff --git a/src/main/presenter/syncPresenter/index.ts b/src/main/presenter/syncPresenter/index.ts index 8558dfb7c..afe6c6dff 100644 --- a/src/main/presenter/syncPresenter/index.ts +++ b/src/main/presenter/syncPresenter/index.ts @@ -626,7 +626,7 @@ export class SyncPresenter implements ISyncPresenter { private async broadcastThreadListUpdateAfterImport(): Promise { try { const { presenter } = await import('../index') - await (presenter?.sessionPresenter as any)?.broadcastThreadListUpdate?.() + await presenter?.broadcastLegacyThreadListUpdate?.() } catch (error) { console.warn('Failed to broadcast thread list update after import:', error) } diff --git a/src/main/presenter/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index 8df500bb9..b570e32b4 100644 --- a/src/main/presenter/tabPresenter.ts +++ b/src/main/presenter/tabPresenter.ts @@ -125,9 +125,9 @@ export class TabPresenter implements ITabPresenter { if (view) { this.detachViewFromWindow(window, view) } - const conversationId = presenter.sessionPresenter.getActiveConversationIdSync(viewId) + const conversationId = presenter.getActiveLegacyConversationIdSync(viewId) if (conversationId) { - void presenter.agentPresenter.cleanupConversation(conversationId) + void presenter.cleanupLegacyConversationRuntime(conversationId) } }) } diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index bd887eb17..a4afbb605 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -1,15 +1,15 @@ import type { IConfigPresenter, IMCPPresenter, - IYoBrowserPresenter, MCPToolDefinition, MCPToolCall, MCPToolResponse } from '@shared/presenter' import { resolveToolOffloadTemplatePath } from '../sessionPresenter/sessionPaths' -import { QUESTION_TOOL_NAME } from '../agentPresenter/tools/questionTool' +import { QUESTION_TOOL_NAME } from '@/lib/agentRuntime/questionTool' import { ToolMapper } from './toolMapper' import { AgentToolManager, type AgentToolCallResult } from '../agentPresenter/acp' +import type { AgentToolRuntimePort } from '../agentPresenter/runtimePorts' import { jsonrepair } from 'jsonrepair' import { CommandPermissionService } from '../permission' @@ -54,9 +54,9 @@ export interface IToolPresenter { interface ToolPresenterOptions { mcpPresenter: IMCPPresenter - yoBrowserPresenter: IYoBrowserPresenter configPresenter: IConfigPresenter commandPermissionHandler?: CommandPermissionService + agentToolRuntime: AgentToolRuntimePort } /** @@ -102,7 +102,8 @@ export class ToolPresenter implements IToolPresenter { this.agentToolManager = new AgentToolManager({ agentWorkspacePath, configPresenter: this.options.configPresenter, - commandPermissionHandler: this.options.commandPermissionHandler + commandPermissionHandler: this.options.commandPermissionHandler, + runtimePort: this.options.agentToolRuntime }) } diff --git a/src/renderer/src/components/chat-input/composables/useContextLength.ts b/src/renderer/src/components/chat-input/composables/useContextLength.ts index 9929e480c..4bcc2e3ac 100644 --- a/src/renderer/src/components/chat-input/composables/useContextLength.ts +++ b/src/renderer/src/components/chat-input/composables/useContextLength.ts @@ -2,7 +2,7 @@ import { computed, Ref, unref } from 'vue' // === Types === -import type { MessageFile } from '@shared/chat' +import type { MessageFile } from '@shared/types/agent-interface' import type { MaybeRef } from 'vue' // === Utils === @@ -25,7 +25,7 @@ export function useContextLength(options: ContextLengthOptions) { return ( approximateTokenSize(inputText.value) + selectedFiles.value.reduce((acc, file) => { - return acc + file.token + return acc + (file.token ?? 0) }, 0) ) }) diff --git a/src/renderer/src/components/chat/ChatAttachmentItem.vue b/src/renderer/src/components/chat/ChatAttachmentItem.vue index c359d6028..5edb92e3f 100644 --- a/src/renderer/src/components/chat/ChatAttachmentItem.vue +++ b/src/renderer/src/components/chat/ChatAttachmentItem.vue @@ -28,7 +28,7 @@