@denizokcu/haze 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +19 -7
- package/dist/cli/commands/chat.js +67 -13
- package/dist/cli/commands/formatters.js +28 -1
- package/dist/cli/commands/streaming.d.ts +1 -0
- package/dist/cli/commands/streaming.js +145 -34
- package/dist/core/goal/completionPolicy.d.ts +1 -0
- package/dist/core/goal/completionPolicy.js +20 -3
- package/dist/core/subagent/subagentRunner.d.ts +33 -0
- package/dist/core/subagent/subagentRunner.js +140 -0
- package/dist/llm/hazeTools.d.ts +48 -0
- package/dist/llm/hazeTools.js +119 -3
- package/dist/llm/systemPrompt.js +8 -4
- package/dist/ui/theme.d.ts +2 -0
- package/dist/ui/theme.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.0 - 2026-06-07
|
|
6
|
+
|
|
7
|
+
- Added ripgrep-backed `grep` for fast workspace search with regex, glob, context-line, case-insensitive, and result-limit options.
|
|
8
|
+
- Added focused `subagent` delegation for independent parallel tasks with fresh context, step caps, concise summaries, tool-call metadata, and parent abort propagation.
|
|
9
|
+
- Added compact inline diff display for successful `editFile` and `replaceLines` calls, including added/removed counts, colored additions/removals, one context line around small changes, and hidden summaries for large diffs.
|
|
10
|
+
- Improved agent-loop completion handling for truncated model output and long-running tool loops.
|
|
11
|
+
- Refined subagent prompting and parent transcript summaries to reduce noise and discourage single-task delegation.
|
|
12
|
+
- Updated release documentation and roadmap state for the 0.1.0 foundation release.
|
|
13
|
+
|
|
5
14
|
## 0.0.3 - 2026-06-06
|
|
6
15
|
|
|
7
16
|
- Added stable transcript rendering for long sessions, compact placeholders for large multiline pastes, and clearer goal/status display.
|
package/README.md
CHANGED
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
A minimal LLM harness for your terminal.
|
|
4
4
|
|
|
5
|
-
## What's new in 0.0
|
|
5
|
+
## What's new in 0.1.0
|
|
6
6
|
|
|
7
|
-
Haze 0.0
|
|
7
|
+
Haze 0.1.0 is the foundation release: the agent can now *find*, *delegate*, and *show its work* without turning your terminal into soup.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- `grep` gives the model fast, targeted codebase search with regex, globs, context lines, and `.gitignore` awareness — no more brute-force file spelunking.
|
|
10
|
+
- Subagents let Haze fan out independent investigations into fresh contexts, then fold the result back into the main turn as a concise summary.
|
|
11
|
+
- File edits now render compact, colorized inline diffs with one context line around the change; big diffs stay summarized so signal beats scrollback.
|
|
12
|
+
- Long-turn handling is calmer: truncated model output and tool-heavy loops recover more gracefully.
|
|
13
|
+
|
|
14
|
+
The result is a more capable agent loop while keeping the core small and inspectable. Haze gives an AI model transparent local tools — read, search, edit, write, list, and run commands — plus focused delegation when work can split safely. Tiny spell, sharper goblin.
|
|
10
15
|
|
|
11
16
|
Haze works with OpenAI-compatible providers, including OpenRouter and local endpoints. Use `/provider` to choose or add one, then `/model` to select a model.
|
|
12
17
|
|
|
@@ -47,7 +52,7 @@ On first run, create or choose a provider, then choose your first model:
|
|
|
47
52
|
`/model` selects the model Haze should use. You can also set one directly:
|
|
48
53
|
|
|
49
54
|
```txt
|
|
50
|
-
/model
|
|
55
|
+
/model anthropic/claude-sonnet-4.6
|
|
51
56
|
/model local:llama3.1
|
|
52
57
|
```
|
|
53
58
|
|
|
@@ -57,7 +62,7 @@ Or use environment variables for any OpenAI-compatible endpoint:
|
|
|
57
62
|
# e.g. OpenRouter, OpenAI, LM Studio, Ollama, or an OpenAI-compatible proxy
|
|
58
63
|
export OPENAI_API_KEY=... # provider API key, if needed; local providers may not need one
|
|
59
64
|
export OPENAI_BASE_URL=https://openrouter.ai/api/v1 # or http://localhost:1234/v1, http://localhost:11434/v1, ...
|
|
60
|
-
export HAZE_MODEL=
|
|
65
|
+
export HAZE_MODEL=anthropic/claude-sonnet-4.6 # or gpt-4.1, llama3.1, qwen2.5-coder, ...
|
|
61
66
|
```
|
|
62
67
|
|
|
63
68
|
Saved settings live in `~/.haze/settings.json`. Providers can include API keys, base URLs, and model lists; local OpenAI-compatible providers can be configured without a key.
|
|
@@ -72,7 +77,7 @@ Open a project and ask for work:
|
|
|
72
77
|
create a calculator in calc-app in ruby with add subtract multiply divide
|
|
73
78
|
```
|
|
74
79
|
|
|
75
|
-
Haze will inspect, write files, run commands, and show compact tool activity inline. Sessions are saved by default so you can resume the latest workspace conversation with `haze --continue` or `/resume`.
|
|
80
|
+
Haze will inspect, search, write files, run commands, and show compact tool activity inline. Small file edits include a colorized line diff with one context line before and after the change; large diffs stay summarized so the transcript does not become a wall of noise. Sessions are saved by default so you can resume the latest workspace conversation with `haze --continue` or `/resume`.
|
|
76
81
|
|
|
77
82
|
Use `/` to discover commands and skills. `Tab` completes the top suggestion.
|
|
78
83
|
|
|
@@ -185,13 +190,20 @@ Haze exposes a deliberately small toolset:
|
|
|
185
190
|
|
|
186
191
|
- `listFiles` — structured discovery, recursive with cursor pagination when needed.
|
|
187
192
|
- `readFile` — read UTF-8 files with optional line ranges.
|
|
193
|
+
- `grep` — ripgrep-backed regex search with path, glob, context-line, case, and result-limit controls.
|
|
188
194
|
- `editFile` — unique text replacements, with line-number-prefix tolerance for common model mistakes.
|
|
189
195
|
- `replaceLines` — line-range edits when exact replacements are awkward; slightly-too-large EOF ranges are clamped.
|
|
190
196
|
- `writeFile` — create files and parent directories.
|
|
191
197
|
- `bash` — run tests, builds, git commands, and inspections.
|
|
192
198
|
- `skill_*` — load Markdown skill instructions on demand.
|
|
193
199
|
|
|
194
|
-
Tool calls are grouped in the transcript so you can see what happened without reading a novella. File-tool failures return structured recovery hints instead of mystery stack traces.
|
|
200
|
+
Tool calls are grouped in the transcript so you can see what happened without reading a novella. Successful targeted file edits show a compact diff with colored additions/removals and one context line around the change when the diff is small; larger diffs are summarized with a pointer to `git diff`. File-tool failures return structured recovery hints instead of mystery stack traces.
|
|
201
|
+
|
|
202
|
+
## Subagents
|
|
203
|
+
|
|
204
|
+
Subagents are a delegation feature, not another file operation. When a request clearly splits into independent parallel work, Haze can spin up focused agents with fresh context, let them inspect or act with their own capped tool loop, then fold their concise summaries back into the main conversation.
|
|
205
|
+
|
|
206
|
+
Use them for parallel investigation across separate areas of a codebase. Do not use them for single sequential tasks where the main agent already has the best context.
|
|
195
207
|
|
|
196
208
|
## Context files
|
|
197
209
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { execFile as execFileCallback } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
@@ -87,6 +87,17 @@ function fullWidthBlankLine(width) {
|
|
|
87
87
|
function ToolMessageText({ text, streaming }) {
|
|
88
88
|
const lines = text.split('\n');
|
|
89
89
|
return _jsx(Box, { flexDirection: "column", children: lines.map((line, index) => {
|
|
90
|
+
const diffRow = /^(\s*\d+\s+)([+-])(.*)$/.exec(line);
|
|
91
|
+
if (diffRow) {
|
|
92
|
+
const [, prefix, marker, rest] = diffRow;
|
|
93
|
+
const isAdd = marker === '+';
|
|
94
|
+
return _jsxs(Text, { color: "white", backgroundColor: isAdd ? theme.successBg : theme.dangerBg, children: [_jsxs(Text, { color: isAdd ? theme.success : theme.danger, backgroundColor: isAdd ? theme.successBg : theme.dangerBg, children: [prefix, marker] }), rest] }, `${index}-${line}`);
|
|
95
|
+
}
|
|
96
|
+
const contextRow = /^(\s*\d+\s+)\s(.*)$/.exec(line);
|
|
97
|
+
if (contextRow) {
|
|
98
|
+
const [, prefix, rest] = contextRow;
|
|
99
|
+
return _jsxs(Text, { color: "white", children: [_jsxs(Text, { color: theme.muted, children: [prefix, " "] }), rest] }, `${index}-${line}`);
|
|
100
|
+
}
|
|
90
101
|
const row = /^(\s*)([✓✗…])\s+(\S+)(.*)$/.exec(line);
|
|
91
102
|
if (!row) {
|
|
92
103
|
return _jsxs(Text, { color: theme.muted, children: [index === 0 && streaming ? _jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, line] }, `${index}-${line}`);
|
|
@@ -133,6 +144,15 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
133
144
|
const [messages, setMessages] = useState([
|
|
134
145
|
{ role: 'system', text: 'Welcome to Haze. Use /help for commands.' }
|
|
135
146
|
]);
|
|
147
|
+
const [liveMessages, setLiveMessages] = useState([]);
|
|
148
|
+
const liveMessagesRef = useRef([]);
|
|
149
|
+
const setLiveMessagesState = (updater) => {
|
|
150
|
+
setLiveMessages(previous => {
|
|
151
|
+
const next = updater(previous);
|
|
152
|
+
liveMessagesRef.current = next;
|
|
153
|
+
return next;
|
|
154
|
+
});
|
|
155
|
+
};
|
|
136
156
|
const [settings, setSettings] = useState({});
|
|
137
157
|
const conversationRef = useRef([]);
|
|
138
158
|
const lastAssistantTextRef = useRef('');
|
|
@@ -224,6 +244,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
224
244
|
sessionRef.current = session;
|
|
225
245
|
conversationRef.current = conversation;
|
|
226
246
|
setSessionLabel(session.id);
|
|
247
|
+
setLiveMessagesState(() => []);
|
|
227
248
|
setMessages(m => [...m, { role: 'system', text: `Resumed session: ${formatSession(session)}` }, ...displayMessagesFromConversation(conversation)]);
|
|
228
249
|
return;
|
|
229
250
|
}
|
|
@@ -233,6 +254,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
233
254
|
function clearConversation() {
|
|
234
255
|
conversationRef.current = [];
|
|
235
256
|
lastAssistantTextRef.current = '';
|
|
257
|
+
setLiveMessagesState(() => []);
|
|
236
258
|
setMessages([{ role: 'system', text: 'Cleared. The void is productive.' }]);
|
|
237
259
|
const session = sessionRef.current;
|
|
238
260
|
if (session)
|
|
@@ -263,6 +285,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
263
285
|
sessionRef.current = session;
|
|
264
286
|
conversationRef.current = conversation;
|
|
265
287
|
setSessionLabel(session.id);
|
|
288
|
+
setLiveMessagesState(() => []);
|
|
266
289
|
setMessages([{ role: 'system', text: `Resumed session: ${formatSession(session)}` }, ...displayMessagesFromConversation(conversation)]);
|
|
267
290
|
}
|
|
268
291
|
function cancelThinking() {
|
|
@@ -507,6 +530,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
507
530
|
newSession: async () => {
|
|
508
531
|
conversationRef.current = [];
|
|
509
532
|
lastAssistantTextRef.current = '';
|
|
533
|
+
setLiveMessagesState(() => []);
|
|
510
534
|
setMessages([{ role: 'system', text: 'Started fresh. The fog parts.' }]);
|
|
511
535
|
await startNewSession('Started a new session.');
|
|
512
536
|
},
|
|
@@ -572,14 +596,39 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
572
596
|
}
|
|
573
597
|
}
|
|
574
598
|
async function runSingleAgentTurn(value, displayValue) {
|
|
599
|
+
const persistUiMessage = (msg) => {
|
|
600
|
+
const session = sessionRef.current;
|
|
601
|
+
if (session)
|
|
602
|
+
void appendSessionEntry(session, { type: 'ui_message', at: new Date().toISOString(), role: msg.role, text: msg.text }).catch(() => undefined);
|
|
603
|
+
};
|
|
604
|
+
const finalizeMessage = (msg) => {
|
|
605
|
+
if (msg.hidden)
|
|
606
|
+
return;
|
|
607
|
+
setMessages(m => [...m, msg]);
|
|
608
|
+
persistUiMessage(msg);
|
|
609
|
+
};
|
|
575
610
|
await runAgentTurn(value, displayValue, contextFiles, {
|
|
576
611
|
addMessage: msg => {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
612
|
+
if (msg.streaming) {
|
|
613
|
+
setLiveMessagesState(m => [...m, msg]);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
finalizeMessage(msg);
|
|
617
|
+
},
|
|
618
|
+
updateMessage: (id, update) => {
|
|
619
|
+
const liveMessage = liveMessagesRef.current.find(msg => msg.id === id);
|
|
620
|
+
if (liveMessage) {
|
|
621
|
+
const updated = { ...liveMessage, ...update };
|
|
622
|
+
if (updated.streaming === false) {
|
|
623
|
+
setLiveMessagesState(m => m.filter(msg => msg.id !== id));
|
|
624
|
+
finalizeMessage(updated);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
setLiveMessagesState(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
setMessages(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg));
|
|
581
631
|
},
|
|
582
|
-
updateMessage: (id, update) => setMessages(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg)),
|
|
583
632
|
setConversation: msgs => {
|
|
584
633
|
conversationRef.current = msgs;
|
|
585
634
|
const session = sessionRef.current;
|
|
@@ -587,6 +636,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
587
636
|
void appendSessionEntry(session, { type: 'conversation_snapshot', at: new Date().toISOString(), messages: msgs }).catch(() => undefined);
|
|
588
637
|
},
|
|
589
638
|
setBusy,
|
|
639
|
+
setBusyLabel,
|
|
590
640
|
debugLog,
|
|
591
641
|
getConversation: () => conversationRef.current,
|
|
592
642
|
getLastAssistantText: () => lastAssistantTextRef.current,
|
|
@@ -602,10 +652,8 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
602
652
|
});
|
|
603
653
|
}
|
|
604
654
|
const visible = messages.filter(message => !message.hidden);
|
|
605
|
-
const transcriptItems = visible
|
|
606
|
-
|
|
607
|
-
.filter(item => !item.message.streaming);
|
|
608
|
-
const liveMessages = visible.filter(message => message.streaming);
|
|
655
|
+
const transcriptItems = visible.map((message, index) => ({ key: messageKey(message, index), message }));
|
|
656
|
+
const activeLiveMessages = liveMessages.filter(message => !message.hidden);
|
|
609
657
|
const activeSelection = activeModel(settings);
|
|
610
658
|
const placeholder = mode === 'provider'
|
|
611
659
|
? 'Choose provider'
|
|
@@ -639,8 +687,9 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
639
687
|
].join('\n')
|
|
640
688
|
: 'First things first: run /provider to choose or add a provider, then select a model.';
|
|
641
689
|
const workspaceLabel = `${process.cwd()}${branchName ? ` (${branchName})` : ''}`;
|
|
642
|
-
const
|
|
643
|
-
const
|
|
690
|
+
const allDisplayMessages = [...messages, ...liveMessages];
|
|
691
|
+
const toolsUsed = toolCallCount(allDisplayMessages);
|
|
692
|
+
const estimatedTokens = estimateConversationTokens(allDisplayMessages);
|
|
644
693
|
const statusDetailLabel = `${conversationRef.current.length} messages / ${toolsUsed} tool call${toolsUsed === 1 ? '' : 's'} / ↑ ~${formatTokenCount(estimatedTokens.input)} ↓ ~${formatTokenCount(estimatedTokens.output)} / ${skills.length} skill${skills.length === 1 ? '' : 's'}${sessionLabel ? ` / ${sessionLabel}` : ''}`;
|
|
645
694
|
const goalText = activeGoalStatus?.replace(/^Goal:\s*/, '');
|
|
646
695
|
const [rawGoalRequest, ...goalStatusParts] = goalText?.split(' · ') ?? [];
|
|
@@ -672,7 +721,12 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
|
|
|
672
721
|
];
|
|
673
722
|
return _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: item => item.kind === 'header'
|
|
674
723
|
? _jsx(Header, { subtitle: item.subtitle, version: version }, item.key)
|
|
675
|
-
: _jsx(MessageView, { message: item.message, width: width }, item.key) }),
|
|
724
|
+
: _jsx(MessageView, { message: item.message, width: width }, item.key) }), activeLiveMessages.length > 0 && _jsx(Box, { flexDirection: "column", flexShrink: 0, children: activeLiveMessages.map((message, index) => _jsx(MessageView, { message: message, width: width }, messageKey(message, index))) }), debug && debugLogs.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, borderStyle: "round", borderColor: theme.muted, paddingX: 1, children: [_jsx(Text, { color: theme.muted, bold: true, children: "Debug" }), debugLogs.map((line, index) => _jsxs(Text, { color: theme.muted, children: ["\u2022 ", line] }, index))] }), queuedFollowUps.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, children: [_jsx(Text, { color: theme.muted, children: "Queued follow-ups:" }), queuedFollowUps.map((item, index) => _jsxs(Text, { color: theme.muted, dimColor: true, children: [" ", index + 1, ". ", item] }, `${index}-${item}`))] }), busy && _jsx(Box, { flexShrink: 0, marginBottom: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: theme.orange, bold: true, children: [_jsx(Spinner, { type: "dots" }), " ", busyLabel] }), _jsx(Text, { color: theme.muted, dimColor: true, children: " \u00B7 type to queue follow-up \u00B7 esc to interrupt" })] }) }), goalText && _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.blue, bold: true, children: "Goal:" }), _jsxs(Text, { color: "white", children: [" ", goalRequest] }), goalStatusText ? _jsxs(Text, { color: theme.orange, children: [" \u00B7 ", goalStatusText] }) : null] }) }), _jsx(Box, { borderStyle: "round", borderColor: theme.deepPurple, paddingX: 1, flexShrink: 0, children: _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(TextInput, { placeholder: placeholder, disabled: busy && mode !== 'chat', mask: mode === 'providerAddKey', historyItems: inputHistory, recordHistory: mode === 'chat', suggestions: inputSuggestions, suggestionMode: mode === 'provider' || mode === 'providerAction' || mode === 'model' ? 'always' : 'slash', submitOnEmpty: mode === 'providerAddKey', onHistoryAdd: persistInputHistory, onCancel: cancelThinking, onEscape: () => {
|
|
725
|
+
if (busy)
|
|
726
|
+
cancelThinking();
|
|
727
|
+
else
|
|
728
|
+
closeInputList();
|
|
729
|
+
}, onSubmit: submit }) }) }), _jsxs(Box, { flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", flexShrink: 1, minWidth: 0, children: [_jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: workspaceLabel }), _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: statusDetailLabel })] }), _jsx(Box, { flexShrink: 0, marginLeft: 2, children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-start", children: activeModelName }) })] })] });
|
|
676
730
|
}
|
|
677
731
|
export async function chatCommand(options = {}) {
|
|
678
732
|
if (process.stdout.isTTY) {
|
|
@@ -19,6 +19,11 @@ export function toolCallSummary(toolName, input) {
|
|
|
19
19
|
const timeout = typeof data.timeoutSeconds === 'number' ? ` (timeout ${data.timeoutSeconds}s)` : '';
|
|
20
20
|
return `bash $ ${data.command}${timeout}`;
|
|
21
21
|
}
|
|
22
|
+
if (toolName === 'grep' && typeof data?.pattern === 'string') {
|
|
23
|
+
const path = typeof data.path === 'string' && data.path !== '.' ? ` in ${data.path}` : '';
|
|
24
|
+
const glob = typeof data.glob === 'string' ? ` (${data.glob})` : '';
|
|
25
|
+
return `grep "${data.pattern}"${path}${glob}`;
|
|
26
|
+
}
|
|
22
27
|
if (toolName === 'listFiles' && typeof data?.path === 'string')
|
|
23
28
|
return `listFiles ${data.path}`;
|
|
24
29
|
if ((toolName === 'readFile' || toolName === 'writeFile') && typeof data?.path === 'string')
|
|
@@ -29,6 +34,10 @@ export function toolCallSummary(toolName, input) {
|
|
|
29
34
|
}
|
|
30
35
|
if (toolName === 'replaceLines' && typeof data?.path === 'string')
|
|
31
36
|
return `replaceLines ${data.path}:${data.startLine}-${data.endLine}`;
|
|
37
|
+
if (toolName === 'subagent' && typeof data?.task === 'string') {
|
|
38
|
+
const taskPreview = data.task.length > 60 ? `${data.task.slice(0, 60).trimEnd()}…` : data.task;
|
|
39
|
+
return `subagent "${taskPreview}"`;
|
|
40
|
+
}
|
|
32
41
|
return `${toolName} ${compact(input)}`;
|
|
33
42
|
}
|
|
34
43
|
export function toolResultSummary(event) {
|
|
@@ -37,11 +46,29 @@ export function toolResultSummary(event) {
|
|
|
37
46
|
const output = event.output;
|
|
38
47
|
if (output?.duplicateSkipped === true)
|
|
39
48
|
return 'skipped duplicate';
|
|
49
|
+
if (typeof output?.totalMatches === 'number') {
|
|
50
|
+
const count = output.totalMatches;
|
|
51
|
+
return count === 0 ? 'no matches' : `${count} match${count === 1 ? '' : 'es'}`;
|
|
52
|
+
}
|
|
40
53
|
if (typeof output?.code === 'number')
|
|
41
54
|
return `exited with code ${output.code}`;
|
|
55
|
+
if (typeof output?.status === 'string' && typeof output?.summary === 'string') {
|
|
56
|
+
const summary = output.summary.split('\n')[0] ?? '';
|
|
57
|
+
const preview = summary.length > 120 ? `${summary.slice(0, 120).trimEnd()}…` : summary;
|
|
58
|
+
const calls = typeof output.toolCallCount === 'number' ? output.toolCallCount : output.toolCalls?.length ?? 0;
|
|
59
|
+
const duration = typeof output.durationMs === 'number' ? ` in ${(output.durationMs / 1000).toFixed(1)}s` : '';
|
|
60
|
+
const meta = calls > 0 ? ` (${calls} call${calls === 1 ? '' : 's'}${duration})` : '';
|
|
61
|
+
return `${output.status}${meta}: ${preview}`;
|
|
62
|
+
}
|
|
42
63
|
if (typeof output?.ok === 'boolean') {
|
|
43
|
-
if (output.ok)
|
|
64
|
+
if (output.ok) {
|
|
65
|
+
if (typeof output.addedLines === 'number' || typeof output.removedLines === 'number') {
|
|
66
|
+
const added = typeof output.addedLines === 'number' ? output.addedLines : 0;
|
|
67
|
+
const removed = typeof output.removedLines === 'number' ? output.removedLines : 0;
|
|
68
|
+
return `Added ${added} line${added === 1 ? '' : 's'}, removed ${removed} line${removed === 1 ? '' : 's'}`;
|
|
69
|
+
}
|
|
44
70
|
return 'completed';
|
|
71
|
+
}
|
|
45
72
|
return typeof output.error === 'string' ? `failed: ${compact(output.error)}` : 'failed';
|
|
46
73
|
}
|
|
47
74
|
return 'completed';
|
|
@@ -13,6 +13,7 @@ export interface StreamCallbacks {
|
|
|
13
13
|
updateMessage: (id: string, update: Partial<Message>) => void;
|
|
14
14
|
setConversation: (messages: ModelMessage[]) => void;
|
|
15
15
|
setBusy: (busy: boolean) => void;
|
|
16
|
+
setBusyLabel?: (label: string) => void;
|
|
16
17
|
debugLog: (line: string) => void;
|
|
17
18
|
getConversation: () => ModelMessage[];
|
|
18
19
|
getLastAssistantText: () => string;
|
|
@@ -10,6 +10,7 @@ import { completionDecision, looksIncomplete, noTextAfterToolPrompt, postContinu
|
|
|
10
10
|
import { createSessionGoal, formatGoalStatus, observeGoalToolEvent } from '../../core/goal/sessionGoal.js';
|
|
11
11
|
import { agentEvent } from '../../core/agent/events.js';
|
|
12
12
|
import { isContextOverflowError, isRetryableModelError } from '../../core/agent/errors.js';
|
|
13
|
+
import { createSubagentTool } from '../../core/subagent/subagentRunner.js';
|
|
13
14
|
function stableToolKey(toolCall) {
|
|
14
15
|
return `${toolCall.toolName}:${JSON.stringify(toolCall.input)}`;
|
|
15
16
|
}
|
|
@@ -44,6 +45,19 @@ function hideSyntheticToolCallMarkup(text) {
|
|
|
44
45
|
.replace(/(^|\n)\s*(?:```(?:xml)?\s*)?(?:xml\s*)?<tool_call>[\s\S]*?<\/tool_call>\s*(?:```)?/gi, '$1')
|
|
45
46
|
.replace(/(^|\n)\s*(?:```(?:xml)?\s*)?(?:xml\s*)?<tool_call>[\s\S]*$/i, '$1');
|
|
46
47
|
}
|
|
48
|
+
function isNonSubstantiveAssistantText(text) {
|
|
49
|
+
return /^[`\s]*$/.test(text);
|
|
50
|
+
}
|
|
51
|
+
function assistantDisplayText(text) {
|
|
52
|
+
return hideSyntheticToolCallMarkup(text).trim();
|
|
53
|
+
}
|
|
54
|
+
function normalizeAssistantText(text) {
|
|
55
|
+
return assistantDisplayText(text)
|
|
56
|
+
.replace(/[`*_~#>\-–—:;,.!?()[\]{}"']/g, '')
|
|
57
|
+
.replace(/\s+/g, ' ')
|
|
58
|
+
.trim()
|
|
59
|
+
.toLowerCase();
|
|
60
|
+
}
|
|
47
61
|
function toolInputPath(input) {
|
|
48
62
|
return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
|
|
49
63
|
? input.path
|
|
@@ -66,7 +80,15 @@ async function abortableDelay(milliseconds, signal) {
|
|
|
66
80
|
}, { once: true });
|
|
67
81
|
});
|
|
68
82
|
}
|
|
69
|
-
const DEFAULT_MAX_OUTPUT_TOKENS =
|
|
83
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
|
|
84
|
+
const IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
85
|
+
const MAIN_STEP_LIMIT = 40;
|
|
86
|
+
const MAIN_TOOL_CALL_LIMIT = 40;
|
|
87
|
+
const MAIN_TOOL_ONLY_STEP_LIMIT = 12;
|
|
88
|
+
const FOLLOW_UP_STEP_LIMIT = 30;
|
|
89
|
+
const FOLLOW_UP_TOOL_CALL_LIMIT = 30;
|
|
90
|
+
const FOLLOW_UP_TOOL_ONLY_STEP_LIMIT = 10;
|
|
91
|
+
const COMPLETION_CONTINUATION_LIMIT = 30;
|
|
70
92
|
function toolOutputOk(output, success) {
|
|
71
93
|
if (!success)
|
|
72
94
|
return false;
|
|
@@ -86,7 +108,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
86
108
|
const resetIdleTimer = () => {
|
|
87
109
|
if (idleTimer)
|
|
88
110
|
clearTimeout(idleTimer);
|
|
89
|
-
idleTimer = setTimeout(() => abortController.abort('Haze turn timed out after no model/tool activity.'),
|
|
111
|
+
idleTimer = setTimeout(() => abortController.abort('Haze turn timed out after no model/tool activity.'), IDLE_TIMEOUT_MS);
|
|
90
112
|
};
|
|
91
113
|
try {
|
|
92
114
|
const m = await model();
|
|
@@ -96,7 +118,8 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
96
118
|
}
|
|
97
119
|
const activeModel = m;
|
|
98
120
|
const skillRegistry = await loadSkillRegistry();
|
|
99
|
-
const
|
|
121
|
+
const subagentTool = createSubagentTool({ model: activeModel, contextFiles });
|
|
122
|
+
const availableTools = { ...hazeTools, subagent: subagentTool, ...buildSkillTools(skillRegistry) };
|
|
100
123
|
const goal = createSessionGoal(value);
|
|
101
124
|
callbacks.setGoalStatus?.(formatGoalStatus(goal));
|
|
102
125
|
const likelyPlanOnlyRequest = isPlanOnlyRequest(value);
|
|
@@ -126,14 +149,35 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
126
149
|
let sawToolCall = false;
|
|
127
150
|
let textAfterTool = false;
|
|
128
151
|
let completionContinuationCount = 0;
|
|
129
|
-
const maxCompletionContinuations =
|
|
152
|
+
const maxCompletionContinuations = COMPLETION_CONTINUATION_LIMIT;
|
|
130
153
|
let editRecoveryPath;
|
|
131
154
|
let editRecoveryReadSatisfied = false;
|
|
132
155
|
const toolSummaries = [];
|
|
156
|
+
const visibleAssistantTexts = new Set();
|
|
157
|
+
const previousAssistantText = normalizeAssistantText(callbacks.getLastAssistantText());
|
|
158
|
+
if (previousAssistantText)
|
|
159
|
+
visibleAssistantTexts.add(previousAssistantText);
|
|
160
|
+
const rememberVisibleAssistantText = (text) => {
|
|
161
|
+
const normalized = normalizeAssistantText(text);
|
|
162
|
+
if (!normalized)
|
|
163
|
+
return;
|
|
164
|
+
visibleAssistantTexts.add(normalized);
|
|
165
|
+
callbacks.setLastAssistantText(text);
|
|
166
|
+
};
|
|
167
|
+
const isDuplicateVisibleAssistantText = (text) => {
|
|
168
|
+
const normalized = normalizeAssistantText(text);
|
|
169
|
+
return normalized.length > 0 && visibleAssistantTexts.has(normalized);
|
|
170
|
+
};
|
|
171
|
+
const isPrefixOfVisibleAssistantText = (text) => {
|
|
172
|
+
const normalized = normalizeAssistantText(text);
|
|
173
|
+
return normalized.length > 0 && [...visibleAssistantTexts].some(previous => previous.startsWith(normalized) && previous !== normalized);
|
|
174
|
+
};
|
|
133
175
|
const toolExecutionContext = { inFlightToolCalls: new Map() };
|
|
134
|
-
|
|
176
|
+
let toolGroupId = `tools-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
177
|
+
const INLINE_DIFF_LINE_LIMIT = 20;
|
|
135
178
|
const toolDisplayItems = [];
|
|
136
179
|
let toolGroupStarted = false;
|
|
180
|
+
let toolGroupFinalized = false;
|
|
137
181
|
function renderToolGroup(streaming) {
|
|
138
182
|
const visibleItems = toolDisplayItems.filter(item => !item.hidden);
|
|
139
183
|
const running = visibleItems.some(item => item.status === 'running');
|
|
@@ -156,12 +200,29 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
156
200
|
const header = running || streaming
|
|
157
201
|
? 'Running tools'
|
|
158
202
|
: `${visibleItems.length} call${visibleItems.length === 1 ? '' : 's'} · ${changes.length} change${changes.length === 1 ? '' : 's'} · ${failures.length} failed${compactSuffix}`;
|
|
159
|
-
const lines =
|
|
203
|
+
const lines = [];
|
|
204
|
+
for (const { item, count } of rows) {
|
|
160
205
|
const icon = item.status === 'running' ? '…' : item.status === 'success' ? '✓' : '✗';
|
|
161
206
|
const countText = count > 1 ? ` ×${count}` : '';
|
|
162
207
|
const result = item.status === 'running' ? '' : ` — ${item.result ?? item.status}${item.durationMs == null ? '' : ` in ${formatSeconds(item.durationMs)}`}`;
|
|
163
|
-
|
|
164
|
-
|
|
208
|
+
lines.push(` ${icon} ${item.summary}${countText}${result}`);
|
|
209
|
+
if (item.diff && item.diff.length > 0 && (item.diffLineCount ?? item.diff.length) <= INLINE_DIFF_LINE_LIMIT) {
|
|
210
|
+
for (const diffLine of item.diff) {
|
|
211
|
+
const lineNumber = diffLine.type === 'add' ? diffLine.newLine : diffLine.oldLine;
|
|
212
|
+
const marker = diffLine.type === 'add' ? '+' : diffLine.type === 'remove' ? '-' : ' ';
|
|
213
|
+
lines.push(` ${String(lineNumber ?? '').padStart(5)} ${marker} ${diffLine.text}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if ((item.diffLineCount ?? 0) > INLINE_DIFF_LINE_LIMIT) {
|
|
217
|
+
lines.push(` diff hidden (${item.diffLineCount} changed lines; run git diff to inspect)`);
|
|
218
|
+
}
|
|
219
|
+
if (item.subItems && item.subItems.length > 0) {
|
|
220
|
+
for (const sub of item.subItems) {
|
|
221
|
+
const subDuration = sub.durationMs > 1000 ? ` (${formatSeconds(sub.durationMs)})` : '';
|
|
222
|
+
lines.push(` · ${sub.name} — ${sub.summary}${subDuration}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
165
226
|
return [header, ...lines].join('\n');
|
|
166
227
|
}
|
|
167
228
|
function updateToolGroup(streaming = true) {
|
|
@@ -173,11 +234,22 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
173
234
|
else {
|
|
174
235
|
callbacks.updateMessage(toolGroupId, { text, streaming });
|
|
175
236
|
}
|
|
237
|
+
if (!streaming)
|
|
238
|
+
toolGroupFinalized = true;
|
|
176
239
|
}
|
|
177
240
|
function recordToolStart(toolCall) {
|
|
241
|
+
if (toolGroupFinalized) {
|
|
242
|
+
toolDisplayItems.length = 0;
|
|
243
|
+
toolGroupId = `tools-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
244
|
+
toolGroupFinalized = false;
|
|
245
|
+
toolGroupStarted = false;
|
|
246
|
+
}
|
|
178
247
|
callbacks.onEvent?.(agentEvent({ type: 'tool_start', id: toolCall.toolCallId, name: toolCall.toolName, input: toolCall.input }));
|
|
179
248
|
toolDisplayItems.push({ id: toolCall.toolCallId, summary: toolCallSummary(toolCall.toolName, toolCall.input), status: 'running' });
|
|
180
249
|
updateToolGroup(true);
|
|
250
|
+
const runningSubagents = toolDisplayItems.filter(item => item.status === 'running' && item.summary.startsWith('subagent')).length;
|
|
251
|
+
if (runningSubagents > 0)
|
|
252
|
+
callbacks.setBusyLabel?.(`Running ${runningSubagents} subagent${runningSubagents === 1 ? '' : 's'}`);
|
|
181
253
|
}
|
|
182
254
|
function recordToolDisplayFinish(event) {
|
|
183
255
|
callbacks.onEvent?.(agentEvent({ type: 'tool_end', id: event.toolCall.toolCallId, name: event.toolCall.toolName, success: event.success, output: event.output, error: event.error, durationMs: event.durationMs }));
|
|
@@ -188,7 +260,29 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
188
260
|
item.result = toolResultSummary(event);
|
|
189
261
|
item.durationMs = event.durationMs;
|
|
190
262
|
item.hidden = isDuplicateSkippedOutput(event.output);
|
|
263
|
+
if (typeof event.output === 'object' && event.output != null) {
|
|
264
|
+
const output = event.output;
|
|
265
|
+
if (typeof output.diffLineCount === 'number')
|
|
266
|
+
item.diffLineCount = output.diffLineCount;
|
|
267
|
+
if (Array.isArray(output.diff))
|
|
268
|
+
item.diff = output.diff;
|
|
269
|
+
}
|
|
270
|
+
if (event.toolCall.toolName === 'subagent' && typeof event.output === 'object' && event.output != null) {
|
|
271
|
+
const out = event.output;
|
|
272
|
+
if (Array.isArray(out.toolCalls)) {
|
|
273
|
+
item.subItems = out.toolCalls.map(tc => ({
|
|
274
|
+
name: tc.name,
|
|
275
|
+
summary: tc.summary,
|
|
276
|
+
durationMs: tc.durationMs,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
191
280
|
updateToolGroup(toolDisplayItems.some(candidate => candidate.status === 'running'));
|
|
281
|
+
const runningSubagents = toolDisplayItems.filter(i => i.status === 'running' && i.summary.startsWith('subagent')).length;
|
|
282
|
+
if (runningSubagents === 0)
|
|
283
|
+
callbacks.setBusyLabel?.('Haze is thinking');
|
|
284
|
+
else
|
|
285
|
+
callbacks.setBusyLabel?.(`Running ${runningSubagents} subagent${runningSubagents === 1 ? '' : 's'}`);
|
|
192
286
|
}
|
|
193
287
|
callbacks.debugLog(`request started with ${requestMessages.length} conversation messages; intent=${goal.normalizedIntent}; action=${likelyActionRequest}`);
|
|
194
288
|
function recordToolFinish(event) {
|
|
@@ -241,12 +335,12 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
241
335
|
messages: continuationMessages,
|
|
242
336
|
tools: availableTools,
|
|
243
337
|
toolChoice: allowTools ? 'auto' : 'none',
|
|
244
|
-
stopWhen: stepCountIs(
|
|
338
|
+
stopWhen: stepCountIs(FOLLOW_UP_STEP_LIMIT),
|
|
245
339
|
abortSignal: abortController.signal,
|
|
246
340
|
experimental_context: toolExecutionContext,
|
|
247
341
|
prepareStep({ steps, messages }) {
|
|
248
342
|
continuationToolCalls = steps.flatMap(step => step.toolCalls).length;
|
|
249
|
-
if (continuationToolCalls >=
|
|
343
|
+
if (continuationToolCalls >= FOLLOW_UP_TOOL_CALL_LIMIT || toolOnlyStepCount(steps) >= FOLLOW_UP_TOOL_ONLY_STEP_LIMIT) {
|
|
250
344
|
return {
|
|
251
345
|
toolChoice: 'none',
|
|
252
346
|
messages: [
|
|
@@ -308,8 +402,8 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
308
402
|
resetIdleTimer();
|
|
309
403
|
const delta = sanitizeAssistantText(rawDelta);
|
|
310
404
|
responseText += delta;
|
|
311
|
-
const displayText =
|
|
312
|
-
if (!displayText && !responseStarted)
|
|
405
|
+
const displayText = assistantDisplayText(responseText);
|
|
406
|
+
if ((!displayText || isNonSubstantiveAssistantText(displayText) || isPrefixOfVisibleAssistantText(displayText)) && !responseStarted)
|
|
313
407
|
continue;
|
|
314
408
|
if (!responseStarted) {
|
|
315
409
|
responseStarted = true;
|
|
@@ -327,15 +421,19 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
327
421
|
catch (error) {
|
|
328
422
|
throw followUpStreamError ?? error;
|
|
329
423
|
}
|
|
330
|
-
const finalText =
|
|
424
|
+
const finalText = assistantDisplayText(responseText);
|
|
425
|
+
const visibleFinalText = finalText;
|
|
426
|
+
const hidden = visibleFinalText.length === 0 || isNonSubstantiveAssistantText(visibleFinalText) || isDuplicateVisibleAssistantText(visibleFinalText);
|
|
331
427
|
if (responseStarted) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
callbacks.
|
|
428
|
+
if (!hidden)
|
|
429
|
+
rememberVisibleAssistantText(visibleFinalText);
|
|
430
|
+
callbacks.onEvent?.(agentEvent({ type: 'message_end', id: responseId, text: visibleFinalText, hidden }));
|
|
431
|
+
callbacks.updateMessage(responseId, { text: visibleFinalText, streaming: false, hidden });
|
|
335
432
|
}
|
|
336
433
|
return { text: finalText, id: responseId, started: responseStarted };
|
|
337
434
|
}
|
|
338
435
|
let streamError;
|
|
436
|
+
let lastFinishReason;
|
|
339
437
|
const result = streamText({
|
|
340
438
|
model: activeModel,
|
|
341
439
|
temperature: 0,
|
|
@@ -343,7 +441,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
343
441
|
system: buildSystemPrompt(contextFiles),
|
|
344
442
|
messages: requestMessages,
|
|
345
443
|
tools: availableTools,
|
|
346
|
-
stopWhen: stepCountIs(
|
|
444
|
+
stopWhen: stepCountIs(MAIN_STEP_LIMIT),
|
|
347
445
|
abortSignal: abortController.signal,
|
|
348
446
|
experimental_context: toolExecutionContext,
|
|
349
447
|
onError({ error }) {
|
|
@@ -384,7 +482,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
384
482
|
],
|
|
385
483
|
};
|
|
386
484
|
}
|
|
387
|
-
if (likelyActionRequest && !mutatingToolSucceeded && consecutiveToolOnlySteps >= 3 && toolCalls.length <
|
|
485
|
+
if (likelyActionRequest && !mutatingToolSucceeded && consecutiveToolOnlySteps >= 3 && toolCalls.length < MAIN_TOOL_CALL_LIMIT) {
|
|
388
486
|
callbacks.debugLog('nudging action request toward mutation after read-only steps');
|
|
389
487
|
return {
|
|
390
488
|
messages: [
|
|
@@ -393,7 +491,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
393
491
|
],
|
|
394
492
|
};
|
|
395
493
|
}
|
|
396
|
-
if (toolCalls.length >=
|
|
494
|
+
if (toolCalls.length >= MAIN_TOOL_CALL_LIMIT || consecutiveToolOnlySteps >= MAIN_TOOL_ONLY_STEP_LIMIT) {
|
|
397
495
|
callbacks.debugLog('forcing text response to avoid tool loop');
|
|
398
496
|
return {
|
|
399
497
|
toolChoice: 'none',
|
|
@@ -408,6 +506,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
408
506
|
return undefined;
|
|
409
507
|
},
|
|
410
508
|
onStepFinish({ stepNumber, text, toolCalls, toolResults, finishReason }) {
|
|
509
|
+
lastFinishReason = finishReason;
|
|
411
510
|
callbacks.debugLog(`step ${stepNumber} finished: ${finishReason}; text=${text.length}; toolCalls=${toolCalls.length}; toolResults=${toolResults.length}`);
|
|
412
511
|
},
|
|
413
512
|
onFinish(event) {
|
|
@@ -440,8 +539,12 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
440
539
|
if (sawToolCall)
|
|
441
540
|
textAfterTool = true;
|
|
442
541
|
if (currentAssistantStarted && currentAssistantText.length > 0 && toolEpoch > currentAssistantToolEpoch) {
|
|
443
|
-
|
|
444
|
-
|
|
542
|
+
const intermediateText = assistantDisplayText(currentAssistantText);
|
|
543
|
+
const hidden = intermediateText.length === 0 || isNonSubstantiveAssistantText(intermediateText) || isDuplicateVisibleAssistantText(intermediateText);
|
|
544
|
+
if (!hidden)
|
|
545
|
+
rememberVisibleAssistantText(intermediateText);
|
|
546
|
+
callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: intermediateText, hidden }));
|
|
547
|
+
callbacks.updateMessage(currentAssistantId, { text: intermediateText, streaming: false, hidden });
|
|
445
548
|
currentAssistantId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
446
549
|
currentAssistantStarted = false;
|
|
447
550
|
currentAssistantText = '';
|
|
@@ -449,8 +552,8 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
449
552
|
}
|
|
450
553
|
assistantText += delta;
|
|
451
554
|
currentAssistantText += delta;
|
|
452
|
-
const displayText =
|
|
453
|
-
if (!displayText && !currentAssistantStarted)
|
|
555
|
+
const displayText = assistantDisplayText(currentAssistantText);
|
|
556
|
+
if ((!displayText || isNonSubstantiveAssistantText(displayText) || isPrefixOfVisibleAssistantText(displayText)) && !currentAssistantStarted)
|
|
454
557
|
continue;
|
|
455
558
|
if (!currentAssistantStarted) {
|
|
456
559
|
assistantStarted = true;
|
|
@@ -473,7 +576,16 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
473
576
|
throw streamError ?? error;
|
|
474
577
|
}
|
|
475
578
|
callbacks.debugLog(`response stream finished; session has ${completedConversation.length} model messages`);
|
|
476
|
-
|
|
579
|
+
if (lastFinishReason === 'length' && !sawToolCall && completionContinuationCount < maxCompletionContinuations) {
|
|
580
|
+
completionContinuationCount += 1;
|
|
581
|
+
callbacks.debugLog('output token limit reached, auto-continuing');
|
|
582
|
+
const continuation = await streamAssistantResponse(completedConversation, 'output token limit reached', 'Your response was cut off because you hit the output token limit. Continue from where you left off — do not repeat what you already said, just pick up exactly where you stopped.', true);
|
|
583
|
+
completedConversation = callbacks.getConversation();
|
|
584
|
+
if (continuation.text) {
|
|
585
|
+
assistantText += '\n' + continuation.text;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const combinedAssistantText = assistantDisplayText(assistantText);
|
|
477
589
|
const decideCompletion = (text) => completionDecision({
|
|
478
590
|
request: value,
|
|
479
591
|
goal,
|
|
@@ -486,7 +598,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
486
598
|
editFileFailed,
|
|
487
599
|
editRecoveryPath,
|
|
488
600
|
});
|
|
489
|
-
let decision = decideCompletion(
|
|
601
|
+
let decision = decideCompletion(combinedAssistantText);
|
|
490
602
|
async function runCompletionLoop(seedConversation, seedText) {
|
|
491
603
|
let loopConversation = seedConversation;
|
|
492
604
|
let latestText = seedText;
|
|
@@ -499,12 +611,9 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
499
611
|
if (continuation.text)
|
|
500
612
|
latestText = continuation.text;
|
|
501
613
|
decision = decideCompletion(latestText);
|
|
502
|
-
if (continuation.started && (decision.needsActionContinuation || decision.needsValidationContinuation)) {
|
|
503
|
-
callbacks.updateMessage(continuation.id, { hidden: true });
|
|
504
|
-
}
|
|
505
614
|
}
|
|
506
615
|
if ((decision.needsActionContinuation || decision.needsValidationContinuation) && completionContinuationCount >= maxCompletionContinuations) {
|
|
507
|
-
callbacks.addMessage({ role: 'assistant', text: 'Stopped after the
|
|
616
|
+
callbacks.addMessage({ role: 'assistant', text: 'Stopped after the autonomous safety limit. The current goal may still need work; ask me to continue and I will resume from the latest tool results.' });
|
|
508
617
|
}
|
|
509
618
|
if (!latestText && toolSummaries.length > 0) {
|
|
510
619
|
const followUp = await streamAssistantResponse(loopConversation, 'completion loop ended without text', noTextAfterToolPrompt(false), false);
|
|
@@ -514,12 +623,14 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
|
|
|
514
623
|
}
|
|
515
624
|
if (assistantStarted) {
|
|
516
625
|
const hidePreToolFragment = sawToolCall && !textAfterTool;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
626
|
+
const visibleFinalAssistantText = assistantDisplayText(currentAssistantText);
|
|
627
|
+
const hidden = visibleFinalAssistantText.length === 0 || isNonSubstantiveAssistantText(visibleFinalAssistantText) || isDuplicateVisibleAssistantText(visibleFinalAssistantText) || hidePreToolFragment;
|
|
628
|
+
if (!hidden)
|
|
629
|
+
rememberVisibleAssistantText(visibleFinalAssistantText);
|
|
630
|
+
callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: visibleFinalAssistantText, hidden }));
|
|
631
|
+
callbacks.updateMessage(currentAssistantId, { text: visibleFinalAssistantText, streaming: false, hidden });
|
|
520
632
|
if (decision.needsActionContinuation || decision.needsValidationContinuation) {
|
|
521
|
-
|
|
522
|
-
await runCompletionLoop(completedConversation, finalAssistantText);
|
|
633
|
+
await runCompletionLoop(completedConversation, combinedAssistantText);
|
|
523
634
|
}
|
|
524
635
|
else if (sawToolCall && !textAfterTool) {
|
|
525
636
|
const followUp = await streamAssistantResponse(completedConversation, 'tool use completed without follow-up text', noTextAfterToolPrompt(false), false);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SessionGoal } from './sessionGoal.js';
|
|
2
2
|
export declare function looksIncomplete(text: string): boolean;
|
|
3
|
+
export declare function looksTruncated(text: string): boolean;
|
|
3
4
|
export declare function looksBlocked(text: string): boolean;
|
|
4
5
|
export interface CompletionPolicyInput {
|
|
5
6
|
request: string;
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { isActionRequest, isPlanOnlyRequest, isValidationRequest } from './requestClassifier.js';
|
|
2
2
|
export function looksIncomplete(text) {
|
|
3
|
-
return /\b(incomplete|what remains|remains:|remaining:|next:|not implemented|not created|no tests exist|created no docs|has not been|have not been|not yet|never executed|not executed|not run|cannot retry|cannot write|cannot validate|tool budget reached)/i.test(text);
|
|
3
|
+
return /\b(incomplete|what remains|remains:|remaining:|next:|unfinished|not implemented|not created|no tests exist|created no docs|has not been|have not been|not yet|never executed|not executed|not run|cannot retry|cannot write|cannot validate|tool budget reached|tool slice reached)/i.test(text);
|
|
4
|
+
}
|
|
5
|
+
export function looksTruncated(text) {
|
|
6
|
+
const trimmed = text.trimEnd();
|
|
7
|
+
if (!trimmed)
|
|
8
|
+
return false;
|
|
9
|
+
const lines = trimmed.split('\n');
|
|
10
|
+
const lastLine = lines[lines.length - 1].trim();
|
|
11
|
+
if (/^#{1,6}\s+\S/.test(lastLine))
|
|
12
|
+
return true;
|
|
13
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(lastLine))
|
|
14
|
+
return true;
|
|
15
|
+
if (trimmed.endsWith(':'))
|
|
16
|
+
return true;
|
|
17
|
+
const fences = (trimmed.match(/```/g) ?? []).length;
|
|
18
|
+
if (fences % 2 !== 0)
|
|
19
|
+
return true;
|
|
20
|
+
return false;
|
|
4
21
|
}
|
|
5
22
|
export function looksBlocked(text) {
|
|
6
23
|
return /\b(blocked|blocker|needs user|need user|missing permission|permission denied|missing dependency|no practical validation|unable to validate|can't validate|cannot validate)\b/i.test(text);
|
|
@@ -9,7 +26,7 @@ export function completionDecision(input) {
|
|
|
9
26
|
const likelyPlanOnlyRequest = isPlanOnlyRequest(input.request);
|
|
10
27
|
const likelyActionRequest = isActionRequest(input.request);
|
|
11
28
|
const likelyValidationRequest = isValidationRequest(input.request);
|
|
12
|
-
const assistantAdmitsIncomplete = looksIncomplete(input.assistantText);
|
|
29
|
+
const assistantAdmitsIncomplete = looksIncomplete(input.assistantText) || looksTruncated(input.assistantText);
|
|
13
30
|
const assistantReportsBlocker = looksBlocked(input.assistantText);
|
|
14
31
|
const requestCompletedByTools = input.mutatingToolSucceeded && input.validationToolSucceeded && !input.editRecoveryPath;
|
|
15
32
|
const changedActionNeedsValidation = likelyActionRequest
|
|
@@ -55,7 +72,7 @@ export function completionDecision(input) {
|
|
|
55
72
|
};
|
|
56
73
|
}
|
|
57
74
|
export function toolLoopBudgetPrompt() {
|
|
58
|
-
return 'Tool
|
|
75
|
+
return 'Tool slice reached for this model step. Do not output XML, JSON tool-call syntax, <tool_call> blocks, or function-call markup. If the current request is complete, summarize only current-turn changes and validation. If the requested change is incomplete, state the next concrete unfinished action briefly so Haze can continue autonomously in a fresh tool slice. Do not claim tools are unavailable, recap unrelated earlier tasks, or provide a generic remains list.';
|
|
59
76
|
}
|
|
60
77
|
export function postContinuationPrompt() {
|
|
61
78
|
return 'Your previous response still described unfinished work, missing validation, or a tool-budget issue. If any tools are still available, complete the remaining edit or run the final validation now. Only call something a blocker if a concrete tool failure prevents progress.';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { streamText } from 'ai';
|
|
2
|
+
import type { ContextFile } from '../../config/contextFiles.js';
|
|
3
|
+
export interface SubagentResult {
|
|
4
|
+
status: 'ok' | 'error' | 'timeout' | 'cancelled';
|
|
5
|
+
summary: string;
|
|
6
|
+
toolCalls: Array<{
|
|
7
|
+
name: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
}>;
|
|
11
|
+
toolCallCount: number;
|
|
12
|
+
tokens: {
|
|
13
|
+
in: number;
|
|
14
|
+
out: number;
|
|
15
|
+
};
|
|
16
|
+
durationMs: number;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function runSubagent(task: string, options: {
|
|
20
|
+
model: Parameters<typeof streamText>[0]['model'];
|
|
21
|
+
contextFiles: ContextFile[];
|
|
22
|
+
allowedTools?: readonly string[];
|
|
23
|
+
maxSteps?: number;
|
|
24
|
+
abortSignal?: AbortSignal;
|
|
25
|
+
}): Promise<SubagentResult>;
|
|
26
|
+
export declare function createSubagentTool(options: {
|
|
27
|
+
model: Parameters<typeof streamText>[0]['model'];
|
|
28
|
+
contextFiles: ContextFile[];
|
|
29
|
+
}): import("ai").Tool<{
|
|
30
|
+
task: string;
|
|
31
|
+
tools?: ("editFile" | "replaceLines" | "writeFile" | "listFiles" | "readFile" | "grep" | "bash")[] | undefined;
|
|
32
|
+
maxSteps?: number | undefined;
|
|
33
|
+
}, SubagentResult>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { streamText, stepCountIs, tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { buildSystemPrompt } from '../../llm/systemPrompt.js';
|
|
4
|
+
import { hazeTools } from '../../llm/hazeTools.js';
|
|
5
|
+
const SUBAGENT_SYSTEM_PROMPT = `You are a focused subagent. Complete the assigned task using all tools available to you, then return a clear summary.
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Use whatever tools you need. You have full access to file tools and bash.
|
|
9
|
+
- If the task requires creating or modifying files, do it directly — do not ask for permission.
|
|
10
|
+
- If a tool fails, read the file again and retry with the correct content.
|
|
11
|
+
- After completing the task, summarize what you did, what files you created or changed, and any important findings.
|
|
12
|
+
- If you cannot complete the task, explain exactly what blocked you and what you tried.
|
|
13
|
+
- Your summary is all the parent agent will see. Be specific: include file paths, function names, and concrete results.`;
|
|
14
|
+
const ALL_TOOLS = ['listFiles', 'readFile', 'grep', 'bash', 'editFile', 'replaceLines', 'writeFile'];
|
|
15
|
+
const STEP_LIMIT = 25;
|
|
16
|
+
const MAX_SUMMARY = 4000;
|
|
17
|
+
const TOOL_ONLY_LIMIT = 12;
|
|
18
|
+
function toolSummary(output) {
|
|
19
|
+
if (typeof output !== 'object' || output == null)
|
|
20
|
+
return 'completed';
|
|
21
|
+
const o = output;
|
|
22
|
+
if (typeof o.totalMatches === 'number')
|
|
23
|
+
return o.totalMatches === 0 ? 'no matches' : `${o.totalMatches} matches`;
|
|
24
|
+
if (typeof o.code === 'number')
|
|
25
|
+
return `exit ${o.code}`;
|
|
26
|
+
if (o.ok === true)
|
|
27
|
+
return 'completed';
|
|
28
|
+
if (o.ok === false && typeof o.error === 'string')
|
|
29
|
+
return `failed: ${o.error.slice(0, 120)}`;
|
|
30
|
+
return 'completed';
|
|
31
|
+
}
|
|
32
|
+
function toolOnlyStepCount(steps) {
|
|
33
|
+
let count = 0;
|
|
34
|
+
for (const step of [...steps].reverse()) {
|
|
35
|
+
if (step.toolCalls.length === 0 || step.text.trim().length > 0)
|
|
36
|
+
break;
|
|
37
|
+
count += 1;
|
|
38
|
+
}
|
|
39
|
+
return count;
|
|
40
|
+
}
|
|
41
|
+
export async function runSubagent(task, options) {
|
|
42
|
+
const start = performance.now();
|
|
43
|
+
const toolNames = (options.allowedTools ?? ALL_TOOLS).filter(t => ALL_TOOLS.includes(t));
|
|
44
|
+
const maxSteps = Math.min(options.maxSteps ?? STEP_LIMIT, STEP_LIMIT);
|
|
45
|
+
const scopedTools = {};
|
|
46
|
+
for (const name of toolNames) {
|
|
47
|
+
const key = name;
|
|
48
|
+
if (key in hazeTools)
|
|
49
|
+
scopedTools[name] = hazeTools[key];
|
|
50
|
+
}
|
|
51
|
+
const toolCallLog = [];
|
|
52
|
+
let tokensIn = 0;
|
|
53
|
+
let tokensOut = 0;
|
|
54
|
+
let lastStep = 0;
|
|
55
|
+
let totalToolCalls = 0;
|
|
56
|
+
try {
|
|
57
|
+
const result = streamText({
|
|
58
|
+
model: options.model,
|
|
59
|
+
temperature: 0,
|
|
60
|
+
maxOutputTokens: 4096,
|
|
61
|
+
system: `${SUBAGENT_SYSTEM_PROMPT}\n\n${buildSystemPrompt(options.contextFiles)}`,
|
|
62
|
+
messages: [{ role: 'user', content: task }],
|
|
63
|
+
tools: scopedTools,
|
|
64
|
+
stopWhen: stepCountIs(maxSteps),
|
|
65
|
+
abortSignal: options.abortSignal,
|
|
66
|
+
experimental_context: { inFlightToolCalls: new Map() },
|
|
67
|
+
prepareStep({ steps }) {
|
|
68
|
+
const calls = steps.flatMap(step => step.toolCalls);
|
|
69
|
+
const consecutiveToolOnly = toolOnlyStepCount(steps);
|
|
70
|
+
if (consecutiveToolOnly >= TOOL_ONLY_LIMIT || calls.length >= maxSteps * 2) {
|
|
71
|
+
return {
|
|
72
|
+
toolChoice: 'none',
|
|
73
|
+
messages: [
|
|
74
|
+
{ role: 'user', content: 'You have done enough tool work. Summarize what you found or did right now.' },
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
},
|
|
80
|
+
onStepFinish({ stepNumber }) {
|
|
81
|
+
lastStep = stepNumber;
|
|
82
|
+
},
|
|
83
|
+
onFinish(event) {
|
|
84
|
+
if (event.usage) {
|
|
85
|
+
tokensIn = event.usage.inputTokens ?? 0;
|
|
86
|
+
tokensOut = event.usage.outputTokens ?? 0;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
experimental_onToolCallFinish(event) {
|
|
90
|
+
if (!event.toolCall)
|
|
91
|
+
return;
|
|
92
|
+
totalToolCalls += 1;
|
|
93
|
+
toolCallLog.push({
|
|
94
|
+
name: event.toolCall.toolName,
|
|
95
|
+
summary: toolSummary(event.output),
|
|
96
|
+
durationMs: event.durationMs,
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
let text = '';
|
|
101
|
+
for await (const delta of result.textStream) {
|
|
102
|
+
text += delta;
|
|
103
|
+
}
|
|
104
|
+
await result.response;
|
|
105
|
+
const summary = (text.trim() || 'Subagent completed without text output.').slice(0, MAX_SUMMARY);
|
|
106
|
+
const durationMs = performance.now() - start;
|
|
107
|
+
const status = options.abortSignal?.aborted ? 'cancelled'
|
|
108
|
+
: lastStep >= maxSteps ? 'timeout'
|
|
109
|
+
: 'ok';
|
|
110
|
+
return { status, summary, toolCalls: toolCallLog, toolCallCount: totalToolCalls, tokens: { in: tokensIn, out: tokensOut }, durationMs };
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return {
|
|
114
|
+
status: options.abortSignal?.aborted ? 'cancelled' : 'error',
|
|
115
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
116
|
+
toolCalls: toolCallLog,
|
|
117
|
+
toolCallCount: totalToolCalls,
|
|
118
|
+
tokens: { in: tokensIn, out: tokensOut },
|
|
119
|
+
durationMs: performance.now() - start,
|
|
120
|
+
error: error instanceof Error ? error.message : String(error),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function createSubagentTool(options) {
|
|
125
|
+
return tool({
|
|
126
|
+
description: 'Spawn subagents to run independent tasks in parallel. ONLY use when a request clearly decomposes into 2+ independent subtasks that can run concurrently — spawn all of them in one step. Do NOT use for single tasks, sequential work, or anything that benefits from conversation context; do those directly instead. Subagents have no conversation history and return a summary.',
|
|
127
|
+
inputSchema: z.object({
|
|
128
|
+
task: z.string().min(1).describe('Clear, specific task for the subagent to complete.'),
|
|
129
|
+
tools: z.array(z.enum(['listFiles', 'readFile', 'grep', 'bash', 'editFile', 'replaceLines', 'writeFile'])).optional().describe('Tools the subagent can use. Defaults to all tools. Restrict to a subset only when the subagent should be read-only.'),
|
|
130
|
+
maxSteps: z.number().int().positive().max(50).optional().describe('Maximum tool-call rounds. Default 25.'),
|
|
131
|
+
}),
|
|
132
|
+
execute: async ({ task, tools, maxSteps }, context) => runSubagent(task, {
|
|
133
|
+
model: options.model,
|
|
134
|
+
contextFiles: options.contextFiles,
|
|
135
|
+
allowedTools: tools,
|
|
136
|
+
maxSteps,
|
|
137
|
+
abortSignal: context.abortSignal,
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
}
|
package/dist/llm/hazeTools.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
type ToolDiffLine = {
|
|
2
|
+
type: 'add' | 'remove' | 'context';
|
|
3
|
+
oldLine?: number;
|
|
4
|
+
newLine?: number;
|
|
5
|
+
text: string;
|
|
6
|
+
};
|
|
1
7
|
export declare const hazeTools: {
|
|
2
8
|
listFiles: import("ai").Tool<{
|
|
3
9
|
path: string;
|
|
@@ -67,6 +73,39 @@ export declare const hazeTools: {
|
|
|
67
73
|
totalLines: number;
|
|
68
74
|
lineNumberedText: string;
|
|
69
75
|
}>;
|
|
76
|
+
grep: import("ai").Tool<{
|
|
77
|
+
pattern: string;
|
|
78
|
+
path: string;
|
|
79
|
+
contextLines: number;
|
|
80
|
+
maxMatches: number;
|
|
81
|
+
caseInsensitive: boolean;
|
|
82
|
+
glob?: string | undefined;
|
|
83
|
+
}, {
|
|
84
|
+
ok: boolean;
|
|
85
|
+
toolName: string;
|
|
86
|
+
path: string | undefined;
|
|
87
|
+
error: string;
|
|
88
|
+
recoverable: boolean;
|
|
89
|
+
suggestedNextStep: string;
|
|
90
|
+
} | {
|
|
91
|
+
ok: true;
|
|
92
|
+
duplicateSkipped: true;
|
|
93
|
+
toolName: string;
|
|
94
|
+
reason: string;
|
|
95
|
+
} | {
|
|
96
|
+
pattern: string;
|
|
97
|
+
path: string;
|
|
98
|
+
glob: string | null;
|
|
99
|
+
caseInsensitive: boolean;
|
|
100
|
+
matches: {
|
|
101
|
+
file: string;
|
|
102
|
+
line: number;
|
|
103
|
+
content: string;
|
|
104
|
+
isContext: boolean;
|
|
105
|
+
}[];
|
|
106
|
+
totalMatches: number;
|
|
107
|
+
truncated: boolean;
|
|
108
|
+
}>;
|
|
70
109
|
replaceLines: import("ai").Tool<{
|
|
71
110
|
path: string;
|
|
72
111
|
startLine: number;
|
|
@@ -94,6 +133,10 @@ export declare const hazeTools: {
|
|
|
94
133
|
endLineClamped: boolean;
|
|
95
134
|
replacementLines: number;
|
|
96
135
|
appended: boolean;
|
|
136
|
+
addedLines: number;
|
|
137
|
+
removedLines: number;
|
|
138
|
+
diffLineCount: number;
|
|
139
|
+
diff: ToolDiffLine[] | undefined;
|
|
97
140
|
}>;
|
|
98
141
|
writeFile: import("ai").Tool<{
|
|
99
142
|
path: string;
|
|
@@ -142,6 +185,10 @@ export declare const hazeTools: {
|
|
|
142
185
|
path: string;
|
|
143
186
|
edits: number;
|
|
144
187
|
approximateMatches: number;
|
|
188
|
+
addedLines: number;
|
|
189
|
+
removedLines: number;
|
|
190
|
+
diffLineCount: number;
|
|
191
|
+
diff: ToolDiffLine[] | undefined;
|
|
145
192
|
}>;
|
|
146
193
|
bash: import("ai").Tool<{
|
|
147
194
|
command: string;
|
|
@@ -150,3 +197,4 @@ export declare const hazeTools: {
|
|
|
150
197
|
}, unknown>;
|
|
151
198
|
};
|
|
152
199
|
export type HazeTools = typeof hazeTools;
|
|
200
|
+
export {};
|
package/dist/llm/hazeTools.js
CHANGED
|
@@ -106,7 +106,7 @@ function isMutatingTool(toolName) {
|
|
|
106
106
|
return ['editFile', 'replaceLines', 'writeFile'].includes(toolName);
|
|
107
107
|
}
|
|
108
108
|
function isReadOnlyFileTool(toolName) {
|
|
109
|
-
return ['listFiles', 'readFile'].includes(toolName);
|
|
109
|
+
return ['listFiles', 'readFile', 'grep'].includes(toolName);
|
|
110
110
|
}
|
|
111
111
|
function inputPath(input) {
|
|
112
112
|
return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
|
|
@@ -120,6 +120,32 @@ function structuredToolFailure(toolName, error, suggestedNextStep, pathForError)
|
|
|
120
120
|
const message = error instanceof Error ? error.message : String(error);
|
|
121
121
|
return { ok: false, toolName, path: pathForError, error: message, recoverable: true, suggestedNextStep };
|
|
122
122
|
}
|
|
123
|
+
const INLINE_DIFF_LINE_LIMIT = 20;
|
|
124
|
+
function splitDiffLines(text) {
|
|
125
|
+
const lines = text.split(/\r?\n/);
|
|
126
|
+
if (text.endsWith('\n') || text.endsWith('\r\n'))
|
|
127
|
+
lines.pop();
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
function lineNumberAtOffset(text, offset) {
|
|
131
|
+
let line = 1;
|
|
132
|
+
for (let index = 0; index < offset; index++) {
|
|
133
|
+
if (text.charCodeAt(index) === 10)
|
|
134
|
+
line += 1;
|
|
135
|
+
}
|
|
136
|
+
return line;
|
|
137
|
+
}
|
|
138
|
+
function replacementDiff(oldText, newText, oldStartLine, newStartLine, context) {
|
|
139
|
+
const oldLines = splitDiffLines(oldText);
|
|
140
|
+
const newLines = splitDiffLines(newText);
|
|
141
|
+
const diff = [];
|
|
142
|
+
if (context?.before)
|
|
143
|
+
diff.push({ type: 'context', ...context.before });
|
|
144
|
+
diff.push(...oldLines.map((text, index) => ({ type: 'remove', oldLine: oldStartLine + index, text })), ...newLines.map((text, index) => ({ type: 'add', newLine: newStartLine + index, text })));
|
|
145
|
+
if (context?.after)
|
|
146
|
+
diff.push({ type: 'context', ...context.after });
|
|
147
|
+
return { diff, addedLines: newLines.length, removedLines: oldLines.length };
|
|
148
|
+
}
|
|
123
149
|
async function runDedupedTool(toolName, input, context, execute) {
|
|
124
150
|
const ctx = hazeContext(context);
|
|
125
151
|
if (!ctx)
|
|
@@ -279,6 +305,67 @@ export const hazeTools = {
|
|
|
279
305
|
}
|
|
280
306
|
}),
|
|
281
307
|
}),
|
|
308
|
+
grep: tool({
|
|
309
|
+
description: 'Search file contents with a regex pattern using ripgrep. Use this to find symbol definitions, usages, string literals, import paths, and code patterns across the workspace. Much faster and more targeted than reading files one by one with readFile. Respects .gitignore by default.',
|
|
310
|
+
inputSchema: z.object({
|
|
311
|
+
pattern: z.string().min(1).describe('Regex pattern to search for (PCRE-compatible). Examples: "function handleClick", "import.*from.*react", "class UserService", "TODO|FIXME"'),
|
|
312
|
+
path: z.string().default('.').describe('Directory or file path to search in, relative to the workspace. Narrow this to focus results.'),
|
|
313
|
+
glob: z.string().optional().describe('File glob filter. Examples: "*.ts", "*.{js,jsx}", "src/**/*.py". Narrows search to matching files.'),
|
|
314
|
+
contextLines: z.number().int().nonnegative().max(5).default(2).describe('Number of context lines before and after each match (0-5). Use 0 for compact output, 2-3 for understanding surrounding code.'),
|
|
315
|
+
maxMatches: z.number().int().positive().max(200).default(50).describe('Maximum number of matches to return. Increase for broad searches, decrease for focused lookups.'),
|
|
316
|
+
caseInsensitive: z.boolean().default(false).describe('Case-insensitive matching. Useful for symbol names that may vary in casing.'),
|
|
317
|
+
}),
|
|
318
|
+
execute: async ({ pattern, path: searchPath, glob, contextLines, maxMatches, caseInsensitive }, context) => runDedupedTool('grep', { pattern, path: searchPath, glob, contextLines, maxMatches, caseInsensitive }, context, async () => {
|
|
319
|
+
try {
|
|
320
|
+
const absolutePath = resolveWorkspacePath(searchPath);
|
|
321
|
+
const args = [
|
|
322
|
+
'--no-heading', '--line-number', '--color=never',
|
|
323
|
+
'--max-count', String(maxMatches),
|
|
324
|
+
'--context', String(contextLines),
|
|
325
|
+
];
|
|
326
|
+
if (caseInsensitive)
|
|
327
|
+
args.push('--ignore-case');
|
|
328
|
+
if (glob)
|
|
329
|
+
args.push('--glob', glob);
|
|
330
|
+
args.push('--', pattern, absolutePath);
|
|
331
|
+
let stdout = '';
|
|
332
|
+
try {
|
|
333
|
+
const result = await execFile('rg', args, { cwd: workspaceRoot(), timeout: 30_000 });
|
|
334
|
+
stdout = result.stdout;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
const code = typeof error === 'object' && error != null && 'code' in error ? error.code : undefined;
|
|
338
|
+
if (code === 1) {
|
|
339
|
+
stdout = '';
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!stdout) {
|
|
346
|
+
return { pattern, path: searchPath, glob: glob ?? null, caseInsensitive, matches: [], totalMatches: 0, truncated: false };
|
|
347
|
+
}
|
|
348
|
+
const { text: output, truncated } = truncate(stdout);
|
|
349
|
+
const lines = output.split('\n').filter(Boolean);
|
|
350
|
+
const matches = [];
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
const match = line.match(/^(\S+?):(\d+)[-:](.*)$/);
|
|
353
|
+
if (!match)
|
|
354
|
+
continue;
|
|
355
|
+
const [, file, lineStr, content] = match;
|
|
356
|
+
if (file && lineStr && content !== undefined) {
|
|
357
|
+
const isContext = line.includes('-');
|
|
358
|
+
const relativePath = path.relative(workspaceRoot(), file);
|
|
359
|
+
matches.push({ file: relativePath, line: Number(lineStr), content, isContext });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { pattern, path: searchPath, glob: glob ?? null, caseInsensitive, matches, totalMatches: matches.filter(m => !m.isContext).length, truncated };
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
return structuredToolFailure('grep', error, 'Check that the search path exists and the pattern is valid regex. Try a narrower path or simpler pattern.', searchPath);
|
|
366
|
+
}
|
|
367
|
+
}),
|
|
368
|
+
}),
|
|
282
369
|
replaceLines: tool({
|
|
283
370
|
description: 'Replace a 1-based inclusive line range in an existing UTF-8 text file. Prefer this after reading a file when exact editFile replacements are ambiguous or fail. If endLine is slightly beyond EOF, it is clamped to the current last line.',
|
|
284
371
|
inputSchema: z.object({
|
|
@@ -304,6 +391,11 @@ export const hazeTools = {
|
|
|
304
391
|
throw new Error(`startLine ${startLine} is beyond end of file (${lines.length} lines)`);
|
|
305
392
|
const effectiveEndLine = !isAppend && endLine > lines.length ? lines.length : endLine;
|
|
306
393
|
const replacementLines = content.length === 0 ? [] : content.split(/\r?\n/);
|
|
394
|
+
const removedText = isAppend ? '' : lines.slice(startLine - 1, effectiveEndLine).join('\n');
|
|
395
|
+
const beforeContext = startLine > 1 ? { oldLine: startLine - 1, newLine: startLine - 1, text: lines[startLine - 2] ?? '' } : undefined;
|
|
396
|
+
const afterContext = !isAppend && effectiveEndLine < lines.length
|
|
397
|
+
? { oldLine: effectiveEndLine + 1, newLine: startLine + replacementLines.length, text: lines[effectiveEndLine] ?? '' }
|
|
398
|
+
: undefined;
|
|
307
399
|
if (isAppend) {
|
|
308
400
|
lines.push(...replacementLines);
|
|
309
401
|
}
|
|
@@ -311,8 +403,10 @@ export const hazeTools = {
|
|
|
311
403
|
lines.splice(startLine - 1, effectiveEndLine - startLine + 1, ...replacementLines);
|
|
312
404
|
}
|
|
313
405
|
const updated = lines.join('\n') + (hasTrailingNewline ? '\n' : '');
|
|
406
|
+
const { diff, addedLines, removedLines } = replacementDiff(removedText, content, startLine, startLine, { before: beforeContext, after: afterContext });
|
|
407
|
+
const diffLineCount = diff.length;
|
|
314
408
|
await fs.writeFile(absolutePath, updated, 'utf8');
|
|
315
|
-
return { ok: true, path: filePath, startLine, endLine: effectiveEndLine, requestedEndLine: endLine, endLineClamped: effectiveEndLine !== endLine, replacementLines: replacementLines.length, appended: isAppend };
|
|
409
|
+
return { ok: true, path: filePath, startLine, endLine: effectiveEndLine, requestedEndLine: endLine, endLineClamped: effectiveEndLine !== endLine, replacementLines: replacementLines.length, appended: isAppend, addedLines, removedLines, diffLineCount, diff: diffLineCount <= INLINE_DIFF_LINE_LIMIT ? diff : undefined };
|
|
316
410
|
}
|
|
317
411
|
catch (error) {
|
|
318
412
|
return structuredToolFailure('replaceLines', error, 'Read the file again for current line numbers, then retry replaceLines with a valid range.', filePath);
|
|
@@ -383,8 +477,30 @@ export const hazeTools = {
|
|
|
383
477
|
for (const range of [...ranges].sort((a, b) => b.start - a.start)) {
|
|
384
478
|
updated = updated.slice(0, range.start) + range.edit.newText + updated.slice(range.end);
|
|
385
479
|
}
|
|
480
|
+
const originalLines = splitDiffLines(original);
|
|
481
|
+
let lineDelta = 0;
|
|
482
|
+
let addedLines = 0;
|
|
483
|
+
let removedLines = 0;
|
|
484
|
+
const diff = [];
|
|
485
|
+
for (const range of ranges) {
|
|
486
|
+
const oldStartLine = lineNumberAtOffset(original, range.start);
|
|
487
|
+
const newStartLine = oldStartLine + lineDelta;
|
|
488
|
+
const oldLineCount = splitDiffLines(range.edit.oldText).length;
|
|
489
|
+
const newLineCount = splitDiffLines(range.edit.newText).length;
|
|
490
|
+
const beforeContext = oldStartLine > 1 ? { oldLine: oldStartLine - 1, newLine: newStartLine - 1, text: originalLines[oldStartLine - 2] ?? '' } : undefined;
|
|
491
|
+
const afterOldLine = oldStartLine + oldLineCount;
|
|
492
|
+
const afterContext = afterOldLine <= originalLines.length
|
|
493
|
+
? { oldLine: afterOldLine, newLine: newStartLine + newLineCount, text: originalLines[afterOldLine - 1] ?? '' }
|
|
494
|
+
: undefined;
|
|
495
|
+
const rangeDiff = replacementDiff(range.edit.oldText, range.edit.newText, oldStartLine, newStartLine, { before: beforeContext, after: afterContext });
|
|
496
|
+
diff.push(...rangeDiff.diff);
|
|
497
|
+
addedLines += rangeDiff.addedLines;
|
|
498
|
+
removedLines += rangeDiff.removedLines;
|
|
499
|
+
lineDelta += rangeDiff.addedLines - rangeDiff.removedLines;
|
|
500
|
+
}
|
|
501
|
+
const diffLineCount = diff.length;
|
|
386
502
|
await fs.writeFile(absolutePath, updated, 'utf8');
|
|
387
|
-
return { ok: true, path: filePath, edits: edits.length, approximateMatches: ranges.filter(range => range.approximate).length };
|
|
503
|
+
return { ok: true, path: filePath, edits: edits.length, approximateMatches: ranges.filter(range => range.approximate).length, addedLines, removedLines, diffLineCount, diff: diffLineCount <= INLINE_DIFF_LINE_LIMIT ? diff : undefined };
|
|
388
504
|
}
|
|
389
505
|
catch (error) {
|
|
390
506
|
return structuredToolFailure('editFile', error, 'Read the file again, then retry with exact current text or use replaceLines with the latest line numbers.', filePath);
|
package/dist/llm/systemPrompt.js
CHANGED
|
@@ -5,21 +5,25 @@ export function buildSystemPrompt(contextFiles = []) {
|
|
|
5
5
|
return `You are Haze, an expert coding assistant operating inside a terminal-based agent CLI. You help users build apps by understanding the current conversation, inspecting projects, running commands, and editing files.
|
|
6
6
|
|
|
7
7
|
Available tools:
|
|
8
|
-
-
|
|
9
|
-
-
|
|
8
|
+
- grep: Fast regex search across the workspace using ripgrep. Use to find symbol definitions, usages, string literals, import paths, and code patterns. Prefer grep over readFile when you need to locate something in the codebase -- grep searches all files at once and returns matching lines with file paths and line numbers. Use glob to narrow to specific file types and path to narrow to specific directories.
|
|
9
|
+
- listFiles: List files and directories in the current workspace. Supports recursive listings and cursor pagination. Use for project structure discovery, not for finding specific code.
|
|
10
|
+
- readFile: Read a specific file when you already know which file to look at. Returns numbered lines for precise edits. Use after grep to read the full context around a match, not to search for code across files.
|
|
10
11
|
- editFile: Edit files with unique text replacements. Use only for small, unambiguous replacements. Put multiple edits to the same file in one editFile call; do not issue parallel separate edits for the same file.
|
|
11
12
|
- replaceLines: Replace a 1-based inclusive line range. Use when editFile is ambiguous or has failed once. To append at EOF, use startLine=totalLines+1 and endLine=totalLines from the latest readFile result. Slightly-too-large endLine values are clamped to EOF.
|
|
12
13
|
- writeFile: Create files, or overwrite existing files only when overwriteExisting=true is intentionally set for a complete rewrite. Prefer editFile/replaceLines for existing files.
|
|
13
14
|
- bash: Run shell commands for tests, builds, scripts, and inspection that cannot be done with file tools. Do not use bash to mutate files unless explicitly requested or file tools cannot do the job.
|
|
15
|
+
- subagent: Spawn focused subagents to run independent tasks in parallel. Each subagent gets a fresh context and full tool access. ONLY use subagents when a request clearly decomposes into 2+ independent subtasks that can run concurrently. Do NOT use subagents for single tasks, sequential work, or tasks that benefit from your full conversation context — do those yourself. Subagents have no access to the conversation history, so the main agent should always handle complex, context-dependent work directly.
|
|
14
16
|
- skill_*: Markdown skills installed in ~/.haze/skills. Use a skill tool when its description matches the user's request; it returns workflow instructions and explicitly referenced files.
|
|
15
17
|
|
|
16
18
|
Guidelines:
|
|
17
19
|
- Be concise, technical, and practical.
|
|
20
|
+
- Only spawn subagents when a request clearly splits into 2+ independent parallel tasks (e.g. "check auth, payments, and users" -> 3 subagents). For everything else, do the work yourself — you have the most context. Never spawn a single subagent for a task you could do directly.
|
|
18
21
|
- You have access to the tools listed above. Never claim that you cannot inspect files, run shell commands, or make file changes when an available tool can do it.
|
|
19
22
|
- Skills are optional instruction bundles. Call a skill tool only when relevant, then follow the returned SKILL.md instructions and references.
|
|
20
23
|
- If answering requires current workspace information, inspect it with tools instead of guessing or saying you cannot access it.
|
|
21
24
|
- When the user asks you to run a command, inspect command output, or reason about local project state, use bash or file tools rather than only explaining what the user could run.
|
|
22
25
|
- Preserve user-provided content exactly. When the user asks to add, modify, or use "this", "that", "it", or previous content, refer to the current conversation and do not substitute different text.
|
|
26
|
+
- Use grep to find code across the workspace. Do not read multiple files one by one to locate a symbol, import, or string -- use grep with a targeted pattern and glob filter instead. Only use readFile after grep has identified the relevant file and line range, or when the user names a specific file.
|
|
23
27
|
- Use listFiles for project discovery instead of bash ls/find. Start non-recursive, use recursive for focused directories, and follow nextCursor only when more listing is genuinely needed.
|
|
24
28
|
- Do not list or read the same path repeatedly unless the file changed or the previous result was insufficient.
|
|
25
29
|
- Read only directly relevant files, usually once. Do not read README/package files unless needed for the task.
|
|
@@ -29,7 +33,7 @@ Guidelines:
|
|
|
29
33
|
- Use writeFile for new files. For existing files, prefer editFile or replaceLines; only set writeFile overwriteExisting=true when a complete rewrite is intentional and safer than targeted edits.
|
|
30
34
|
- Use bash mainly for tests, builds, package scripts, and commands that are not covered by file tools. Do not combine validation with file mutation in one shell command; use file tools for edits and bash only for validation/inspection.
|
|
31
35
|
- After making changes, validate with the project's relevant test/typecheck/build command when practical. After editing source or test files in languages with syntax checkers, run the syntax check before the full test command when practical. Once a requested change is edited and validation passes, summarize; do not continue inspecting files.
|
|
32
|
-
- For action requests such as "add", "create", "write", "implement", "update", "fix", "test", or "document",
|
|
36
|
+
- For action requests such as "add", "create", "write", "implement", "update", "fix", "test", or "document", work autonomously until the requested goal is complete, validation has run when practical, a concrete blocker prevents progress, or a user decision is required. Do not stop after only inspecting files.
|
|
33
37
|
- Requests like "create a plan", "make a plan", or "outline a plan" are planning requests, not implementation requests. If you create a plan document, summarize it; do not start implementing or validating unless asked.
|
|
34
38
|
- If editFile or replaceLines fails, read the affected file again with readFile before another edit attempt, then make one smaller targeted change; do not batch speculative replacements. Bash/cat does not satisfy this recovery step.
|
|
35
39
|
- For plan-only requests, stop after creating/updating the plan artifact and summarize it; do not edit source files or run validation in the same turn.
|
|
@@ -37,7 +41,7 @@ Guidelines:
|
|
|
37
41
|
- After tool use, always respond with a concise summary of what changed or what failed for the current user request only. Do not recap unrelated earlier tasks unless directly relevant.
|
|
38
42
|
- Do not call ordinary unfinished work or unresolved optional scope a blocker. A blocker is a concrete tool failure, missing/ambiguous requirement, permission problem, or unavailable dependency.
|
|
39
43
|
- For Ruby ad-hoc checks, prefer adding/running Minitest tests. If a one-liner is truly useful, use ruby -I. -e with require "file" rather than require_relative from -e.
|
|
40
|
-
- Do not say tools are unavailable just because a tool
|
|
44
|
+
- Do not say tools are unavailable just because a tool slice or loop guard was mentioned; if you can still call tools in the current turn, continue the requested work. If a local tool slice ends and work remains, state the next concrete unfinished action rather than asking the user to type continue.
|
|
41
45
|
- Do not claim tests passed or commands succeeded unless you actually ran them in the current turn and saw success.
|
|
42
46
|
- Ask before destructive actions.
|
|
43
47
|
- Show file paths clearly when working with files.${projectContext}
|
package/dist/ui/theme.d.ts
CHANGED
package/dist/ui/theme.js
CHANGED