Skip to content

Commit 44664a4

Browse files
feat(ai): smarter context, auto device detection, batch commands, retry, conversation compression
1. Structured Terminal Context: - Parse raw terminal buffer into hostname, prompt, and last 5 command/output pairs - Send structured context to AI instead of raw text dump - Hostname extracted from Cisco/Linux/Junos/MikroTik/FortiGate prompt patterns 2. Auto Device Detection: - When deviceType='auto', detect from terminal output before sending to AI - Uses existing deviceDetector rules against live terminal buffer - Falls back to 'generic' if detection fails 3. Multi-command batch tool (run_commands): - New tool that executes 2-5 commands sequentially, returns all results at once - Saves round-trips vs individual run_command calls - AI instructed to prefer batch when commands are independent 4. Retry with better context: - When a read-only command returns empty/minimal output, append a system note - Hints AI about possible causes (wrong syntax for device, pager, etc.) - AI can then retry with vendor-appropriate command variant 5. Conversation compression: - Replace naive trim (drop middle messages) with smart compression - Summarizes middle messages into a compact block - Always preserves first message (intent) and last 30 messages (recent context)
1 parent 20444a0 commit 44664a4

4 files changed

Lines changed: 274 additions & 35 deletions

File tree

src/main/ai.ts

Lines changed: 140 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,11 @@ const CREATE_PLAN_TOOL = {
7474
const RUN_COMMAND_TOOL = {
7575
name: 'run_command',
7676
description:
77-
'Execute a command on a connected network device or server. ' +
78-
'In troubleshoot mode: ONLY use read-only/display commands (show, display, ping, traceroute, ls, ps, df, cat, ip, ss, netstat, journalctl, hostname, uname, ifconfig, arp). ' +
79-
'In full-access mode: any command is allowed including configuration changes. ' +
80-
'When multiple sessions are open, use target_session to specify which device to run the command on.',
77+
'Execute a single command on a connected network device or server. ' +
78+
'In troubleshoot mode: ONLY use read-only/display commands. ' +
79+
'In full-access mode: any command is allowed. ' +
80+
'When multiple sessions are open, use target_session to specify which device. ' +
81+
'For running 2-5 independent commands efficiently, prefer run_commands (batch) instead.',
8182
input_schema: {
8283
type: 'object' as const,
8384
properties: {
@@ -89,6 +90,36 @@ const RUN_COMMAND_TOOL = {
8990
},
9091
}
9192

93+
const RUN_COMMANDS_TOOL = {
94+
name: 'run_commands',
95+
description:
96+
'Execute 2-5 commands sequentially on a device and return ALL results at once. ' +
97+
'Use this instead of multiple run_command calls when commands are independent (e.g. "show ip bgp summary" + "show ip route summary" + "show interfaces brief"). ' +
98+
'Each command runs after the previous finishes. All outputs are collected and returned together. ' +
99+
'This saves round-trips and is faster than calling run_command multiple times.',
100+
input_schema: {
101+
type: 'object' as const,
102+
properties: {
103+
commands: {
104+
type: 'array',
105+
items: {
106+
type: 'object',
107+
properties: {
108+
command: { type: 'string', description: 'Command to execute' },
109+
reason: { type: 'string', description: 'Why this command is needed' },
110+
},
111+
required: ['command', 'reason'],
112+
},
113+
minItems: 2,
114+
maxItems: 5,
115+
description: 'Array of commands to execute in order',
116+
},
117+
target_session: { type: 'string', description: 'Session ID. Omit for active session.' },
118+
},
119+
required: ['commands'],
120+
},
121+
}
122+
92123
// ── System prompt builder ────────────────────────────────────────────────────
93124

94125
function buildSystemPrompt(payload: ChatPayload): string {
@@ -505,12 +536,47 @@ ${payload.terminalContext || '(empty — session just started)'}
505536
`
506537
}
507538

508-
// ── Simple context trim (no summarization needed — backend handles limits) ────
539+
// ── Smart conversation management ─────────────────────────────────────────────
509540

510-
function trimMessages(messages: AnthropicMessage[], maxMessages = 40): AnthropicMessage[] {
541+
/**
542+
* Instead of blindly trimming messages, compress the conversation:
543+
* 1. Always keep the first user message (original intent)
544+
* 2. Always keep the last N messages (recent context)
545+
* 3. Summarize the middle section into a compact "conversation so far" block
546+
*/
547+
function compressConversation(messages: AnthropicMessage[], maxMessages = 40): AnthropicMessage[] {
511548
if (messages.length <= maxMessages) return messages
512-
// Always keep the first message (user intent) and the last N-1
513-
return [messages[0], ...messages.slice(-(maxMessages - 1))]
549+
550+
const keepRecent = Math.min(30, maxMessages - 2)
551+
const first = messages[0]
552+
const recent = messages.slice(-keepRecent)
553+
const middle = messages.slice(1, messages.length - keepRecent)
554+
555+
// Build a summary of the middle section
556+
const summaryParts: string[] = []
557+
for (const msg of middle) {
558+
if (typeof msg.content === 'string' && msg.content.trim()) {
559+
const role = msg.role === 'user' ? 'Engineer' : 'ARIA'
560+
// Truncate long messages in the summary
561+
const content = msg.content.length > 200
562+
? msg.content.slice(0, 200) + '...'
563+
: msg.content
564+
summaryParts.push(`[${role}]: ${content}`)
565+
} else if (Array.isArray(msg.content)) {
566+
// Tool use/result blocks — just note them
567+
const hasToolUse = (msg.content as Array<{type?: string}>).some(b => b.type === 'tool_use')
568+
const hasToolResult = (msg.content as Array<{type?: string}>).some(b => b.type === 'tool_result')
569+
if (hasToolUse) summaryParts.push('[ARIA ran commands]')
570+
if (hasToolResult) summaryParts.push('[Command results received]')
571+
}
572+
}
573+
574+
const summaryMessage: AnthropicMessage = {
575+
role: 'user',
576+
content: `[CONVERSATION SUMMARY — ${middle.length} earlier messages compressed]\n${summaryParts.join('\n')}`,
577+
}
578+
579+
return [first, summaryMessage, ...recent]
514580
}
515581

516582
// ── SSE stream parser ─────────────────────────────────────────────────────────
@@ -571,7 +637,7 @@ async function callBackendTurn(
571637
deviceId: getDeviceId(),
572638
system: systemPrompt,
573639
messages,
574-
tools: [CREATE_PLAN_TOOL, RUN_COMMAND_TOOL],
640+
tools: [CREATE_PLAN_TOOL, RUN_COMMAND_TOOL, RUN_COMMANDS_TOOL],
575641
max_tokens: 8096,
576642
}
577643

@@ -652,6 +718,21 @@ async function callBackendTurn(
652718
return { toolCalls, assistantContent: contentBlocks, inputTokens, outputTokens, stopReason, textCollected }
653719
}
654720

721+
// ── Wait for tool result from renderer ────────────────────────────────────────
722+
723+
function waitForToolResult(timeoutMs = 300_000): Promise<string> {
724+
return new Promise<string>((resolve) => {
725+
const timer = setTimeout(() => {
726+
_pendingToolResolve = null
727+
resolve('(no response — command was not approved or timed out)')
728+
}, timeoutMs)
729+
_pendingToolResolve = (out: string) => {
730+
clearTimeout(timer)
731+
resolve(out)
732+
}
733+
})
734+
}
735+
655736
// ── Core agentic loop ────────────────────────────────────────────────────────
656737

657738
async function runAiLoop(
@@ -660,7 +741,7 @@ async function runAiLoop(
660741
getWindow: () => BrowserWindow | null,
661742
): Promise<void> {
662743
const systemPrompt = buildSystemPrompt(payload)
663-
let messages = trimMessages([...payload.messages] as AnthropicMessage[])
744+
let messages = compressConversation([...payload.messages] as AnthropicMessage[])
664745

665746
_abortController = new AbortController()
666747

@@ -720,6 +801,37 @@ async function runAiLoop(
720801
continue
721802
}
722803

804+
// run_commands (batch): execute multiple commands sequentially, return all at once
805+
if (toolBlock.name === 'run_commands') {
806+
const batchInput = toolBlock.input as {
807+
commands: Array<{ command: string; reason: string }>
808+
target_session?: string
809+
}
810+
811+
const allOutputs: string[] = []
812+
for (const cmd of batchInput.commands) {
813+
if (_abortController.signal.aborted) break
814+
815+
getWindow()?.webContents.send('ai:tool-call', {
816+
id: `${toolBlock.id}_${allOutputs.length}`,
817+
command: cmd.command,
818+
reason: cmd.reason,
819+
targetSession: batchInput.target_session,
820+
})
821+
822+
const output = await waitForToolResult()
823+
allOutputs.push(`> ${cmd.command}\n${output}`)
824+
}
825+
826+
const combined = allOutputs.join('\n\n')
827+
toolResults.push({
828+
type: 'tool_result',
829+
tool_use_id: toolBlock.id,
830+
content: combined,
831+
})
832+
continue
833+
}
834+
723835
// run_command: send to renderer, wait for execution result
724836
const input = toolBlock.input as { command: string; reason: string; target_session?: string }
725837

@@ -730,23 +842,25 @@ async function runAiLoop(
730842
targetSession: input.target_session,
731843
})
732844

733-
// Wait for tool result from renderer (up to 300s)
734-
const output = await new Promise<string>((resolve) => {
735-
const timer = setTimeout(() => {
736-
_pendingToolResolve = null
737-
resolve('(no response — command was not approved or timed out)')
738-
}, 300_000)
739-
_pendingToolResolve = (out: string) => {
740-
clearTimeout(timer)
741-
resolve(out)
742-
}
743-
})
845+
const output = await waitForToolResult()
744846

745-
toolResults.push({
746-
type: 'tool_result',
747-
tool_use_id: toolBlock.id,
748-
content: output,
749-
})
847+
// Retry logic: if output looks empty/failed and command is read-only, add a hint
848+
const looksEmpty = !output || output === '(no output)' || output.trim().length < 5
849+
const retriable = /^(show|display|get|ping|traceroute|ls|ps|df|ip\s|ss\s|netstat)/i.test(input.command.trim())
850+
851+
if (looksEmpty && retriable) {
852+
toolResults.push({
853+
type: 'tool_result',
854+
tool_use_id: toolBlock.id,
855+
content: `${output}\n\n[SYSTEM NOTE: Command returned minimal/no output. Possible causes: (1) command syntax wrong for this device type, (2) device is still processing, (3) pager ate the output. Consider retrying with a vendor-appropriate variant of the command, or add a pipe filter.]`,
856+
})
857+
} else {
858+
toolResults.push({
859+
type: 'tool_result',
860+
tool_use_id: toolBlock.id,
861+
content: output,
862+
})
863+
}
750864
}
751865

752866
if (_abortController.signal.aborted) break

src/renderer/src/components/ai/AiPanel.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { cn } from '../../lib/utils'
77
import { AiMessage } from './AiMessage'
88
import { Session } from '../../types'
99
import { terminalRegistry } from '../../lib/terminalRegistry'
10+
import { detectDeviceType } from '../../lib/deviceDetector'
1011
import { DeviceType } from '../../types'
1112

1213
// ── Quick Commands per device type ────────────────────────────────────────────
@@ -332,6 +333,14 @@ export function AiPanel({ activeSession, splitSession, allSessions, getTerminalC
332333
const ctx = proactiveContext ?? getTerminalContext()
333334
const conn = activeSession?.connection
334335

336+
// Auto-detect device type from terminal context when set to 'auto'
337+
let resolvedDeviceType = conn?.deviceType ?? 'generic'
338+
if (resolvedDeviceType === 'auto') {
339+
const rawCtx = terminalRegistry.get(activeSession?.id ?? '')?.getContext(200) ?? ''
340+
const detected = detectDeviceType(rawCtx)
341+
resolvedDeviceType = detected ?? 'generic'
342+
}
343+
335344
// Build history AFTER adding the user message (reads fresh state via getState())
336345
const history = buildMessages()
337346

@@ -348,7 +357,7 @@ export function AiPanel({ activeSession, splitSession, allSessions, getTerminalC
348357
await window.api.ai.chat({
349358
messages,
350359
terminalContext: ctx,
351-
deviceType: conn?.deviceType ?? 'generic',
360+
deviceType: resolvedDeviceType,
352361
host: conn?.host ?? 'unknown',
353362
protocol: conn?.protocol ?? 'ssh',
354363
permission: sessionPermission,

src/renderer/src/components/terminal/TerminalArea.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TabBar } from './TabBar'
44
import { TerminalTab } from './TerminalTab'
55
import { AiPanel } from '../ai/AiPanel'
66
import { HomeScreen } from '../home/HomeScreen'
7-
import { terminalRegistry } from '../../lib/terminalRegistry'
7+
import { terminalRegistry, buildStructuredContext, formatStructuredContext } from '../../lib/terminalRegistry'
88

99
const AI_PANEL_DEFAULT_WIDTH = 340
1010
const AI_PANEL_MIN_WIDTH = 260
@@ -39,14 +39,21 @@ export function TerminalArea(): JSX.Element {
3939
}, [aiWidth])
4040

4141
// Callbacks for AiPanel — includes split session context when active
42-
const getTerminalContext = useCallback((lines = 120) => {
42+
const getTerminalContext = useCallback((lines = 150) => {
4343
if (!activeSessionId) return ''
44-
const primary = terminalRegistry.get(activeSessionId)?.getContext(lines) ?? ''
45-
if (!isSplit || !splitSessionId) return primary
46-
const splitSession = sessions.find(s => s.id === splitSessionId)
47-
const secondary = terminalRegistry.get(splitSessionId)?.getContext(lines) ?? ''
48-
if (!secondary) return primary
49-
return `=== ${activeSession?.connection.name ?? 'Primary'} ===\n${primary}\n\n=== ${splitSession?.connection.name ?? 'Secondary'} ===\n${secondary}`
44+
const primaryRaw = terminalRegistry.get(activeSessionId)?.getContext(lines) ?? ''
45+
const primaryCtx = buildStructuredContext(primaryRaw)
46+
const primaryFormatted = formatStructuredContext(primaryCtx, activeSession?.connection.name)
47+
48+
if (!isSplit || !splitSessionId) return primaryFormatted
49+
50+
const splitSess = sessions.find(s => s.id === splitSessionId)
51+
const secondaryRaw = terminalRegistry.get(splitSessionId)?.getContext(lines) ?? ''
52+
if (!secondaryRaw) return primaryFormatted
53+
54+
const secondaryCtx = buildStructuredContext(secondaryRaw)
55+
const secondaryFormatted = formatStructuredContext(secondaryCtx, splitSess?.connection.name)
56+
return `=== PRIMARY SESSION ===\n${primaryFormatted}\n\n=== SPLIT SESSION ===\n${secondaryFormatted}`
5057
}, [activeSessionId, splitSessionId, isSplit, sessions, activeSession])
5158

5259
const sendToTerminal = useCallback((data: string) => {

0 commit comments

Comments
 (0)