agent-sh 0.15.0 → 0.15.2
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/dist/agent/agent-loop.js +11 -8
- package/dist/agent/events.d.ts +4 -0
- package/docs/README.md +14 -0
- package/docs/agent.md +398 -0
- package/docs/architecture.md +196 -0
- package/docs/context-management.md +200 -0
- package/docs/extensions.md +951 -0
- package/docs/library.md +84 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/tui-composition.md +294 -0
- package/docs/usage.md +306 -0
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +2 -2
- package/examples/extensions/ashi/README.md +2 -2
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
- package/examples/extensions/ashi/package.json +5 -3
- package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
- package/examples/extensions/ashi/src/cli.ts +9 -8
- package/examples/extensions/ashi/src/dialogs.ts +16 -1
- package/examples/extensions/ashi/src/events.ts +1 -0
- package/examples/extensions/ashi/src/frontend.ts +26 -6
- package/examples/extensions/ashi/src/renderer.ts +24 -4
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
- package/examples/extensions/ashi/src/ui.ts +11 -0
- package/examples/extensions/ashi-ink/package.json +2 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/package.json +3 -1
- package/src/agent/agent-loop.ts +1566 -0
- package/src/agent/entry-format.ts +19 -0
- package/src/agent/events.ts +153 -0
- package/src/agent/extensions/rolling-history/constants.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +202 -0
- package/src/agent/extensions/rolling-history/recall.ts +131 -0
- package/src/agent/extensions/rolling-history/strategy.ts +404 -0
- package/src/agent/host-types.ts +192 -0
- package/src/agent/index.ts +591 -0
- package/src/agent/live-view.ts +279 -0
- package/src/agent/llm-client.ts +111 -0
- package/src/agent/llm-facade.ts +43 -0
- package/src/agent/normalize-args.ts +61 -0
- package/src/agent/nuclear-form.ts +382 -0
- package/src/agent/providers/deepseek.ts +39 -0
- package/src/agent/providers/ollama.ts +92 -0
- package/src/agent/providers/openai-compatible.ts +36 -0
- package/src/agent/providers/openai.ts +52 -0
- package/src/agent/providers/opencode.ts +142 -0
- package/src/agent/providers/openrouter.ts +105 -0
- package/src/agent/providers/zai-coding-plan.ts +33 -0
- package/src/agent/session-store.ts +336 -0
- package/src/agent/skills.ts +228 -0
- package/src/agent/store.ts +310 -0
- package/src/agent/subagent.ts +305 -0
- package/src/agent/system-prompt.ts +151 -0
- package/src/agent/token-budget.ts +12 -0
- package/src/agent/tool-protocol.ts +722 -0
- package/src/agent/tool-registry.ts +66 -0
- package/src/agent/tools/bash.ts +95 -0
- package/src/agent/tools/edit-file.ts +154 -0
- package/src/agent/tools/expand-home.ts +7 -0
- package/src/agent/tools/glob.ts +108 -0
- package/src/agent/tools/grep.ts +228 -0
- package/src/agent/tools/list-skills.ts +37 -0
- package/src/agent/tools/ls.ts +81 -0
- package/src/agent/tools/pwsh.ts +140 -0
- package/src/agent/tools/read-file.ts +164 -0
- package/src/agent/tools/write-file.ts +72 -0
- package/src/agent/types.ts +149 -0
- package/src/cli/args.ts +91 -0
- package/src/cli/auth/cli.ts +244 -0
- package/src/cli/auth/discover.ts +52 -0
- package/src/cli/auth/keys.ts +143 -0
- package/src/cli/index.ts +295 -0
- package/src/cli/init.ts +74 -0
- package/src/cli/install.ts +439 -0
- package/src/cli/shell-env.ts +68 -0
- package/src/cli/subcommands.ts +24 -0
- package/src/core/event-bus.ts +252 -0
- package/src/core/extension-loader.ts +347 -0
- package/src/core/index.ts +152 -0
- package/src/core/settings.ts +398 -0
- package/src/core/types.ts +61 -0
- package/src/extensions/file-autocomplete.ts +71 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/slash-commands/events.ts +14 -0
- package/src/extensions/slash-commands/index.ts +269 -0
- package/src/shell/events.ts +73 -0
- package/src/shell/host-types.ts +150 -0
- package/src/shell/index.ts +159 -0
- package/src/shell/input-handler.ts +505 -0
- package/src/shell/output-parser.ts +156 -0
- package/src/shell/shell-context.ts +193 -0
- package/src/shell/shell.ts +414 -0
- package/src/shell/strategies/bash.ts +83 -0
- package/src/shell/strategies/fish.ts +77 -0
- package/src/shell/strategies/index.ts +24 -0
- package/src/shell/strategies/types.ts +64 -0
- package/src/shell/strategies/zsh.ts +92 -0
- package/src/shell/terminal.ts +124 -0
- package/src/shell/tui-input-view.ts +222 -0
- package/src/shell/tui-renderer.ts +1126 -0
- package/src/utils/ansi.ts +140 -0
- package/src/utils/box-frame.ts +138 -0
- package/src/utils/compositor.ts +157 -0
- package/src/utils/diff-renderer.ts +829 -0
- package/src/utils/diff.ts +244 -0
- package/src/utils/executor.ts +305 -0
- package/src/utils/file-watcher.ts +110 -0
- package/src/utils/floating-panel.ts +1160 -0
- package/src/utils/handler-registry.ts +110 -0
- package/src/utils/line-editor.ts +636 -0
- package/src/utils/markdown.ts +437 -0
- package/src/utils/message-utils.ts +113 -0
- package/src/utils/package-version.ts +12 -0
- package/src/utils/palette.ts +64 -0
- package/src/utils/ref-counter.ts +9 -0
- package/src/utils/ripgrep-path.ts +17 -0
- package/src/utils/shell-output-spill.ts +76 -0
- package/src/utils/stream-transform.ts +292 -0
- package/src/utils/terminal-buffer.ts +213 -0
- package/src/utils/tool-display.ts +315 -0
- package/src/utils/tool-interactive.ts +71 -0
- package/src/utils/tty.ts +14 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolProtocol — abstracts how tools are presented to the LLM and how
|
|
3
|
+
* tool calls are parsed from responses.
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* "api" — tools sent via OpenAI tools param, parsed from delta.tool_calls
|
|
7
|
+
* "inline" — tools described as text, tool calls are JSON code blocks
|
|
8
|
+
*
|
|
9
|
+
* The agent loop uses this interface uniformly so the rest of the code
|
|
10
|
+
* doesn't need to know which mode is active.
|
|
11
|
+
*/
|
|
12
|
+
import type { ChatCompletionTool } from "./llm-client.js";
|
|
13
|
+
import { contentText, type ToolDefinition, type ImageContent } from "./types.js";
|
|
14
|
+
import type { LiveView } from "./live-view.js";
|
|
15
|
+
|
|
16
|
+
export interface PendingToolCall {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
argumentsJson: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToolResult {
|
|
23
|
+
callId: string;
|
|
24
|
+
toolName: string;
|
|
25
|
+
content: string | import("./types.js").ImageContent[];
|
|
26
|
+
isError: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Streaming filter — strips tool calls from display output. */
|
|
30
|
+
export interface StreamFilter {
|
|
31
|
+
feed(chunk: string): string;
|
|
32
|
+
flush(): string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ToolProtocol {
|
|
36
|
+
readonly mode: string;
|
|
37
|
+
|
|
38
|
+
/** Tools to pass in the API request's `tools` parameter. undefined = omit. */
|
|
39
|
+
getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined;
|
|
40
|
+
|
|
41
|
+
/** Extra text for dynamic context (tool catalog for inline mode). */
|
|
42
|
+
getToolPrompt(tools: ToolDefinition[]): string;
|
|
43
|
+
|
|
44
|
+
/** Extract tool calls from a completed response. */
|
|
45
|
+
extractToolCalls(
|
|
46
|
+
responseText: string,
|
|
47
|
+
streamedCalls: PendingToolCall[],
|
|
48
|
+
): PendingToolCall[];
|
|
49
|
+
|
|
50
|
+
/** Rewrite a tool call before execution (e.g., unwrap meta-tool). */
|
|
51
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall;
|
|
52
|
+
|
|
53
|
+
/** Record the assistant turn in conversation state. */
|
|
54
|
+
recordAssistant(
|
|
55
|
+
conv: LiveView,
|
|
56
|
+
text: string,
|
|
57
|
+
toolCalls: PendingToolCall[],
|
|
58
|
+
extras?: Record<string, unknown>,
|
|
59
|
+
): void;
|
|
60
|
+
|
|
61
|
+
/** Record all tool results for a batch as conversation messages. */
|
|
62
|
+
recordResults(conv: LiveView, results: ToolResult[]): void;
|
|
63
|
+
|
|
64
|
+
/** Create a stream filter for stripping tool calls from display. null = pass-through. */
|
|
65
|
+
createStreamFilter(toolNames: string[]): StreamFilter | null;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extra tool definitions the protocol wants registered in the tool registry.
|
|
69
|
+
* Used by deferred-lookup mode to register its `load_tool` meta-tool.
|
|
70
|
+
* Default: none.
|
|
71
|
+
*/
|
|
72
|
+
getProtocolTools?(): ToolDefinition[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── API mode (current behavior) ──────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export class ApiToolProtocol implements ToolProtocol {
|
|
78
|
+
readonly mode = "api" as const;
|
|
79
|
+
|
|
80
|
+
getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined {
|
|
81
|
+
if (tools.length === 0) return undefined;
|
|
82
|
+
return tools.map((t) => ({
|
|
83
|
+
type: "function" as const,
|
|
84
|
+
function: {
|
|
85
|
+
name: t.name,
|
|
86
|
+
description: t.description,
|
|
87
|
+
parameters: t.input_schema,
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getToolPrompt(): string {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
extractToolCalls(
|
|
97
|
+
_text: string,
|
|
98
|
+
streamedCalls: PendingToolCall[],
|
|
99
|
+
): PendingToolCall[] {
|
|
100
|
+
return streamedCalls;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall {
|
|
104
|
+
return tc;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
recordAssistant(
|
|
108
|
+
conv: LiveView,
|
|
109
|
+
text: string,
|
|
110
|
+
toolCalls: PendingToolCall[],
|
|
111
|
+
extras?: Record<string, unknown>,
|
|
112
|
+
): void {
|
|
113
|
+
const calls = toolCalls.length
|
|
114
|
+
? toolCalls.map((tc) => ({
|
|
115
|
+
id: tc.id,
|
|
116
|
+
function: { name: tc.name, arguments: tc.argumentsJson },
|
|
117
|
+
}))
|
|
118
|
+
: undefined;
|
|
119
|
+
conv.addAssistantMessage(text || null, calls, extras);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
recordResults(conv: LiveView, results: ToolResult[]): void {
|
|
123
|
+
for (const r of results) {
|
|
124
|
+
const content = r.isError ? `Error: ${contentText(r.content)}` : r.content;
|
|
125
|
+
conv.addToolResult(r.callId, content, r.isError);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
createStreamFilter(): null {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Inline mode (JSON code block tool calls) ─────────────────────
|
|
135
|
+
|
|
136
|
+
export class InlineToolProtocol implements ToolProtocol {
|
|
137
|
+
readonly mode = "inline" as const;
|
|
138
|
+
private callCounter = 0;
|
|
139
|
+
|
|
140
|
+
getApiTools(): undefined {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getToolPrompt(tools: ToolDefinition[]): string {
|
|
145
|
+
if (tools.length === 0) return "";
|
|
146
|
+
|
|
147
|
+
const lines = [
|
|
148
|
+
"",
|
|
149
|
+
"# Tools",
|
|
150
|
+
"",
|
|
151
|
+
"To call a tool, write a ```tool fenced block with JSON:",
|
|
152
|
+
"",
|
|
153
|
+
"```tool",
|
|
154
|
+
'{"tool": "grep", "pattern": "TODO", "path": "src/"}',
|
|
155
|
+
"```",
|
|
156
|
+
"",
|
|
157
|
+
"The `tool` field selects which tool. All other fields are arguments.",
|
|
158
|
+
"Multiple tool blocks allowed per response.",
|
|
159
|
+
"",
|
|
160
|
+
"Available: " + tools.map((t) => `${t.name}${formatParams(t.input_schema)}`).join(", "),
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall {
|
|
167
|
+
return tc;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
extractToolCalls(
|
|
171
|
+
text: string,
|
|
172
|
+
_streamedCalls: PendingToolCall[],
|
|
173
|
+
): PendingToolCall[] {
|
|
174
|
+
const calls: PendingToolCall[] = [];
|
|
175
|
+
// Match ```tool ... ``` blocks
|
|
176
|
+
const regex = /```tool\s*\n([\s\S]*?)```/g;
|
|
177
|
+
let match;
|
|
178
|
+
while ((match = regex.exec(text)) !== null) {
|
|
179
|
+
const body = match[1]!.trim();
|
|
180
|
+
try {
|
|
181
|
+
const obj = JSON.parse(body);
|
|
182
|
+
const name = obj.tool;
|
|
183
|
+
if (typeof name !== "string") continue;
|
|
184
|
+
const { tool: _, ...args } = obj;
|
|
185
|
+
calls.push({
|
|
186
|
+
id: `inline_${++this.callCounter}`,
|
|
187
|
+
name,
|
|
188
|
+
argumentsJson: JSON.stringify(args),
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
// Not valid JSON — skip
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return calls;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
recordAssistant(
|
|
198
|
+
conv: LiveView,
|
|
199
|
+
text: string,
|
|
200
|
+
_toolCalls: PendingToolCall[],
|
|
201
|
+
extras?: Record<string, unknown>,
|
|
202
|
+
): void {
|
|
203
|
+
conv.addAssistantMessage(text || null, undefined, extras);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
recordResults(conv: LiveView, results: ToolResult[]): void {
|
|
207
|
+
if (results.length === 0) return;
|
|
208
|
+
const parts = results.map((r) => {
|
|
209
|
+
const status = r.isError ? "error" : "ok";
|
|
210
|
+
return `[${r.toolName} ${r.callId} ${status}]\n${contentText(r.content)}`;
|
|
211
|
+
});
|
|
212
|
+
conv.addToolResultInline(parts.join("\n\n"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
createStreamFilter(_toolNames: string[]): StreamFilter {
|
|
216
|
+
return new CodeBlockFilter();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Code block stream filter ────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Strips ```tool ... ``` blocks from streamed text.
|
|
224
|
+
* Simple state machine: normal → in_fence → normal.
|
|
225
|
+
*/
|
|
226
|
+
class CodeBlockFilter implements StreamFilter {
|
|
227
|
+
private buf = "";
|
|
228
|
+
private inFence = false;
|
|
229
|
+
private lastEmittedNewlines = 0; // track trailing newlines to collapse blanks
|
|
230
|
+
|
|
231
|
+
feed(chunk: string): string {
|
|
232
|
+
this.buf += chunk;
|
|
233
|
+
let raw = "";
|
|
234
|
+
|
|
235
|
+
while (this.buf.length > 0) {
|
|
236
|
+
if (this.inFence) {
|
|
237
|
+
const closeIdx = this.buf.indexOf("```");
|
|
238
|
+
if (closeIdx !== -1) {
|
|
239
|
+
// Skip past closing ``` and any trailing whitespace on that line
|
|
240
|
+
let end = closeIdx + 3;
|
|
241
|
+
while (end < this.buf.length && this.buf[end] === "\n") end++;
|
|
242
|
+
this.buf = this.buf.slice(end);
|
|
243
|
+
this.inFence = false;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// No closing yet — keep buffering
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const openIdx = this.buf.indexOf("```tool");
|
|
251
|
+
if (openIdx !== -1) {
|
|
252
|
+
// Emit everything before the fence, trimming trailing newline
|
|
253
|
+
let before = this.buf.slice(0, openIdx);
|
|
254
|
+
if (before.endsWith("\n")) before = before.slice(0, -1);
|
|
255
|
+
raw += before;
|
|
256
|
+
this.buf = this.buf.slice(openIdx + 7); // skip ```tool
|
|
257
|
+
this.inFence = true;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Stray ``` on its own line (residual closing fence)
|
|
262
|
+
const strayIdx = this.buf.indexOf("```");
|
|
263
|
+
if (strayIdx !== -1) {
|
|
264
|
+
// Check if it's just backticks on a line (possibly with whitespace)
|
|
265
|
+
const lineStart = this.buf.lastIndexOf("\n", strayIdx - 1) + 1;
|
|
266
|
+
const lineEnd = this.buf.indexOf("\n", strayIdx);
|
|
267
|
+
const line = this.buf.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
|
268
|
+
if (line === "```") {
|
|
269
|
+
raw += this.buf.slice(0, lineStart);
|
|
270
|
+
this.buf = this.buf.slice(lineEnd === -1 ? this.buf.length : lineEnd + 1);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Could be a partial match at the end
|
|
276
|
+
const marker = "```tool";
|
|
277
|
+
let partial = false;
|
|
278
|
+
for (let i = Math.min(marker.length - 1, this.buf.length); i >= 1; i--) {
|
|
279
|
+
if (this.buf.endsWith(marker.slice(0, i))) {
|
|
280
|
+
raw += this.buf.slice(0, this.buf.length - i);
|
|
281
|
+
this.buf = this.buf.slice(this.buf.length - i);
|
|
282
|
+
partial = true;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (partial) break;
|
|
287
|
+
|
|
288
|
+
// No fence anywhere — emit all
|
|
289
|
+
raw += this.buf;
|
|
290
|
+
this.buf = "";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return this.collapseNewlines(raw);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
flush(): string {
|
|
297
|
+
const out = this.collapseNewlines(this.buf);
|
|
298
|
+
this.buf = "";
|
|
299
|
+
this.inFence = false;
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private collapseNewlines(text: string): string {
|
|
304
|
+
if (!text) return text;
|
|
305
|
+
// Count leading newlines and merge with trailing from last emit
|
|
306
|
+
let i = 0;
|
|
307
|
+
while (i < text.length && text[i] === "\n") i++;
|
|
308
|
+
const leading = i;
|
|
309
|
+
const totalNewlines = this.lastEmittedNewlines + leading;
|
|
310
|
+
|
|
311
|
+
// Allow at most 2 consecutive newlines
|
|
312
|
+
let prefix = "";
|
|
313
|
+
if (leading > 0) {
|
|
314
|
+
const allowed = Math.max(0, 2 - this.lastEmittedNewlines);
|
|
315
|
+
prefix = "\n".repeat(Math.min(leading, allowed));
|
|
316
|
+
text = text.slice(leading);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
320
|
+
|
|
321
|
+
// Track trailing newlines for next call
|
|
322
|
+
let trailing = 0;
|
|
323
|
+
let j = text.length;
|
|
324
|
+
while (j > 0 && text[j - 1] === "\n") { j--; trailing++; }
|
|
325
|
+
this.lastEmittedNewlines = trailing > 0 ? trailing : (prefix ? totalNewlines - leading + prefix.length : 0);
|
|
326
|
+
|
|
327
|
+
return prefix + text;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
function formatParams(schema: Record<string, unknown>): string {
|
|
334
|
+
const props = schema.properties as Record<string, any> | undefined;
|
|
335
|
+
if (!props || Object.keys(props).length === 0) return "()";
|
|
336
|
+
|
|
337
|
+
const required = new Set((schema.required as string[]) ?? []);
|
|
338
|
+
const params = Object.entries(props).map(([name, prop]) => {
|
|
339
|
+
const opt = required.has(name) ? "" : "?";
|
|
340
|
+
const enumVals = prop.enum as string[] | undefined;
|
|
341
|
+
if (enumVals) return `${name}${opt}: ${enumVals.join("|")}`;
|
|
342
|
+
return `${name}${opt}`;
|
|
343
|
+
});
|
|
344
|
+
return `(${params.join(", ")})`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Deferred mode (core tools full schema, extensions via meta-tool) ──
|
|
348
|
+
|
|
349
|
+
const META_TOOL_NAME = "use_extension";
|
|
350
|
+
|
|
351
|
+
export class DeferredToolProtocol implements ToolProtocol {
|
|
352
|
+
readonly mode = "deferred" as const;
|
|
353
|
+
private coreNames: Set<string>;
|
|
354
|
+
/** Cached extension tool schemas for arg validation. */
|
|
355
|
+
private extSchemas = new Map<string, Record<string, unknown>>();
|
|
356
|
+
|
|
357
|
+
constructor(coreNames: string[]) {
|
|
358
|
+
this.coreNames = new Set(coreNames);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined {
|
|
362
|
+
const core = tools.filter((t) => this.coreNames.has(t.name));
|
|
363
|
+
const ext = tools.filter((t) => !this.coreNames.has(t.name));
|
|
364
|
+
|
|
365
|
+
// Cache extension schemas for validation in rewriteToolCall
|
|
366
|
+
this.extSchemas.clear();
|
|
367
|
+
for (const t of ext) {
|
|
368
|
+
this.extSchemas.set(t.name, t.input_schema);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const apiTools: ChatCompletionTool[] = core.map((t) => ({
|
|
372
|
+
type: "function" as const,
|
|
373
|
+
function: {
|
|
374
|
+
name: t.name,
|
|
375
|
+
description: t.description,
|
|
376
|
+
parameters: t.input_schema,
|
|
377
|
+
},
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
if (ext.length > 0) {
|
|
381
|
+
const catalog = ext
|
|
382
|
+
.map((t) => `${t.name}${formatParams(t.input_schema)}`)
|
|
383
|
+
.join(", ");
|
|
384
|
+
apiTools.push({
|
|
385
|
+
type: "function" as const,
|
|
386
|
+
function: {
|
|
387
|
+
name: META_TOOL_NAME,
|
|
388
|
+
description: `Call an extension tool. Available: ${catalog}`,
|
|
389
|
+
parameters: {
|
|
390
|
+
type: "object" as const,
|
|
391
|
+
properties: {
|
|
392
|
+
name: { type: "string", description: "Tool name to call" },
|
|
393
|
+
args: {
|
|
394
|
+
type: "object",
|
|
395
|
+
description: "Tool arguments",
|
|
396
|
+
properties: {},
|
|
397
|
+
additionalProperties: true,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
required: ["name"],
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return apiTools.length > 0 ? apiTools : undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
getToolPrompt(): string {
|
|
410
|
+
return "";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
extractToolCalls(
|
|
414
|
+
_text: string,
|
|
415
|
+
streamedCalls: PendingToolCall[],
|
|
416
|
+
): PendingToolCall[] {
|
|
417
|
+
return streamedCalls;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall {
|
|
421
|
+
if (tc.name !== META_TOOL_NAME) return tc;
|
|
422
|
+
// Unwrap: use_extension(name="foo", args={...}) → foo({...})
|
|
423
|
+
try {
|
|
424
|
+
const parsed = JSON.parse(tc.argumentsJson);
|
|
425
|
+
const targetName = parsed.name as string;
|
|
426
|
+
const targetArgs = (parsed.args ?? {}) as Record<string, unknown>;
|
|
427
|
+
|
|
428
|
+
// Validate: does the extension exist?
|
|
429
|
+
const schema = this.extSchemas.get(targetName);
|
|
430
|
+
if (!schema) {
|
|
431
|
+
const available = [...this.extSchemas.keys()].join(", ");
|
|
432
|
+
return {
|
|
433
|
+
id: tc.id,
|
|
434
|
+
name: META_TOOL_NAME,
|
|
435
|
+
argumentsJson: JSON.stringify({
|
|
436
|
+
_error: `Unknown extension "${targetName}". Available: ${available}`,
|
|
437
|
+
}),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Validate: check for unknown/missing params against schema
|
|
442
|
+
const schemaProps = schema.properties as Record<string, unknown> | undefined;
|
|
443
|
+
const requiredParams = new Set((schema.required as string[]) ?? []);
|
|
444
|
+
if (schemaProps) {
|
|
445
|
+
const validParams = new Set(Object.keys(schemaProps));
|
|
446
|
+
const providedParams = Object.keys(targetArgs);
|
|
447
|
+
|
|
448
|
+
const unknown = providedParams.filter((p) => !validParams.has(p));
|
|
449
|
+
const missing = [...requiredParams].filter((p) => !targetArgs[p]);
|
|
450
|
+
|
|
451
|
+
if (unknown.length > 0 || missing.length > 0) {
|
|
452
|
+
const expected = [...validParams]
|
|
453
|
+
.map((p) => `${p}${requiredParams.has(p) ? " (required)" : ""}`)
|
|
454
|
+
.join(", ");
|
|
455
|
+
let hint = `Wrong arguments for "${targetName}". Expected params: ${expected}.`;
|
|
456
|
+
if (unknown.length > 0) hint += ` Unknown: ${unknown.join(", ")}.`;
|
|
457
|
+
if (missing.length > 0) hint += ` Missing: ${missing.join(", ")}.`;
|
|
458
|
+
return {
|
|
459
|
+
id: tc.id,
|
|
460
|
+
name: META_TOOL_NAME,
|
|
461
|
+
argumentsJson: JSON.stringify({ _error: hint }),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
id: tc.id,
|
|
468
|
+
name: targetName,
|
|
469
|
+
argumentsJson: JSON.stringify(targetArgs),
|
|
470
|
+
};
|
|
471
|
+
} catch {
|
|
472
|
+
return tc; // Let it fail naturally downstream
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
recordAssistant(
|
|
477
|
+
conv: LiveView,
|
|
478
|
+
text: string,
|
|
479
|
+
toolCalls: PendingToolCall[],
|
|
480
|
+
extras?: Record<string, unknown>,
|
|
481
|
+
): void {
|
|
482
|
+
const calls = toolCalls.length
|
|
483
|
+
? toolCalls.map((tc) => ({
|
|
484
|
+
id: tc.id,
|
|
485
|
+
function: { name: tc.name, arguments: tc.argumentsJson },
|
|
486
|
+
}))
|
|
487
|
+
: undefined;
|
|
488
|
+
conv.addAssistantMessage(text || null, calls, extras);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
recordResults(conv: LiveView, results: ToolResult[]): void {
|
|
492
|
+
for (const r of results) {
|
|
493
|
+
const content = r.isError ? `Error: ${contentText(r.content)}` : r.content;
|
|
494
|
+
conv.addToolResult(r.callId, content, r.isError);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
createStreamFilter(): null {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Deferred-lookup mode (load-on-demand with full schema) ──────
|
|
504
|
+
//
|
|
505
|
+
// Like deferred, but instead of wrapping extension calls through a meta-
|
|
506
|
+
// tool dispatcher, we expose a `load_tool` meta-tool that returns the
|
|
507
|
+
// full schema as a tool result AND mutates the protocol's loaded set.
|
|
508
|
+
// Loaded tools become first-class on the NEXT LLM call — the model calls
|
|
509
|
+
// them natively with complete schema fidelity. One round-trip per group
|
|
510
|
+
// of tools loaded, not per call. Prevents the whole class of bugs where
|
|
511
|
+
// models guess arg names from a schema they can only see partially.
|
|
512
|
+
|
|
513
|
+
export class DeferredLookupProtocol implements ToolProtocol {
|
|
514
|
+
readonly mode = "deferred-lookup" as const;
|
|
515
|
+
private coreNames: Set<string>;
|
|
516
|
+
private loadedExt = new Set<string>();
|
|
517
|
+
/** Cache of the current tools list so load_tool's execute can find schemas. */
|
|
518
|
+
private toolsRef: ToolDefinition[] = [];
|
|
519
|
+
|
|
520
|
+
constructor(coreNames: string[]) {
|
|
521
|
+
this.coreNames = new Set(coreNames);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined {
|
|
525
|
+
this.toolsRef = tools;
|
|
526
|
+
|
|
527
|
+
const visible: ChatCompletionTool[] = [];
|
|
528
|
+
const unloadedExt: string[] = [];
|
|
529
|
+
|
|
530
|
+
for (const t of tools) {
|
|
531
|
+
if (t.name === "load_tool") continue; // rebuilt below with fresh catalog
|
|
532
|
+
const isCore = this.coreNames.has(t.name);
|
|
533
|
+
const isLoaded = this.loadedExt.has(t.name);
|
|
534
|
+
if (isCore || isLoaded) {
|
|
535
|
+
visible.push({
|
|
536
|
+
type: "function" as const,
|
|
537
|
+
function: {
|
|
538
|
+
name: t.name,
|
|
539
|
+
description: t.description,
|
|
540
|
+
parameters: t.input_schema,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
} else {
|
|
544
|
+
unloadedExt.push(t.name);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (unloadedExt.length > 0) {
|
|
549
|
+
visible.push({
|
|
550
|
+
type: "function" as const,
|
|
551
|
+
function: {
|
|
552
|
+
name: "load_tool",
|
|
553
|
+
description:
|
|
554
|
+
`Load extension tool schemas so you can call them on the next turn. ` +
|
|
555
|
+
`Unloaded: ${unloadedExt.join(", ")}. ` +
|
|
556
|
+
`After load_tool succeeds, call those tools directly — not through load_tool again.`,
|
|
557
|
+
parameters: {
|
|
558
|
+
type: "object" as const,
|
|
559
|
+
properties: {
|
|
560
|
+
names: {
|
|
561
|
+
type: "array",
|
|
562
|
+
items: { type: "string" },
|
|
563
|
+
description: "Names of extension tools to load.",
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
required: ["names"],
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return visible.length > 0 ? visible : undefined;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
getToolPrompt(): string {
|
|
576
|
+
return "";
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
extractToolCalls(
|
|
580
|
+
_text: string,
|
|
581
|
+
streamedCalls: PendingToolCall[],
|
|
582
|
+
): PendingToolCall[] {
|
|
583
|
+
return streamedCalls;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall {
|
|
587
|
+
return tc; // no dispatching needed — load_tool is a real registered tool
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
recordAssistant(
|
|
591
|
+
conv: LiveView,
|
|
592
|
+
text: string,
|
|
593
|
+
toolCalls: PendingToolCall[],
|
|
594
|
+
extras?: Record<string, unknown>,
|
|
595
|
+
): void {
|
|
596
|
+
const calls = toolCalls.length
|
|
597
|
+
? toolCalls.map((tc) => ({
|
|
598
|
+
id: tc.id,
|
|
599
|
+
function: { name: tc.name, arguments: tc.argumentsJson },
|
|
600
|
+
}))
|
|
601
|
+
: undefined;
|
|
602
|
+
conv.addAssistantMessage(text || null, calls, extras);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
recordResults(conv: LiveView, results: ToolResult[]): void {
|
|
606
|
+
for (const r of results) {
|
|
607
|
+
const content = r.isError ? `Error: ${contentText(r.content)}` : r.content;
|
|
608
|
+
conv.addToolResult(r.callId, content, r.isError);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
createStreamFilter(): null {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
getProtocolTools(): ToolDefinition[] {
|
|
617
|
+
// load_tool is registered as a real tool so the executor can run it
|
|
618
|
+
// through the normal dispatch path. Its execute closes over the protocol
|
|
619
|
+
// instance to mutate the loadedExt set and return schemas.
|
|
620
|
+
const self = this;
|
|
621
|
+
return [
|
|
622
|
+
{
|
|
623
|
+
name: "load_tool",
|
|
624
|
+
displayName: "Load tools",
|
|
625
|
+
description:
|
|
626
|
+
"Load extension tool schemas so you can call them natively on the next turn.",
|
|
627
|
+
input_schema: {
|
|
628
|
+
type: "object",
|
|
629
|
+
properties: {
|
|
630
|
+
names: {
|
|
631
|
+
type: "array",
|
|
632
|
+
items: { type: "string" },
|
|
633
|
+
description: "Names of extension tools to load.",
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
required: ["names"],
|
|
637
|
+
},
|
|
638
|
+
showOutput: false,
|
|
639
|
+
getDisplayInfo: () => ({ kind: "read" }),
|
|
640
|
+
formatCall: (args) => {
|
|
641
|
+
const names = Array.isArray(args.names) ? (args.names as string[]) : [];
|
|
642
|
+
return names.join(", ");
|
|
643
|
+
},
|
|
644
|
+
async execute(args) {
|
|
645
|
+
const names = Array.isArray(args.names) ? (args.names as string[]) : [];
|
|
646
|
+
if (names.length === 0) {
|
|
647
|
+
return { content: "No tool names provided. Pass { names: [...] }.", exitCode: 1, isError: true };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const loaded: string[] = [];
|
|
651
|
+
const alreadyLoaded: string[] = [];
|
|
652
|
+
const errors: string[] = [];
|
|
653
|
+
const sections: string[] = [];
|
|
654
|
+
|
|
655
|
+
for (const name of names) {
|
|
656
|
+
const tool = self.toolsRef.find((t) => t.name === name);
|
|
657
|
+
if (!tool) {
|
|
658
|
+
errors.push(`Unknown tool: ${name}`);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (self.coreNames.has(name) || name === "load_tool") {
|
|
662
|
+
errors.push(`${name} is already available — no need to load.`);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (self.loadedExt.has(name)) {
|
|
666
|
+
alreadyLoaded.push(name);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
self.loadedExt.add(name);
|
|
670
|
+
loaded.push(name);
|
|
671
|
+
sections.push(
|
|
672
|
+
`## ${name}\n${tool.description}\n\nSchema:\n\`\`\`json\n${JSON.stringify(tool.input_schema, null, 2)}\n\`\`\``,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const lines: string[] = [];
|
|
677
|
+
if (loaded.length > 0) {
|
|
678
|
+
lines.push(
|
|
679
|
+
`Loaded ${loaded.length} tool(s): ${loaded.join(", ")}. ` +
|
|
680
|
+
`They are now available as first-class tools on your next turn — call directly.`,
|
|
681
|
+
);
|
|
682
|
+
lines.push("");
|
|
683
|
+
lines.push(sections.join("\n\n"));
|
|
684
|
+
}
|
|
685
|
+
if (alreadyLoaded.length > 0) {
|
|
686
|
+
lines.push(`Already loaded: ${alreadyLoaded.join(", ")}.`);
|
|
687
|
+
}
|
|
688
|
+
if (errors.length > 0) {
|
|
689
|
+
lines.push(`Errors:\n${errors.map((e) => `- ${e}`).join("\n")}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
content: lines.join("\n") || "Nothing to do.",
|
|
694
|
+
exitCode: 0,
|
|
695
|
+
isError: loaded.length === 0 && alreadyLoaded.length === 0 && errors.length > 0,
|
|
696
|
+
};
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ── Factory ─────────────────────────────────────────────────────
|
|
704
|
+
|
|
705
|
+
/** Core tool names — always sent with full schema. */
|
|
706
|
+
const CORE_TOOLS = [
|
|
707
|
+
"bash", "read_file", "write_file", "edit_file",
|
|
708
|
+
"grep", "glob", "ls",
|
|
709
|
+
"list_skills",
|
|
710
|
+
"conversation_recall",
|
|
711
|
+
];
|
|
712
|
+
|
|
713
|
+
export function createToolProtocol(
|
|
714
|
+
mode: "api" | "inline" | "deferred" | "deferred-lookup",
|
|
715
|
+
extraCore: string[] = [],
|
|
716
|
+
): ToolProtocol {
|
|
717
|
+
const core = extraCore.length === 0 ? CORE_TOOLS : [...CORE_TOOLS, ...extraCore];
|
|
718
|
+
if (mode === "inline") return new InlineToolProtocol();
|
|
719
|
+
if (mode === "deferred") return new DeferredToolProtocol(core);
|
|
720
|
+
if (mode === "deferred-lookup") return new DeferredLookupProtocol(core);
|
|
721
|
+
return new ApiToolProtocol();
|
|
722
|
+
}
|