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);
+ }}
/>