@jxburros/ai-nugget 0.3.1 → 0.6.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.
Files changed (183) hide show
  1. package/README.md +134 -271
  2. package/UPGRADING.md +158 -0
  3. package/dist/adapters/engines/anthropic.d.ts +7 -1
  4. package/dist/adapters/engines/anthropic.js +64 -37
  5. package/dist/adapters/engines/base.d.ts +29 -2
  6. package/dist/adapters/engines/base.js +52 -8
  7. package/dist/adapters/engines/google.d.ts +17 -1
  8. package/dist/adapters/engines/google.js +93 -29
  9. package/dist/adapters/engines/ollama.d.ts +2 -1
  10. package/dist/adapters/engines/ollama.js +51 -15
  11. package/dist/adapters/engines/openaiChat.d.ts +2 -1
  12. package/dist/adapters/engines/openaiChat.js +60 -30
  13. package/dist/adapters/profiles.d.ts +21 -1
  14. package/dist/adapters/profiles.js +12 -2
  15. package/dist/agent/loop.d.ts +43 -1
  16. package/dist/agent/loop.js +120 -13
  17. package/dist/cjs/adapters/engines/anthropic.js +308 -0
  18. package/dist/cjs/adapters/engines/base.js +147 -0
  19. package/dist/cjs/adapters/engines/google.js +284 -0
  20. package/dist/cjs/adapters/engines/ollama.js +199 -0
  21. package/dist/cjs/adapters/engines/openaiChat.js +261 -0
  22. package/dist/cjs/adapters/index.js +22 -0
  23. package/dist/cjs/adapters/profiles.js +125 -0
  24. package/dist/cjs/agent/index.js +18 -0
  25. package/dist/cjs/agent/loop.js +372 -0
  26. package/dist/cjs/agent/tools.js +51 -0
  27. package/dist/cjs/connect.js +27 -0
  28. package/dist/cjs/errors.js +110 -0
  29. package/dist/cjs/handler.js +561 -0
  30. package/dist/cjs/index.js +27 -0
  31. package/dist/cjs/json.js +121 -0
  32. package/dist/cjs/keys.js +81 -0
  33. package/dist/cjs/package.json +3 -0
  34. package/dist/cjs/policy.js +42 -0
  35. package/dist/cjs/redact.js +78 -0
  36. package/dist/cjs/tokens.js +22 -0
  37. package/dist/cjs/transport.js +172 -0
  38. package/dist/cjs/types.js +6 -0
  39. package/dist/cjs/util.js +112 -0
  40. package/dist/connect.d.ts +31 -0
  41. package/dist/connect.js +25 -0
  42. package/dist/errors.js +25 -1
  43. package/dist/handler.d.ts +61 -1
  44. package/dist/handler.js +247 -61
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.js +1 -0
  47. package/dist/keys.js +1 -4
  48. package/dist/redact.d.ts +18 -0
  49. package/dist/redact.js +29 -1
  50. package/dist/transport.d.ts +3 -1
  51. package/dist/transport.js +21 -1
  52. package/dist/types.d.ts +96 -2
  53. package/dist/util.d.ts +16 -0
  54. package/dist/util.js +50 -0
  55. package/nugget/VERSION.txt +2 -2
  56. package/nugget/dist/adapters/engines/anthropic.d.ts +14 -0
  57. package/nugget/dist/adapters/engines/anthropic.d.ts.map +1 -0
  58. package/nugget/dist/adapters/engines/anthropic.js +305 -0
  59. package/nugget/dist/adapters/engines/anthropic.js.map +1 -0
  60. package/nugget/dist/adapters/engines/base.d.ts +50 -0
  61. package/nugget/dist/adapters/engines/base.d.ts.map +1 -0
  62. package/nugget/dist/adapters/engines/base.js +137 -0
  63. package/nugget/dist/adapters/engines/base.js.map +1 -0
  64. package/nugget/dist/adapters/engines/google.d.ts +24 -0
  65. package/nugget/dist/adapters/engines/google.d.ts.map +1 -0
  66. package/nugget/dist/adapters/engines/google.js +281 -0
  67. package/nugget/dist/adapters/engines/google.js.map +1 -0
  68. package/nugget/dist/adapters/engines/ollama.d.ts +11 -0
  69. package/nugget/dist/adapters/engines/ollama.d.ts.map +1 -0
  70. package/nugget/dist/adapters/engines/ollama.js +196 -0
  71. package/nugget/dist/adapters/engines/ollama.js.map +1 -0
  72. package/nugget/dist/adapters/engines/openaiChat.d.ts +16 -0
  73. package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -0
  74. package/nugget/dist/adapters/engines/openaiChat.js +258 -0
  75. package/nugget/dist/adapters/engines/openaiChat.js.map +1 -0
  76. package/nugget/dist/adapters/index.d.ts +5 -0
  77. package/nugget/dist/adapters/index.d.ts.map +1 -0
  78. package/nugget/dist/adapters/index.js +17 -0
  79. package/nugget/dist/adapters/index.js.map +1 -0
  80. package/nugget/dist/adapters/profiles.d.ts +62 -0
  81. package/nugget/dist/adapters/profiles.d.ts.map +1 -0
  82. package/nugget/dist/adapters/profiles.js +121 -0
  83. package/nugget/dist/adapters/profiles.js.map +1 -0
  84. package/nugget/dist/agent/index.d.ts +3 -0
  85. package/nugget/dist/agent/index.d.ts.map +1 -0
  86. package/nugget/dist/agent/index.js +3 -0
  87. package/nugget/dist/agent/index.js.map +1 -0
  88. package/nugget/dist/agent/loop.d.ts +112 -0
  89. package/nugget/dist/agent/loop.d.ts.map +1 -0
  90. package/nugget/dist/agent/loop.js +370 -0
  91. package/nugget/dist/agent/loop.js.map +1 -0
  92. package/nugget/dist/agent/tools.d.ts +29 -0
  93. package/nugget/dist/agent/tools.d.ts.map +1 -0
  94. package/nugget/dist/agent/tools.js +48 -0
  95. package/nugget/dist/agent/tools.js.map +1 -0
  96. package/nugget/dist/cjs/adapters/engines/anthropic.js +308 -0
  97. package/nugget/dist/cjs/adapters/engines/base.js +147 -0
  98. package/nugget/dist/cjs/adapters/engines/google.js +284 -0
  99. package/nugget/dist/cjs/adapters/engines/ollama.js +199 -0
  100. package/nugget/dist/cjs/adapters/engines/openaiChat.js +261 -0
  101. package/nugget/dist/cjs/adapters/index.js +22 -0
  102. package/nugget/dist/cjs/adapters/profiles.js +125 -0
  103. package/nugget/dist/cjs/agent/index.js +18 -0
  104. package/nugget/dist/cjs/agent/loop.js +372 -0
  105. package/nugget/dist/cjs/agent/tools.js +51 -0
  106. package/nugget/dist/cjs/connect.js +27 -0
  107. package/nugget/dist/cjs/errors.js +110 -0
  108. package/nugget/dist/cjs/handler.js +561 -0
  109. package/nugget/dist/cjs/index.js +27 -0
  110. package/nugget/dist/cjs/json.js +121 -0
  111. package/nugget/dist/cjs/keys.js +81 -0
  112. package/nugget/dist/cjs/package.json +3 -0
  113. package/nugget/dist/cjs/policy.js +42 -0
  114. package/nugget/dist/cjs/redact.js +78 -0
  115. package/nugget/dist/cjs/tokens.js +22 -0
  116. package/nugget/dist/cjs/transport.js +172 -0
  117. package/nugget/dist/cjs/types.js +6 -0
  118. package/nugget/dist/cjs/util.js +112 -0
  119. package/nugget/dist/connect.d.ts +31 -0
  120. package/nugget/dist/connect.d.ts.map +1 -0
  121. package/nugget/dist/connect.js +25 -0
  122. package/nugget/dist/connect.js.map +1 -0
  123. package/nugget/dist/errors.d.ts +22 -0
  124. package/nugget/dist/errors.d.ts.map +1 -0
  125. package/nugget/dist/errors.js +104 -0
  126. package/nugget/dist/errors.js.map +1 -0
  127. package/nugget/dist/handler.d.ts +108 -0
  128. package/nugget/dist/handler.d.ts.map +1 -0
  129. package/nugget/dist/handler.js +557 -0
  130. package/nugget/dist/handler.js.map +1 -0
  131. package/nugget/dist/index.d.ts +12 -0
  132. package/nugget/dist/index.d.ts.map +1 -0
  133. package/nugget/dist/index.js +12 -0
  134. package/nugget/dist/index.js.map +1 -0
  135. package/nugget/dist/json.d.ts +7 -0
  136. package/nugget/dist/json.d.ts.map +1 -0
  137. package/nugget/dist/json.js +114 -0
  138. package/nugget/dist/json.js.map +1 -0
  139. package/nugget/dist/keys.d.ts +7 -0
  140. package/nugget/dist/keys.d.ts.map +1 -0
  141. package/nugget/dist/keys.js +75 -0
  142. package/nugget/dist/keys.js.map +1 -0
  143. package/nugget/dist/policy.d.ts +6 -0
  144. package/nugget/dist/policy.d.ts.map +1 -0
  145. package/nugget/dist/policy.js +37 -0
  146. package/nugget/dist/policy.js.map +1 -0
  147. package/nugget/dist/redact.d.ts +26 -0
  148. package/nugget/dist/redact.d.ts.map +1 -0
  149. package/nugget/dist/redact.js +74 -0
  150. package/nugget/dist/redact.js.map +1 -0
  151. package/nugget/dist/tokens.d.ts +5 -0
  152. package/nugget/dist/tokens.d.ts.map +1 -0
  153. package/nugget/dist/tokens.js +18 -0
  154. package/nugget/dist/tokens.js.map +1 -0
  155. package/nugget/dist/transport.d.ts +30 -0
  156. package/nugget/dist/transport.d.ts.map +1 -0
  157. package/nugget/dist/transport.js +165 -0
  158. package/nugget/dist/transport.js.map +1 -0
  159. package/nugget/dist/types.d.ts +285 -0
  160. package/nugget/dist/types.d.ts.map +1 -0
  161. package/nugget/dist/types.js +4 -0
  162. package/nugget/dist/types.js.map +1 -0
  163. package/nugget/dist/util.d.ts +24 -0
  164. package/nugget/dist/util.d.ts.map +1 -0
  165. package/nugget/dist/util.js +101 -0
  166. package/nugget/dist/util.js.map +1 -0
  167. package/nugget/src/adapters/engines/anthropic.ts +62 -37
  168. package/nugget/src/adapters/engines/base.ts +50 -7
  169. package/nugget/src/adapters/engines/google.ts +92 -29
  170. package/nugget/src/adapters/engines/ollama.ts +51 -17
  171. package/nugget/src/adapters/engines/openaiChat.ts +61 -32
  172. package/nugget/src/adapters/profiles.ts +53 -3
  173. package/nugget/src/agent/loop.ts +158 -14
  174. package/nugget/src/connect.ts +48 -0
  175. package/nugget/src/errors.ts +25 -1
  176. package/nugget/src/handler.ts +286 -59
  177. package/nugget/src/index.ts +1 -0
  178. package/nugget/src/keys.ts +1 -5
  179. package/nugget/src/redact.ts +32 -1
  180. package/nugget/src/transport.ts +20 -1
  181. package/nugget/src/types.ts +96 -1
  182. package/nugget/src/util.ts +55 -0
  183. package/package.json +20 -6
