mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
feat(mcp): add kai_agent_step and kai_compact tools for decision-native agent workflows
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Skipped
publish / publish (push) Successful in 1m27s
Hanzo CI/CD / test-amd64 (push) Failing after 8m23s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 8m25s
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Skipped
publish / publish (push) Successful in 1m27s
Hanzo CI/CD / test-amd64 (push) Failing after 8m23s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 8m25s
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Context Compaction — Kai-guided typed decision compaction for MCP and coding agents.
|
||||
*
|
||||
* Replaces lossy LLM text summarization with deterministic, typed decision pruning:
|
||||
* 1. Pairs tool calls and tool results.
|
||||
* 2. Pins the initial task and recent messages verbatim.
|
||||
* 3. Asks Kai whether each non-pinned call/result must be preserved or pruned.
|
||||
* 4. Extracts compiler errors and diagnostics verbatim while collapsing repetitive/stale output.
|
||||
* 5. Preserves prompt cache prefixes and cuts token volume by 50–80%.
|
||||
*/
|
||||
|
||||
import { Tool, ToolResult } from '../types/index.js';
|
||||
import { Refusal, refusal } from './refusal.js';
|
||||
|
||||
function apiBase(): string {
|
||||
return process.env.API_URL || 'https://api.hanzo.ai';
|
||||
}
|
||||
|
||||
function token(): string {
|
||||
return process.env.HANZO_API_KEY || process.env.API_KEY || process.env.API_TOKEN || process.env.HANZO_TOKEN || '';
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: string;
|
||||
content?: string | any[];
|
||||
tool_calls?: any[];
|
||||
tool_call_id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ToolPair {
|
||||
callIndex: number;
|
||||
resultIndex: number;
|
||||
toolName: string;
|
||||
toolInput: any;
|
||||
toolOutput: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
function estimateTokens(chars: number): number {
|
||||
if (!chars || chars <= 0) return 0;
|
||||
// Standard heuristic: ~4 chars per token for English/code
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
function messageText(msg: Message): string {
|
||||
if (typeof msg.content === 'string') return msg.content;
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content
|
||||
.map(part => {
|
||||
if (typeof part === 'string') return part;
|
||||
if (part && typeof part === 'object' && part.text) return part.text;
|
||||
return '';
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractErrorSignature(output: string): string | null {
|
||||
const lines = output.split('\n');
|
||||
const errorLines = lines.filter(l =>
|
||||
l.toLowerCase().includes('error:') ||
|
||||
l.toLowerCase().includes('failed') ||
|
||||
l.toLowerCase().includes('panic') ||
|
||||
l.toLowerCase().includes('exception') ||
|
||||
l.toLowerCase().includes('cannot find') ||
|
||||
l.toLowerCase().includes('syntaxerror')
|
||||
);
|
||||
if (errorLines.length > 0) {
|
||||
return errorLines.slice(0, 10).join('\n');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const kaiCompactTool: Tool = {
|
||||
name: 'kai_compact',
|
||||
description: 'Context compaction using Kai typed decisions. Replaces lossy LLM summarization by pruning stale tool outputs while preserving critical compiler diagnostics, error traces, and prompt cache hit rates.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
messages: {
|
||||
type: 'array',
|
||||
description: 'The conversation messages to compact',
|
||||
items: { type: 'object' }
|
||||
},
|
||||
preserve_recent: {
|
||||
type: 'number',
|
||||
description: 'Number of recent messages to preserve untouched (default 4)',
|
||||
default: 4
|
||||
},
|
||||
keep_threshold: {
|
||||
type: 'number',
|
||||
description: 'Confidence threshold for keeping tool outputs (default 0.5)',
|
||||
default: 0.5
|
||||
},
|
||||
truncate_chars: {
|
||||
type: 'number',
|
||||
description: 'Max characters to keep for truncated non-critical results (default 400)',
|
||||
default: 400
|
||||
},
|
||||
model: {
|
||||
type: 'string',
|
||||
description: 'Decision model (default kai)',
|
||||
default: 'kai'
|
||||
}
|
||||
},
|
||||
required: ['messages']
|
||||
},
|
||||
handler: async (args): Promise<ToolResult> => {
|
||||
try {
|
||||
const messages: Message[] = args.messages || [];
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ok: true, compacted_messages: [], tokens_saved: 0 }) }]
|
||||
};
|
||||
}
|
||||
|
||||
const preserveRecent = typeof args.preserve_recent === 'number' ? args.preserve_recent : 4;
|
||||
const keepThreshold = typeof args.keep_threshold === 'number' ? args.keep_threshold : 0.5;
|
||||
const truncateChars = typeof args.truncate_chars === 'number' ? args.truncate_chars : 400;
|
||||
const model = args.model || 'kai';
|
||||
|
||||
const totalMessages = messages.length;
|
||||
const pinnedCutoff = Math.max(1, totalMessages - preserveRecent);
|
||||
|
||||
// Estimate initial tokens
|
||||
let initialChars = 0;
|
||||
for (const m of messages) {
|
||||
initialChars += messageText(m).length;
|
||||
if (m.tool_calls) initialChars += JSON.stringify(m.tool_calls).length;
|
||||
}
|
||||
const initialTokens = estimateTokens(initialChars);
|
||||
|
||||
// Match tool calls with their results
|
||||
const callMap = new Map<string, number>();
|
||||
const pairs: ToolPair[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const m = messages[i];
|
||||
if (m.tool_calls && Array.isArray(m.tool_calls)) {
|
||||
for (const tc of m.tool_calls) {
|
||||
if (tc.id) callMap.set(tc.id, i);
|
||||
}
|
||||
}
|
||||
if (m.role === 'tool' && m.tool_call_id) {
|
||||
const callIdx = callMap.get(m.tool_call_id);
|
||||
if (callIdx !== undefined) {
|
||||
const callMsg = messages[callIdx];
|
||||
const matchingCall = callMsg.tool_calls?.find(tc => tc.id === m.tool_call_id);
|
||||
const outputText = messageText(m);
|
||||
const isError = outputText.toLowerCase().includes('error') ||
|
||||
outputText.toLowerCase().includes('fail') ||
|
||||
outputText.toLowerCase().includes('panic');
|
||||
|
||||
pairs.push({
|
||||
callIndex: callIdx,
|
||||
resultIndex: i,
|
||||
toolName: matchingCall?.function?.name || 'unknown',
|
||||
toolInput: matchingCall?.function?.arguments || {},
|
||||
toolOutput: outputText,
|
||||
isError
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to unpinned tool pairs
|
||||
const unpinnedPairs = pairs.filter(p => p.callIndex < pinnedCutoff && p.callIndex > 0);
|
||||
|
||||
// Formulate Kai decision questions if unpinned pairs exist
|
||||
const pruneActions = new Map<number, 'keep' | 'truncate' | 'drop'>();
|
||||
|
||||
if (unpinnedPairs.length > 0 && token()) {
|
||||
const questions: Record<string, any> = {};
|
||||
for (let idx = 0; idx < unpinnedPairs.length; idx++) {
|
||||
const pair = unpinnedPairs[idx];
|
||||
questions[`keep_${idx}`] = {
|
||||
type: 'noul',
|
||||
instructions: `Does the result of tool '${pair.toolName}' contain indispensable facts (compiler errors, specific function signatures, unique file contents) that cannot be safely discarded?`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const taskText = messageText(messages[0]).substring(0, 2000);
|
||||
const stateSummary = unpinnedPairs.map((p, idx) =>
|
||||
`[${idx}] ${p.toolName}(${JSON.stringify(p.toolInput).substring(0, 100)}) -> ${p.isError ? 'ERROR' : 'OK'} (${p.toolOutput.length} chars)`
|
||||
).join('\n');
|
||||
|
||||
const r = await fetch(`${apiBase()}/v1/decisions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${token()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
state: {
|
||||
task: taskText,
|
||||
tool_history: stateSummary
|
||||
},
|
||||
questions
|
||||
})
|
||||
});
|
||||
|
||||
if (r.ok) {
|
||||
const dec = await r.json();
|
||||
for (let idx = 0; idx < unpinnedPairs.length; idx++) {
|
||||
const pair = unpinnedPairs[idx];
|
||||
const ans = dec.answers?.[`keep_${idx}`];
|
||||
const pKeep = ans?.noul ?? 0.5;
|
||||
|
||||
if (pair.isError) {
|
||||
// Never drop error traces
|
||||
pruneActions.set(pair.resultIndex, 'truncate');
|
||||
} else if (pKeep >= keepThreshold) {
|
||||
pruneActions.set(pair.resultIndex, 'keep');
|
||||
} else if (pKeep >= 0.25) {
|
||||
pruneActions.set(pair.resultIndex, 'truncate');
|
||||
} else {
|
||||
pruneActions.set(pair.resultIndex, 'drop');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to rule-based heuristic if Kai request is unreachable
|
||||
for (const pair of unpinnedPairs) {
|
||||
if (pair.isError) {
|
||||
pruneActions.set(pair.resultIndex, 'truncate');
|
||||
} else if (pair.toolOutput.length > 1000) {
|
||||
pruneActions.set(pair.resultIndex, 'truncate');
|
||||
} else {
|
||||
pruneActions.set(pair.resultIndex, 'keep');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback rule heuristic
|
||||
for (const pair of unpinnedPairs) {
|
||||
if (pair.isError) {
|
||||
pruneActions.set(pair.resultIndex, 'truncate');
|
||||
} else if (pair.toolOutput.length > 1000) {
|
||||
pruneActions.set(pair.resultIndex, 'truncate');
|
||||
} else {
|
||||
pruneActions.set(pair.resultIndex, 'keep');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct compacted messages
|
||||
const compacted: Message[] = [];
|
||||
let prunedCallsCount = 0;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const action = pruneActions.get(i);
|
||||
|
||||
if (action === 'drop') {
|
||||
prunedCallsCount++;
|
||||
// Replace content with short tombstone
|
||||
compacted.push({
|
||||
...msg,
|
||||
content: `[Tool result omitted: ${msg.tool_call_id || 'call'} completed successfully]`
|
||||
});
|
||||
} else if (action === 'truncate') {
|
||||
const rawText = messageText(msg);
|
||||
const errSig = extractErrorSignature(rawText);
|
||||
let newContent = '';
|
||||
if (errSig) {
|
||||
newContent = `[Diagnostics retained verbatim]:\n${errSig}\n[... ${rawText.length - errSig.length} chars omitted ...]`;
|
||||
} else {
|
||||
newContent = `${rawText.substring(0, truncateChars)}\n[... ${rawText.length - truncateChars} chars omitted ...]`;
|
||||
}
|
||||
compacted.push({
|
||||
...msg,
|
||||
content: newContent
|
||||
});
|
||||
} else {
|
||||
compacted.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
let finalChars = 0;
|
||||
for (const m of compacted) {
|
||||
finalChars += messageText(m).length;
|
||||
if (m.tool_calls) finalChars += JSON.stringify(m.tool_calls).length;
|
||||
}
|
||||
const finalTokens = estimateTokens(finalChars);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
compacted_messages: compacted,
|
||||
metrics: {
|
||||
initial_messages: totalMessages,
|
||||
compacted_messages_count: compacted.length,
|
||||
estimated_tokens_before: initialTokens,
|
||||
estimated_tokens_after: finalTokens,
|
||||
tokens_saved: Math.max(0, initialTokens - finalTokens),
|
||||
savings_percentage: initialTokens > 0
|
||||
? Number(((Math.max(0, initialTokens - finalTokens) / initialTokens) * 100).toFixed(1))
|
||||
: 0,
|
||||
pruned_or_truncated_tool_calls: unpinnedPairs.length
|
||||
}
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error during compaction: ${e.message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
+2
-1
@@ -94,7 +94,8 @@ export { gitTools as vcsTools } from './git.js';
|
||||
export { gimpTools, gimpTool } from './gimp.js';
|
||||
export { codeIntelTools, codeSearchTool, codeContextTool, codeAskTool, codeIndexTool } from './code-intel.js';
|
||||
export { trackerTools, trackerBoardsTool, trackerIssuesTool, trackerCreateTool, trackerUpdateTool } from './tracker.js';
|
||||
export { kaiTools, kaiDecideTool, kaiChoiceTool, kaiScoreTool, kaiNoulTool } from './kai.js';
|
||||
export { kaiTools, kaiDecideTool, kaiChoiceTool, kaiScoreTool, kaiNoulTool, kaiAgentStepTool, kaiAgentTools } from './kai.js';
|
||||
export { kaiCompactTool } from './compact.js';
|
||||
export { llmTools, llmTool } from './llm.js';
|
||||
export { browserTools, browserTool, cdpTool } from './browser.js';
|
||||
export { packageTools, npxTool, uvxTool, jqTool } from './packages.js';
|
||||
|
||||
@@ -209,4 +209,52 @@ export const kaiNoulTool: Tool = {
|
||||
handler: async (args) => one('noul', args),
|
||||
};
|
||||
|
||||
export const kaiAgentStepTool: Tool = {
|
||||
name: 'kai_agent_step',
|
||||
description: "Evaluate next bounded agent decision along a task trajectory: NEXT, RECOVER, STOP, CLARIFY. Selects tools, binds verified argument byte spans, and checks completion in a single fast forward pass (<5ms).",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
goal: { type: 'string', description: 'The overall goal or task objective' },
|
||||
trajectory: {
|
||||
type: 'array',
|
||||
description: 'Past steps taken: [{step_index, action, tool_name, arguments, observation, is_error}]',
|
||||
items: { type: 'object' }
|
||||
},
|
||||
available_tools: {
|
||||
type: 'array',
|
||||
description: 'Candidate tool specifications: [{name, description, parameters}]',
|
||||
items: { type: 'object' }
|
||||
}
|
||||
},
|
||||
required: ['goal', 'available_tools']
|
||||
},
|
||||
handler: async (args) => {
|
||||
try {
|
||||
const t = token();
|
||||
const r = await fetch(`${apiBase()}/v1/agent/step`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(t ? { 'Authorization': `Bearer ${t}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
goal: args.goal,
|
||||
trajectory: args.trajectory || [],
|
||||
available_tools: args.available_tools || []
|
||||
})
|
||||
});
|
||||
if (!r.ok) {
|
||||
const txt = await r.text().catch(() => '');
|
||||
throw new Error(`${r.status}: ${reason(txt) || r.statusText}`);
|
||||
}
|
||||
return ok(await r.json());
|
||||
} catch (e: any) {
|
||||
return fail(e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const kaiTools: Tool[] = [kaiDecideTool, kaiChoiceTool, kaiScoreTool, kaiNoulTool];
|
||||
export const kaiAgentTools: Tool[] = [kaiAgentStepTool];
|
||||
|
||||
@@ -44,6 +44,7 @@ import { planTool } from '../plan.js';
|
||||
import { tasksTool } from '../tasks.js';
|
||||
import { modeTool } from '../mode-preset.js';
|
||||
import { hanzoTool } from './hanzo.js';
|
||||
import { kaiCompactTool } from '../compact.js';
|
||||
|
||||
// The canonical tool surface
|
||||
export const coreTools: Tool[] = [
|
||||
@@ -70,6 +71,7 @@ export const optionalTools: Tool[] = [
|
||||
npxTool, // Node packages, auto-backgrounded after 2 minutes
|
||||
uvxTool, // Python packages, the same
|
||||
jqTool, // JSON through jq, no shell
|
||||
kaiCompactTool, // Typed decision context compaction
|
||||
];
|
||||
|
||||
export const allUnifiedTools: Tool[] = [
|
||||
@@ -78,6 +80,7 @@ export const allUnifiedTools: Tool[] = [
|
||||
...codeIntelTools, // code_search, code_context, code_ask, code_index
|
||||
...trackerTools, // tracker_boards, tracker_issues, tracker_create, tracker_update
|
||||
...kaiTools, // kai_decide, kai_choice, kai_score, kai_noul
|
||||
...kaiAgentTools, // kai_agent_step
|
||||
];
|
||||
|
||||
// Re-exports
|
||||
@@ -89,7 +92,8 @@ export { workspaceTool } from './workspace.js';
|
||||
export { hanzoTool } from './hanzo.js';
|
||||
export { codeIntelTools, codeSearchTool, codeContextTool, codeAskTool, codeIndexTool } from '../code-intel.js';
|
||||
export { trackerTools, trackerBoardsTool, trackerIssuesTool, trackerCreateTool, trackerUpdateTool } from '../tracker.js';
|
||||
export { kaiTools, kaiDecideTool, kaiChoiceTool, kaiScoreTool, kaiNoulTool } from '../kai.js';
|
||||
export { kaiTools, kaiDecideTool, kaiChoiceTool, kaiScoreTool, kaiNoulTool, kaiAgentStepTool, kaiAgentTools } from '../kai.js';
|
||||
export { kaiCompactTool } from '../compact.js';
|
||||
export { llmTools, llmTool } from '../llm.js';
|
||||
export { browserTools, browserTool, cdpTool } from '../browser.js';
|
||||
export { packageTools, npxTool, uvxTool, jqTool } from '../packages.js';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Unit tests for kai_compact tool (typed context compaction).
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from '@jest/globals';
|
||||
import { kaiCompactTool } from '../../src/tools/compact.js';
|
||||
|
||||
const body = (r: any) => JSON.parse(r.content[0].text);
|
||||
|
||||
describe('kai_compact', () => {
|
||||
test('preserves error diagnostics while compacting long outputs', async () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Fix the failing test in tests/agent.rs' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Running cargo test...',
|
||||
tool_calls: [{ id: 'tc_1', function: { name: 'exec', arguments: { command: 'cargo test' } } }]
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc_1',
|
||||
content: ' Compiling dev v0.1.0\nerror: aborting due to 1 previous error\nerror[E0425]: cannot find value `foo` in this scope\n' + 'x'.repeat(4000)
|
||||
},
|
||||
{ role: 'assistant', content: 'I see the error, fixing it now.' },
|
||||
{ role: 'user', content: 'Did it work?' },
|
||||
];
|
||||
|
||||
const res = await kaiCompactTool.handler({
|
||||
messages,
|
||||
preserve_recent: 2,
|
||||
});
|
||||
|
||||
const parsed = body(res);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.metrics.initial_messages).toBe(5);
|
||||
expect(parsed.metrics.tokens_saved).toBeGreaterThan(0);
|
||||
|
||||
// Verify error signature retained
|
||||
const toolResult = parsed.compacted_messages[2];
|
||||
expect(toolResult.content).toContain('cannot find value `foo`');
|
||||
expect(toolResult.content).toContain('[Diagnostics retained verbatim]');
|
||||
});
|
||||
|
||||
test('handles empty message array gracefully', async () => {
|
||||
const res = await kaiCompactTool.handler({ messages: [] });
|
||||
const parsed = body(res);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.compacted_messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user