feat: Implement background task management for agent instances

- Added functionality to restore heartbeat timers and alarms for active agents upon service initialization.
- Introduced methods to retrieve active background tasks and cancel them via the UI.
- Enhanced alarm clock tool to persist alarm data in the database, ensuring alarms survive app restarts.
- Updated agent instance schema to include scheduled alarm data.
- Modified prompt concatenation logic to support context window size for message history.
- Removed system prompt parameter from model parameters schema and related components.
- Improved UI to display and manage background tasks, including heartbeat and alarm details.
This commit is contained in:
linonetwo 2026-03-05 18:27:21 +08:00
parent 02c610c3d7
commit 0312a49925
37 changed files with 1099 additions and 279 deletions

View file

@ -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 `<tool_use name="wiki-search">{"filter":"[title` without a closing `</tool_use>` tag. The existing regex `/<tool_use\s+name="[^"]*"[^>]*>[\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: `/<tool_use\s[^>]*>[\s\S]*$/gi`, `/<function_call\s[^>]*>[\s\S]*$/gi`, `/<functions_result>[\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 `<functions_result>` 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 `<tool_use>` 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 `<tool_use>` XML tags to users
- **Problem**: `BaseMessageRenderer` rendered `message.content` verbatim with no processing. Messages containing `<tool_use>`, `<function_call>`, `<parallel_tool_calls>`, or `<functions_result>` 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 `<functions_result>` 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 `<functions_result>Tool: ...\nResult: ...</functions_result>` text.
- **Fix**: Created `ToolResultRenderer.tsx` — a generic collapsible card that shows tool name, truncated result preview, and expandable full parameters + result. Registered with pattern `/<functions_result>/` 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 `</functions_result>`
- **Problem**: Renderers used `/Result:\s*(.+)/s` which with the `s` flag greedily matched past the JSON into the `</functions_result>` 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 `<input type="file">` 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 <beforeCommitHash>:<file>`.
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

View file

@ -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

View file

@ -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 });
}
}

View file

@ -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",

View file

@ -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": "编辑智能体",

View file

@ -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(),
},

View file

@ -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 (
<ContentContainer>
<FallbackHeader>
<TabListDropdown />
</FallbackHeader>
<NewTabContent
tab={{
id: `${TEMP_TAB_ID_PREFIX}new-tab`,

View file

@ -1,9 +1,10 @@
import CloseIcon from '@mui/icons-material/Close';
import { Box, IconButton } from '@mui/material';
import { Box, IconButton, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import React from 'react';
import { ChatTabContent } from '../../ChatTabContent';
import { TabListDropdown } from '../components/TabBar/TabListDropdown';
import { useTabStore } from '../store/tabStore';
import { TabItem, TabType } from '../types/tab';
import { CreateNewAgentContent } from './TabTypes/CreateNewAgentContent';
@ -42,6 +43,16 @@ const SplitViewHeader = styled(Box)`
border-bottom: 1px solid ${props => 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<TabContentViewProps> = ({ tab, isSplitView
</IconButton>
</SplitViewHeader>
)}
{/* Non-chat tabs get a minimal header with tab list; Chat tabs have their own ChatHeader */}
{tab.type !== TabType.CHAT && !isSplitView && (
<GenericTabHeader>
<TabListDropdown />
<Typography variant='body2' noWrap sx={{ flex: 1, fontWeight: 500 }}>
{tab.title}
</Typography>
</GenericTabHeader>
)}
{renderContent()}
</ContentContainer>
);

View file

@ -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 <WebIcon fontSize='small' />;
case TabType.CHAT:
return <ChatIcon fontSize='small' />;
case TabType.SPLIT_VIEW:
return <SplitscreenIcon fontSize='small' />;
default:
return <TabIcon fontSize='small' />;
}
}
export const TabListDropdown: React.FC = () => {
const { t } = useTranslation('agent');
const { tabs, activeTabId, setActiveTab, closeTab, addTab } = useTabStore();
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
const open = Boolean(anchorElement);
const handleToggle = useCallback((event: React.MouseEvent<HTMLElement>) => {
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 (
<>
<Tooltip title={t('Tab.TabList')}>
<IconButton
size='small'
onClick={handleToggle}
data-testid='tab-list-button'
sx={{ ml: 0.5 }}
>
<TabIcon fontSize='small' />
{tabs.length > 1 && (
<Typography
variant='caption'
sx={{
position: 'absolute',
top: -2,
right: -2,
fontSize: 10,
fontWeight: 700,
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 16,
height: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{tabs.length}
</Typography>
)}
</IconButton>
</Tooltip>
{/* Standalone new-tab button — keeps backward-compatible test selector */}
<Tooltip title={t('NewTab.NewTab')}>
<IconButton
size='small'
onClick={() => {
void handleNewTab();
}}
data-tab-id='new-tab-button'
data-testid='new-tab-button'
>
<AddIcon fontSize='small' />
</IconButton>
</Tooltip>
<Popper
open={open}
anchorEl={anchorElement}
placement='bottom-start'
style={{ zIndex: 1500 }}
modifiers={[{ name: 'offset', options: { offset: [0, 4] } }]}
>
<ClickAwayListener onClickAway={handleClose}>
<DropdownPaper data-testid='tab-list-dropdown'>
<List dense disablePadding sx={{ py: 0.5 }}>
{sortedTabs.map((tab: TabItem) => (
<TabEntry
key={tab.id}
active={tab.id === activeTabId}
onClick={() => {
handleSelectTab(tab.id);
}}
data-testid={`tab-list-item-${tab.id}`}
>
<ListItemIcon sx={{ minWidth: 32 }}>
{getTabIcon(tab.type)}
</ListItemIcon>
<ListItemText
primary={tab.title}
slotProps={{ primary: { noWrap: true, variant: 'body2' } }}
/>
<IconButton
size='small'
className='tab-close'
sx={{ opacity: 0, transition: 'opacity 0.15s', ml: 0.5 }}
onClick={(event) => {
handleCloseTab(event, tab.id);
}}
data-testid={`tab-close-${tab.id}`}
>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
</TabEntry>
))}
</List>
<Divider />
<Box sx={{ p: 0.5 }}>
<ListItemButton
onClick={() => {
void handleNewTab();
}}
sx={{ borderRadius: 6, mx: 0.5 }}
data-testid='tab-list-new-tab'
>
<ListItemIcon sx={{ minWidth: 32 }}>
<AddIcon fontSize='small' />
</ListItemIcon>
<ListItemText
primary={t('NewTab.NewTab')}
slotProps={{ primary: { variant: 'body2' } }}
/>
</ListItemButton>
</Box>
</DropdownPaper>
</ClickAwayListener>
</Popper>
</>
);
};

View file

@ -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 {
<>
<TabStoreInitializer />
<AgentLayout>
<VerticalTabBar />
<TabContentArea />
</AgentLayout>
</>

View file

@ -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<AgentSwitcherProps> = ({ currentAgentDefId,
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
const [agentDefs, setAgentDefs] = useState<AgentDefinition[]>([]);
const open = Boolean(anchorElement);
const searchInputReference = useRef<HTMLInputElement>(null);
const handleClick = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
@ -58,9 +58,9 @@ export const AgentSwitcher: React.FC<AgentSwitcherProps> = ({ 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<AgentSwitcherProps> = ({ 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<AgentSwitcherProps> = ({ currentAgentDefId,
<Typography variant='caption' sx={{ fontWeight: 500, lineHeight: 1.4 }}>
{displayName}
</Typography>
<ArrowDropUpIcon sx={{ fontSize: 16, transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
<ArrowDropDownIcon sx={{ fontSize: 16, transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
</SwitcherButton>
<Popper
open={open}
anchorEl={anchorElement}
placement='top-start'
placement='bottom-start'
style={{ zIndex: 1500 }}
modifiers={[{ name: 'offset', options: { offset: [0, 4] } }]}
>
<ClickAwayListener onClickAway={handleClose}>
<DropdownPaper data-testid='agent-switcher-dropdown'>
<List dense disablePadding>
{agentDefs.map((agentDefinition) => (
<ListItemButton
key={agentDefinition.id}
selected={agentDefinition.id === currentAgentDefId}
onClick={() => {
handleSelect(agentDefinition.id);
}}
data-testid={`agent-switcher-option-${agentDefinition.id}`}
<Autocomplete<AgentDefinition, false, true>
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) => (
<TextField
{...params}
inputRef={searchInputReference}
placeholder='Search agents...'
autoFocus
data-testid='agent-switcher-search'
sx={{ mb: 0.5 }}
/>
)}
renderOption={(props, option) => (
<Box
component='li'
{...props}
key={option.id}
data-testid={`agent-switcher-option-${option.id}`}
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start !important', py: 0.5 }}
>
<ListItemText
primary={agentDefinition.name ?? agentDefinition.id}
secondary={agentDefinition.description}
slotProps={{ secondary: { noWrap: true, variant: 'caption' } }}
/>
</ListItemButton>
))}
{agentDefs.length === 0 && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant='caption' color='text.secondary'>Loading...</Typography>
<Typography variant='body2' sx={{ fontWeight: option.id === currentAgentDefId ? 600 : 400 }}>
{option.name ?? option.id}
</Typography>
{option.description && (
<Typography variant='caption' color='text.secondary' noWrap sx={{ maxWidth: '100%' }}>
{option.description}
</Typography>
)}
</Box>
)}
</List>
slotProps={{
paper: { sx: { boxShadow: 'none', border: 'none' } },
listbox: { sx: { maxHeight: 280 }, 'data-testid': 'agent-switcher-listbox' } as React.HTMLAttributes<HTMLUListElement> & { 'data-testid': string },
}}
// Prevent the autocomplete from closing the parent popper
disablePortal
// No clear button, selection always set
disableClearable
/>
</DropdownPaper>
</ClickAwayListener>
</Popper>

View file

@ -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<ChatHeaderProps> = ({
loading,
onOpenParameters,
inputText,
currentAgentDefId,
onSwitchAgent,
isStreaming,
}) => {
const { t } = useTranslation('agent');
const preference = usePreferenceObservable();
@ -72,7 +80,17 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
return (
<Header>
<ChatTitle title={title} agent={agent} updateAgent={updateAgent} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
<TabListDropdown />
<ChatTitle title={title} agent={agent} updateAgent={updateAgent} />
{onSwitchAgent && (
<AgentSwitcher
currentAgentDefId={currentAgentDefId}
onSwitch={onSwitchAgent}
disabled={loading || isStreaming}
/>
)}
</Box>
<ControlsContainer>
<IconButton
size='small'

View file

@ -10,7 +10,6 @@ import { styled } from '@mui/material/styles';
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { WikiTiddlerAttachment } from '../hooks/useMessageHandling';
import { AgentSwitcher } from './AgentSwitcher';
const Wrapper = styled(Box)`
display: flex;
@ -48,8 +47,6 @@ interface InputContainerProps {
selectedWikiTiddlers?: WikiTiddlerAttachment[];
onWikiTiddlerSelect?: (tiddler: WikiTiddlerAttachment) => void;
onRemoveWikiTiddler?: (index: number) => void;
currentAgentDefId?: string;
onSwitchAgent?: (agentDefId: string) => void;
}
const attachmentListboxSlotProps: React.HTMLAttributes<HTMLUListElement> & { 'data-testid': string } = {
'data-testid': 'attachment-listbox',
@ -73,8 +70,6 @@ export const InputContainer: React.FC<InputContainerProps> = ({
selectedWikiTiddlers = [],
onWikiTiddlerSelect,
onRemoveWikiTiddler,
currentAgentDefId,
onSwitchAgent,
}) => {
const { t } = useTranslation('agent');
const fileInputReference = React.useRef<HTMLInputElement>(null);
@ -297,13 +292,6 @@ export const InputContainer: React.FC<InputContainerProps> = ({
accept='image/*'
onChange={handleFileChange}
/>
{onSwitchAgent && (
<AgentSwitcher
currentAgentDefId={currentAgentDefId}
onSwitch={onSwitchAgent}
disabled={disabled || isStreaming}
/>
)}
<IconButton
onClick={handleAttachmentClick}
disabled={disabled || isStreaming}

View file

@ -4,12 +4,14 @@ import CloseIcon from '@mui/icons-material/Close';
import EditIcon from '@mui/icons-material/Edit';
import FullscreenIcon from '@mui/icons-material/Fullscreen';
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import SaveIcon from '@mui/icons-material/Save';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import MuiDialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import Snackbar from '@mui/material/Snackbar';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -39,6 +41,7 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
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<PromptPreviewDialogProps> = ({
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<PromptPreviewDialogProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<Box>{t('Prompt.Preview')}</Box>
<Box sx={{ display: 'flex' }}>
{/* Save to definition button — only in edit mode */}
{(showEdit || showSideBySide) && agent?.agentDefId && (
<Tooltip title={t('Preference.SaveToDefinition')}>
<IconButton
aria-label='save-to-definition'
onClick={() => {
void handleSaveToDefinition();
}}
sx={{ mr: 1 }}
data-testid='save-to-definition-button'
>
<SaveIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title={sideBySideTooltip}>
<IconButton
aria-label={sideBySideTooltip}
@ -231,6 +266,14 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
</Box>
)}
</MuiDialogContent>
<Snackbar
open={savedSnackbarOpen}
autoHideDuration={2000}
onClose={() => {
setSavedSnackbarOpen(false);
}}
message={t('Preference.SaveToDefinitionDescription')}
/>
</Dialog>
);
};

View file

@ -209,6 +209,9 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ 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<ChatTabContentProps> = ({ 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<ChatTabContentProps> = ({ tab, isSplitView
temperature: 0.7,
maxTokens: 1000,
topP: 0.95,
systemPrompt: '',
},
}}
onSave={async (newConfig) => {

View file

@ -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;

View file

@ -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;
}
/**

View file

@ -46,7 +46,7 @@ describe('AgentInstance failure path - external API logs on error', () => {
const dbService = container.get<IDatabaseService>(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<typeof basicPromptConcatHandler>[0];

View file

@ -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<IDatabaseService>(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));

View file

@ -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

View file

@ -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<typeof setInterval>;
config: AgentHeartbeatConfig;
agentId: string;
}
const activeHeartbeats = new Map<string, HeartbeatEntry>();
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()];
}

View file

@ -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<void> {
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<void> {
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<void> {
// 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<void> {
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<AgentInstance | undefined>;
/**
* Subscribe to agent instance message status updates

View file

@ -278,6 +278,26 @@ export interface IAgentInstanceService {
* @returns Array of changed files with their status
*/
getTurnChangedFiles(agentId: string, userMessageId: string): Promise<Array<{ path: string; status: string }>>;
/**
* 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<void>;
}
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,
},
};

View file

@ -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<IPrompt['role']>;
const role: PromptRole = normalizeRole(message.role);
delete found.prompt.text;

View file

@ -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({

View file

@ -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<string, ReturnType<typeof setTimeout>>();
/** Persist alarm data to DB so it survives app restarts */
async function persistAlarm(agentId: string, data: { wakeAtISO: string; reminderMessage?: string; repeatIntervalMinutes?: number } | null): Promise<void> {
try {
const databaseService = container.get<IDatabaseService>(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<IAgentInstanceService>(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<IAgentInstanceService>(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;

View file

@ -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<string, unknown>);
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<string, unknown>,
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 = `<functions_result>
Tool: ${options.toolName}
Parameters: ${JSON.stringify(options.parameters)}
${options.isError ? 'Error' : 'Result'}: ${options.result}
${options.isError ? 'Error' : 'Result'}: ${resultContent}
</functions_result>`;
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<string, unknown>) => Promise<ToolExecutionResult>; 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);

View file

@ -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<AgentDefinition> {
@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<AgentInstance> {
@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' })

View file

@ -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);

View file

@ -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);

View file

@ -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');

View file

@ -167,7 +167,6 @@ export default {
},
modelParameters: {
temperature: 0.7,
systemPrompt: 'You are a helpful assistant.',
topP: 0.95,
},
},

View file

@ -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<typeof streamFromProvider>;
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', {

View file

@ -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<BackgroundTask[]>([]);
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 {
/>
</ListItemButton>
<ListItemButton
onClick={() => { setToolApprovalDialogOpen(true); }}
onClick={() => {
setToolApprovalDialogOpen(true);
}}
>
<SecurityIcon sx={{ mr: 1 }} color='action' />
<ListItemText
@ -96,9 +125,68 @@ export function AIAgent(props: ISectionProps): React.JSX.Element {
</List>
</Paper>
{/* Background Tasks — Scheduled auto-wake tasks */}
<SectionTitle>{t('Preference.BackgroundTasks')}</SectionTitle>
<Paper elevation={0}>
<List dense disablePadding>
<ListItem>
<ListItemText
primary={t('Preference.BackgroundTasksDescription')}
/>
<Button
size='small'
onClick={() => {
void fetchBackgroundTasks();
}}
>
{t('Refresh')}
</Button>
</ListItem>
{backgroundTasks.length === 0 && (
<ListItem>
<Typography variant='body2' color='text.secondary' sx={{ py: 1 }}>
{t('Preference.NoBackgroundTasks')}
</Typography>
</ListItem>
)}
{backgroundTasks.map((task) => (
<ListItem key={`${task.agentId}-${task.type}`}>
<Chip
icon={task.type === 'heartbeat' ? <FavoriteIcon /> : <AlarmIcon />}
label={task.type === 'heartbeat' ? 'Heartbeat' : 'Alarm'}
size='small'
color={task.type === 'heartbeat' ? 'success' : 'warning'}
variant='outlined'
sx={{ mr: 1 }}
/>
<ListItemText
primary={task.agentName ?? task.agentId}
secondary={task.type === 'heartbeat'
? `Every ${task.intervalSeconds ?? '?'}s — ${task.message ?? ''}`
: `${task.wakeAtISO ?? '?'}${task.repeatIntervalMinutes ? ` (repeat every ${task.repeatIntervalMinutes}min)` : ''}${task.message ?? ''}`}
/>
<Tooltip title={t('Preference.CancelTask')}>
<IconButton
size='small'
onClick={async () => {
await window.service.agentInstance.cancelBackgroundTask(task.agentId, task.type);
void fetchBackgroundTasks();
}}
data-testid={`cancel-bg-task-${task.agentId}-${task.type}`}
>
<DeleteIcon fontSize='small' />
</IconButton>
</Tooltip>
</ListItem>
))}
</List>
</Paper>
<ToolApprovalSettingsDialog
open={toolApprovalDialogOpen}
onClose={() => { setToolApprovalDialogOpen(false); }}
onClose={() => {
setToolApprovalDialogOpen(false);
}}
/>
<Dialog

View file

@ -91,7 +91,6 @@ const mockAIConfig = {
},
modelParameters: {
temperature: 0.7,
systemPrompt: 'You are a helpful assistant.',
topP: 0.95,
},
};
@ -212,7 +211,6 @@ describe('ExternalAPI Component', () => {
// 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,
},
}),

View file

@ -13,7 +13,6 @@ describe('useAIConfigManagement', () => {
},
modelParameters: {
temperature: 0.7,
systemPrompt: 'You are a helpful assistant.',
topP: 0.95,
},
};

View file

@ -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<HTMLInputElement>) => {
setParameters((previous) => ({
...previous,
systemPrompt: event.target.value,
}));
};
return (
<Dialog open={open} onClose={onClose} maxWidth='md' fullWidth>
<DialogTitle>{t('Preference.ModelParameters', { ns: 'agent' })}</DialogTitle>
@ -144,18 +134,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
helperText={t('Preference.MaxTokensDescription', { ns: 'agent' })}
/>
</FormControl>
<FormControl fullWidth sx={{ mt: 3 }}>
<TextField
label={t('Preference.SystemPrompt', { ns: 'agent' })}
value={parameters.systemPrompt}
onChange={handleSystemPromptChange}
multiline
rows={4}
placeholder={t('Preference.SystemPromptPlaceholder', { ns: 'agent' })}
helperText={t('Preference.SystemPromptDescription', { ns: 'agent' })}
/>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('Cancel')}</Button>