diff --git a/.changeset/deterministic-workflow-macros.md b/.changeset/deterministic-workflow-macros.md new file mode 100644 index 000000000..9c5307a29 --- /dev/null +++ b/.changeset/deterministic-workflow-macros.md @@ -0,0 +1,5 @@ +--- +'@nanocollective/nanocoder': minor +--- + +feat: add read-only sequence tracker and /macros command to discover recurring workflows (#1585) diff --git a/source/app/utils/app-util.ts b/source/app/utils/app-util.ts index 1a339eae6..1468216cc 100644 --- a/source/app/utils/app-util.ts +++ b/source/app/utils/app-util.ts @@ -15,6 +15,7 @@ import { DELAY_COMMAND_COMPLETE_MS, MAX_SESSION_NAME_LENGTH, } from '@/constants'; +import {getSequenceTracker} from '@/macros/sequence-tracker'; import {sharedProposalStore} from '@/memory/proposal-store'; import {CheckpointManager} from '@/services/checkpoint-manager'; import {clearPendingHookContext} from '@/services/lifecycle-hooks'; @@ -341,6 +342,11 @@ async function handleSpecialCommand( case SPECIAL_COMMANDS.CLEAR: await onClearMessages(); sharedProposalStore.clear(); + try { + getSequenceTracker().clear(); + } catch { + // Non-fatal + } options.onClearCounterIncrement?.(); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; diff --git a/source/commands/lazy-registry.ts b/source/commands/lazy-registry.ts index a0d5de83a..2797dca02 100644 --- a/source/commands/lazy-registry.ts +++ b/source/commands/lazy-registry.ts @@ -223,6 +223,12 @@ export const lazyCommands: LazyCommand[] = [ 'Tune model settings (parameters, tool profiles, prompt, compaction)', load: () => import('@/commands/tune').then(m => m.tuneCommand), }, + { + name: 'macros', + description: + 'Inspect repeated read-only tool sequences (/macros, /macros clear)', + load: () => import('@/commands/macros').then(m => m.macrosCommand), + }, { name: 'schedule', description: diff --git a/source/commands/macros.spec.tsx b/source/commands/macros.spec.tsx new file mode 100644 index 000000000..cf64c7431 --- /dev/null +++ b/source/commands/macros.spec.tsx @@ -0,0 +1,97 @@ +import test from 'ava'; +import React from 'react'; +import {getSequenceTracker, resetSequenceTracker} from '@/macros/sequence-tracker'; +import {macrosCommand} from './macros'; + +test.beforeEach(() => { + resetSequenceTracker(); +}); + +test.serial('macrosCommand renders empty view when no patterns recorded', async t => { + const result = await macrosCommand.handler([], [], { + provider: 'ollama', + model: 'test-model', + tokens: 0, + getMessageTokens: () => 0, + }); + + t.truthy(result); + t.true(React.isValidElement(result)); +}); + +test.serial('macrosCommand renders patterns when sequences are tracked', async t => { + const tracker = getSequenceTracker(); + // Run 1 + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + + // Run 2 (repeat) + tracker.breakChain(); + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + + const result = await macrosCommand.handler([], [], { + provider: 'ollama', + model: 'test-model', + tokens: 0, + getMessageTokens: () => 0, + }); + + t.truthy(result); + t.true(React.isValidElement(result)); + t.is(tracker.getCandidates().length, 1); +}); + +test.serial('macrosCommand handles clear subcommand', async t => { + const tracker = getSequenceTracker(); + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + + t.is(tracker.getPatterns().length, 1); + + const result = await macrosCommand.handler(['clear'], [], { + provider: 'ollama', + model: 'test-model', + tokens: 0, + getMessageTokens: () => 0, + }); + + t.truthy(result); + t.is(tracker.getPatterns().length, 0); +}); diff --git a/source/commands/macros.tsx b/source/commands/macros.tsx new file mode 100644 index 000000000..0dcdbb414 --- /dev/null +++ b/source/commands/macros.tsx @@ -0,0 +1,96 @@ +import {Box, Text} from 'ink'; +import React from 'react'; +import {TitledBoxWithPreferences} from '@/components/ui/titled-box'; +import {useTerminalWidth} from '@/hooks/useTerminalWidth'; +import {useTheme} from '@/hooks/useTheme'; +import {getSequenceTracker} from '@/macros/sequence-tracker'; +import {generateKey} from '@/session/key-generator'; +import type {Command} from '@/types/index'; +import type {WorkflowPattern} from '@/types/macros'; +import {successMsg} from '@/utils/message-factory'; + +export interface MacroWorkflowViewProps { + patterns: WorkflowPattern[]; +} + +export function MacroWorkflowView({patterns}: MacroWorkflowViewProps) { + const boxWidth = useTerminalWidth(); + const {colors} = useTheme(); + + return ( + + + + Tracked Read-Only Workflow Patterns: + + + + {patterns.length === 0 ? ( + + + No repeated read-only tool sequences detected yet in this session. + + + ) : ( + patterns.map(p => ( + + + + [{p.id}] + + {p.signature} + + {' '} + — {p.occurrences} {p.occurrences === 1 ? 'time' : 'times'} + + + + )) + )} + + + + Tip: Discovered read-only patterns represent recurring candidate + workflows in this session. + + + + ); +} + +export const macrosCommand: Command = { + name: 'macros', + description: + 'Inspect repeated read-only tool sequences (/macros, /macros clear)', + handler: async (args, _messages, _metadata) => { + const tracker = getSequenceTracker(); + const sub = (args[0] ?? '').toLowerCase().trim(); + + if (sub === 'clear') { + tracker.clear(); + return successMsg( + 'Cleared sequence tracker history and discovered patterns.', + 'macros', + ); + } + + const patterns = tracker.getCandidates(); + return React.createElement(MacroWorkflowView, { + key: generateKey('macros'), + patterns, + }); + }, +}; diff --git a/source/hooks/chat-handler/conversation/tool-executor.spec.ts b/source/hooks/chat-handler/conversation/tool-executor.spec.ts index 797012c28..e203851b6 100644 --- a/source/hooks/chat-handler/conversation/tool-executor.spec.ts +++ b/source/hooks/chat-handler/conversation/tool-executor.spec.ts @@ -1,6 +1,7 @@ import test from 'ava'; import {displayExecutedTool, executeToolsDirectly} from './tool-executor.js'; import type {ToolCall, ToolResult} from '@/types/core'; +import {getSequenceTracker} from '@/macros/sequence-tracker'; // ============================================================================ // Test Helpers @@ -1209,3 +1210,111 @@ test.serial( setToolRegistryGetter(createMockToolRegistry); }, ); + +test.serial( + 'executeToolsDirectly - records read-only tool executions into SequenceTracker', + async t => { + const tracker = getSequenceTracker(); + tracker.clear(); + + const toolCalls: ToolCall[] = [ + { + id: 'call_ro_1', + function: { + name: 'test_tool', + arguments: JSON.stringify({path: 'source/test.ts'}), + }, + }, + ]; + + const mockToolManager = createMockToolManager({ + readOnlyTools: ['test_tool'], + }); + + await executeToolsDirectly( + toolCalls, + mockToolManager as any, + createMockConversationStateManager() as any, + () => {}, + ); + + const history = tracker.getHistory(); + t.true(history.length >= 1); + const last = history[history.length - 1]; + t.is(last.toolName, 'test_tool'); + t.is((last.inputArgs as any).path, 'source/test.ts'); + t.true(last.readOnly); + t.true(last.success); + }, +); + +test.serial( + 'executeToolsDirectly - breaks chain on non-read-only tool call', + async t => { + const tracker = getSequenceTracker(); + tracker.clear(); + + // Execute read-only tool first + await executeToolsDirectly( + [ + { + id: 'call_ro', + function: {name: 'test_tool', arguments: '{"path": "a"}'}, + }, + ], + createMockToolManager({readOnlyTools: ['test_tool']}) as any, + createMockConversationStateManager() as any, + () => {}, + ); + + t.is(tracker.getCurrentChain().length, 1); + + // Execute non-read-only tool + await executeToolsDirectly( + [ + { + id: 'call_mut', + function: {name: 'mutating_tool', arguments: '{"content": "b"}'}, + }, + ], + createMockToolManager({readOnlyTools: []}) as any, + createMockConversationStateManager() as any, + () => {}, + ); + + // Chain must be broken + t.is(tracker.getCurrentChain().length, 0); + }, +); + +test.serial( + 'executeToolsDirectly - tags concurrent read-only tools with parallelBatch', + async t => { + const tracker = getSequenceTracker(); + tracker.clear(); + + // Two read-only tools executed together + await executeToolsDirectly( + [ + { + id: 'call_ro_1', + function: {name: 'tool1', arguments: '{}'}, + }, + { + id: 'call_ro_2', + function: {name: 'tool2', arguments: '{}'}, + }, + ], + createMockToolManager({readOnlyTools: ['tool1', 'tool2']}) as any, + createMockConversationStateManager() as any, + () => {}, + ); + + const chain = tracker.getCurrentChain(); + t.is(chain.length, 2); + t.true(chain[0].parallelBatch); + t.true(chain[1].parallelBatch); + }, +); + + diff --git a/source/hooks/chat-handler/conversation/tool-executor.tsx b/source/hooks/chat-handler/conversation/tool-executor.tsx index cecc8003a..80d8580fa 100644 --- a/source/hooks/chat-handler/conversation/tool-executor.tsx +++ b/source/hooks/chat-handler/conversation/tool-executor.tsx @@ -4,6 +4,7 @@ import AgentProgress, {MultiAgentProgress} from '@/components/agent-progress'; import BashProgress from '@/components/bash-progress'; import {ErrorMessage} from '@/components/message-box'; import {getShowAgentBashOutput} from '@/config/preferences'; +import {getSequenceTracker} from '@/macros/sequence-tracker'; import type {BashExecutionState} from '@/services/bash-executor'; import { clearAllSubagentProgress, @@ -87,6 +88,7 @@ export interface ToolDisplayOptions { onCompactToolCount?: (toolName: string) => void; onLiveTaskUpdate?: (tasks?: Task[]) => void; nonInteractiveMode?: boolean; + isParallelBatch?: boolean; } /** @@ -140,6 +142,34 @@ export const displayExecutedTool = async ( toolCall, result.content, ); + + try { + const tracker = getSequenceTracker(); + const isReadOnly = toolManager?.isReadOnly(result.name) ?? false; + const isError = isToolResultError(result); + + if (!isReadOnly || isError) { + tracker.breakChain(); + } else { + const parsedArgs = parseToolArguments(toolCall.function.arguments); + const inputArgs = + typeof parsedArgs === 'object' && parsedArgs !== null + ? (parsedArgs as Record) + : {}; + + tracker.recordExecution({ + toolName: result.name, + inputArgs, + success: true, + readOnly: true, + parallelBatch: options?.isParallelBatch, + timestamp: Date.now(), + }); + } + } catch { + // Non-fatal tracking fallback + } + const expandId = recordExpandableToolResult(toolCall, result, bashState); if ( @@ -554,6 +584,11 @@ export const executeToolsDirectly = async ( result.content, ); } + try { + getSequenceTracker().breakChain(); + } catch { + // Non-fatal tracking fallback + } continue; } @@ -578,6 +613,11 @@ export const executeToolsDirectly = async ( } } + const isParallelBatch = type === 'readOnly' && group.length > 1; + const displayOpts = isParallelBatch + ? {...options, isParallelBatch: true} + : options; + // Display results in order for (const execution of executions) { directResults.push(execution.result); @@ -586,7 +626,7 @@ export const executeToolsDirectly = async ( toolManager, addToChatQueue, conversationStateManager, - options, + displayOpts, ); } } diff --git a/source/macros/sequence-tracker.spec.ts b/source/macros/sequence-tracker.spec.ts new file mode 100644 index 000000000..523b81917 --- /dev/null +++ b/source/macros/sequence-tracker.spec.ts @@ -0,0 +1,263 @@ +import test from 'ava'; +import {SequenceTracker} from './sequence-tracker.js'; + +test('SequenceTracker detects repeated multi-step read-only tool sequences', t => { + const tracker = new SequenceTracker({minOccurrencesForCandidate: 2}); + + // Sequence 1: find_files -> read_file + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {pattern: '*.ts'}, + success: true, + readOnly: true, + durationMs: 100, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {path: 'source/app.tsx'}, + success: true, + readOnly: true, + durationMs: 50, + timestamp: Date.now(), + }); + + // After 1 run, pattern exists with 1 occurrence, not yet a candidate + const patterns1 = tracker.getPatterns(1); + t.is(patterns1.length, 1); + t.is(patterns1[0].signature, 'find_files -> read_file'); + t.is(tracker.getCandidates().length, 0); + + // Sequence 2: find_files -> read_file again + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {pattern: '*.spec.ts'}, + success: true, + readOnly: true, + durationMs: 120, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {path: 'source/app.spec.tsx'}, + success: true, + readOnly: true, + durationMs: 40, + timestamp: Date.now(), + }); + + // Now it has 2 occurrences and is a candidate macro + const candidates = tracker.getCandidates(); + t.is(candidates.length, 1); + t.is(candidates[0].signature, 'find_files -> read_file'); + t.is(candidates[0].occurrences, 2); + t.truthy(candidates[0].exemplarRecords); + t.is(candidates[0].exemplarRecords?.length, 2); + t.is(candidates[0].exemplarRecords?.[0].inputArgs.pattern, '*.spec.ts'); + t.is(candidates[0].exemplarRecords?.[1].inputArgs.path, 'source/app.spec.tsx'); +}); + +test('SequenceTracker preserves parallelBatch tag on exemplar records', t => { + const tracker = new SequenceTracker({minOccurrencesForCandidate: 1}); + + tracker.recordExecution({ + toolName: 'search_file_contents', + inputArgs: {query: 'foo'}, + success: true, + readOnly: true, + parallelBatch: true, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {path: 'foo.ts'}, + success: true, + readOnly: true, + parallelBatch: true, + timestamp: Date.now(), + }); + + const patterns = tracker.getPatterns(1); + t.is(patterns.length, 1); + t.true(patterns[0].exemplarRecords?.[0].parallelBatch); + t.true(patterns[0].exemplarRecords?.[1].parallelBatch); +}); + +test('SequenceTracker ignores non-read-only tool executions and breaks chains', t => { + const tracker = new SequenceTracker({minOccurrencesForCandidate: 1}); + + // find_files (read-only) -> write_file (non-read-only) -> read_file (read-only) + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {pattern: '*.ts'}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'write_file', + inputArgs: {path: 'test.txt', content: 'hello'}, + success: true, + readOnly: false, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {path: 'test.txt'}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + + // No contiguous 2+ read-only pattern should be extracted + t.is(tracker.getPatterns().length, 0); + t.is(tracker.getCurrentChain().length, 1); +}); + +test('SequenceTracker breakChain explicitly resets the active chain', t => { + const tracker = new SequenceTracker({minOccurrencesForCandidate: 1}); + + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + + t.is(tracker.getCurrentChain().length, 1); + + // Explicitly break chain (e.g., agent batch or mutation occurred) + tracker.breakChain(); + + t.is(tracker.getCurrentChain().length, 0); + + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + + // Separated by breakChain, so no 2-step sequence formed + t.is(tracker.getPatterns().length, 0); + t.is(tracker.getCurrentChain().length, 1); +}); + +test('SequenceTracker ignores failed tool executions and breaks chains', t => { + const tracker = new SequenceTracker(); + + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {pattern: 'fail'}, + success: false, + readOnly: true, + durationMs: 10, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {path: 'app.ts'}, + success: true, + readOnly: true, + durationMs: 10, + timestamp: Date.now(), + }); + + // No successful 2-step sequence formed + t.is(tracker.getPatterns().length, 0); +}); + +test('SequenceTracker evicts history beyond maxHistory', t => { + const tracker = new SequenceTracker({maxHistory: 3}); + + for (let i = 0; i < 5; i++) { + tracker.recordExecution({ + toolName: `read_file_${i}`, + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + } + + t.is(tracker.getHistory().length, 3); + t.is(tracker.getHistory()[0].toolName, 'read_file_2'); + t.is(tracker.getHistory()[2].toolName, 'read_file_4'); +}); + +test('SequenceTracker clear resets patterns, history, and active chain', t => { + const tracker = new SequenceTracker(); + tracker.recordExecution({ + toolName: 'find_files', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + + t.is(tracker.getPatterns().length, 1); + t.is(tracker.getHistory().length, 2); + t.is(tracker.getCurrentChain().length, 2); + + tracker.clear(); + + t.is(tracker.getPatterns().length, 0); + t.is(tracker.getHistory().length, 0); + t.is(tracker.getCurrentChain().length, 0); +}); + +test('SequenceTracker counts non-overlapping repeats and does not inflate single run of identical tools', t => { + const tracker = new SequenceTracker({minOccurrencesForCandidate: 2}); + + // A single run of four read_file calls + for (let i = 0; i < 4; i++) { + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {path: `file_${i}.ts`}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + } + + // Should not be reported as a repeated candidate in a single run + const patterns = tracker.getPatterns(1); + const readRepeatPattern = patterns.find( + p => p.signature === 'read_file -> read_file', + ); + t.truthy(readRepeatPattern); + t.is(readRepeatPattern?.occurrences, 1); + t.is(tracker.getCandidates().length, 0); + + // A second separate streak of read_file calls (e.g. after a chain break / new turn) + tracker.breakChain(); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {path: 'other_1.ts'}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + tracker.recordExecution({ + toolName: 'read_file', + inputArgs: {path: 'other_2.ts'}, + success: true, + readOnly: true, + timestamp: Date.now(), + }); + + // Now it has occurred in 2 separate runs and becomes a candidate + const candidates = tracker.getCandidates(); + t.is(candidates.length, 1); + t.is(candidates[0].signature, 'read_file -> read_file'); + t.is(candidates[0].occurrences, 2); +}); diff --git a/source/macros/sequence-tracker.ts b/source/macros/sequence-tracker.ts new file mode 100644 index 000000000..6d0c09289 --- /dev/null +++ b/source/macros/sequence-tracker.ts @@ -0,0 +1,178 @@ +import type {ToolExecutionRecord, WorkflowPattern} from '@/types/macros'; + +export interface SequenceTrackerOptions { + maxHistory?: number; + minSequenceLength?: number; + maxSequenceLength?: number; + minOccurrencesForCandidate?: number; +} + +export class SequenceTracker { + private readonly history: ToolExecutionRecord[] = []; + private currentChain: ToolExecutionRecord[] = []; + private readonly patterns: Map = new Map(); + private readonly patternLastEndIndex: Map = new Map(); + private readonly patternLastStreakId: Map = new Map(); + private currentStreakTool: string | null = null; + private currentStreakId = 0; + private executionCounter = 0; + private readonly maxHistory: number; + private readonly minSeqLen: number; + private readonly maxSeqLen: number; + private readonly minOccurrences: number; + + constructor(options?: SequenceTrackerOptions) { + this.maxHistory = options?.maxHistory ?? 100; + this.minSeqLen = options?.minSequenceLength ?? 2; + this.maxSeqLen = options?.maxSequenceLength ?? 5; + this.minOccurrences = options?.minOccurrencesForCandidate ?? 2; + } + + recordExecution(record: ToolExecutionRecord): void { + this.history.push(record); + if (this.history.length > this.maxHistory) { + this.history.shift(); + } + + // Only successful read-only actions form repeatable macro chains + if (!record.success || !record.readOnly) { + this.breakChain(); + return; + } + + if (record.toolName === this.currentStreakTool) { + // Continues current streak of the same tool + } else { + this.currentStreakTool = record.toolName; + this.currentStreakId += 1; + } + + const currentIndex = this.executionCounter; + this.executionCounter += 1; + + this.currentChain.push(record); + if (this.currentChain.length > this.maxSeqLen) { + this.currentChain.shift(); + } + + this.extractPatternsFromCurrentChain(currentIndex); + } + + /** + * Explicitly breaks the active contiguous chain of read-only tool calls. + * Interleaved mutating actions, failed calls, and agent subagent batches + * invoke this so they do not falsely link independent read-only calls. + */ + breakChain(): void { + this.currentChain = []; + this.currentStreakTool = null; + this.currentStreakId += 1; + } + + private extractPatternsFromCurrentChain(currentIndex: number): void { + const chainLen = this.currentChain.length; + for ( + let len = this.minSeqLen; + len <= this.maxSeqLen && len <= chainLen; + len++ + ) { + const slice = this.currentChain.slice(chainLen - len); + const sequence = slice.map(r => r.toolName); + const signature = sequence.join(' -> '); + const exemplarRecords = slice.map(r => ({ + ...r, + inputArgs: {...r.inputArgs}, + })); + + const startIndex = currentIndex - len + 1; + const lastEnd = this.patternLastEndIndex.get(signature) ?? -1; + + // Count non-overlapping repeats: slice must start after the end of the previous match + if (startIndex <= lastEnd) { + continue; + } + + // If all tools in the sequence are identical (e.g. read_file -> read_file), + // require that it comes from a separate streak/run so a single run of calling + // the same tool repeatedly does not falsely report as a repeated macro. + const isAllSameTool = sequence.every(name => name === sequence[0]); + if (isAllSameTool) { + const lastStreak = this.patternLastStreakId.get(signature); + if (lastStreak !== undefined && lastStreak === this.currentStreakId) { + continue; + } + this.patternLastStreakId.set(signature, this.currentStreakId); + } + + this.patternLastEndIndex.set(signature, currentIndex); + + const existing = this.patterns.get(signature); + if (existing) { + existing.occurrences += 1; + existing.lastExecutedAt = Date.now(); + existing.exemplarRecords = exemplarRecords; + } else { + this.patterns.set(signature, { + id: `pattern_${Math.abs(hashString(signature))}`, + signature, + sequence, + occurrences: 1, + lastExecutedAt: Date.now(), + exemplarRecords, + }); + } + } + } + + getPatterns(minOccurrences?: number): WorkflowPattern[] { + const threshold = minOccurrences ?? 1; + return Array.from(this.patterns.values()) + .filter(p => p.occurrences >= threshold) + .sort((a, b) => b.occurrences - a.occurrences); + } + + getCandidates(): WorkflowPattern[] { + return this.getPatterns(this.minOccurrences); + } + + getHistory(): ReadonlyArray { + return [...this.history]; + } + + getCurrentChain(): ReadonlyArray { + return [...this.currentChain]; + } + + clear(): void { + this.history.length = 0; + this.currentChain = []; + this.patterns.clear(); + this.patternLastEndIndex.clear(); + this.patternLastStreakId.clear(); + this.currentStreakTool = null; + this.currentStreakId = 0; + this.executionCounter = 0; + } +} + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return hash; +} + +let globalSequenceTracker: SequenceTracker | null = null; + +export function getSequenceTracker(): SequenceTracker { + if (!globalSequenceTracker) { + globalSequenceTracker = new SequenceTracker(); + } + return globalSequenceTracker; +} + +export function resetSequenceTracker(): void { + globalSequenceTracker = null; +} diff --git a/source/types/macros.ts b/source/types/macros.ts new file mode 100644 index 000000000..78d70f7a5 --- /dev/null +++ b/source/types/macros.ts @@ -0,0 +1,18 @@ +export interface ToolExecutionRecord { + toolName: string; + inputArgs: Record; + success: boolean; + readOnly: boolean; + parallelBatch?: boolean; + durationMs?: number; + timestamp: number; +} + +export interface WorkflowPattern { + id: string; + signature: string; + sequence: string[]; + occurrences: number; + lastExecutedAt: number; + exemplarRecords?: ToolExecutionRecord[]; +}