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
5 changes: 5 additions & 0 deletions .changeset/deterministic-workflow-macros.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@nanocollective/nanocoder': minor
---

feat: add read-only sequence tracker and /macros command to discover recurring workflows (#1585)
6 changes: 6 additions & 0 deletions source/app/utils/app-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions source/commands/lazy-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions source/commands/macros.spec.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
96 changes: 96 additions & 0 deletions source/commands/macros.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TitledBoxWithPreferences
title="/macros · discovered workflow patterns"
width={boxWidth}
borderColor={colors.primary}
paddingX={2}
paddingY={1}
flexDirection="column"
marginBottom={1}
>
<Box marginBottom={1}>
<Text bold color={colors.text}>
Tracked Read-Only Workflow Patterns:
</Text>
</Box>

{patterns.length === 0 ? (
<Box marginLeft={2} marginBottom={1}>
<Text color={colors.secondary}>
No repeated read-only tool sequences detected yet in this session.
</Text>
</Box>
) : (
patterns.map(p => (
<Box
key={p.id}
flexDirection="column"
marginLeft={2}
marginBottom={1}
>
<Box>
<Text bold color={colors.primary}>
[{p.id}]
</Text>
<Text color={colors.text}> {p.signature}</Text>
<Text color={colors.secondary}>
{' '}
— {p.occurrences} {p.occurrences === 1 ? 'time' : 'times'}
</Text>
</Box>
</Box>
))
)}

<Box marginTop={1}>
<Text color={colors.secondary}>
Tip: Discovered read-only patterns represent recurring candidate
workflows in this session.
</Text>
</Box>
</TitledBoxWithPreferences>
);
}

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,
});
},
};
109 changes: 109 additions & 0 deletions source/hooks/chat-handler/conversation/tool-executor.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
},
);


Loading
Loading