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.
Files changed (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. 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
+ }