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,279 @@
1
+ import type { ChatCompletionMessageParam, AgentShMessage } from "./llm-client.js";
2
+ import type { HandlerFunctions } from "../utils/handler-registry.js";
3
+ import type { ImageContent } from "./types.js";
4
+
5
+ export interface CompactResult {
6
+ before: number;
7
+ after: number;
8
+ evictedCount: number;
9
+ [extra: string]: unknown;
10
+ }
11
+
12
+ export class LiveView {
13
+ private messages: ChatCompletionMessageParam[] = [];
14
+ private messagesDirty = true;
15
+ private cachedMessagesJson: string | null = null;
16
+
17
+ readonly instanceId: string;
18
+ private readonly handlers: HandlerFunctions | null;
19
+
20
+ private lastApiTokenCount: number | null = null;
21
+ private lastApiMessageCount: number = 0;
22
+
23
+ // Mid-tool-pair user/system messages are buffered and flushed after
24
+ // the trailing tool_result — splicing into the gap breaks
25
+ // reasoning_content pairing on strict providers.
26
+ private pendingMessages: Array<{ kind: "system" | "user"; text: string }> = [];
27
+
28
+ constructor(handlers?: HandlerFunctions, instanceId: string = "0000") {
29
+ this.handlers = handlers ?? null;
30
+ this.instanceId = instanceId;
31
+ }
32
+
33
+ private getMessagesJson(): string {
34
+ if (this.messagesDirty || this.cachedMessagesJson === null) {
35
+ this.cachedMessagesJson = JSON.stringify(this.messages);
36
+ this.messagesDirty = false;
37
+ }
38
+ return this.cachedMessagesJson;
39
+ }
40
+
41
+ private invalidateMessagesCache(): void {
42
+ this.messagesDirty = true;
43
+ this.cachedMessagesJson = null;
44
+ }
45
+
46
+ addUserMessage(text: string, images?: ImageContent[]): void {
47
+ if (images?.length) {
48
+ const parts: Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> = [];
49
+ if (text) parts.push({ type: "text", text });
50
+ for (const img of images) {
51
+ parts.push({ type: "image_url", image_url: { url: `data:${img.mimeType};base64,${img.data}` } });
52
+ }
53
+ this.messages.push({ role: "user", content: parts } as unknown as ChatCompletionMessageParam);
54
+ } else {
55
+ this.messages.push({ role: "user", content: text });
56
+ }
57
+ this.invalidateMessagesCache();
58
+ }
59
+
60
+ addAssistantMessage(
61
+ content: string | null,
62
+ toolCalls?: { id: string; function: { name: string; arguments: string } }[],
63
+ extras?: Record<string, unknown>,
64
+ ): void {
65
+ const hasToolCalls = !!toolCalls?.length;
66
+
67
+ // Promote reasoning into content on reasoning-only turns; strict
68
+ // providers (DeepSeek native) reject content="" with no tool_calls.
69
+ if (!content && !hasToolCalls) {
70
+ const r = (extras?.reasoning_content ?? extras?.reasoning) as unknown;
71
+ if (typeof r === "string" && r) content = r;
72
+ }
73
+ if (!content && !hasToolCalls) return;
74
+
75
+ const base: Record<string, unknown> = {
76
+ role: "assistant",
77
+ content: hasToolCalls ? (content ?? null) : content,
78
+ };
79
+ if (hasToolCalls) {
80
+ base.tool_calls = toolCalls!.map((tc) => ({
81
+ id: tc.id,
82
+ type: "function" as const,
83
+ function: tc.function,
84
+ }));
85
+ }
86
+ if (extras) Object.assign(base, extras);
87
+ this.messages.push(base as unknown as ChatCompletionMessageParam);
88
+ this.invalidateMessagesCache();
89
+ }
90
+
91
+ addToolResult(toolCallId: string, content: string | ImageContent[], isError = false): void {
92
+ if (typeof content === "string") {
93
+ this.messages.push({ role: "tool", tool_call_id: toolCallId, content });
94
+ } else {
95
+ const parts: Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> = [];
96
+ for (const img of content) {
97
+ parts.push({ type: "image_url", image_url: { url: `data:${img.mimeType};base64,${img.data}` } });
98
+ }
99
+ const label = isError ? `Error: [${content.length} image(s)]` : `[${content.length} image(s)]`;
100
+ parts.unshift({ type: "text", text: label });
101
+ this.messages.push({ role: "tool", tool_call_id: toolCallId, content: parts } as unknown as ChatCompletionMessageParam);
102
+ }
103
+ this.invalidateMessagesCache();
104
+ this.flushPendingMessages();
105
+ }
106
+
107
+ addToolResultInline(content: string): void {
108
+ this.messages.push({ role: "user", content });
109
+ this.invalidateMessagesCache();
110
+ this.flushPendingMessages();
111
+ }
112
+
113
+ /** Safe from any context: queues if mid-tool-pair, appends otherwise. */
114
+ addSystemNote(text: string): void {
115
+ if (this.hasOpenToolCalls()) {
116
+ this.pendingMessages.push({ kind: "system", text });
117
+ return;
118
+ }
119
+ this.messages.push({ role: "user", content: text });
120
+ this.invalidateMessagesCache();
121
+ }
122
+
123
+ appendUserMessage(text: string): void {
124
+ if (this.hasOpenToolCalls()) {
125
+ this.pendingMessages.push({ kind: "user", text });
126
+ return;
127
+ }
128
+ this.addUserMessage(text);
129
+ }
130
+
131
+ private hasOpenToolCalls(): boolean {
132
+ for (let i = this.messages.length - 1; i >= 0; i--) {
133
+ const msg = this.messages[i]!;
134
+ if (msg.role === "tool") continue;
135
+ if (msg.role !== "assistant") return false;
136
+ if (!("tool_calls" in msg) || !msg.tool_calls) return false;
137
+ const answered = new Set<string>();
138
+ for (let j = i + 1; j < this.messages.length; j++) {
139
+ const m = this.messages[j]!;
140
+ if (m.role !== "tool") break;
141
+ answered.add((m as { tool_call_id: string }).tool_call_id);
142
+ }
143
+ return msg.tool_calls.some((tc) => !answered.has(tc.id));
144
+ }
145
+ return false;
146
+ }
147
+
148
+ private flushPendingMessages(): void {
149
+ if (this.pendingMessages.length === 0) return;
150
+ if (this.hasOpenToolCalls()) return;
151
+ const pending = this.pendingMessages;
152
+ this.pendingMessages = [];
153
+ for (const m of pending) {
154
+ if (m.kind === "user") {
155
+ this.addUserMessage(m.text);
156
+ } else {
157
+ this.messages.push({ role: "user", content: m.text });
158
+ }
159
+ }
160
+ this.invalidateMessagesCache();
161
+ }
162
+
163
+ /** Send-shaped; may be longer than get() (dangling calls stubbed) — never link()/replace() by these indices. */
164
+ forLLM(): ChatCompletionMessageParam[] {
165
+ return this.normalizeReasoningConsistency(
166
+ this.stubDanglingToolCalls(this.dropOrphanToolMessages(this.messages)),
167
+ );
168
+ }
169
+
170
+ get(): AgentShMessage[] {
171
+ return this.messages as AgentShMessage[];
172
+ }
173
+
174
+ replace(msgs: AgentShMessage[]): void {
175
+ this.replaceMessages(msgs as ChatCompletionMessageParam[]);
176
+ }
177
+
178
+ link(index: number, entryId: string): void {
179
+ const m = this.messages[index];
180
+ if (!m) throw new Error(`LiveView.link: no message at index ${index}`);
181
+ const am = m as AgentShMessage;
182
+ am.meta = { ...am.meta, entryId };
183
+ }
184
+
185
+ /** DeepSeek 400s on tool messages without a matching tool_call;
186
+ * compaction can leave such orphans. */
187
+ private dropOrphanToolMessages(
188
+ messages: ChatCompletionMessageParam[],
189
+ ): ChatCompletionMessageParam[] {
190
+ const knownIds = new Set<string>();
191
+ const result: ChatCompletionMessageParam[] = [];
192
+ for (const msg of messages) {
193
+ if (msg.role === "assistant" && "tool_calls" in msg && msg.tool_calls) {
194
+ for (const tc of msg.tool_calls) knownIds.add(tc.id);
195
+ }
196
+ if (msg.role === "tool" && !knownIds.has((msg as { tool_call_id: string }).tool_call_id)) {
197
+ continue;
198
+ }
199
+ result.push(msg);
200
+ }
201
+ return result;
202
+ }
203
+
204
+ /** Stub missing tool results after a mid-execution interrupt so
205
+ * DeepSeek doesn't 400 on dangling tool_calls. */
206
+ private stubDanglingToolCalls(
207
+ messages: ChatCompletionMessageParam[],
208
+ ): ChatCompletionMessageParam[] {
209
+ const result: ChatCompletionMessageParam[] = [];
210
+ let i = 0;
211
+ while (i < messages.length) {
212
+ const msg = messages[i]!;
213
+ result.push(msg);
214
+ i++;
215
+ if (msg.role !== "assistant" || !("tool_calls" in msg) || !msg.tool_calls) continue;
216
+ const seen = new Set<string>();
217
+ while (i < messages.length && messages[i]!.role === "tool") {
218
+ const t = messages[i]! as ChatCompletionMessageParam & { role: "tool"; tool_call_id: string };
219
+ seen.add(t.tool_call_id);
220
+ result.push(t);
221
+ i++;
222
+ }
223
+ for (const tc of msg.tool_calls) {
224
+ if (!seen.has(tc.id)) {
225
+ result.push({ role: "tool", tool_call_id: tc.id, content: "[cancelled]" });
226
+ }
227
+ }
228
+ }
229
+ return result;
230
+ }
231
+
232
+ /** DeepSeek 400s if any assistant in a thinking-mode conversation
233
+ * is missing `reasoning_content`. Cross-alias `reasoning` (from
234
+ * OpenRouter) and stub gaps with "". */
235
+ private normalizeReasoningConsistency(
236
+ messages: ChatCompletionMessageParam[],
237
+ ): ChatCompletionMessageParam[] {
238
+ const needsNormalize = messages.some(
239
+ (m) => m.role === "assistant" && (
240
+ (m as any).reasoning !== undefined ||
241
+ (m as any).reasoning_content !== undefined ||
242
+ (m as any).reasoning_details !== undefined
243
+ ),
244
+ );
245
+ if (!needsNormalize) return messages;
246
+ return messages.map((m) => {
247
+ if (m.role !== "assistant") return m;
248
+ const a = m as any;
249
+ if (a.reasoning_content !== undefined) return m;
250
+ return { ...m, reasoning_content: a.reasoning ?? "" } as ChatCompletionMessageParam;
251
+ });
252
+ }
253
+
254
+ /** Invalidates the API token baseline since the new array's count is unknown. */
255
+ replaceMessages(messages: ChatCompletionMessageParam[]): void {
256
+ this.messages = messages;
257
+ this.invalidateMessagesCache();
258
+ this.lastApiTokenCount = null;
259
+ this.lastApiMessageCount = 0;
260
+ this.flushPendingMessages();
261
+ }
262
+
263
+ updateApiTokenCount(promptTokens: number): void {
264
+ this.lastApiTokenCount = promptTokens;
265
+ this.lastApiMessageCount = this.messages.length;
266
+ }
267
+
268
+ estimatePromptTokens(): number {
269
+ if (this.lastApiTokenCount === null) return this.estimateTokens();
270
+ const trailing = this.messages.length - this.lastApiMessageCount;
271
+ if (trailing <= 0) return this.lastApiTokenCount;
272
+ const trailingMessages = this.messages.slice(this.lastApiMessageCount);
273
+ return this.lastApiTokenCount + Math.ceil(JSON.stringify(trailingMessages).length / 4);
274
+ }
275
+
276
+ estimateTokens(): number {
277
+ return Math.ceil(this.getMessagesJson().length / 4);
278
+ }
279
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Thin, stateless wrapper around the OpenAI SDK.
3
+ * No agent-sh knowledge — just a configured client.
4
+ *
5
+ * Used by both AgentLoop (full tool loop) and fast-path features
6
+ * (command suggestions, completions).
7
+ */
8
+ import OpenAI from "openai";
9
+ import type {
10
+ ChatCompletionMessageParam,
11
+ ChatCompletionTool,
12
+ ChatCompletionCreateParamsStreaming,
13
+ ChatCompletionCreateParamsNonStreaming,
14
+ } from "openai/resources/chat/completions.js";
15
+
16
+ export type { ChatCompletionMessageParam, ChatCompletionTool };
17
+
18
+ export type AgentShMessage = ChatCompletionMessageParam & {
19
+ meta?: Record<string, unknown>;
20
+ };
21
+
22
+ export function stripMeta(m: ChatCompletionMessageParam): ChatCompletionMessageParam {
23
+ if (!("meta" in m)) return m;
24
+ const { meta: _meta, ...rest } = m as ChatCompletionMessageParam & { meta?: unknown };
25
+ return rest as ChatCompletionMessageParam;
26
+ }
27
+
28
+ export interface LlmClientConfig {
29
+ apiKey: string;
30
+ baseURL?: string;
31
+ model: string;
32
+ /** Sent as OpenRouter X-Title; ignored by other providers. */
33
+ appName?: string;
34
+ /** Sent as OpenRouter HTTP-Referer; ignored by other providers. */
35
+ appUrl?: string;
36
+ }
37
+
38
+ function attributionHeaders(config: LlmClientConfig): Record<string, string> {
39
+ return {
40
+ "HTTP-Referer": config.appUrl ?? "https://agent-sh.dev",
41
+ "X-Title": config.appName ?? "agent-sh",
42
+ "X-OpenRouter-Categories": "cli-agent,programming-app",
43
+ };
44
+ }
45
+
46
+ export class LlmClient {
47
+ private client: OpenAI;
48
+ public model: string;
49
+
50
+ constructor(private config: LlmClientConfig) {
51
+ this.client = new OpenAI({
52
+ apiKey: config.apiKey,
53
+ baseURL: config.baseURL,
54
+ defaultHeaders: attributionHeaders(config),
55
+ });
56
+ this.model = config.model;
57
+ }
58
+
59
+ /** Swap the underlying client config at runtime (e.g. provider switch). */
60
+ reconfigure(newConfig: LlmClientConfig): void {
61
+ this.config = newConfig;
62
+ this.client = new OpenAI({
63
+ apiKey: newConfig.apiKey,
64
+ baseURL: newConfig.baseURL,
65
+ defaultHeaders: attributionHeaders(newConfig),
66
+ });
67
+ this.model = newConfig.model;
68
+ }
69
+
70
+ stream(opts: StreamOpts) {
71
+ const { signal, messages, tools, model, max_tokens, ...rest } = opts;
72
+ const body = {
73
+ ...rest,
74
+ model: model ?? this.model,
75
+ messages: messages.map(stripMeta),
76
+ tools: tools?.length ? tools : undefined,
77
+ max_tokens: max_tokens ?? 65536,
78
+ stream: true as const,
79
+ stream_options: { include_usage: true },
80
+ };
81
+ return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal });
82
+ }
83
+
84
+ async complete(opts: CompleteOpts): Promise<string> {
85
+ const { messages, model, max_tokens, ...rest } = opts;
86
+ const body = {
87
+ ...rest,
88
+ model: model ?? this.model,
89
+ messages: messages.map(stripMeta),
90
+ max_tokens: max_tokens ?? 1024,
91
+ };
92
+ const response = await this.client.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming);
93
+ return response.choices[0]?.message?.content ?? "";
94
+ }
95
+ }
96
+
97
+ /** Known fields are typed; extras are forwarded verbatim to the SDK so
98
+ * provider hooks can ship non-standard params (thinking, reasoning, …). */
99
+ export type StreamOpts = {
100
+ messages: ChatCompletionMessageParam[];
101
+ tools?: ChatCompletionTool[];
102
+ model?: string;
103
+ max_tokens?: number;
104
+ signal?: AbortSignal;
105
+ } & Record<string, unknown>;
106
+
107
+ export type CompleteOpts = {
108
+ messages: ChatCompletionMessageParam[];
109
+ model?: string;
110
+ max_tokens?: number;
111
+ } & Record<string, unknown>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * ctx.agent.llm facade — delegates to an `llm:invoke` handler defined
3
+ * by the ash backend. Other backends (claude-code, pi, opencode) bring
4
+ * their own LLM and do not define this handler; `available` is false
5
+ * under those backends and calls reject.
6
+ */
7
+ import type { LlmInterface, LlmMessage, LlmSession } from "./host-types.js";
8
+
9
+ interface HandlerGate {
10
+ list: () => string[];
11
+ call: (name: string, ...args: unknown[]) => unknown;
12
+ }
13
+
14
+ export function createLlmFacade(handlers: HandlerGate): LlmInterface {
15
+ const invoke = (messages: LlmMessage[], maxTokens?: number, model?: string, reasoningEffort?: string): Promise<string> => {
16
+ const result = handlers.call("llm:invoke", messages, { maxTokens, model, reasoningEffort });
17
+ if (result === undefined) return Promise.reject(new Error("ctx.agent.llm: no LLM backend available"));
18
+ return result as Promise<string>;
19
+ };
20
+ return {
21
+ get available() { return handlers.list().includes("llm:invoke"); },
22
+ ask: ({ query, system, maxTokens, model, reasoningEffort }) => {
23
+ const messages: LlmMessage[] = [];
24
+ if (system) messages.push({ role: "system", content: system });
25
+ messages.push({ role: "user", content: query });
26
+ return invoke(messages, maxTokens, model, reasoningEffort);
27
+ },
28
+ session: (opts = {}) => {
29
+ const messages: LlmMessage[] = [];
30
+ if (opts.system) messages.push({ role: "system", content: opts.system });
31
+ const session: LlmSession = {
32
+ async send(message) {
33
+ messages.push({ role: "user", content: message });
34
+ const reply = await invoke(messages, opts.maxTokens, opts.model, opts.reasoningEffort);
35
+ messages.push({ role: "assistant", content: reply });
36
+ return reply;
37
+ },
38
+ history: () => messages.slice(),
39
+ };
40
+ return session;
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Schema-aware tool-arg normalization.
3
+ *
4
+ * Some LLMs (notably Claude) occasionally emit nested object/array
5
+ * tool-call arguments as JSON-encoded strings instead of native
6
+ * objects, despite the schema declaring `type: "object"` /
7
+ * `type: "array"`. The discrepancy was diagnosed by the superash field
8
+ * test (2026-05-03 / commit `b9efd47`):
9
+ *
10
+ * describe_demos: 'task' arrived as a string (length 1267)
11
+ * last char code: 93 (']')
12
+ * truncation suspected: true
13
+ *
14
+ * Tool handlers downstream had to add ad-hoc JSON.parse fallbacks. This
15
+ * helper centralizes the fix at the kernel boundary: after parsing the
16
+ * outer `argumentsJson`, walk each top-level field; for any field whose
17
+ * schema declares `object` or `array` but whose value is a string, run
18
+ * a single JSON.parse pass. On parse failure (e.g. truncated content),
19
+ * the string is left as-is — the tool can produce a clean error.
20
+ *
21
+ * Top-level only by design. Recursing into nested object schemas would
22
+ * change semantics for tools that legitimately accept stringified
23
+ * payloads as inner fields, and the observed wild cases all stringify
24
+ * at the top level.
25
+ */
26
+
27
+ /** Subset of a JSON Schema root we care about: `properties` keyed by
28
+ * field name, each declaring a `type`. Anything else is ignored. */
29
+ type ToolInputSchema = {
30
+ properties?: Record<string, { type?: string } | unknown>;
31
+ [k: string]: unknown;
32
+ };
33
+
34
+ /** Normalize tool-call args against the tool's input_schema. Pure: does
35
+ * not mutate `args`. Returns a new object with stringified-then-decoded
36
+ * fields swapped in where applicable. */
37
+ export function normalizeToolArgs(
38
+ args: Record<string, unknown>,
39
+ schema: unknown,
40
+ ): Record<string, unknown> {
41
+ if (!schema || typeof schema !== "object") return args;
42
+ const properties = (schema as ToolInputSchema).properties;
43
+ if (!properties || typeof properties !== "object") return args;
44
+
45
+ let out: Record<string, unknown> | null = null;
46
+ for (const [field, fieldSchema] of Object.entries(properties)) {
47
+ if (!fieldSchema || typeof fieldSchema !== "object") continue;
48
+ const expectedType = (fieldSchema as { type?: unknown }).type;
49
+ if (expectedType !== "object" && expectedType !== "array") continue;
50
+ const value = args[field];
51
+ if (typeof value !== "string") continue;
52
+ try {
53
+ const parsed = JSON.parse(value);
54
+ if (out === null) out = { ...args };
55
+ out[field] = parsed;
56
+ } catch {
57
+ // Leave as string — downstream tool can produce a useful error.
58
+ }
59
+ }
60
+ return out ?? args;
61
+ }