diff --git a/docs/AgentTODO.md b/docs/AgentTODO.md index 945f1ab5..06a16734 100644 --- a/docs/AgentTODO.md +++ b/docs/AgentTODO.md @@ -1,149 +1,66 @@ -# Agent Enhancement Plan — Progress Tracker +# Agent Enhancement — Session 2026-03-05 -Last updated: 2026-02-27 +## Changes Made -## Summary +### 1. Agent Switcher moved to header with AutoComplete search -15-step plan to enhance TidGi Agent. All functional steps complete. Git-based turn rollback feature added (2026-02-27). +- Moved `AgentSwitcher` from `InputContainer` (bottom left) to `ChatHeader` (top bar, next to title) +- Dropdown now opens **downward** instead of upward +- Added MUI `Autocomplete` with inline search box — filters agents by name and description +- Cleaned up unused props from `InputContainer` and `ChatTabContent` -## Status Legend +### 2. Tool Approval wired into execution flow -- ✅ Complete -- 🔶 Partial — needs finishing work -- ❌ Not started +- `evaluateApproval()` + `requestApproval()` now called before every `executeToolCall()` in `defineTool.ts` +- Also integrated into `executeAllMatchingToolCalls()` for batch approval +- Denied tools return an error tool result and yield back to the agent +- Pending tools block execution until the user responds via `resolveApproval()` -## Per-Step Status +### 3. Context window token truncation -| # | Step | Status | Notes | -|---|------|--------|-------| -| 1 | Recursion→Iteration + Termination tools | ✅ | while-loop + maxIterations guard + alarmClock in default config | -| 2 | Token counting + pie chart | ✅ | — | -| 3 | System prompt rewrite | ✅ | — | -| 4 | Parallel tool call support | ✅ | — | -| 5 | API retry mechanism | ✅ | `withRetry` wired into `generateFromAI` via `streamFromProvider` | -| 6 | MCP integration | ✅ | Not in default config by design — user adds manually | -| 7 | Approval mechanism | 🔶 | IPC + UI + infrastructure done, but NOT wired into tool execution flow. `requestApproval()` never called. | -| 8 | Wikitext rendering + MessageRenderer | ✅ | Uses `useRenderWikiText` hook, streaming split support | -| 9 | Streaming performance | ✅ | — | -| 10 | New tools (12+) | ✅ | All exist and registered, alarmClock now in default config | -| 11 | Sub-agent support | ✅ | — | -| 12 | God class splitting | ✅ | index.ts: 972→663, defineTool.ts: 820→596 | -| 13 | Code quality / tests | ✅ | 29 new MessageRenderer unit tests, all 473 tests pass | -| 14 | Approval UI settings | 🔶 | UI exists but no backend integration (approval infra unused) | -| 15 | Documentation | ✅ | Stale links fixed | +- Added `contextWindowSize` optional field to `FullReplacementParameterSchema` +- After `filterMessagesByDuration()`, messages are now trimmed by token count +- Oldest messages are removed first; 70% of context budget allocated to history +- Default set to 120,000 tokens in both Task Agent and Plan Agent configs -## Critical Bugs Fixed (2026-02-26 session 3: Rendering Pipeline & Store Reactivity) +### 4. Heartbeat auto-wake configuration -### 14. Zustand store direct mutation — streaming UI never updated -- **Problem**: In `agentActions.ts`, message subscription updates used `get().messages.set(id, msg)` — directly mutating the Map without calling `set()`. Zustand subscribers were never notified, so `MessageBubble` components did not re-render during streaming. The UI only updated when the stream ended (via `setMessageStreaming(false)` which DID call `set()`). -- **Fix**: Changed to immutable pattern: `set(state => { const newMessages = new Map(state.messages); newMessages.set(id, msg); return { messages: newMessages }; })`. Also fixed `subscribeToUpdates` new-message path to create a new Map instead of mutating in-place. +- New `AgentHeartbeatConfig` interface on `AgentDefinition` (enabled, intervalSeconds, message, activeHoursStart/End) +- `heartbeatManager.ts` manages per-agent `setInterval` timers +- Heartbeats started after successful framework run, stopped on close/cancel/delete +- Active hours filtering supports overnight ranges (e.g. 22:00-06:00) +- Persisted via `heartbeat` column on `AgentDefinitionEntity` -### 15. `stripToolXml` didn't handle partial/unclosed tags during streaming -- **Problem**: During streaming, the assistant message content may contain `{"filter":"[title` without a closing `` tag. The existing regex `/]*>[\s\S]*?<\/tool_use>/gi` only matched complete tags. Partial tags showed as raw XML to users during streaming. -- **Fix**: Added additional regex patterns to strip partial/unclosed tags: `/]*>[\s\S]*$/gi`, `/]*>[\s\S]*$/gi`, `/[\s\S]*$/gi`, and `/<(?:tool_use|function_call|...)\\b[^>]*$/gi` for incomplete opening tags. +### 5. Alarm clock recurring support -### 16. Renderer fallbacks showed raw XML -- **Problem**: All 5 specialized renderers (AskQuestion, ToolResult, ToolApproval, EditDiff, TodoList) had fallback paths that rendered `message.content` directly when JSON parsing failed. This showed raw `` XML to users. -- **Fix**: Exported `stripToolXml()` from `BaseMessageRenderer` and applied it in all fallback paths. Returns `null` if stripped content is empty. +- `alarm-clock` tool now accepts optional `repeatIntervalMinutes` parameter +- First fire happens at the specified `wakeAtISO`, then repeats at the interval +- `cancelAlarm()` clears both `setTimeout` and `setInterval` timers -### 17. Renderer registration in useEffect — not available on first render -- **Problem**: `useRegisterMessageRenderers()` registered renderers in `useEffect`, which runs AFTER the first render. Messages loaded from persistence were rendered with `BaseMessageRenderer` (no pattern matching) on initial mount, because `renderersRegistry` was still empty. -- **Fix**: Moved all `registerMessageRenderer()` calls to module scope in `useMessageRendering.ts`. Registration now happens at ES module import time, before any React component renders. +### 6. Tool result smart truncation -### 8. Assistant message with raw `` XML shown permanently for ask-question/summary tools -- **Problem**: `addToolResult()` did NOT mark the assistant message as a tool-call message (set `duration=1` + `containsToolCall` metadata). Only `yieldToSelf()` did this, but `askQuestion.ts`, `summary.ts`, and `modelContextProtocol.ts` used `addToolResult()` directly without calling `yieldToSelf()`. -- **Fix**: Moved the assistant-message-marking logic INTO `addToolResult()` itself. Now every `addToolResult()` call automatically sets `duration=1` and `containsToolCall=true` on the latest assistant message. Simplified `yieldToSelf()` to only set `yieldNextRoundTo = 'self'`. +- `addToolResult()` now truncates results exceeding 32,000 characters +- Truncated results include a `[... truncated]` marker with original length +- Prevents a single wiki-search result from consuming the entire context window -### 9. BaseMessageRenderer showed raw `` XML tags to users -- **Problem**: `BaseMessageRenderer` rendered `message.content` verbatim with no processing. Messages containing ``, ``, ``, or `` tags showed raw XML. -- **Fix**: Added `stripToolXml()` utility that removes all tool-related XML tags. Applied in both `BaseMessageRenderer` and `ThinkingMessageRenderer` (which also showed raw XML in its `mainContent`). +### 7. Step definition fix for embedding-only mock rules -### 10. No generic ToolResultRenderer — 12+ tools showed raw `` XML -- **Problem**: Only 4 tools (ask-question, edit-tiddler, todo, tool-approval) had custom renderers. All other tools (wiki-search, backlinks, toc, list-tiddlers, recent, zx-script, web-fetch, etc.) showed raw `Tool: ...\nResult: ...` text. -- **Fix**: Created `ToolResultRenderer.tsx` — a generic collapsible card that shows tool name, truncated result preview, and expandable full parameters + result. Registered with pattern `//` at priority 10 (lowest, so specific renderers take precedence). +- `agent.ts` step definition now accepts mock rules with only `embedding` (no `response`) +- Previously `if (response) rules.push(...)` would skip embedding-only rules -### 11. All Result JSON parsers were greedy — captured past `` -- **Problem**: Renderers used `/Result:\s*(.+)/s` which with the `s` flag greedily matched past the JSON into the `` closing tag. `JSON.parse()` would fail on the trailing XML. -- **Fix**: Changed all 5 renderers + `todo.ts` backend to use `/Result:\s*(.+?)\s*(?:<\/functions_result>|$)/s` — non-greedy match that stops before the closing tag. +## Files Changed -### 12. ask-question tool only supported single-select -- **Problem**: `askQuestion` tool schema only had `question`, `options`, and `allowFreeform`. No way for the agent to ask multi-select questions or text-only inputs. -- **Fix**: Added `inputType` field (`'single-select' | 'multi-select' | 'text'`). `AskQuestionRenderer` now supports: single-select (clickable chips), multi-select (checkboxes + submit button), and text-only (just a text box). The result JSON includes `inputType` for proper rendering. - -### 13. E2E image upload test opened native OS file dialog -- **Problem**: The test clicked "Add Image" button which triggered `fileInputReference.current?.click()`, opening the native OS file dialog that blocks the test runner. -- **Fix**: Simplified the test to directly use `setInputFiles()` on the hidden `` element, bypassing the native dialog entirely. - -## New Files Created - -- `src/pages/ChatTabContent/components/MessageRenderer/ToolResultRenderer.tsx` — Generic tool result renderer -- `src/pages/ChatTabContent/components/MessageRenderer/__tests__/MessageRenderers.test.tsx` — 26 unit tests covering AskQuestion (single/multi/text), ToolResult, ToolApproval, BaseMessageRenderer XML stripping, and pattern routing - -## Critical Bugs Fixed (2026-02-26 session 1) - -### 1. `input-required` status never set (ask-question tool broken) -- **Problem**: `askQuestion.ts` claimed "framework will set status to input-required" but no code did this. Status was always `'completed'` after ask-question, making the agent appear finished. -- **Fix**: Added `inputRequired()` to `statusUtilities.ts`, added `yieldToHuman()` to tool handler context (`defineToolTypes.ts` + `defineTool.ts`), `askQuestion.ts` now calls `yieldToHuman()` to signal the framework. `taskAgent.ts` checks for `yieldNextRoundTo === 'human'` and yields `inputRequired(...)` instead of `completed(...)`. - -### 2. Summary tool caused one extra unwanted round -- **Problem**: `summary.ts` used `executeToolCall()` which auto-calls `yieldToSelf()`, causing the agent to run one more LLM round after the summary was produced. -- **Fix**: Changed to use `addToolResult()` directly (like `askQuestion.ts`), avoiding the automatic `yieldToSelf()`. - -### 3. ToolApprovalRenderer Allow/Deny buttons were non-functional -- **Problem**: Both `handleApprove` and `handleDeny` had `// TODO: expose resolveApproval via IPC` placeholders. The backend `resolveApproval()` function existed but was never exposed via IPC. -- **Fix**: Added `resolveToolApproval(approvalId, decision)` to `IAgentInstanceService` interface + IPC descriptor + service implementation. `ToolApprovalRenderer` now calls `window.service.agentInstance.resolveToolApproval(...)`. - -### 4. AskQuestionRenderer answered state lost on page refresh -- **Problem**: `answered` was React local state (`useState(false)`), reset on every component remount. -- **Fix**: Initialized from `message.metadata.askQuestionAnswered`, persisted via `debounceUpdateMessage()` when user answers. - -### 5. WikitextMessageRenderer always fell back to plain text -- **Problem**: `renderWikitext()` function had `setError(true)` as the first line with `// TODO: get active workspace ID`. It never actually rendered wikitext. -- **Fix**: Replaced with `useRenderWikiText` hook (from `@services/wiki/hooks`) which handles workspace resolution. Streaming support: only renders complete blocks (split on `\n\n`), trailing text shown with reduced opacity. - -### 6. `input-required` not treated as terminal state in frontend -- **Problem**: Frontend `agentActions.ts` only treated `completed|failed|canceled` as terminal states. `input-required` messages kept streaming flags active indefinitely. -- **Fix**: Added `input-required` to terminal state checks in both agent-level and message-level subscription handlers. - -### 7. ask-question tool result duration was 0 -- **Problem**: `duration: 0` meant the question was immediately excluded from AI context in subsequent rounds, so the AI couldn't see what it had asked. -- **Fix**: Changed to `duration: 3` so the question stays visible in context for a few rounds. - -## Extracted Files (Step 12) - -### From `agentInstance/index.ts` (972→663 lines): -- `agentRepository.ts` (166 lines) — CRUD: createAgent, getAgent, updateAgent, deleteAgent, getAgents -- `agentMessagePersistence.ts` (150 lines) — saveUserMessage, createDebouncedMessageUpdater - -### From `tools/defineTool.ts` (820→596 lines): -- `defineToolTypes.ts` (234 lines) — All type/interface definitions -- `toolRegistry.ts` (47 lines) — registerToolDefinition, getAllToolDefinitions, getToolDefinition - -## Future Work - -New tool files without unit tests: -- summary, alarmClock, backlinks, toc, recent, listTiddlers -- getErrors, zxScript, webFetch, spawnAgent, editTiddler -- approval, parallelExecution, tokenEstimator, retryUtility -- matchAllToolCallings (in responsePatternUtility) - -Remaining renderer improvements: -- MarkdownRenderer — currently falls back to BaseMessageRenderer -- HtmlRenderer — currently falls back to BaseMessageRenderer - -## Git-based Turn Rollback (2026-02-27) - -Records HEAD commit hash for all wiki workspaces before each agent turn starts. -After the turn, compares commits to detect changed files and displays "X files changed" in the TurnActionBar. -Users can click Rollback to restore files via `git show :`. - -Files changed: -- `src/services/git/gitOperations.ts` — added `getHeadCommitHash`, `restoreFileFromCommit`, `getChangedFilesBetweenCommits` -- `src/services/agentInstance/interface.ts` — added `rollbackTurn`, `getTurnChangedFiles` to interface + IPC descriptor -- `src/services/agentInstance/index.ts` — records beforeCommitMap in sendMsgToAgent, implements rollbackTurn/getTurnChangedFiles -- `src/pages/ChatTabContent/components/TurnActionBar.tsx` — files changed chip, rollback button, rolled-back indicator - -E2E tests: -- `features/agentTool.feature` — added "delete removes turn" and "rollback button hidden for plain text" scenarios -- `features/agentTool.feature` — fixed "retry and delete" → split into separate retry/delete scenarios -- `features/streamingStatus.feature` — removed redundant 3rd round in streaming status test +- `src/pages/ChatTabContent/components/AgentSwitcher.tsx` — Rewritten with Autocomplete +- `src/pages/ChatTabContent/components/ChatHeader.tsx` — Now hosts AgentSwitcher +- `src/pages/ChatTabContent/components/InputContainer.tsx` — Removed AgentSwitcher +- `src/pages/ChatTabContent/index.tsx` — Props routing updated +- `src/services/agentDefinition/interface.ts` — Added AgentHeartbeatConfig +- `src/services/agentDefinition/index.ts` — Heartbeat in CRUD and initialization +- `src/services/agentInstance/heartbeatManager.ts` — New file +- `src/services/agentInstance/index.ts` — Heartbeat lifecycle integration +- `src/services/agentInstance/tools/defineTool.ts` — Approval + truncation wiring +- `src/services/agentInstance/tools/alarmClock.ts` — Recurring alarm support +- `src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts` — Token trimming +- `src/services/agentInstance/agentFrameworks/taskAgents.json` — contextWindowSize defaults +- `src/services/database/schema/agent.ts` — Heartbeat column +- `features/stepDefinitions/agent.ts` — Embedding rule fix \ No newline at end of file diff --git a/features/agent.feature b/features/agent.feature index 4876cd5d..fd703a73 100644 --- a/features/agent.feature +++ b/features/agent.feature @@ -84,9 +84,10 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation @agent Scenario: Close all tabs then create default agent from fallback page # Ensure starting from black/fallback page with no open tabs + # Open tab list dropdown and close tabs from there Given I click on a "new tab button" element with selector "[data-tab-id='new-tab-button']" - When I click all "tab" elements matching selector "[data-testid='tab']" - When I click all "close tab button" elements matching selector "[data-testid='tab-close-button']" + When I click on a "tab list dropdown" element with selector "[data-testid='tab-list-button']" + When I click all "close tab button" elements matching selector "[data-testid^='tab-close-']" # When there is no active tab, this is "fallback new tab", it has same thing as new tab. And I should see "new tab button and Create Default Agent" elements with selectors: | element description | selector | @@ -96,7 +97,9 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation And I should see a "Create Default Agent" element with selector "[data-testid='create-default-agent-button']" When I click on a "create default agent button" element with selector "[data-testid='create-default-agent-button']" And I should see a "message input box" element with selector "[data-testid='agent-message-input']" - Then I click all "close tab button" elements matching selector "[data-testid='tab-close-button']" + # Close remaining tabs via dropdown + Then I click on a "tab list dropdown" element with selector "[data-testid='tab-list-button']" + Then I click all "close tab button" elements matching selector "[data-testid^='tab-close-']" @agent @mockOpenAI Scenario: Streamed assistant response can be cancelled mid-stream and send button returns diff --git a/features/stepDefinitions/agent.ts b/features/stepDefinitions/agent.ts index fa1b9483..2ce2b5db 100644 --- a/features/stepDefinitions/agent.ts +++ b/features/stepDefinitions/agent.ts @@ -125,7 +125,8 @@ Given('I have started the mock OpenAI server', function(this: ApplicationWorld, embedding = generateSemanticEmbedding(embeddingTag); } - if (response) rules.push({ response, stream, embedding }); + // Include rules with a response OR an embedding — MockOpenAIServer separates them into chatRules vs embeddingRules internally + if (response || embedding) rules.push({ response, stream, embedding }); } } @@ -166,7 +167,8 @@ Given('I add mock OpenAI responses:', function(this: ApplicationWorld, dataTable embedding = generateSemanticEmbedding(embeddingTag); } - if (response) rules.push({ response, stream, embedding }); + // Include rules with a response OR an embedding — MockOpenAIServer separates them into chatRules vs embeddingRules internally + if (response || embedding) rules.push({ response, stream, embedding }); } } diff --git a/localization/locales/en/agent.json b/localization/locales/en/agent.json index d140b772..94f41d29 100644 --- a/localization/locales/en/agent.json +++ b/localization/locales/en/agent.json @@ -218,6 +218,12 @@ "NoPresetSelected": "No preset model selected", "NoProvidersAvailable": "No providers available", "OpenDatabaseFolder": "Open Database Folder", + "BackgroundTasks": "Background Tasks", + "BackgroundTasksDescription": "Active scheduled agent tasks (heartbeats and alarms). These wake agents automatically on a timer.", + "NoBackgroundTasks": "No active background tasks.", + "CancelTask": "Cancel this background task", + "SaveToDefinition": "Save to Definition", + "SaveToDefinitionDescription": "Apply current prompt configuration to the agent definition so new instances also use it", "PresetModels": "Preset Models", "PresetProvider": "Preset Provider", "ProviderAddedSuccessfully": "Provider added successfully", @@ -631,6 +637,7 @@ "NoTabs": "No tabs in split-screen view" }, "Tab": { + "TabList": "Tabs", "Title": { "CreateNewAgent": "Create New Agent", "EditAgentDefinition": "Edit Agent", diff --git a/localization/locales/zh-Hans/agent.json b/localization/locales/zh-Hans/agent.json index 6ae9b05a..9197271d 100644 --- a/localization/locales/zh-Hans/agent.json +++ b/localization/locales/zh-Hans/agent.json @@ -216,6 +216,12 @@ "NoPresetSelected": "不使用预设模型", "NoProvidersAvailable": "没有可用的提供商", "OpenDatabaseFolder": "打开数据库文件夹", + "BackgroundTasks": "后台任务", + "BackgroundTasksDescription": "活跃的智能体自动唤醒定时任务(心跳和闹钟)。这些任务会按计划自动唤醒智能体。", + "NoBackgroundTasks": "没有活跃的后台任务。", + "CancelTask": "取消此后台任务", + "SaveToDefinition": "保存到定义", + "SaveToDefinitionDescription": "将当前提示词配置保存到智能体定义,使新实例也使用此配置", "PresetModels": "预设模型", "PresetProvider": "预置提供商", "ProviderAddedSuccessfully": "提供商添加成功", @@ -612,6 +618,7 @@ "NoTabs": "分屏视图中没有标签" }, "Tab": { + "TabList": "标签页", "Title": { "CreateNewAgent": "创建新智能体", "EditAgentDefinition": "编辑智能体", diff --git a/src/__tests__/__mocks__/window.ts b/src/__tests__/__mocks__/window.ts index 46441017..2c60f39f 100644 --- a/src/__tests__/__mocks__/window.ts +++ b/src/__tests__/__mocks__/window.ts @@ -50,7 +50,7 @@ Object.defineProperty(window, 'observables', { externalAPI: { defaultConfig$: new BehaviorSubject({ default: { provider: 'openai', model: 'gpt-4' }, - modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 }, + modelParameters: { temperature: 0.7, topP: 0.95 }, }).asObservable(), providers$: new BehaviorSubject([]).asObservable(), }, diff --git a/src/pages/Agent/TabContent/TabContentArea.tsx b/src/pages/Agent/TabContent/TabContentArea.tsx index c4f193c2..1e913077 100644 --- a/src/pages/Agent/TabContent/TabContentArea.tsx +++ b/src/pages/Agent/TabContent/TabContentArea.tsx @@ -1,5 +1,6 @@ import { Box } from '@mui/material'; import { styled } from '@mui/material/styles'; +import { TabListDropdown } from '../components/TabBar/TabListDropdown'; import { TEMP_TAB_ID_PREFIX } from '../constants/tab'; import { useTabStore } from '../store/tabStore'; import { TabState, TabType } from '../types/tab'; @@ -10,12 +11,21 @@ import { NewTabContent } from './TabTypes/NewTabContent'; const ContentContainer = styled(Box)` flex: 1; display: flex; + flex-direction: column; height: 100%; position: relative; overflow: hidden; background-color: ${props => props.theme.palette.background.paper}; `; +const FallbackHeader = styled(Box)` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid ${props => props.theme.palette.divider}; +`; + export const TabContentArea: React.FC = () => { const { tabs, activeTabId } = useTabStore(); @@ -34,6 +44,9 @@ export const TabContentArea: React.FC = () => { // Render new tab page when no active tab return ( + + + props.theme.palette.divider}; `; +/** Minimal header with TabListDropdown for non-chat tab types */ +const GenericTabHeader = styled(Box)` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid ${props => props.theme.palette.divider}; + background-color: ${props => props.theme.palette.background.paper}; +`; + /** * Tab Content View Component * Renders different content components based on tab type and handles split view mode @@ -85,6 +96,15 @@ export const TabContentView: React.FC = ({ tab, isSplitView )} + {/* Non-chat tabs get a minimal header with tab list; Chat tabs have their own ChatHeader */} + {tab.type !== TabType.CHAT && !isSplitView && ( + + + + {tab.title} + + + )} {renderContent()} ); diff --git a/src/pages/Agent/components/TabBar/TabListDropdown.tsx b/src/pages/Agent/components/TabBar/TabListDropdown.tsx new file mode 100644 index 00000000..4206a042 --- /dev/null +++ b/src/pages/Agent/components/TabBar/TabListDropdown.tsx @@ -0,0 +1,195 @@ +// Compact dropdown for tab switching, placed in the chat header +import AddIcon from '@mui/icons-material/Add'; +import ChatIcon from '@mui/icons-material/Chat'; +import CloseIcon from '@mui/icons-material/Close'; +import SplitscreenIcon from '@mui/icons-material/Splitscreen'; +import TabIcon from '@mui/icons-material/Tab'; +import WebIcon from '@mui/icons-material/Web'; +import { Box, ClickAwayListener, Divider, IconButton, List, ListItemButton, ListItemIcon, ListItemText, Paper, Popper, Tooltip, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useTabStore } from '../../store/tabStore'; +import { TabItem, TabType } from '../../types/tab'; + +const DropdownPaper = styled(Paper)(({ theme }) => ({ + minWidth: 260, + maxWidth: 400, + maxHeight: 420, + borderRadius: 8, + boxShadow: theme.shadows[8], + overflow: 'auto', +})); + +const TabEntry = styled(ListItemButton, { shouldForwardProp: (p) => p !== 'active' })<{ active?: boolean }>(({ theme, active }) => ({ + borderRadius: 6, + margin: '1px 4px', + backgroundColor: active ? theme.palette.action.selected : 'transparent', + '&:hover .tab-close': { + opacity: 1, + }, +})); + +function getTabIcon(type: TabType) { + switch (type) { + case TabType.WEB: + return ; + case TabType.CHAT: + return ; + case TabType.SPLIT_VIEW: + return ; + default: + return ; + } +} + +export const TabListDropdown: React.FC = () => { + const { t } = useTranslation('agent'); + const { tabs, activeTabId, setActiveTab, closeTab, addTab } = useTabStore(); + const [anchorElement, setAnchorElement] = useState(null); + const open = Boolean(anchorElement); + + const handleToggle = useCallback((event: React.MouseEvent) => { + setAnchorElement((previous) => (previous ? null : event.currentTarget)); + }, []); + + const handleClose = useCallback(() => { + setAnchorElement(null); + }, []); + + const handleSelectTab = useCallback((tabId: string) => { + setActiveTab(tabId); + handleClose(); + }, [setActiveTab, handleClose]); + + const handleCloseTab = useCallback((event: React.MouseEvent, tabId: string) => { + event.stopPropagation(); + closeTab(tabId); + }, [closeTab]); + + const handleNewTab = useCallback(async () => { + await addTab(TabType.NEW_TAB); + handleClose(); + }, [addTab, handleClose]); + + // Sort: pinned first, then by creation time (newest first) + const sortedTabs = [...tabs].sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + return b.createdAt - a.createdAt; + }); + + const activeTab = tabs.find(tab => tab.id === activeTabId); + + return ( + <> + + + + {tabs.length > 1 && ( + + {tabs.length} + + )} + + + {/* Standalone new-tab button — keeps backward-compatible test selector */} + + { + void handleNewTab(); + }} + data-tab-id='new-tab-button' + data-testid='new-tab-button' + > + + + + + + + + + {sortedTabs.map((tab: TabItem) => ( + { + handleSelectTab(tab.id); + }} + data-testid={`tab-list-item-${tab.id}`} + > + + {getTabIcon(tab.type)} + + + { + handleCloseTab(event, tab.id); + }} + data-testid={`tab-close-${tab.id}`} + > + + + + ))} + + + + { + void handleNewTab(); + }} + sx={{ borderRadius: 6, mx: 0.5 }} + data-testid='tab-list-new-tab' + > + + + + + + + + + + + ); +}; diff --git a/src/pages/Agent/index.tsx b/src/pages/Agent/index.tsx index 95e33170..db63622d 100644 --- a/src/pages/Agent/index.tsx +++ b/src/pages/Agent/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { VerticalTabBar } from './components/TabBar/VerticalTabBar'; import { TabStoreInitializer } from './components/TabStoreInitializer'; import { AgentLayout } from './components/UI/AgentLayout'; import { TabContentArea } from './TabContent/TabContentArea'; @@ -10,7 +9,6 @@ export default function Agent(): React.JSX.Element { <> - diff --git a/src/pages/ChatTabContent/components/AgentSwitcher.tsx b/src/pages/ChatTabContent/components/AgentSwitcher.tsx index 5ff39546..ee875eda 100644 --- a/src/pages/ChatTabContent/components/AgentSwitcher.tsx +++ b/src/pages/ChatTabContent/components/AgentSwitcher.tsx @@ -1,17 +1,17 @@ -// Upward-expanding dropdown for switching agent definitions, similar to VSCode's mode switcher +// Downward-expanding agent picker with autocomplete search, placed in the header bar -import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import SmartToyIcon from '@mui/icons-material/SmartToy'; -import { Box, ClickAwayListener, List, ListItemButton, ListItemText, Paper, Popper, Typography } from '@mui/material'; +import { Autocomplete, Box, ClickAwayListener, Paper, Popper, TextField, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import type { AgentDefinition } from '@services/agentDefinition/interface'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; const SwitcherButton = styled(Box)<{ disabled?: boolean }>(({ theme, disabled }) => ({ display: 'flex', alignItems: 'center', gap: 4, - padding: '2px 8px 2px 6px', + padding: '2px 10px 2px 6px', borderRadius: 12, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, @@ -26,12 +26,11 @@ const SwitcherButton = styled(Box)<{ disabled?: boolean }>(({ theme, disabled }) })); const DropdownPaper = styled(Paper)(({ theme }) => ({ - minWidth: 220, - maxWidth: 360, - maxHeight: 320, - overflow: 'auto', + minWidth: 280, + maxWidth: 420, borderRadius: 8, boxShadow: theme.shadows[8], + padding: theme.spacing(1), })); interface AgentSwitcherProps { @@ -44,6 +43,7 @@ export const AgentSwitcher: React.FC = ({ currentAgentDefId, const [anchorElement, setAnchorElement] = useState(null); const [agentDefs, setAgentDefs] = useState([]); const open = Boolean(anchorElement); + const searchInputReference = useRef(null); const handleClick = useCallback( (event: React.MouseEvent) => { @@ -58,9 +58,9 @@ export const AgentSwitcher: React.FC = ({ currentAgentDefId, }, []); const handleSelect = useCallback( - (definitionId: string) => { - if (definitionId !== currentAgentDefId) { - onSwitch(definitionId); + (definition: AgentDefinition) => { + if (definition.id && definition.id !== currentAgentDefId) { + onSwitch(definition.id); } handleClose(); }, @@ -80,6 +80,17 @@ export const AgentSwitcher: React.FC = ({ currentAgentDefId, })(); }, [open]); + // Auto-focus search input when dropdown opens + useEffect(() => { + if (open) { + // Small delay to let popper render + const timer = setTimeout(() => { + searchInputReference.current?.focus(); + }, 50); + return () => clearTimeout(timer); + } + }, [open]); + const currentDefinition = agentDefs.find((d) => d.id === currentAgentDefId); const displayName = currentDefinition?.name ?? currentAgentDefId ?? 'Agent'; @@ -94,41 +105,71 @@ export const AgentSwitcher: React.FC = ({ currentAgentDefId, {displayName} - + - - {agentDefs.map((agentDefinition) => ( - { - handleSelect(agentDefinition.id); - }} - data-testid={`agent-switcher-option-${agentDefinition.id}`} + + open + autoHighlight + size='small' + options={agentDefs} + getOptionLabel={(option) => option.name ?? option.id} + value={currentDefinition ?? (agentDefs[0] as AgentDefinition | undefined) ?? { id: '', agentFrameworkConfig: {} } as AgentDefinition} + onChange={(_event, value) => handleSelect(value)} + filterOptions={(options, state) => { + const query = state.inputValue.toLowerCase(); + if (!query) return options; + return options.filter((o) => + (o.name ?? o.id).toLowerCase().includes(query) + || (o.description ?? '').toLowerCase().includes(query), + ); + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => ( + - - - ))} - {agentDefs.length === 0 && ( - - Loading... + + {option.name ?? option.id} + + {option.description && ( + + {option.description} + + )} )} - + slotProps={{ + paper: { sx: { boxShadow: 'none', border: 'none' } }, + listbox: { sx: { maxHeight: 280 }, 'data-testid': 'agent-switcher-listbox' } as React.HTMLAttributes & { 'data-testid': string }, + }} + // Prevent the autocomplete from closing the parent popper + disablePortal + // No clear button, selection always set + disableClearable + /> diff --git a/src/pages/ChatTabContent/components/ChatHeader.tsx b/src/pages/ChatTabContent/components/ChatHeader.tsx index 1c067135..f8ee910f 100644 --- a/src/pages/ChatTabContent/components/ChatHeader.tsx +++ b/src/pages/ChatTabContent/components/ChatHeader.tsx @@ -8,7 +8,9 @@ import { usePreferenceObservable } from '@services/preferences/hooks'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useShallow } from 'zustand/react/shallow'; +import { TabListDropdown } from '../../Agent/components/TabBar/TabListDropdown'; import { useAgentChatStore } from '../../Agent/store/agentChatStore/index'; +import { AgentSwitcher } from './AgentSwitcher'; import { APILogsDialog } from './APILogsDialog'; import ChatTitle from './ChatTitle'; import { CompactModelSelector } from './CompactModelSelector'; @@ -33,6 +35,9 @@ interface ChatHeaderProps { loading: boolean; onOpenParameters: () => void; inputText?: string; + currentAgentDefId?: string; + onSwitchAgent?: (agentDefId: string) => void; + isStreaming?: boolean; } /** @@ -43,6 +48,9 @@ export const ChatHeader: React.FC = ({ loading, onOpenParameters, inputText, + currentAgentDefId, + onSwitchAgent, + isStreaming, }) => { const { t } = useTranslation('agent'); const preference = usePreferenceObservable(); @@ -72,7 +80,17 @@ export const ChatHeader: React.FC = ({ return (
- + + + + {onSwitchAgent && ( + + )} + void; onRemoveWikiTiddler?: (index: number) => void; - currentAgentDefId?: string; - onSwitchAgent?: (agentDefId: string) => void; } const attachmentListboxSlotProps: React.HTMLAttributes & { 'data-testid': string } = { 'data-testid': 'attachment-listbox', @@ -73,8 +70,6 @@ export const InputContainer: React.FC = ({ selectedWikiTiddlers = [], onWikiTiddlerSelect, onRemoveWikiTiddler, - currentAgentDefId, - onSwitchAgent, }) => { const { t } = useTranslation('agent'); const fileInputReference = React.useRef(null); @@ -297,13 +292,6 @@ export const InputContainer: React.FC = ({ accept='image/*' onChange={handleFileChange} /> - {onSwitchAgent && ( - - )} = ({ const [baseMode, setBaseMode] = useState<'preview' | 'edit'>(initialBaseMode); const [showSideBySide, setShowSideBySide] = useState(false); const [baseModeBeforeSideBySide, setBaseModeBeforeSideBySide] = useState<'preview' | 'edit'>(initialBaseMode); + const [savedSnackbarOpen, setSavedSnackbarOpen] = useState(false); const { loading: agentFrameworkConfigLoading, @@ -48,6 +51,23 @@ export const PromptPreviewDialog: React.FC = ({ agentId: agent?.id, }); + /** Copy current instance prompt config to the agent definition */ + const handleSaveToDefinition = useCallback(async () => { + if (!agent?.agentDefId || !agentFrameworkConfig) return; + try { + const agentDefinition = await window.service.agentDefinition.getAgentDef(agent.agentDefId); + if (agentDefinition) { + await window.service.agentDefinition.updateAgentDef({ + ...agentDefinition, + agentFrameworkConfig, + }); + setSavedSnackbarOpen(true); + } + } catch (error) { + void window.service.native.log('error', 'Failed to save config to definition', { error }); + } + }, [agent?.agentDefId, agentFrameworkConfig]); + const { getPreviewPromptResult, previewLoading, @@ -146,6 +166,21 @@ export const PromptPreviewDialog: React.FC = ({ {t('Prompt.Preview')} + {/* Save to definition button — only in edit mode */} + {(showEdit || showSideBySide) && agent?.agentDefId && ( + + { + void handleSaveToDefinition(); + }} + sx={{ mr: 1 }} + data-testid='save-to-definition-button' + > + + + + )} = ({ )} + { + setSavedSnackbarOpen(false); + }} + message={t('Preference.SaveToDefinitionDescription')} + /> ); }; diff --git a/src/pages/ChatTabContent/index.tsx b/src/pages/ChatTabContent/index.tsx index f1e2e952..d2307a64 100644 --- a/src/pages/ChatTabContent/index.tsx +++ b/src/pages/ChatTabContent/index.tsx @@ -209,6 +209,9 @@ export const ChatTabContent: React.FC = ({ tab, isSplitView onOpenParameters={handleOpenParameters} loading={isWorking} inputText={message} + currentAgentDefId={tab.agentDefId} + onSwitchAgent={handleSwitchAgent} + isStreaming={isStreaming} /> {/* Messages container with all chat bubbles */} @@ -256,8 +259,6 @@ export const ChatTabContent: React.FC = ({ tab, isSplitView selectedWikiTiddlers={selectedWikiTiddlers} onWikiTiddlerSelect={handleWikiTiddlerSelect} onRemoveWikiTiddler={handleRemoveWikiTiddler} - currentAgentDefId={tab.agentDefId} - onSwitchAgent={handleSwitchAgent} /> {/* Model parameter dialog */} @@ -273,7 +274,6 @@ export const ChatTabContent: React.FC = ({ tab, isSplitView temperature: 0.7, maxTokens: 1000, topP: 0.95, - systemPrompt: '', }, }} onSave={async (newConfig) => { diff --git a/src/services/agentDefinition/index.ts b/src/services/agentDefinition/index.ts index ba734029..2afca8ea 100644 --- a/src/services/agentDefinition/index.ts +++ b/src/services/agentDefinition/index.ts @@ -82,6 +82,7 @@ export class AgentDefinitionService implements IAgentDefinitionService { agentFrameworkConfig: defaultAgent.agentFrameworkConfig, aiApiConfig: defaultAgent.aiApiConfig, agentTools: defaultAgent.agentTools, + heartbeat: (defaultAgent as AgentDefinition).heartbeat, }) ); // Save all default agents to database @@ -143,7 +144,7 @@ export class AgentDefinitionService implements IAgentDefinitionService { throw new Error(`Agent definition not found: ${agent.id}`); } - const pickedProperties = pick(agent, ['name', 'description', 'avatarUrl', 'agentFrameworkID', 'agentFrameworkConfig', 'aiApiConfig']); + const pickedProperties = pick(agent, ['name', 'description', 'avatarUrl', 'agentFrameworkID', 'agentFrameworkConfig', 'aiApiConfig', 'heartbeat']); Object.assign(existingAgent, pickedProperties); await this.agentDefRepository!.save(existingAgent); @@ -175,6 +176,7 @@ export class AgentDefinitionService implements IAgentDefinitionService { agentFrameworkConfig: entity.agentFrameworkConfig || {}, aiApiConfig: entity.aiApiConfig || undefined, agentTools: entity.agentTools || undefined, + heartbeat: entity.heartbeat || undefined, })); return agentDefs; @@ -216,6 +218,7 @@ export class AgentDefinitionService implements IAgentDefinitionService { agentFrameworkConfig: entity.agentFrameworkConfig || {}, aiApiConfig: entity.aiApiConfig || undefined, agentTools: entity.agentTools || undefined, + heartbeat: entity.heartbeat || undefined, }; return agentDefinition; diff --git a/src/services/agentDefinition/interface.ts b/src/services/agentDefinition/interface.ts index 8f97798e..5f74b63e 100644 --- a/src/services/agentDefinition/interface.ts +++ b/src/services/agentDefinition/interface.ts @@ -16,6 +16,22 @@ export interface AgentToolConfig { tags?: string[]; } +/** + * Heartbeat configuration for automatic periodic agent wake-up + */ +export interface AgentHeartbeatConfig { + /** Whether heartbeat is enabled */ + enabled: boolean; + /** Interval in seconds between automatic wake-ups. Min 60s. */ + intervalSeconds: number; + /** Message sent to the agent on each heartbeat tick */ + message: string; + /** Optional: only run heartbeat between these hours (24h format, e.g. "09:00"-"18:00") */ + activeHoursStart?: string; + /** Optional: end of active hours */ + activeHoursEnd?: string; +} + /** * Tool calling match result */ @@ -55,6 +71,11 @@ export interface AgentDefinition { * Tools available to this agent */ agentTools?: AgentToolConfig[]; + /** + * Heartbeat configuration — periodically wake the agent with an automated message. + * Like OpenClaw-style autonomous agents that check in regularly. + */ + heartbeat?: AgentHeartbeatConfig; } /** diff --git a/src/services/agentInstance/__tests__/index.failure.test.ts b/src/services/agentInstance/__tests__/index.failure.test.ts index ea8267cf..448eac51 100644 --- a/src/services/agentInstance/__tests__/index.failure.test.ts +++ b/src/services/agentInstance/__tests__/index.failure.test.ts @@ -46,7 +46,7 @@ describe('AgentInstance failure path - external API logs on error', () => { const dbService = container.get(serviceIdentifier.Database); const aiSettings = { providers: [{ provider: 'test-provider', models: [{ name: 'test-model' }] }], - defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, + defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } }, }; vi.spyOn(dbService, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined)); @@ -79,7 +79,7 @@ describe('AgentInstance failure path - external API logs on error', () => { id: 'def-1', name: 'Def 1', agentFrameworkConfig: {}, - aiApiConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, + aiApiConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } }, }, isCancelled: () => false, } as Parameters[0]; diff --git a/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.failure.test.ts b/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.failure.test.ts index dbcc727f..0ecb1c8d 100644 --- a/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.failure.test.ts +++ b/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.failure.test.ts @@ -34,7 +34,7 @@ function makeContext(agentId: string, agentDefId: string, messages: AgentInstanc id: agentDefId, name: 'Test Agent', agentFrameworkConfig: {}, - aiApiConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } } as AiAPIConfig, + aiApiConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } } as AiAPIConfig, }, isCancelled: () => false, } as unknown as AgentFrameworkContext; @@ -75,7 +75,7 @@ describe('basicPromptConcatHandler - failure path persists error message and log const dbService = container.get(serviceIdentifier.Database); const aiSettings = { providers: [{ provider: 'test-provider', models: [{ name: 'test-model' }] }], - defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, + defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } }, }; vi.spyOn(dbService, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined)); diff --git a/src/services/agentInstance/agentFrameworks/taskAgents.json b/src/services/agentInstance/agentFrameworks/taskAgents.json index 443acbde..ad2a9f92 100644 --- a/src/services/agentInstance/agentFrameworks/taskAgents.json +++ b/src/services/agentInstance/agentFrameworks/taskAgents.json @@ -69,7 +69,8 @@ "toolId": "fullReplacement", "fullReplacementParam": { "targetId": "default-history", - "sourceType": "historyOfSession" + "sourceType": "historyOfSession", + "contextWindowSize": 120000 }, "caption": "聊天历史", "forbidOverrides": true @@ -342,7 +343,8 @@ "toolId": "fullReplacement", "fullReplacementParam": { "targetId": "default-history", - "sourceType": "historyOfSession" + "sourceType": "historyOfSession", + "contextWindowSize": 120000 }, "caption": "聊天历史", "forbidOverrides": true diff --git a/src/services/agentInstance/heartbeatManager.ts b/src/services/agentInstance/heartbeatManager.ts new file mode 100644 index 00000000..bd506fdc --- /dev/null +++ b/src/services/agentInstance/heartbeatManager.ts @@ -0,0 +1,105 @@ +/** + * Heartbeat Manager — Periodically wakes agents that have heartbeat configured. + * + * Each agent definition can have a `heartbeat` config specifying an interval and message. + * The manager runs timers per agent-instance and sends automated messages to trigger the agent loop. + * Active hours filtering is supported to restrict heartbeats to certain times of day. + */ +import type { AgentHeartbeatConfig } from '@services/agentDefinition/interface'; +import { logger } from '@services/libs/log'; +import type { IAgentInstanceService } from './interface'; + +interface HeartbeatEntry { + timerId: ReturnType; + config: AgentHeartbeatConfig; + agentId: string; +} + +const activeHeartbeats = new Map(); + +function isWithinActiveHours(config: AgentHeartbeatConfig): boolean { + if (!config.activeHoursStart || !config.activeHoursEnd) return true; + const now = new Date(); + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + + const parseTime = (t: string): number => { + const [h, m] = t.split(':').map(Number); + return (h ?? 0) * 60 + (m ?? 0); + }; + + const start = parseTime(config.activeHoursStart); + const end = parseTime(config.activeHoursEnd); + + // Handle overnight ranges (e.g. 22:00 - 06:00) + if (start <= end) { + return currentMinutes >= start && currentMinutes <= end; + } + return currentMinutes >= start || currentMinutes <= end; +} + +/** + * Start a heartbeat timer for an agent instance. + * If one already exists for this agent, it's replaced. + */ +export function startHeartbeat( + agentId: string, + config: AgentHeartbeatConfig, + agentInstanceService: IAgentInstanceService, +): void { + // Clean up existing heartbeat for this agent + stopHeartbeat(agentId); + + if (!config.enabled) return; + + const intervalMs = Math.max(config.intervalSeconds, 60) * 1000; + const message = config.message || '[Heartbeat] Periodic check-in. Review your tasks and take any pending actions.'; + + const timerId = setInterval(async () => { + if (!isWithinActiveHours(config)) { + logger.debug('Heartbeat skipped — outside active hours', { agentId }); + return; + } + + try { + await agentInstanceService.sendMsgToAgent(agentId, { + text: `[Heartbeat] ${message}`, + }); + logger.info('Heartbeat triggered', { agentId, intervalSeconds: config.intervalSeconds }); + } catch (error) { + logger.error('Heartbeat failed to send message', { error, agentId }); + } + }, intervalMs); + + activeHeartbeats.set(agentId, { timerId, config, agentId }); + logger.info('Heartbeat started', { agentId, intervalMs, activeHoursStart: config.activeHoursStart, activeHoursEnd: config.activeHoursEnd }); +} + +/** + * Stop a heartbeat timer for an agent instance. + */ +export function stopHeartbeat(agentId: string): void { + const entry = activeHeartbeats.get(agentId); + if (entry) { + clearInterval(entry.timerId); + activeHeartbeats.delete(agentId); + logger.debug('Heartbeat stopped', { agentId }); + } +} + +/** + * Stop all active heartbeats (for shutdown). + */ +export function stopAllHeartbeats(): void { + for (const [agentId, entry] of activeHeartbeats) { + clearInterval(entry.timerId); + logger.debug('Heartbeat stopped during shutdown', { agentId }); + } + activeHeartbeats.clear(); +} + +/** + * Get all active heartbeat agent IDs. + */ +export function getActiveHeartbeats(): string[] { + return [...activeHeartbeats.keys()]; +} diff --git a/src/services/agentInstance/index.ts b/src/services/agentInstance/index.ts index 901406f9..8e0c97be 100644 --- a/src/services/agentInstance/index.ts +++ b/src/services/agentInstance/index.ts @@ -21,7 +21,9 @@ import { isWikiWorkspace } from '@services/workspaces/interface'; import { createDebouncedMessageUpdater, saveUserMessage as saveUserMessageHelper } from './agentMessagePersistence'; import * as repo from './agentRepository'; +import { getActiveHeartbeats, startHeartbeat, stopHeartbeat } from './heartbeatManager'; import type { AgentInstance, AgentInstanceLatestStatus, AgentInstanceMessage, IAgentInstanceService } from './interface'; +import { cancelAlarm, getActiveAlarmAgentIds, scheduleAlarmTimer } from './tools/alarmClock'; @injectable() export class AgentInstanceService implements IAgentInstanceService { @@ -47,6 +49,8 @@ export class AgentInstanceService implements IAgentInstanceService { try { await this.initializeDatabase(); await this.initializeFrameworks(); + // Restore heartbeat timers and alarms for active agents after DB + frameworks are ready + await this.restoreBackgroundTasks(); } catch (error) { logger.error('Failed to initialize agent instance service', { error }); throw error; @@ -87,6 +91,57 @@ export class AgentInstanceService implements IAgentInstanceService { this.registerFramework('basicPromptConcatHandler', basicPromptConcatHandler, getPromptConcatAgentFrameworkConfigJsonSchema()); } + /** + * Restore heartbeat timers and alarm timers for active agents after app restart. + * Heartbeats: read from AgentDefinition.heartbeat for all non-closed instances. + * Alarms: read from AgentInstance.scheduledAlarm for all non-closed instances. + */ + private async restoreBackgroundTasks(): Promise { + if (!this.agentInstanceRepository) return; + try { + // Find all non-closed, non-volatile agent instances with their definitions + const activeInstances = await this.agentInstanceRepository.find({ + where: { closed: false, volatile: false }, + relations: ['agentDefinition'], + }); + + let heartbeatsRestored = 0; + let alarmsRestored = 0; + + for (const instance of activeInstances) { + // Restore heartbeat from definition + const heartbeatConfig = instance.agentDefinition?.heartbeat; + if (heartbeatConfig?.enabled) { + startHeartbeat(instance.id, heartbeatConfig, this); + heartbeatsRestored++; + } + + // Restore persisted alarm + const alarm = instance.scheduledAlarm; + if (alarm?.wakeAtISO) { + const wakeAt = new Date(alarm.wakeAtISO); + const now = new Date(); + // For one-shot alarms in the past, fire immediately + // For recurring alarms, always restore + if (alarm.repeatIntervalMinutes || wakeAt.getTime() > now.getTime()) { + scheduleAlarmTimer(instance.id, alarm.wakeAtISO, alarm.reminderMessage, alarm.repeatIntervalMinutes); + alarmsRestored++; + } else { + // Past one-shot alarm — fire it now and clear + scheduleAlarmTimer(instance.id, new Date().toISOString(), alarm.reminderMessage); + alarmsRestored++; + } + } + } + + if (heartbeatsRestored > 0 || alarmsRestored > 0) { + logger.info('Background tasks restored', { heartbeatsRestored, alarmsRestored, totalInstances: activeInstances.length }); + } + } catch (error) { + logger.error('Failed to restore background tasks', { error }); + } + } + /** * Register a framework with an optional schema * @param frameworkId ID for the framework @@ -160,6 +215,7 @@ export class AgentInstanceService implements IAgentInstanceService { public async deleteAgent(agentId: string): Promise { this.ensureRepositories(); try { + stopHeartbeat(agentId); await repo.deleteAgent(this.agentInstanceRepository!, this.agentMessageRepository!, agentId); this.cleanupAgentSubscriptions(agentId); } catch (error) { @@ -334,13 +390,19 @@ export class AgentInstanceService implements IAgentInstanceService { } // Trigger agentStatusChanged hook with actual terminal state (completed, input-required, etc.) + const terminalState = (lastResult.state ?? 'completed') as 'working' | 'completed' | 'failed' | 'canceled'; await frameworkHooks.agentStatusChanged.promise({ agentFrameworkContext: frameworkContext, status: { - state: lastResult.state ?? 'completed', + state: terminalState, modified: new Date(), }, }); + + // Start heartbeat timer if the agent definition has heartbeat config + if (agentDefinition.heartbeat?.enabled) { + startHeartbeat(agentId, agentDefinition.heartbeat, this); + } } // Remove cancel token after generator completes @@ -392,6 +454,9 @@ export class AgentInstanceService implements IAgentInstanceService { } public async cancelAgent(agentId: string): Promise { + // Stop heartbeat on cancel + stopHeartbeat(agentId); + // Cancel any pending ask-question promises so the agent loop can exit try { const { cancelPendingQuestions } = require('./tools/askQuestionPending') as typeof import('./tools/askQuestionPending'); @@ -479,6 +544,8 @@ export class AgentInstanceService implements IAgentInstanceService { this.ensureRepositories(); try { + stopHeartbeat(agentId); + // Get agent instance const instanceEntity = await this.agentInstanceRepository!.findOne({ where: { id: agentId }, @@ -557,7 +624,7 @@ export class AgentInstanceService implements IAgentInstanceService { }); if (agent) { const deletedSet = new Set(messageIds); - agent.messages = agent.messages.filter(m => !deletedSet.has(m.id)); + agent.messages = (agent.messages ?? []).filter(m => !deletedSet.has(m.id)); await this.agentInstanceRepository.save(agent); } } @@ -649,6 +716,72 @@ export class AgentInstanceService implements IAgentInstanceService { return { rolledBack, errors }; } + public async getBackgroundTasks(): Promise< + Array<{ + agentId: string; + agentName?: string; + type: 'heartbeat' | 'alarm'; + intervalSeconds?: number; + wakeAtISO?: string; + message?: string; + repeatIntervalMinutes?: number; + }> + > { + const tasks: Array<{ + agentId: string; + agentName?: string; + type: 'heartbeat' | 'alarm'; + intervalSeconds?: number; + wakeAtISO?: string; + message?: string; + repeatIntervalMinutes?: number; + }> = []; + + // Collect heartbeats from in-memory registry + const heartbeatAgentIds = getActiveHeartbeats(); + for (const agentId of heartbeatAgentIds) { + const agent = await this.getAgent(agentId); + const agentDefinition = agent?.agentDefId ? await this.agentDefinitionService.getAgentDef(agent.agentDefId) : undefined; + const heartbeatConfig = agentDefinition?.heartbeat; + tasks.push({ + agentId, + agentName: agent?.name ?? agentDefinition?.name, + type: 'heartbeat', + intervalSeconds: heartbeatConfig?.intervalSeconds, + message: heartbeatConfig?.message, + }); + } + + // Collect alarms from in-memory registry + const alarmAgentIds = getActiveAlarmAgentIds(); + for (const agentId of alarmAgentIds) { + const agent = await this.getAgent(agentId); + // Read persisted alarm for details + if (this.agentInstanceRepository) { + const entity = await this.agentInstanceRepository.findOne({ where: { id: agentId } }); + tasks.push({ + agentId, + agentName: agent?.name, + type: 'alarm', + wakeAtISO: entity?.scheduledAlarm?.wakeAtISO, + message: entity?.scheduledAlarm?.reminderMessage, + repeatIntervalMinutes: entity?.scheduledAlarm?.repeatIntervalMinutes, + }); + } + } + + return tasks; + } + + public async cancelBackgroundTask(agentId: string, type: 'heartbeat' | 'alarm'): Promise { + if (type === 'heartbeat') { + stopHeartbeat(agentId); + } else if (type === 'alarm') { + cancelAlarm(agentId); + } + logger.info('Background task cancelled from UI', { agentId, type }); + } + public subscribeToAgentUpdates(agentId: string): Observable; /** * Subscribe to agent instance message status updates diff --git a/src/services/agentInstance/interface.ts b/src/services/agentInstance/interface.ts index 47851631..b6277dd3 100644 --- a/src/services/agentInstance/interface.ts +++ b/src/services/agentInstance/interface.ts @@ -278,6 +278,26 @@ export interface IAgentInstanceService { * @returns Array of changed files with their status */ getTurnChangedFiles(agentId: string, userMessageId: string): Promise>; + + /** + * Get all active background tasks (heartbeats + alarms) for display in settings UI. + */ + getBackgroundTasks(): Promise< + Array<{ + agentId: string; + agentName?: string; + type: 'heartbeat' | 'alarm'; + intervalSeconds?: number; + wakeAtISO?: string; + message?: string; + repeatIntervalMinutes?: number; + }> + >; + + /** + * Cancel a background task by agent ID and type. + */ + cancelBackgroundTask(agentId: string, type: 'heartbeat' | 'alarm'): Promise; } export const AgentInstanceServiceIPCDescriptor = { @@ -300,6 +320,8 @@ export const AgentInstanceServiceIPCDescriptor = { sendMsgToAgent: ProxyPropertyType.Function, subscribeToAgentUpdates: ProxyPropertyType.Function$, getTurnChangedFiles: ProxyPropertyType.Function, + getBackgroundTasks: ProxyPropertyType.Function, + cancelBackgroundTask: ProxyPropertyType.Function, updateAgent: ProxyPropertyType.Function, }, }; diff --git a/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts b/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts index fb8a2e31..8a9c71c5 100644 --- a/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts +++ b/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts @@ -10,6 +10,7 @@ import { z } from 'zod/v4'; import type { AgentResponse } from '../../tools/types'; import { filterMessagesByDuration } from '../../utilities/messageDurationFilter'; import { normalizeRole } from '../../utilities/normalizeRole'; +import { estimateTokens } from '../../utilities/tokenEstimator'; import type { IPrompt } from '../promptConcatSchema'; import { registerModifier } from './defineModifier'; @@ -27,6 +28,10 @@ export const FullReplacementParameterSchema = z.object({ title: t('Schema.FullReplacement.SourceTypeTitle'), description: t('Schema.FullReplacement.SourceType'), }), + contextWindowSize: z.number().optional().meta({ + title: 'Context Window Size', + description: 'Max tokens for message history. Oldest messages are trimmed when exceeded. 0 or empty = no limit.', + }), }).meta({ title: t('Schema.FullReplacement.Title'), description: t('Schema.FullReplacement.Description'), @@ -87,9 +92,39 @@ const fullReplacementDefinition = registerModifier({ // Apply duration filtering to exclude expired messages const filteredHistory = filterMessagesByDuration(messagesCopy); - if (filteredHistory.length > 0) { + // Apply context window token trimming — remove oldest messages when total exceeds limit + const contextWindowSize = config.contextWindowSize; + let trimmedHistory = filteredHistory; + if (contextWindowSize && contextWindowSize > 0 && filteredHistory.length > 0) { + let totalTokens = 0; + for (const message of filteredHistory) { + totalTokens += estimateTokens(message.content); + } + // Reserve ~30% of context window for system prompts + tool definitions + current user message + const historyBudget = Math.floor(contextWindowSize * 0.7); + if (totalTokens > historyBudget) { + // Remove from the front (oldest) until we fit + trimmedHistory = []; + let runningTokens = 0; + for (let index = filteredHistory.length - 1; index >= 0; index--) { + const msgTokens = estimateTokens(filteredHistory[index].content); + if (runningTokens + msgTokens > historyBudget) break; + runningTokens += msgTokens; + trimmedHistory.unshift(filteredHistory[index]); + } + logger.debug('Trimmed history to fit context window', { + originalMessages: filteredHistory.length, + trimmedMessages: trimmedHistory.length, + totalTokensBefore: totalTokens, + totalTokensAfter: runningTokens, + historyBudget, + }); + } + } + + if (trimmedHistory.length > 0) { found.prompt.children = []; - filteredHistory.forEach((message, index: number) => { + trimmedHistory.forEach((message, index: number) => { type PromptRole = NonNullable; const role: PromptRole = normalizeRole(message.role); delete found.prompt.text; diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/modelParameters.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/modelParameters.ts index c8c95d24..79ff697c 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/modelParameters.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/modelParameters.ts @@ -37,10 +37,6 @@ export const ModelParametersSchema = z.object({ title: t('Schema.ModelParameters.TopPTitle'), description: t('Schema.ModelParameters.TopP'), }), - systemPrompt: z.string().optional().meta({ - title: t('Schema.ModelParameters.SystemPromptTitle'), - description: t('Schema.ModelParameters.SystemPrompt'), - }), }) .catchall(z.unknown()) .meta({ diff --git a/src/services/agentInstance/tools/alarmClock.ts b/src/services/agentInstance/tools/alarmClock.ts index 795339f1..2b0567d8 100644 --- a/src/services/agentInstance/tools/alarmClock.ts +++ b/src/services/agentInstance/tools/alarmClock.ts @@ -1,8 +1,11 @@ /** * Alarm Clock Tool — terminates the current agent loop and schedules a self-wake at a future time. * The agent can use this to "sleep" and resume later. + * Alarms are persisted to the database so they survive app restarts. */ import { container } from '@services/container'; +import type { IDatabaseService } from '@services/database/interface'; +import { AgentInstanceEntity } from '@services/database/schema/agent'; import { t } from '@services/libs/i18n/placeholder'; import { logger } from '@services/libs/log'; import serviceIdentifier from '@services/serviceIdentifier'; @@ -28,15 +31,90 @@ const AlarmClockToolSchema = z.object({ title: 'Reminder message', description: 'A message to send to yourself when you wake up, to remind you what to do next.', }), + repeatIntervalMinutes: z.number().optional().meta({ + title: 'Repeat interval (minutes)', + description: 'If set, the alarm repeats at this interval after the initial wake time. 0 or omitted = one-shot.', + }), }).meta({ title: 'alarm-clock', - description: 'Set a future wake-up time and temporarily exit the conversation loop. At the scheduled time, the agent will receive the reminder message and resume working.', - examples: [{ wakeAtISO: '2025-12-01T09:00:00Z', reminderMessage: 'Check if the daily note was created successfully.' }], + description: + 'Set a future wake-up time and temporarily exit the conversation loop. At the scheduled time, the agent will receive the reminder message and resume working. Optionally set a repeat interval for recurring wake-ups.', + examples: [ + { wakeAtISO: '2025-12-01T09:00:00Z', reminderMessage: 'Check if the daily note was created successfully.' }, + { wakeAtISO: '2025-12-01T09:00:00Z', reminderMessage: 'Hourly check-in', repeatIntervalMinutes: 60 }, + ], }); /** Active timers keyed by agentId, so they can be cancelled on agent close */ const activeTimers = new Map>(); +/** Persist alarm data to DB so it survives app restarts */ +async function persistAlarm(agentId: string, data: { wakeAtISO: string; reminderMessage?: string; repeatIntervalMinutes?: number } | null): Promise { + try { + const databaseService = container.get(serviceIdentifier.Database); + const dataSource = await databaseService.getDatabase('agent'); + const repository = dataSource.getRepository(AgentInstanceEntity); + await repository.update(agentId, { scheduledAlarm: data }); + } catch (error) { + logger.warn('Failed to persist alarm data', { agentId, error }); + } +} + +/** + * Schedule alarm timer (used both during tool execution and on app restart restore). + * Returns the timer handle. + */ +export function scheduleAlarmTimer( + agentId: string, + wakeAtISO: string, + reminderMessage?: string, + repeatIntervalMinutes?: number, +): void { + const wakeAt = new Date(wakeAtISO); + const now = new Date(); + const delayMs = Math.max(0, wakeAt.getTime() - now.getTime()); + const repeatMs = repeatIntervalMinutes ? Math.max(repeatIntervalMinutes, 1) * 60_000 : 0; + + // Clear existing timer + const existing = activeTimers.get(agentId); + if (existing) { + clearTimeout(existing); + clearInterval(existing); + } + + const sendWakeMessage = async () => { + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + const message = reminderMessage || `Alarm: You scheduled a wake-up for ${wakeAtISO}. Continue your previous task.`; + await agentInstanceService.sendMsgToAgent(agentId, { text: `[Alarm Clock] ${message}` }); + logger.info('Alarm clock fired', { agentId, wakeAtISO, repeat: repeatMs > 0 }); + } catch (error) { + logger.error('Alarm clock failed to send wake-up message', { error, agentId }); + } + }; + + if (repeatMs > 0) { + const firstTimer = setTimeout(() => { + void sendWakeMessage(); + const interval = setInterval(() => { + void sendWakeMessage(); + }, repeatMs); + activeTimers.set(agentId, interval); + }, delayMs); + activeTimers.set(agentId, firstTimer); + } else { + const timer = setTimeout(async () => { + activeTimers.delete(agentId); + // Clear persisted alarm after one-shot fires + void persistAlarm(agentId, null); + await sendWakeMessage(); + }, delayMs); + activeTimers.set(agentId, timer); + } + + logger.info('Alarm scheduled', { agentId, wakeAtISO, delayMs, repeatIntervalMinutes }); +} + const alarmClockDefinition = registerToolDefinition({ toolId: 'alarmClock', displayName: 'Alarm Clock', @@ -58,31 +136,22 @@ const alarmClockDefinition = registerToolDefinition({ const now = new Date(); const delayMs = Math.max(0, wakeAt.getTime() - now.getTime()); const agentId = agentFrameworkContext.agent.id; + const repeatMs = parameters.repeatIntervalMinutes ? Math.max(parameters.repeatIntervalMinutes, 1) * 60_000 : 0; - // Clear any existing timer for this agent - const existing = activeTimers.get(agentId); - if (existing) clearTimeout(existing); + // Schedule the timer + scheduleAlarmTimer(agentId, parameters.wakeAtISO, parameters.reminderMessage, parameters.repeatIntervalMinutes); - // Schedule wake-up - const timer = setTimeout(async () => { - activeTimers.delete(agentId); - try { - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - const message = parameters.reminderMessage || `Alarm: You scheduled a wake-up for ${parameters.wakeAtISO}. Continue your previous task.`; - await agentInstanceService.sendMsgToAgent(agentId, { text: `[Alarm Clock] ${message}` }); - logger.info('Alarm clock fired, sent wake-up message', { agentId, wakeAtISO: parameters.wakeAtISO }); - } catch (error) { - logger.error('Alarm clock failed to send wake-up message', { error, agentId }); - } - }, delayMs); + // Persist to DB so it survives restarts + void persistAlarm(agentId, { + wakeAtISO: parameters.wakeAtISO, + reminderMessage: parameters.reminderMessage, + repeatIntervalMinutes: parameters.repeatIntervalMinutes, + }); - activeTimers.set(agentId, timer); - - logger.info('Alarm clock set', { agentId, wakeAtISO: parameters.wakeAtISO, delayMs }); - // Do NOT call yieldToSelf — the loop will end, returning control to user + const repeatInfo = repeatMs > 0 ? ` Repeats every ${parameters.repeatIntervalMinutes} minutes.` : ''; return { success: true, - data: `Alarm set for ${parameters.wakeAtISO} (in ${Math.round(delayMs / 1000)}s). Exiting loop now. I will resume when the alarm fires.`, + data: `Alarm set for ${parameters.wakeAtISO} (in ${Math.round(delayMs / 1000)}s).${repeatInfo} Exiting loop now. I will resume when the alarm fires.`, }; }); }, @@ -93,8 +162,21 @@ export function cancelAlarm(agentId: string): void { const timer = activeTimers.get(agentId); if (timer) { clearTimeout(timer); + clearInterval(timer); activeTimers.delete(agentId); } + // Also clear persisted alarm + void persistAlarm(agentId, null); +} + +/** Check if an alarm is active for an agent */ +export function hasActiveAlarm(agentId: string): boolean { + return activeTimers.has(agentId); +} + +/** Get all agent IDs with active alarms */ +export function getActiveAlarmAgentIds(): string[] { + return [...activeTimers.keys()]; } export const alarmClockTool = alarmClockDefinition.tool; diff --git a/src/services/agentInstance/tools/defineTool.ts b/src/services/agentInstance/tools/defineTool.ts index 5c179f56..0ee9d121 100644 --- a/src/services/agentInstance/tools/defineTool.ts +++ b/src/services/agentInstance/tools/defineTool.ts @@ -20,6 +20,15 @@ import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; import { findPromptById } from '../promptConcat/promptConcat'; import type { IPrompt } from '../promptConcat/promptConcatSchema'; import { schemaToToolContent } from '../utilities/schemaToToolContent'; +import { evaluateApproval, requestApproval } from './approval'; +import type { ToolApprovalConfig } from './types'; + +/** + * Maximum characters for a single tool result before truncation. + * ~8000 tokens at ~4 chars/token = 32000 chars. + * Prevents a single search result from consuming the entire context window. + */ +const MAX_TOOL_RESULT_CHARS = 32_000; import type { AddToolResultOptions, InjectContentOptions, @@ -286,6 +295,43 @@ export function defineTool< // Validate parameters const validatedParameters = toolSchema.parse(toolCall.parameters); + // Check approval before execution + const approvalConfig = ourToolConfig.approval as ToolApprovalConfig | undefined; + const decision = evaluateApproval(approvalConfig, String(toolName), validatedParameters as Record); + if (decision === 'deny') { + handlerContext.addToolResult({ + toolName: String(toolName), + parameters: validatedParameters, + result: 'Tool execution denied by approval policy.', + isError: true, + duration: 2, + }); + handlerContext.yieldToSelf(); + return true; + } + if (decision === 'pending') { + const approvalId = `approval-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const userDecision = await requestApproval({ + approvalId, + agentId: agentFrameworkContext.agent.id, + toolName: String(toolName), + parameters: validatedParameters as Record, + originalText: toolCall.originalText, + created: new Date(), + }); + if (userDecision === 'deny') { + handlerContext.addToolResult({ + toolName: String(toolName), + parameters: validatedParameters, + result: 'Tool execution denied by user.', + isError: true, + duration: 2, + }); + handlerContext.yieldToSelf(); + return true; + } + } + // Execute the tool const result = await executor(validatedParameters); @@ -347,10 +393,19 @@ export function defineTool< addToolResult: (options: AddToolResultOptions) => { const now = new Date(); + + // Truncate excessively long results to prevent context window overflow + let resultContent = options.result; + if (resultContent.length > MAX_TOOL_RESULT_CHARS) { + const truncated = resultContent.slice(0, MAX_TOOL_RESULT_CHARS); + resultContent = `${truncated}\n\n[... truncated — result was ${resultContent.length} chars, showing first ${MAX_TOOL_RESULT_CHARS}]`; + logger.debug('Tool result truncated', { toolName: options.toolName, originalLength: options.result.length, truncatedTo: MAX_TOOL_RESULT_CHARS }); + } + const toolResultText = ` Tool: ${options.toolName} Parameters: ${JSON.stringify(options.parameters)} -${options.isError ? 'Error' : 'Result'}: ${options.result} +${options.isError ? 'Error' : 'Result'}: ${resultContent} `; const toolResultMessage: AgentInstanceMessage = { @@ -456,6 +511,47 @@ ${options.isError ? 'Error' : 'Result'}: ${options.result} // Build entries for parallel execution const entries: Array<{ call: ToolCallingMatch & { found: true }; executor: (params: Record) => Promise; timeoutMs?: number }> = []; + + // Check approval once for the batch — use the first call's parameters as representative + const approvalConfig = ourToolConfig.approval as ToolApprovalConfig | undefined; + const batchDecision = evaluateApproval(approvalConfig, String(toolName), matchingCalls[0]?.parameters ?? {}); + if (batchDecision === 'deny') { + for (const call of matchingCalls) { + handlerContext.addToolResult({ + toolName: String(toolName), + parameters: call.parameters, + result: 'Tool execution denied by approval policy.', + isError: true, + duration: toolResultDuration, + }); + } + handlerContext.yieldToSelf(); + return matchingCalls.length; + } + if (batchDecision === 'pending') { + const approvalId = `approval-batch-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const userDecision = await requestApproval({ + approvalId, + agentId: agentFrameworkContext.agent.id, + toolName: String(toolName), + parameters: { _batchSize: matchingCalls.length, _firstCallParams: matchingCalls[0]?.parameters }, + created: new Date(), + }); + if (userDecision === 'deny') { + for (const call of matchingCalls) { + handlerContext.addToolResult({ + toolName: String(toolName), + parameters: call.parameters, + result: 'Tool execution denied by user.', + isError: true, + duration: toolResultDuration, + }); + } + handlerContext.yieldToSelf(); + return matchingCalls.length; + } + } + for (const call of matchingCalls) { try { const validatedParameters = toolSchema.parse(call.parameters); diff --git a/src/services/database/schema/agent.ts b/src/services/database/schema/agent.ts index 5fe7d9b4..5abaa4d2 100644 --- a/src/services/database/schema/agent.ts +++ b/src/services/database/schema/agent.ts @@ -1,4 +1,4 @@ -import type { AgentDefinition, AgentToolConfig } from '@services/agentDefinition/interface'; +import type { AgentDefinition, AgentHeartbeatConfig, AgentToolConfig } from '@services/agentDefinition/interface'; import type { AgentInstance, AgentInstanceLatestStatus, AgentInstanceMessage } from '@services/agentInstance/interface'; import type { AiAPIConfig } from '@services/agentInstance/promptConcat/promptConcatSchema'; import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, OneToMany, PrimaryColumn, UpdateDateColumn } from 'typeorm'; @@ -43,6 +43,10 @@ export class AgentDefinitionEntity implements Partial { @Column({ type: 'simple-json', nullable: true }) agentTools?: AgentToolConfig[]; + /** Heartbeat configuration for periodic auto-wake */ + @Column({ type: 'simple-json', nullable: true }) + heartbeat?: AgentHeartbeatConfig; + /** Creation timestamp */ @CreateDateColumn() createdAt!: Date; @@ -97,6 +101,14 @@ export class AgentInstanceEntity implements Partial { @Column({ default: false }) volatile: boolean = false; + /** Persisted alarm data — survives app restart. Null when no alarm is active. */ + @Column({ type: 'simple-json', nullable: true }) + scheduledAlarm?: { + wakeAtISO: string; + reminderMessage?: string; + repeatIntervalMinutes?: number; + } | null; + // Relation to AgentDefinition @ManyToOne(() => AgentDefinitionEntity, definition => definition.instances) @JoinColumn({ name: 'agentDefId' }) diff --git a/src/services/externalAPI/__tests__/autoFillDefaultModels.test.ts b/src/services/externalAPI/__tests__/autoFillDefaultModels.test.ts index 7a5e7552..69e5c1c6 100644 --- a/src/services/externalAPI/__tests__/autoFillDefaultModels.test.ts +++ b/src/services/externalAPI/__tests__/autoFillDefaultModels.test.ts @@ -34,7 +34,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => { it('should expose defaultConfig$ observable to frontend', () => { const mockConfig: AiAPIConfig = { default: { provider: 'openai', model: 'gpt-4' }, - modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 }, + modelParameters: { temperature: 0.7, topP: 0.95 }, }; const configSubject = new BehaviorSubject(mockConfig); @@ -86,7 +86,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => { it('should emit updated config when auto-fill happens', () => { const initialConfig: AiAPIConfig = { default: undefined, - modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 }, + modelParameters: { temperature: 0.7, topP: 0.95 }, }; const configSubject = new BehaviorSubject(initialConfig); @@ -122,7 +122,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => { const configWithExisting: AiAPIConfig = { default: { provider: 'anthropic', model: 'claude-3' }, embedding: { provider: 'openai', model: 'existing-embedding-model' }, - modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 }, + modelParameters: { temperature: 0.7, topP: 0.95 }, }; const configSubject = new BehaviorSubject(configWithExisting); @@ -150,7 +150,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => { it('should only auto-fill when default is empty', () => { const configWithoutEmbedding: AiAPIConfig = { default: { provider: 'openai', model: 'gpt-4' }, - modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 }, + modelParameters: { temperature: 0.7, topP: 0.95 }, }; const configSubject = new BehaviorSubject(configWithoutEmbedding); @@ -179,7 +179,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => { it('should support multiple subscribers to observable', () => { const mockConfig: AiAPIConfig = { default: { provider: 'openai', model: 'gpt-4' }, - modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 }, + modelParameters: { temperature: 0.7, topP: 0.95 }, }; const configSubject = new BehaviorSubject(mockConfig); diff --git a/src/services/externalAPI/__tests__/externalAPI.logging.test.ts b/src/services/externalAPI/__tests__/externalAPI.logging.test.ts index c9dce801..de1fa5d2 100644 --- a/src/services/externalAPI/__tests__/externalAPI.logging.test.ts +++ b/src/services/externalAPI/__tests__/externalAPI.logging.test.ts @@ -36,7 +36,7 @@ describe('ExternalAPIService logging', () => { // Set up provider config BEFORE initialization const aiSettings: AIGlobalSettings = { providers: [{ provider: 'test-provider', apiKey: 'fake', models: [{ name: 'test-model' }] }], - defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, + defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } }, }; db.setSetting('aiSettings', aiSettings); @@ -79,7 +79,7 @@ describe('ExternalAPIService logging', () => { // Set up provider config WITHOUT apiKey BEFORE initialization to trigger error const aiSettings: AIGlobalSettings = { providers: [{ provider: 'test-provider', models: [{ name: 'test-model' }] }], // No apiKey - defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, + defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } }, }; db.setSetting('aiSettings', aiSettings); diff --git a/src/services/externalAPI/callProviderAPI.ts b/src/services/externalAPI/callProviderAPI.ts index 3a5bb67d..4fafbb9f 100644 --- a/src/services/externalAPI/callProviderAPI.ts +++ b/src/services/externalAPI/callProviderAPI.ts @@ -60,7 +60,7 @@ export function streamFromProvider( const provider = modelConfig.provider; const model = modelConfig.model; const modelParameters = config.modelParameters || {}; - const { temperature = 0.7, systemPrompt: fallbackSystemPrompt = 'You are a helpful assistant.' } = modelParameters; + const { temperature = 0.7 } = modelParameters; logger.info(`Using AI provider: ${provider}, model: ${model}`); @@ -81,9 +81,9 @@ export function streamFromProvider( providerConfig.apiKey, ); - // Extract system message from messages if present, otherwise use fallback + // Extract system message from messages if present const systemMessage = messages.find(message => message.role === 'system'); - const systemPrompt = (systemMessage ? getFormattedContent(systemMessage.content) : undefined) || fallbackSystemPrompt; + const systemPrompt = (systemMessage ? getFormattedContent(systemMessage.content) : undefined) || 'You are a helpful assistant.'; // Filter out system messages from the messages array since we're handling them separately const nonSystemMessages = messages.filter(message => message.role !== 'system'); diff --git a/src/services/externalAPI/defaultProviders.ts b/src/services/externalAPI/defaultProviders.ts index 79b59121..96008118 100644 --- a/src/services/externalAPI/defaultProviders.ts +++ b/src/services/externalAPI/defaultProviders.ts @@ -167,7 +167,6 @@ export default { }, modelParameters: { temperature: 0.7, - systemPrompt: 'You are a helpful assistant.', topP: 0.95, }, }, diff --git a/src/services/externalAPI/index.ts b/src/services/externalAPI/index.ts index 9b848920..b6e3e688 100644 --- a/src/services/externalAPI/index.ts +++ b/src/services/externalAPI/index.ts @@ -18,7 +18,6 @@ import { streamFromProvider } from './callProviderAPI'; import { generateSpeechFromProvider } from './callSpeechAPI'; import { generateTranscriptionFromProvider } from './callTranscriptionsAPI'; import { extractErrorDetails } from './errorHandlers'; -import { DEFAULT_RETRY_CONFIG, withRetry } from './retryUtility'; import type { AIEmbeddingResponse, AIGlobalSettings, @@ -30,6 +29,7 @@ import type { IExternalAPIService, ModelInfo, } from './interface'; +import { DEFAULT_RETRY_CONFIG, withRetry } from './retryUtility'; /** * Simplified request context @@ -62,7 +62,6 @@ export class ExternalAPIService implements IExternalAPIService { }, modelParameters: { temperature: 0.7, - systemPrompt: 'You are a helpful assistant.', topP: 0.95, }, }, @@ -616,12 +615,13 @@ export class ExternalAPIService implements IExternalAPIService { let result: ReturnType; try { result = await withRetry( - async () => streamFromProvider( - config, - messages, - controller.signal, - providerConfig, - ), + async () => + streamFromProvider( + config, + messages, + controller.signal, + providerConfig, + ), DEFAULT_RETRY_CONFIG, (attempt, maxAttempts, delayMs, error) => { logger.info('Retrying AI stream creation', { diff --git a/src/windows/Preferences/sections/AIAgent.tsx b/src/windows/Preferences/sections/AIAgent.tsx index 36c44b6f..ce248a60 100644 --- a/src/windows/Preferences/sections/AIAgent.tsx +++ b/src/windows/Preferences/sections/AIAgent.tsx @@ -1,7 +1,10 @@ +import AlarmIcon from '@mui/icons-material/Alarm'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import DeleteIcon from '@mui/icons-material/Delete'; +import FavoriteIcon from '@mui/icons-material/Favorite'; import SecurityIcon from '@mui/icons-material/Security'; -import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, List, ListItemButton } from '@mui/material'; -import React, { useEffect, useState } from 'react'; +import { Button, Chip, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, List, ListItemButton, Tooltip, Typography } from '@mui/material'; +import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ListItem, ListItemText } from '@/components/ListItem'; @@ -9,11 +12,35 @@ import { Paper, SectionTitle } from '../PreferenceComponents'; import type { ISectionProps } from '../useSections'; import { ToolApprovalSettingsDialog } from './ExternalAPI/components/ToolApprovalSettingsDialog'; +interface BackgroundTask { + agentId: string; + agentName?: string; + type: 'heartbeat' | 'alarm'; + intervalSeconds?: number; + wakeAtISO?: string; + message?: string; + repeatIntervalMinutes?: number; +} + export function AIAgent(props: ISectionProps): React.JSX.Element { const { t } = useTranslation('agent'); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [toolApprovalDialogOpen, setToolApprovalDialogOpen] = useState(false); const [agentInfo, setAgentInfo] = useState<{ exists: boolean; size?: number; path?: string }>({ exists: false }); + const [backgroundTasks, setBackgroundTasks] = useState([]); + + const fetchBackgroundTasks = useCallback(async () => { + try { + const tasks = await window.service.agentInstance.getBackgroundTasks(); + setBackgroundTasks(tasks); + } catch { + // Service may not be ready yet + } + }, []); + + useEffect(() => { + void fetchBackgroundTasks(); + }, [fetchBackgroundTasks]); useEffect(() => { const fetchInfo = async () => { @@ -84,7 +111,9 @@ export function AIAgent(props: ISectionProps): React.JSX.Element { /> { setToolApprovalDialogOpen(true); }} + onClick={() => { + setToolApprovalDialogOpen(true); + }} > + {/* Background Tasks — Scheduled auto-wake tasks */} + {t('Preference.BackgroundTasks')} + + + + + + + {backgroundTasks.length === 0 && ( + + + {t('Preference.NoBackgroundTasks')} + + + )} + {backgroundTasks.map((task) => ( + + : } + label={task.type === 'heartbeat' ? 'Heartbeat' : 'Alarm'} + size='small' + color={task.type === 'heartbeat' ? 'success' : 'warning'} + variant='outlined' + sx={{ mr: 1 }} + /> + + + { + await window.service.agentInstance.cancelBackgroundTask(task.agentId, task.type); + void fetchBackgroundTasks(); + }} + data-testid={`cancel-bg-task-${task.agentId}-${task.type}`} + > + + + + + ))} + + + { setToolApprovalDialogOpen(false); }} + onClose={() => { + setToolApprovalDialogOpen(false); + }} /> { // No embedding modelParameters: { temperature: 0.7, - systemPrompt: 'You are a helpful assistant.', topP: 0.95, }, }), @@ -272,7 +270,6 @@ describe('ExternalAPI Component', () => { }, modelParameters: { temperature: 0.7, - systemPrompt: 'You are a helpful assistant.', topP: 0.95, }, }), diff --git a/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts b/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts index 511e2a61..60013012 100644 --- a/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts +++ b/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts @@ -13,7 +13,6 @@ describe('useAIConfigManagement', () => { }, modelParameters: { temperature: 0.7, - systemPrompt: 'You are a helpful assistant.', topP: 0.95, }, }; diff --git a/src/windows/Preferences/sections/ExternalAPI/components/AIModelParametersDialog.tsx b/src/windows/Preferences/sections/ExternalAPI/components/AIModelParametersDialog.tsx index dbf2d263..7cf9f9bc 100644 --- a/src/windows/Preferences/sections/ExternalAPI/components/AIModelParametersDialog.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/components/AIModelParametersDialog.tsx @@ -25,7 +25,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod temperature: 0.7, maxTokens: 1000, topP: 0.95, - systemPrompt: '', }); // Update local state when config changes @@ -35,7 +34,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod temperature: config.modelParameters.temperature ?? 0.7, maxTokens: config.modelParameters.maxTokens ?? 1000, topP: config.modelParameters.topP ?? 0.95, - systemPrompt: config.modelParameters.systemPrompt ?? '', }); } }, [config]); @@ -82,14 +80,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod } }; - // System prompt handler - const handleSystemPromptChange = (event: React.ChangeEvent) => { - setParameters((previous) => ({ - ...previous, - systemPrompt: event.target.value, - })); - }; - return ( {t('Preference.ModelParameters', { ns: 'agent' })} @@ -144,18 +134,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod helperText={t('Preference.MaxTokensDescription', { ns: 'agent' })} /> - - - -