Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/agents/promptManager/services/CustomPromptStorageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export class CustomPromptStorageService {
private db: MigratableDatabase | null;
private settings: Settings;
private migrated = false;
private onChange: (() => void) | null = null;

constructor(rawDb: DatabaseInput, settings: Settings) {
// Wrap raw db in adapter if provided
Expand All @@ -81,6 +82,27 @@ export class CustomPromptStorageService {
this.initialize();
}

/**
* Register a change listener fired after any prompt mutation (create/update/delete/toggle/setEnabled).
* Best-effort notification for consumers that need to refresh derived data (e.g. ToolManager schema).
* Replaces any previously registered listener. Pass null to detach.
*/
setOnChange(listener: (() => void) | null): void {
this.onChange = listener;
}

/**
* Fire the change listener safely — errors are swallowed so mutations never fail due to listener bugs.
*/
private notifyChange(): void {
if (!this.onChange) return;
try {
this.onChange();
} catch (error) {
console.error('[CustomPromptStorageService] onChange listener threw:', error);
}
}

/**
* Initialize the service and migrate data from data.json if needed
*/
Expand Down Expand Up @@ -285,6 +307,7 @@ export class CustomPromptStorageService {
customPromptsSettings.prompts.push(newPrompt);
await this.settings.saveSettings();

this.notifyChange();
return newPrompt;
}

Expand Down Expand Up @@ -352,6 +375,8 @@ export class CustomPromptStorageService {

prompts[index] = { ...prompts[index], ...updates };
await this.settings.saveSettings();

this.notifyChange();
}

/**
Expand All @@ -376,6 +401,7 @@ export class CustomPromptStorageService {
if (index !== -1) {
prompts.splice(index, 1);
await this.settings.saveSettings();
this.notifyChange();
}
}

Expand Down Expand Up @@ -410,6 +436,7 @@ export class CustomPromptStorageService {
async setEnabled(enabled: boolean): Promise<void> {
this.ensureCustomPromptsSettings().enabled = enabled;
await this.settings.saveSettings();
this.notifyChange();
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/agents/toolManager/toolManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ export class ToolManagerAgent extends BaseAgent {
}

registerDynamicAgent(agent: IAgent): void {
if (this.allAgents.has(agent.name)) {
throw new Error(`Agent ${agent.name} is already registered`);
}
this.allAgents.set(agent.name, agent);
this.getToolsTool.refreshDescription();
}
Expand Down
36 changes: 34 additions & 2 deletions src/services/WorkspaceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class WorkspaceService {
private sessionService: WorkspaceSessionService;
private stateService: WorkspaceStateService;
private systemGuidesProvider: SystemGuidesWorkspaceProvider | null;
private onChange: (() => void) | null = null;

constructor(
private plugin: Plugin,
Expand Down Expand Up @@ -79,6 +80,29 @@ export class WorkspaceService {
);
}

/**
* Register a change listener fired after any workspace-level mutation
* (create/update/delete). Best-effort hook for consumers that need to refresh
* derived data (e.g. ToolManager getTools description). Replaces any previously
* registered listener. Pass null to detach.
*/
setOnChange(listener: (() => void) | null): void {
this.onChange = listener;
}

/**
* Fire the change listener safely — errors are swallowed so mutations never
* fail due to listener bugs.
*/
private notifyChange(): void {
if (!this.onChange) return;
try {
this.onChange();
} catch (error) {
console.error('[WorkspaceService] onChange listener threw:', error);
}
}

/**
* Resolve the storage adapter if available and ready.
* Delegates to shared DualBackendExecutor helper.
Expand Down Expand Up @@ -316,6 +340,12 @@ export class WorkspaceService {
* Create new workspace (writes file + updates index)
*/
async createWorkspace(data: Partial<IndividualWorkspace>): Promise<IndividualWorkspace> {
const result = await this.createWorkspaceInternal(data);
this.notifyChange();
return result;
}

private async createWorkspaceInternal(data: Partial<IndividualWorkspace>): Promise<IndividualWorkspace> {
// Use new adapter if available and ready (avoids blocking on SQLite initialization)
const adapterForCreate = this.getReadyAdapter();
if (adapterForCreate) {
Expand Down Expand Up @@ -397,7 +427,7 @@ export class WorkspaceService {
*/
async updateWorkspace(id: string, updates: Partial<IndividualWorkspace>): Promise<void> {
this.ensureSystemWorkspaceMutable(id);
return withDualBackend(
await withDualBackend(
this.storageAdapterOrGetter,
async (adapter) => {
const hybridUpdates: Partial<HybridTypes.WorkspaceMetadata> = {};
Expand Down Expand Up @@ -444,6 +474,7 @@ export class WorkspaceService {
await this.indexManager.updateWorkspaceInIndex(updatedWorkspace);
}
);
this.notifyChange();
}

/**
Expand Down Expand Up @@ -473,7 +504,7 @@ export class WorkspaceService {
*/
async deleteWorkspace(id: string): Promise<void> {
this.ensureSystemWorkspaceMutable(id);
return withDualBackend(
await withDualBackend(
this.storageAdapterOrGetter,
async (adapter) => {
await adapter.deleteWorkspace(id);
Expand All @@ -483,6 +514,7 @@ export class WorkspaceService {
await this.indexManager.removeWorkspaceFromIndex(id);
}
);
this.notifyChange();
}

// ============================================================================
Expand Down
14 changes: 13 additions & 1 deletion src/services/agent/AgentInitializationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,12 +435,24 @@ export class AgentInitializationService {
return false;
}

/**
* Get the internal CustomPromptStorageService instance used to build SchemaData.
* Returns undefined if not set (plugin settings were unavailable at init time).
* Callers use this to register change listeners for live schema refresh.
*/
getCustomPromptStorage(): CustomPromptStorageService | undefined {
return this.customPromptStorage;
}

/**
* Build schema data for ToolManager
* Fetches workspaces, custom agents, and vault root structure
* Non-blocking: uses JSONL/data.json fallback if SQLite isn't ready
*
* Also invoked by AgentRegistrationService to rebuild SchemaData on mutation
* events so the getTools description stays fresh after startup.
*/
private async buildSchemaData(): Promise<{
async buildSchemaData(): Promise<{
workspaces: { name: string; description?: string }[];
customAgents: { name: string; description?: string }[];
vaultRoot: string[];
Expand Down
111 changes: 110 additions & 1 deletion src/services/agent/AgentRegistrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* Dependencies: AgentInitializationService, AgentValidationService
*/

import { App, Plugin, Events } from 'obsidian';
import { App, Plugin, Events, TAbstractFile } from 'obsidian';
import NexusPlugin from '../../main';
import { AgentManager } from '../AgentManager';
import type { ServiceManager } from '../../core/ServiceManager';
Expand All @@ -21,6 +21,8 @@ import type { AppManager } from '../apps/AppManager';
import type { IAgent } from '../../agents/interfaces/IAgent';
import { ToolManagerAgent } from '../../agents/toolManager/toolManager';
import type { MemorySettings } from '../../types';
import { WorkspaceService } from '../WorkspaceService';
import { PromptManagerAgent } from '../../agents/promptManager/promptManager';

export interface AgentRegistrationServiceInterface {
/**
Expand Down Expand Up @@ -97,6 +99,109 @@ export class AgentRegistrationService implements AgentRegistrationServiceInterfa
}
}

/**
* Get the ToolManagerAgent from the agent registry, or null if unavailable.
* ToolManager may be absent during early startup or if PHASE 4 init failed.
*/
private getToolManagerAgent(): ToolManagerAgent | null {
try {
const toolManager = this.agentManager.getAgent('toolManager');
return toolManager instanceof ToolManagerAgent ? toolManager : null;
} catch {
return null;
}
}

/**
* Rebuild SchemaData from the current project state and push it to ToolManager
* so the getTools description stays fresh after startup. Best-effort: swallows
* errors so mutation paths never fail due to a schema refresh.
*
* Fire-and-forget by default — callers on hot save paths should not await.
*/
private refreshToolManagerSchema(): void {
const toolManager = this.getToolManagerAgent();
if (!toolManager) return;

void (async () => {
try {
const schemaData = await this.initializationService.buildSchemaData();
toolManager.refreshSchemaData(schemaData);
} catch (error) {
// Best-effort: stale schema is acceptable; failing the mutation isn't.
logger.systemWarn(`Failed to refresh ToolManager schema: ${(error as Error).message}`);
}
})();
}

/**
* Attach change listeners to WorkspaceService, CustomPromptStorageService, and
* the vault root so ToolManager's getTools description tracks project state.
*
* Invoked after PHASE 4 (ToolManager init) so all dependencies are available.
* Called from doInitializeAllAgents; no external caller should invoke this.
*/
private wireSchemaRefreshListeners(): void {
// Workspaces — single shared instance via ServiceManager.
try {
if (this.serviceManager) {
const workspaceService = this.serviceManager.getServiceIfReady<WorkspaceService>('workspaceService');
if (workspaceService) {
workspaceService.setOnChange(() => this.refreshToolManagerSchema());
}
}
} catch (error) {
logger.systemWarn(`Failed to wire WorkspaceService change listener: ${(error as Error).message}`);
}

// Custom agents — mutations run through PromptManagerAgent.storageService (the
// instance the tool handlers write to). The AgentInitializationService holds
// a separate reader instance used by buildSchemaData; they share the underlying
// data.json + SQLite store, so wiring the mutation instance is sufficient.
try {
const promptManager = this.agentManager.getAgent('promptManager');
if (promptManager instanceof PromptManagerAgent) {
promptManager.getStorageService().setOnChange(() => this.refreshToolManagerSchema());
}

// Also wire the reader instance if it exists and is a separate object — cheap
// defensive coverage for settings-panel mutations that might hit that path.
const readerStorage = this.initializationService.getCustomPromptStorage();
if (readerStorage && (!(promptManager instanceof PromptManagerAgent)
|| readerStorage !== promptManager.getStorageService())) {
readerStorage.setOnChange(() => this.refreshToolManagerSchema());
}
} catch (error) {
logger.systemWarn(`Failed to wire CustomPromptStorageService change listener: ${(error as Error).message}`);
}

// Vault root — listen for top-level file/folder create/delete/rename so the
// `Vault: [...]` hint in getTools tracks actual structure. Obsidian's vault
// events fire for the whole tree, so filter to root-level children only.
try {
const isRootChild = (file: TAbstractFile | null | undefined): boolean => {
if (!file) return false;
// Root children have paths with no '/' separator (e.g. 'Inbox' or 'note.md')
// whereas nested files look like 'Folder/note.md'.
return !file.path.includes('/');
};
const handler = (file: TAbstractFile): void => {
if (isRootChild(file)) this.refreshToolManagerSchema();
};
const renameHandler = (file: TAbstractFile, oldPath: string): void => {
if (isRootChild(file) || !oldPath.includes('/')) this.refreshToolManagerSchema();
};

// Register via plugin.registerEvent so they unhook on plugin unload.
const pluginWithRegister = this.plugin as Plugin;
pluginWithRegister.registerEvent(this.app.vault.on('create', handler));
pluginWithRegister.registerEvent(this.app.vault.on('delete', handler));
pluginWithRegister.registerEvent(this.app.vault.on('rename', renameHandler));
} catch (error) {
logger.systemWarn(`Failed to wire vault root listeners for schema refresh: ${(error as Error).message}`);
}
}

constructor(
private app: App,
private plugin: Plugin | NexusPlugin,
Expand Down Expand Up @@ -218,6 +323,10 @@ export class AgentRegistrationService implements AgentRegistrationServiceInterfa
// PHASE 4: ToolManager MUST be last (needs all other agents including apps)
await this.safeInitialize('toolManager', () => this.initializationService.initializeToolManager());

// PHASE 5: Wire change listeners for live SchemaData refresh. Non-fatal —
// listener registration failures log a warning but don't break init.
this.wireSchemaRefreshListeners();

logger.systemLog('Using native chatbot UI instead of ChatAgent');

// Calculate final statistics
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/ToolManagerDynamicRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ describe('ToolManagerAgent dynamic registry updates', () => {
});
});

it('throws when the same agent name is registered twice', () => {
const toolManager = createToolManager();
toolManager.registerDynamicAgent(new StubAgent());

expect(() => toolManager.registerDynamicAgent(new StubAgent())).toThrow(
'Agent webTools is already registered'
);
});

it('removes dynamically unregistered agents from discovery', async () => {
const toolManager = createToolManager();
toolManager.registerDynamicAgent(new StubAgent());
Expand Down
Loading