@@ -34,6 +34,20 @@ export interface ProviderProfile {
34
34
  maxTokensRequired?: boolean;
35
35
  maxTokensParam?: 'max_tokens' | 'max_completion_tokens';
36
36
  supportsJsonSchema?: boolean;
37
+ /**
38
+ * Default value for the `{apiVersion}` token in `urlTemplate` (Azure OpenAI).
39
+ * Overridable per call via `ChatRequest.providerOptions.apiVersion`, so a
40
+ * retired Azure api-version never requires a library release.
41
+ */
42
+ apiVersion?: string;
43
+ /**
44
+ * Provider honors an `Idempotency-Key` request header. When set, the handler
45
+ * attaches one key per logical call and reuses it across retries, so a retry
46
+ * after a connection drops post-generation is deduped by the provider rather
47
+ * than billed twice. Left off unless the provider documents the header —
48
+ * sending an unknown header is harmless, but claiming the guarantee is not.
49
+ */
50
+ supportsIdempotencyKey?: boolean;
37
51
  };
38
52
  listModelsPath?: string;
39
53
  healthPath?: string;
@@ -42,20 +56,46 @@ export interface ProviderProfile {
42
56
  const HOSTED_CLOUD: ProviderCapabilities = { nativeTools: true, jsonMode: true, local: false, embeddable: false };
43
57
  const LOCAL_RUNTIME: ProviderCapabilities = { nativeTools: false, jsonMode: false, local: true, embeddable: true };
44
58
 
45
- export const PROVIDER_PROFILES: Record<string, ProviderProfile> = {
59
+ /**
60
+ * The provider keys that have a profile in {@link PROVIDER_PROFILES}. Used to
61
+ * type `Connection.provider` so IDEs autocomplete valid names and typos are a
62
+ * compile error rather than a silent fall-through to `openai-compat`.
63
+ */
64
+ export type KnownProvider =
65
+ | 'openai'
66
+ | 'azure-openai'
67
+ | 'openrouter'
68
+ | 'groq'
69
+ | 'deepseek'
70
+ | 'mistral'
71
+ | 'together'
72
+ | 'fireworks'
73
+ | 'cerebras'
74
+ | 'moonshot'
75
+ | 'cohere'
76
+ | 'perplexity'
77
+ | 'lmstudio'
78
+ | 'llamacpp'
79
+ | 'vllm'
80
+ | 'ollama'
81
+ | 'anthropic'
82
+ | 'google'
83
+ | 'openai-compat';
84
+
85
+ export const PROVIDER_PROFILES: Record<KnownProvider, ProviderProfile> & Record<string, ProviderProfile> = {
46
86
  openai: {
47
87
  engine: 'openaiChat',
48
88
  defaultBaseUrl: 'https://api.openai.com/v1',
49
89
  auth: 'bearer',
50
90
  listModelsPath: '/models',
51
91
  capabilities: HOSTED_CLOUD,
52
- quirks: { supportsUsageInStream: true, maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
92
+ quirks: { supportsUsageInStream: true, maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true, supportsIdempotencyKey: true },
53
93
  },
54
94
  'azure-openai': {
55
95
  engine: 'openaiChat',
56
96
  auth: 'api-key-header',
57
97
  capabilities: HOSTED_CLOUD,
58
- quirks: { urlTemplate: '{baseUrl}/openai/deployments/{model}/chat/completions?api-version=2024-10-21', maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
98
+ quirks: { urlTemplate: '{baseUrl}/openai/deployments/{model}/chat/completions?api-version={apiVersion}', apiVersion: '2024-10-21', maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
59
99
  },
60
100
  openrouter: {
61
101
  engine: 'openaiChat',
@@ -71,6 +111,12 @@ export const PROVIDER_PROFILES: Record<string, ProviderProfile> = {
71
111
  mistral: { engine: 'openaiChat', defaultBaseUrl: 'https://api.mistral.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
72
112
  together: { engine: 'openaiChat', defaultBaseUrl: 'https://api.together.xyz/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
73
113
  fireworks: { engine: 'openaiChat', defaultBaseUrl: 'https://api.fireworks.ai/inference/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
114
+ cerebras: { engine: 'openaiChat', defaultBaseUrl: 'https://api.cerebras.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
115
+ moonshot: { engine: 'openaiChat', defaultBaseUrl: 'https://api.moonshot.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
116
+ // Cohere's OpenAI-compatible surface lives under /compatibility/v1.
117
+ cohere: { engine: 'openaiChat', defaultBaseUrl: 'https://api.cohere.ai/compatibility/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
118
+ // Perplexity has no public model-listing endpoint, so listModels resolves empty.
119
+ perplexity: { engine: 'openaiChat', defaultBaseUrl: 'https://api.perplexity.ai', auth: 'bearer', capabilities: HOSTED_CLOUD },
74
120
  lmstudio: {
75
121
  engine: 'openaiChat',
76
122
  defaultBaseUrl: 'http://localhost:1234/v1',
@@ -128,6 +174,10 @@ export const PROVIDER_PROFILES: Record<string, ProviderProfile> = {
128
174
  // Unknown endpoint — usually pointed at a local/self-hosted server, so
129
175
  // assume the conservative local-runtime defaults rather than the hosted-cloud ones.
130
176
  capabilities: LOCAL_RUNTIME,
177
+ // Most servers reached through this escape hatch (JX Runtime and other
178
+ // local OpenAI-compatible runtimes) don't require a key at all; keep
179
+ // `auth: 'bearer'` for the ones that do (a key is only sent if provided).
180
+ quirks: { keyOptional: true },
131
181
  },
132
182
  };
133
183
 
@@ -3,8 +3,8 @@ import { AIError } from '../errors.js';
3
3
  import { extractJson } from '../json.js';
4
4
  import { mergeUsage } from '../tokens.js';
5
5
  import type { AIHandler } from '../handler.js';
6
- import type { ChatMessage, Connection, StreamEvent, ToolCall, Usage } from '../types.js';
7
- import { sleep } from '../util.js';
6
+ import type { AIErrorKind, ChatMessage, Connection, StreamEvent, ToolCall, Usage } from '../types.js';
7
+ import { asRecord, sleep } from '../util.js';
8
8
  import type { ToolSpec } from './tools.js';
9
9
  import { validateToolArgs } from './tools.js';
10
10
 
@@ -25,12 +25,40 @@ export interface AgentOptions {
25
25
  * dependent, not protocol-guaranteed.
26
26
  */
27
27
  toolMode?: 'native' | 'promptJson' | 'auto';
28
+ /**
29
+ * Discovered capabilities of the specific model (e.g. Ollama `/api/show`
30
+ * `["tools"]`). When `toolMode` is `auto`, a `tools` capability here upgrades a
31
+ * local model to `native` tool-calling instead of the profile's conservative
32
+ * `promptJson` default — so a capable local model isn't forced onto the fallback.
33
+ */
34
+ modelCapabilities?: string[];
35
+ /** Sampler/decoding options forwarded to every model turn (parity with a plain chat call). */
36
+ temperature?: number;
37
+ maxTokens?: number;
38
+ topP?: number;
39
+ stopSequences?: string[];
40
+ /** Provider-native passthrough forwarded to every turn — see {@link ChatRequest.providerOptions}. */
41
+ providerOptions?: Record<string, unknown>;
28
42
  budget?: {
29
43
  maxSteps?: number;
30
44
  maxTokens?: number;
31
45
  deadlineMs?: number;
32
46
  };
33
47
  approval?: ApprovalGate;
48
+ /**
49
+ * Which tools require the approval gate. `sideEffectsOnly` (default) gates only
50
+ * tools declared `sideEffects: true`; `all` gates every tool — so `tool_denied`
51
+ * is reachable without marking each tool, and a consumer can require approval
52
+ * for a whole run in one place.
53
+ */
54
+ approvalMode?: 'sideEffectsOnly' | 'all';
55
+ /**
56
+ * Bounds and framing applied to each tool result before it re-enters the
57
+ * model's context. `maxChars` truncates oversized results (with a notice) so a
58
+ * single large return can't blow the next turn's window; `wrapUntrusted` fences
59
+ * the result in an `<untrusted_tool_output>` envelope. Both are opt-in.
60
+ */
61
+ toolResult?: { maxChars?: number; wrapUntrusted?: boolean };
34
62
  onEvent?: (e: AgentEvent) => void;
35
63
  signal?: AbortSignal;
36
64
  metadata?: Record<string, unknown>;
@@ -45,6 +73,9 @@ export type ApprovalGate = (req: {
45
73
  export type AgentEvent =
46
74
  | StreamEvent
47
75
  | { type: 'step_start'; step: number }
76
+ // Discloses which tool-calling protocol this run resolved to, so a
77
+ // `promptJson` fallback is visible rather than silent.
78
+ | { type: 'tool_mode'; mode: 'native' | 'promptJson' }
48
79
  | { type: 'tool_start'; step: number; call: ToolCall }
49
80
  | { type: 'tool_result'; step: number; call: ToolCall; result: unknown; isError: boolean }
50
81
  | { type: 'tool_denied'; step: number; call: ToolCall; reason: string }
@@ -56,6 +87,11 @@ export interface AgentResult {
56
87
  usage: Usage;
57
88
  steps: number;
58
89
  stopReason: 'finished' | 'max_steps' | 'budget' | 'deadline' | 'canceled' | 'error';
90
+ /**
91
+ * Populated when `stopReason` is `error`: the failure that ended the run, so
92
+ * the caller can see what went wrong without replaying the event stream.
93
+ */
94
+ error?: { kind: AIErrorKind; message: string };
59
95
  }
60
96
 
61
97
  export function runAgent(opts: AgentOptions): AsyncIterable<AgentEvent> & { result: Promise<AgentResult> } {
@@ -77,6 +113,10 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
77
113
  let step = 0;
78
114
  let settled = false;
79
115
 
116
+ // Disclose the resolved tool protocol once up front (a silent promptJson
117
+ // fallback was the friction behind FR‑2).
118
+ yield emit(opts, { type: 'tool_mode', mode: toolMode });
119
+
80
120
  try {
81
121
  while (step < maxSteps) {
82
122
  step += 1;
@@ -86,15 +126,31 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
86
126
  const calls: ToolCall[] = [];
87
127
  let stepText = '';
88
128
  let streamFailure: AIError | undefined;
129
+ // In promptJson mode a tool directive must not leak into the visible
130
+ // stream, so directive-looking text is withheld from `delta` events (still
131
+ // accumulated in `stepText` for parsing). Prose streams normally.
132
+ const filter = toolMode === 'promptJson' ? new PromptJsonDeltaFilter() : undefined;
89
133
  const requestMessages = toolMode === 'promptJson' ? withPromptJsonInstruction(messages, opts.tools) : messages;
90
134
  for await (const event of opts.handler.stream(opts.connection, {
91
135
  model: opts.model,
92
136
  messages: requestMessages,
93
137
  tools: toolMode === 'promptJson' ? undefined : opts.tools,
138
+ temperature: opts.temperature,
139
+ maxTokens: opts.maxTokens,
140
+ topP: opts.topP,
141
+ stopSequences: opts.stopSequences,
142
+ providerOptions: opts.providerOptions,
94
143
  signal: agentSignal.signal,
95
144
  metadata: { ...opts.metadata, agentStep: step },
96
145
  })) {
97
- if (event.type === 'delta') stepText += event.text;
146
+ if (event.type === 'delta') {
147
+ stepText += event.text;
148
+ if (filter) {
149
+ const visible = filter.push(event.text);
150
+ if (visible) yield emit(opts, { type: 'delta', text: visible });
151
+ continue;
152
+ }
153
+ }
98
154
  if (event.type === 'tool_call') calls.push(event.call);
99
155
  if (event.type === 'error') streamFailure = event.error;
100
156
  if (event.type === 'done') {
@@ -103,10 +159,16 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
103
159
  }
104
160
  yield emit(opts, event);
105
161
  }
162
+ // If withheld text turned out not to be a real directive, flush it so the
163
+ // user still sees the model's answer.
164
+ if (filter && calls.length === 0) {
165
+ const flushed = filter.flush();
166
+ if (flushed) yield emit(opts, { type: 'delta', text: flushed });
167
+ }
106
168
 
107
169
  // A handler-level failure (auth, policy, cancel, exhausted retries) must
108
170
  // stop the loop honestly rather than looking like an empty completion.
109
- if (streamFailure) return yield* yieldDone(agentSignal.timedOut() ? 'deadline' : streamFailure.kind === 'canceled' ? 'canceled' : 'error');
171
+ if (streamFailure) return yield* yieldDone(agentSignal.timedOut() ? 'deadline' : streamFailure.kind === 'canceled' ? 'canceled' : 'error', errorInfo(streamFailure));
110
172
 
111
173
  finalText = stepText;
112
174
  messages.push(toolMode === 'promptJson'
@@ -127,7 +189,8 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
127
189
  continue;
128
190
  }
129
191
  let args = validation.args;
130
- if (tool.sideEffects) {
192
+ const requiresApproval = tool.sideEffects || opts.approvalMode === 'all';
193
+ if (requiresApproval) {
131
194
  if (!opts.approval) {
132
195
  yield* appendToolDenied(opts, messages, step, call, 'No approval gate configured');
133
196
  continue;
@@ -165,8 +228,16 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
165
228
  continue;
166
229
  }
167
230
  try {
168
- messages.push({ role: 'tool', toolCallId: call.id, name: call.name, content: JSON.stringify(executed.result) });
169
- yield emit(opts, { type: 'tool_result', step, call, result: executed.result, isError: false });
231
+ // Guard against a tool returning `undefined` (JSON.stringify(undefined)
232
+ // is the value `undefined`, not a string) — an unguarded value here
233
+ // used to crash the next step's message mapper and kill the whole run.
234
+ const serialized = JSON.stringify(executed.result) ?? 'null';
235
+ messages.push({ role: 'tool', toolCallId: call.id, name: call.name, content: formatToolResult(serialized, opts.toolResult) });
236
+ // A tool that returns a structured `{ ok: false, ... }` is a recoverable
237
+ // error the model should react to — surface it as an error result.
238
+ const record = asRecord(executed.result);
239
+ const isError = record?.ok === false;
240
+ yield emit(opts, { type: 'tool_result', step, call, result: executed.result, isError });
170
241
  } catch (error) {
171
242
  const message = error instanceof Error ? error.message : 'Tool result was not serializable';
172
243
  yield* appendToolError(opts, messages, step, call, message);
@@ -179,7 +250,8 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
179
250
  }
180
251
  return yield* yieldDone('max_steps');
181
252
  } catch (error) {
182
- const result = makeResult(finalText, messages, usage ?? { estimated: true }, step, agentSignal.timedOut() ? 'deadline' : (agentSignal.signal?.aborted || (error instanceof AIError && error.kind === 'canceled')) ? 'canceled' : 'error');
253
+ const stopReason = agentSignal.timedOut() ? 'deadline' : (agentSignal.signal?.aborted || (error instanceof AIError && error.kind === 'canceled')) ? 'canceled' : 'error';
254
+ const result = makeResult(finalText, messages, usage ?? { estimated: true }, step, stopReason, stopReason === 'error' ? errorInfo(error) : undefined);
183
255
  settled = true;
184
256
  resolveResult(result);
185
257
  yield emit(opts, { type: 'agent_done', result });
@@ -191,8 +263,8 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
191
263
  }
192
264
  }
193
265
 
194
- function* yieldDone(stopReason: AgentResult['stopReason']): Generator<AgentEvent> {
195
- const result = makeResult(finalText, messages, usage ?? { estimated: true }, step, stopReason);
266
+ function* yieldDone(stopReason: AgentResult['stopReason'], error?: AgentResult['error']): Generator<AgentEvent> {
267
+ const result = makeResult(finalText, messages, usage ?? { estimated: true }, step, stopReason, error);
196
268
  settled = true;
197
269
  resolveResult(result);
198
270
  yield emit(opts, { type: 'agent_done', result });
@@ -204,8 +276,68 @@ function emit<T extends AgentEvent>(opts: AgentOptions, event: T): T {
204
276
  return event;
205
277
  }
206
278
 
207
- function makeResult(finalText: string, messages: ChatMessage[], usage: Usage, steps: number, stopReason: AgentResult['stopReason']): AgentResult {
208
- return { finalText, messages, usage, steps, stopReason };
279
+ function makeResult(finalText: string, messages: ChatMessage[], usage: Usage, steps: number, stopReason: AgentResult['stopReason'], error?: AgentResult['error']): AgentResult {
280
+ return error ? { finalText, messages, usage, steps, stopReason, error } : { finalText, messages, usage, steps, stopReason };
281
+ }
282
+
283
+ function errorInfo(error: unknown): { kind: AIErrorKind; message: string } {
284
+ if (error instanceof AIError) return { kind: error.kind, message: error.message };
285
+ return { kind: 'tool_error', message: error instanceof Error ? error.message : 'Agent run failed' };
286
+ }
287
+
288
+ /**
289
+ * Applies the optional size cap and untrusted-content framing to a serialized
290
+ * tool result before it re-enters the model's context.
291
+ */
292
+ function formatToolResult(serialized: string, opts: AgentOptions['toolResult']): string {
293
+ let out = serialized;
294
+ const max = opts?.maxChars;
295
+ if (max !== undefined && out.length > max) {
296
+ out = `${out.slice(0, max)}\n…[truncated ${out.length - max} chars]`;
297
+ }
298
+ if (opts?.wrapUntrusted) {
299
+ out = `<untrusted_tool_output>\n${out}\n</untrusted_tool_output>`;
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /**
305
+ * Withholds a promptJson tool directive from the visible `delta` stream. Once
306
+ * the leading non-whitespace character is known: a `{`, `[`, or fence marks a
307
+ * directive candidate whose text is buffered (never streamed); anything else is
308
+ * prose that streams through untouched. `flush()` returns any buffered text when
309
+ * the stream ended without yielding a parsed directive.
310
+ */
311
+ class PromptJsonDeltaFilter {
312
+ private decided = false;
313
+ private withhold = false;
314
+ private buffer = '';
315
+
316
+ push(text: string): string {
317
+ if (!this.decided) {
318
+ this.buffer += text;
319
+ const lead = this.buffer.replace(/^\s+/, '');
320
+ if (lead.length === 0) return '';
321
+ this.decided = true;
322
+ this.withhold = /^[[{`]/.test(lead);
323
+ if (this.withhold) return '';
324
+ const flushed = this.buffer;
325
+ this.buffer = '';
326
+ return flushed;
327
+ }
328
+ if (this.withhold) {
329
+ this.buffer += text;
330
+ return '';
331
+ }
332
+ return text;
333
+ }
334
+
335
+ flush(): string {
336
+ if (!this.withhold) return '';
337
+ const out = this.buffer;
338
+ this.buffer = '';
339
+ return out;
340
+ }
209
341
  }
210
342
 
211
343
  function* appendToolError(opts: AgentOptions, messages: ChatMessage[], step: number, call: ToolCall, message: string): Generator<AgentEvent> {
@@ -222,14 +354,22 @@ function* appendToolDenied(opts: AgentOptions, messages: ChatMessage[], step: nu
222
354
  function resolveToolMode(opts: AgentOptions): 'native' | 'promptJson' {
223
355
  const mode = opts.toolMode ?? 'auto';
224
356
  if (mode !== 'auto') return mode;
357
+ // A model that actually advertises tool support gets native tool-calling even
358
+ // on a local runtime whose profile is conservatively `nativeTools: false`.
359
+ if (opts.modelCapabilities?.includes('tools')) return 'native';
225
360
  return profileFor(opts.connection.provider, opts.connection.baseUrl).capabilities.nativeTools ? 'native' : 'promptJson';
226
361
  }
227
362
 
228
363
  function withPromptJsonInstruction(messages: ChatMessage[], tools: ToolSpec[]): ChatMessage[] {
364
+ // Include each tool's parameter schema, not just its description, so the model
365
+ // knows the argument shape instead of guessing it (FR‑3).
366
+ const catalog = tools
367
+ .map((tool) => `- ${tool.name}: ${tool.description}\n parameters: ${JSON.stringify(tool.parameters)}`)
368
+ .join('\n');
229
369
  return [
230
370
  {
231
371
  role: 'system',
232
- content: `When you need tools, respond only with JSON. For one tool: {"tool":"name","input":{...}}. For several in one turn: {"tools":[{"tool":"name","input":{...}}]}. Available tools: ${tools.map((tool) => `${tool.name}: ${tool.description}`).join('; ')}`,
372
+ content: `When you need tools, respond only with JSON. For one tool: {"tool":"name","input":{...}}. For several in one turn: {"tools":[{"tool":"name","input":{...}}]}. The "input" object must match the tool's parameters schema. Available tools:\n${catalog}`,
233
373
  },
234
374
  ...messages.map(toPromptJsonMessage),
235
375
  ];
@@ -268,7 +408,7 @@ function callsFromPromptJson(text: string): ToolCall[] {
268
408
  const record = entry as Record<string, unknown>;
269
409
  if (typeof record.tool !== 'string') continue;
270
410
  calls.push({
271
- id: crypto.randomUUID(),
411
+ id: randomId(),
272
412
  name: record.tool,
273
413
  arguments: record.input && typeof record.input === 'object' ? record.input : {},
274
414
  raw: JSON.stringify(record.input ?? {}),
@@ -277,6 +417,10 @@ function callsFromPromptJson(text: string): ToolCall[] {
277
417
  return calls;
278
418
  }
279
419
 
420
+ function randomId(): string {
421
+ return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
422
+ }
423
+
280
424
  function textContent(content: ChatMessage['content']): string {
281
425
  if (typeof content === 'string') return content;
282
426
  if (!content) return '';
@@ -0,0 +1,48 @@
1
+ import type { Connection } from './types.js';
2
+ import { globalEnv } from './util.js';
3
+
4
+ export interface EnvConnectionOptions {
5
+ /** Read from this env-like object instead of the process/global environment. */
6
+ env?: Record<string, string | undefined>;
7
+ /** Connection id. Defaults to `'app'`. */
8
+ id?: string;
9
+ /** Env var names to read (override if an app already uses different names). */
10
+ providerVar?: string;
11
+ modelVar?: string;
12
+ keyEnvVar?: string;
13
+ baseUrlVar?: string;
14
+ /** Fallbacks used when the corresponding env var is unset. */
15
+ defaultProvider?: string;
16
+ defaultModel?: string;
17
+ defaultKeyEnv?: string;
18
+ }
19
+
20
+ export interface EnvConnection {
21
+ connection: Connection;
22
+ model: string;
23
+ }
24
+
25
+ /**
26
+ * Resolves a server-side `Connection` plus a default `model` from app-owned
27
+ * env vars (`AI_PROVIDER`, `AI_MODEL`, `AI_KEY_ENV`, `AI_BASE_URL` unless
28
+ * overridden). This only collapses the config-reading boilerplate that was
29
+ * being hand-rolled the same way in every small server app; it does not add
30
+ * policy. `provider`/`baseUrl` still come from the server's own environment,
31
+ * never from client input, and the returned `keyRef` still resolves through
32
+ * `KeySource` at call time like any other `Connection`.
33
+ */
34
+ export function envConnection(options: EnvConnectionOptions = {}): EnvConnection {
35
+ const env = options.env ?? globalEnv();
36
+ const provider = env[options.providerVar ?? 'AI_PROVIDER']?.trim() || options.defaultProvider || 'openai';
37
+ const model = env[options.modelVar ?? 'AI_MODEL']?.trim() || options.defaultModel || 'gpt-4o-mini';
38
+ const keyEnvName = env[options.keyEnvVar ?? 'AI_KEY_ENV']?.trim() || options.defaultKeyEnv || 'OPENAI_API_KEY';
39
+ const baseUrl = env[options.baseUrlVar ?? 'AI_BASE_URL']?.trim();
40
+
41
+ const connection: Connection = {
42
+ id: options.id ?? 'app',
43
+ provider,
44
+ keyRef: { kind: 'env', name: keyEnvName },
45
+ ...(baseUrl ? { baseUrl } : {}),
46
+ };
47
+ return { connection, model };
48
+ }
@@ -1,5 +1,15 @@
1
+ import { createDefaultRedactor } from './redact.js';
1
2
  import type { AIErrorKind } from './types.js';
2
3
 
4
+ /**
5
+ * Pattern-based redaction applied at the wire boundary, before an error object
6
+ * exists. `AIHandler` redacts again on the way out (adding session-registered
7
+ * exact secrets), but doing it here means a provider body that echoes a
8
+ * recognizable secret can never be carried by an `AIError` in the first place —
9
+ * including on code paths that never reach the handler's own catch blocks.
10
+ */
11
+ const wireRedactor = createDefaultRedactor();
12
+
3
13
  export class AIError extends Error {
4
14
  kind: AIErrorKind;
5
15
  status?: number;
@@ -34,9 +44,13 @@ export function defaultRetryable(kind: AIErrorKind): boolean {
34
44
  }
35
45
 
36
46
  export function classify(status: number, body = '', provider?: string, headers?: Headers): AIError {
37
- const excerpt = body.slice(0, 200);
47
+ const excerpt = wireRedactor.redact(body.slice(0, 200));
38
48
  const lower = body.toLowerCase();
39
49
  let kind: AIErrorKind = 'server';
50
+ // 403 is classified as `auth` (non-retryable) deliberately: a forbidden
51
+ // response almost always means a bad/insufficient credential or a blocked
52
+ // region/model, none of which a retry fixes. Callers that know a specific
53
+ // provider returns a retryable 403 can reclassify in a `beforeCall`/adapter.
40
54
  if (status === 401 || status === 403) kind = 'auth';
41
55
  else if (status === 408) kind = 'timeout';
42
56
  else if (status === 429) kind = 'rate_limit';
@@ -44,7 +58,17 @@ export function classify(status: number, body = '', provider?: string, headers?:
44
58
  kind = lower.includes('context') || lower.includes('maximum context') || lower.includes('token limit')
45
59
  ? 'context_length'
46
60
  : 'invalid_request';
61
+ } else if (status === 404 || status === 410) {
62
+ // A missing route/deployment is not a malformed request: it is usually a
63
+ // wrong baseUrl or model name, and sometimes a temporarily misrouted load
64
+ // balancer. `not_found` keeps it distinguishable from `invalid_request` so
65
+ // apps don't tell a user "your request was invalid" for an infra problem.
66
+ // Non-retryable by default — a caller that knows its 404s are transient can
67
+ // retry on `kind === 'not_found'` itself.
68
+ kind = 'not_found';
47
69
  } else if (status >= 500) kind = 'server';
70
+ // Everything else in 4xx (409 conflict, 405, 415, 451, …) is a genuine
71
+ // client-side request problem, so `invalid_request` is honest for it.
48
72
  else kind = 'invalid_request';
49
73
 
50
74
  return new AIError(`HTTP ${status}: ${excerpt}`, {