@startsimpli/llm 0.4.13 → 0.4.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Generic LLM service infrastructure — provider abstraction, retry logic, JSON extraction",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -6,7 +6,7 @@ import { describe, it, expect, vi } from 'vitest';
6
6
  import { runAgentTurn, buildToolRegistry } from '../agent/agent';
7
7
  import type { ToolProvider } from '../agent/types';
8
8
  import type { ILLMProvider } from '../providers/interface';
9
- import type { LLMResponse } from '../types';
9
+ import type { LLMResponse, NativeToolResponse } from '../types';
10
10
 
11
11
  /** Returns the queued responses in order; repeats the last once exhausted. */
12
12
  class QueuedProvider implements ILLMProvider {
@@ -111,3 +111,76 @@ describe('runAgentTurn', () => {
111
111
  expect(() => buildToolRegistry([a, b])).toThrow(/duplicate tool name 'dup'/);
112
112
  });
113
113
  });
114
+
115
+ /** Provider that supports NATIVE tool-calling: returns queued {content, toolCalls}. */
116
+ class NativeQueuedProvider implements ILLMProvider {
117
+ readonly name = 'native';
118
+ readonly availableModels = ['n'] as const;
119
+ readonly defaultModel = 'n';
120
+ private i = 0;
121
+ constructor(private readonly turns: NativeToolResponse[]) {}
122
+ async generate(): Promise<LLMResponse> {
123
+ return { content: '', usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, model: 'n' };
124
+ }
125
+ async generateWithTools(): Promise<NativeToolResponse> {
126
+ const t = this.turns[Math.min(this.i, this.turns.length - 1)];
127
+ this.i += 1;
128
+ return t;
129
+ }
130
+ isAvailable() {
131
+ return true;
132
+ }
133
+ }
134
+
135
+ describe('runAgentTurn (native tool-calling)', () => {
136
+ it('uses the native loop, dispatches the structured call, returns plain-text final', async () => {
137
+ const handler = vi.fn(async (input: { types: string[] }) => ({ saved: input.types.length }));
138
+ const provider = new NativeQueuedProvider([
139
+ { content: null, toolCalls: [{ id: 'c1', name: 'save_schema', arguments: { types: ['Creator', 'Campaign'] } }] },
140
+ { content: 'All set — I created Creator and Campaign.', toolCalls: [] },
141
+ ]);
142
+ const foundry: ToolProvider = {
143
+ name: 'foundry',
144
+ tools: [{ name: 'save_schema', description: 'persist', inputSchema: { type: 'object' }, handler }],
145
+ };
146
+ const res = await runAgentTurn({
147
+ provider,
148
+ toolProviders: [foundry],
149
+ messages: msgs('track creators and campaigns'),
150
+ context: { tenant: 'x' },
151
+ });
152
+ expect(handler).toHaveBeenCalledWith({ types: ['Creator', 'Campaign'] }, { tenant: 'x' });
153
+ expect(res.toolCalls).toEqual([
154
+ { tool: 'save_schema', input: { types: ['Creator', 'Campaign'] }, output: { saved: 2 } },
155
+ ]);
156
+ // The final is the model's REAL text — no JSON echo, no synthesized fallback needed.
157
+ expect(res.finalText).toBe('All set — I created Creator and Campaign.');
158
+ expect(res.steps).toBe(2);
159
+ });
160
+
161
+ it('does NOT retry a satisfied call (unlike the forced-json string protocol)', async () => {
162
+ const handler = vi.fn(async () => ({ ok: true }));
163
+ const provider = new NativeQueuedProvider([
164
+ { content: null, toolCalls: [{ id: 'c1', name: 'save', arguments: {} }] },
165
+ { content: 'Done.', toolCalls: [] },
166
+ ]);
167
+ const p: ToolProvider = { name: 'p', tools: [{ name: 'save', description: 'd', handler }] };
168
+ const res = await runAgentTurn({ provider, toolProviders: [p], messages: msgs('go') });
169
+ expect(handler).toHaveBeenCalledTimes(1); // one clean call, not N retries
170
+ expect(res.finalText).toBe('Done.');
171
+ });
172
+
173
+ it('captures a handler error in the native loop', async () => {
174
+ const provider = new NativeQueuedProvider([
175
+ { content: null, toolCalls: [{ id: 'c1', name: 'boom', arguments: {} }] },
176
+ { content: 'handled', toolCalls: [] },
177
+ ]);
178
+ const p: ToolProvider = {
179
+ name: 'p',
180
+ tools: [{ name: 'boom', description: 'd', handler: async () => { throw new Error('db down'); } }],
181
+ };
182
+ const res = await runAgentTurn({ provider, toolProviders: [p], messages: msgs('go') });
183
+ expect(res.toolCalls[0].error).toBe('db down');
184
+ expect(res.finalText).toBe('handled');
185
+ });
186
+ });
@@ -10,6 +10,7 @@
10
10
  * changing the ToolProvider API.
11
11
  */
12
12
  import { extractJson } from '../utils/json-extraction';
13
+ import type { NativeChatMessage, NativeToolSpec } from '../types';
13
14
  import type {
14
15
  AgentMessage,
15
16
  AgentToolCall,
@@ -75,9 +76,76 @@ function parseToolCall(content: string): ParsedToolCall | null {
75
76
  return { tool: obj.tool, input: 'input' in obj ? obj.input : {} };
76
77
  }
77
78
 
79
+ /** Native (structured) tool loop for providers that implement generateWithTools.
80
+ * Tool calls come back structured (no JSON-in-text parsing) and finals are real
81
+ * text, so there's no ambiguity and the model doesn't re-emit a satisfied call. */
82
+ async function runNativeToolLoop(
83
+ opts: RunAgentOptions,
84
+ registry: Map<string, ToolDefinition>,
85
+ ): Promise<AgentTurnResult> {
86
+ const { provider, context = {}, maxSteps = 6, generateOptions } = opts;
87
+ const system = opts.systemPrompt?.trim() ?? '';
88
+ const toolSpecs: NativeToolSpec[] = [...registry.values()].map((t) => ({
89
+ name: t.name,
90
+ description: t.description,
91
+ parameters: t.inputSchema ?? { type: 'object', properties: {} },
92
+ }));
93
+
94
+ const messages: NativeChatMessage[] = opts.messages.map((m) => ({
95
+ role: m.role === 'tool' ? 'tool' : m.role,
96
+ content: m.content,
97
+ }));
98
+ const toolCalls: AgentToolCall[] = [];
99
+ const toAgent = (): AgentMessage[] =>
100
+ messages.map((m) => ({ role: m.role, content: m.content ?? '' }));
101
+
102
+ for (let step = 1; step <= maxSteps; step++) {
103
+ const resp = await provider.generateWithTools!(messages, system, toolSpecs, generateOptions);
104
+ if (!resp.toolCalls.length) {
105
+ messages.push({ role: 'assistant', content: resp.content ?? '' });
106
+ return { finalText: resp.content ?? '', toolCalls, messages: toAgent(), steps: step, stoppedReason: 'final' };
107
+ }
108
+ messages.push({ role: 'assistant', content: resp.content, toolCalls: resp.toolCalls });
109
+ for (const call of resp.toolCalls) {
110
+ const record: AgentToolCall = { tool: call.name, input: call.arguments };
111
+ const tool = registry.get(call.name);
112
+ if (!tool) {
113
+ record.error = `unknown tool '${call.name}'`;
114
+ messages.push({ role: 'tool', toolCallId: call.id, content: `ERROR: ${record.error}` });
115
+ } else {
116
+ try {
117
+ const output = await tool.handler(call.arguments, context);
118
+ record.output = output;
119
+ messages.push({ role: 'tool', toolCallId: call.id, content: JSON.stringify(output ?? null) });
120
+ } catch (err) {
121
+ record.error = err instanceof Error ? err.message : String(err);
122
+ messages.push({ role: 'tool', toolCallId: call.id, content: `ERROR: ${record.error}` });
123
+ }
124
+ }
125
+ toolCalls.push(record);
126
+ }
127
+ }
128
+
129
+ // Budget exhausted — ask for a plain-text wrap-up (tool_choice stays 'auto').
130
+ const resp = await provider.generateWithTools!(
131
+ messages,
132
+ `${system}\n\nYou have reached the tool-call limit. Reply to the user now in plain text.`,
133
+ toolSpecs,
134
+ generateOptions,
135
+ );
136
+ messages.push({ role: 'assistant', content: resp.content ?? '' });
137
+ return { finalText: resp.content ?? '', toolCalls, messages: toAgent(), steps: maxSteps, stoppedReason: 'max_steps' };
138
+ }
139
+
78
140
  export async function runAgentTurn(opts: RunAgentOptions): Promise<AgentTurnResult> {
79
141
  const { provider, toolProviders, context = {}, maxSteps = 6, generateOptions } = opts;
80
142
  const registry = buildToolRegistry(toolProviders);
143
+
144
+ // Prefer native structured tool-calling when the provider supports it.
145
+ if (typeof provider.generateWithTools === 'function' && registry.size > 0) {
146
+ return runNativeToolLoop(opts, registry);
147
+ }
148
+
81
149
  const protocol = toolProtocolPrompt(registry);
82
150
  const baseSystem = [opts.systemPrompt?.trim(), protocol].filter(Boolean).join('\n\n');
83
151
 
package/src/index.ts CHANGED
@@ -11,6 +11,10 @@ export type {
11
11
  JsonExtractionResult,
12
12
  ValidationResult,
13
13
  LLMServiceConfig,
14
+ NativeToolSpec,
15
+ NativeToolCall,
16
+ NativeChatMessage,
17
+ NativeToolResponse,
14
18
  } from './types';
15
19
  export { LLM_DEFAULTS } from './types';
16
20
 
@@ -1,4 +1,10 @@
1
- import type { LLMResponse, GenerateOptions } from '../types';
1
+ import type {
2
+ LLMResponse,
3
+ GenerateOptions,
4
+ NativeToolSpec,
5
+ NativeChatMessage,
6
+ NativeToolResponse,
7
+ } from '../types';
2
8
 
3
9
  /**
4
10
  * Contract that every LLM provider must implement.
@@ -29,6 +35,20 @@ export interface ILLMProvider {
29
35
  options?: GenerateOptions
30
36
  ): Promise<LLMResponse>;
31
37
 
38
+ /**
39
+ * Optional NATIVE (structured) tool-calling. Providers backed by a real tools
40
+ * API (OpenAI functions, Anthropic tool blocks) implement this so the agent
41
+ * harness can use structured tool calls + plain-text finals instead of the
42
+ * fragile JSON-in-text protocol. If omitted, the harness falls back to the
43
+ * string protocol — so existing providers (Mock, etc.) keep working unchanged.
44
+ */
45
+ generateWithTools?(
46
+ messages: NativeChatMessage[],
47
+ systemPrompt: string,
48
+ tools: NativeToolSpec[],
49
+ options?: GenerateOptions
50
+ ): Promise<NativeToolResponse>;
51
+
32
52
  /** Check if the provider is properly configured and available */
33
53
  isAvailable(): boolean;
34
54
 
@@ -10,7 +10,14 @@
10
10
  */
11
11
 
12
12
  import type { ILLMProvider } from './interface';
13
- import type { LLMResponse, GenerateOptions, ProviderConfig } from '../types';
13
+ import type {
14
+ LLMResponse,
15
+ GenerateOptions,
16
+ ProviderConfig,
17
+ NativeToolSpec,
18
+ NativeChatMessage,
19
+ NativeToolResponse,
20
+ } from '../types';
14
21
  import { LLM_DEFAULTS } from '../types';
15
22
  import { LLMProviderError } from '../errors';
16
23
  import type { LLMErrorCode } from '../errors';
@@ -54,6 +61,30 @@ interface OpenAIErrorResponse {
54
61
  };
55
62
  }
56
63
 
64
+ interface OpenAIToolCall {
65
+ id: string;
66
+ type: 'function';
67
+ function: { name: string; arguments: string };
68
+ }
69
+
70
+ interface OpenAIToolMessage {
71
+ role: 'system' | 'user' | 'assistant' | 'tool';
72
+ content: string | null;
73
+ tool_calls?: OpenAIToolCall[];
74
+ tool_call_id?: string;
75
+ }
76
+
77
+ interface OpenAIToolsResponse {
78
+ id: string;
79
+ model: string;
80
+ choices: Array<{
81
+ index: number;
82
+ message: { role: string; content: string | null; tool_calls?: OpenAIToolCall[] };
83
+ finish_reason: string;
84
+ }>;
85
+ usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
86
+ }
87
+
57
88
  const OPENAI_MODELS = [
58
89
  'gpt-5.2',
59
90
  'gpt-5.1',
@@ -210,6 +241,136 @@ export class OpenAIProvider implements ILLMProvider {
210
241
  }
211
242
  }
212
243
 
244
+ /**
245
+ * Native tool-calling via OpenAI's `tools` API. Unlike generate(), this does
246
+ * NOT force response_format=json_object — so the model returns structured
247
+ * tool_calls when calling a tool and plain text when it's done, which the agent
248
+ * harness can tell apart unambiguously (no JSON-in-text parsing).
249
+ */
250
+ async generateWithTools(
251
+ messages: NativeChatMessage[],
252
+ systemPrompt: string,
253
+ tools: NativeToolSpec[],
254
+ options?: GenerateOptions
255
+ ): Promise<NativeToolResponse> {
256
+ if (!this.isAvailable()) {
257
+ throw new LLMProviderError(
258
+ 'OpenAI API key not configured',
259
+ 'AUTHENTICATION_ERROR',
260
+ false,
261
+ undefined,
262
+ this.name
263
+ );
264
+ }
265
+
266
+ const model = options?.model ?? this.config.defaultModel;
267
+ const isBaseGpt5 = model === 'gpt-5' || model === 'gpt-5.1' || model === 'gpt-5.2';
268
+ const isFineTuned = model.startsWith('ft:');
269
+ const temperature = isBaseGpt5 && !isFineTuned
270
+ ? 1.0
271
+ : (options?.temperature ?? this.config.defaultTemperature);
272
+ const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
273
+ const timeout = options?.timeout ?? this.config.timeoutMs;
274
+
275
+ const oaiMessages: OpenAIToolMessage[] = [
276
+ { role: 'system', content: systemPrompt },
277
+ ...messages.map((m): OpenAIToolMessage => {
278
+ if (m.role === 'tool') {
279
+ return { role: 'tool', content: m.content ?? '', tool_call_id: m.toolCallId };
280
+ }
281
+ if (m.role === 'assistant' && m.toolCalls?.length) {
282
+ return {
283
+ role: 'assistant',
284
+ content: m.content,
285
+ tool_calls: m.toolCalls.map((tc) => ({
286
+ id: tc.id,
287
+ type: 'function',
288
+ function: { name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) },
289
+ })),
290
+ };
291
+ }
292
+ return { role: m.role, content: m.content ?? '' };
293
+ }),
294
+ ];
295
+
296
+ const requestBody = {
297
+ model,
298
+ messages: oaiMessages,
299
+ temperature,
300
+ max_tokens: maxTokens,
301
+ tools: tools.map((t) => ({
302
+ type: 'function' as const,
303
+ function: { name: t.name, description: t.description, parameters: t.parameters },
304
+ })),
305
+ tool_choice: 'auto' as const,
306
+ };
307
+
308
+ const controller = new AbortController();
309
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
310
+ try {
311
+ const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
312
+ method: 'POST',
313
+ headers: {
314
+ 'Content-Type': 'application/json',
315
+ Authorization: `Bearer ${this.config.apiKey}`,
316
+ },
317
+ body: JSON.stringify(requestBody),
318
+ signal: controller.signal,
319
+ });
320
+ clearTimeout(timeoutId);
321
+
322
+ if (!response.ok) {
323
+ const errorData = (await response.json().catch(() => ({
324
+ error: { message: 'Unknown error' },
325
+ }))) as OpenAIErrorResponse;
326
+ throw this.handleApiError(response.status, errorData);
327
+ }
328
+
329
+ const data = (await response.json()) as OpenAIToolsResponse;
330
+ const choice = data.choices?.[0];
331
+ const msg = choice?.message;
332
+ const toolCalls = (msg?.tool_calls ?? []).map((tc) => {
333
+ let args: unknown = {};
334
+ try {
335
+ args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
336
+ } catch {
337
+ args = { _raw: tc.function.arguments };
338
+ }
339
+ return { id: tc.id, name: tc.function.name, arguments: args };
340
+ });
341
+ return {
342
+ content: msg?.content ?? null,
343
+ toolCalls,
344
+ usage: {
345
+ promptTokens: data.usage?.prompt_tokens ?? 0,
346
+ completionTokens: data.usage?.completion_tokens ?? 0,
347
+ totalTokens: data.usage?.total_tokens ?? 0,
348
+ },
349
+ model: data.model,
350
+ finishReason: choice?.finish_reason,
351
+ };
352
+ } catch (error) {
353
+ clearTimeout(timeoutId);
354
+ if (error instanceof LLMProviderError) throw error;
355
+ if (error instanceof Error && error.name === 'AbortError') {
356
+ throw new LLMProviderError(
357
+ `Request timed out after ${timeout}ms`,
358
+ 'TIMEOUT',
359
+ true,
360
+ undefined,
361
+ this.name
362
+ );
363
+ }
364
+ throw new LLMProviderError(
365
+ `OpenAI tools request failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
366
+ 'UNKNOWN_ERROR',
367
+ true,
368
+ undefined,
369
+ this.name
370
+ );
371
+ }
372
+ }
373
+
213
374
  estimateCost(
214
375
  promptTokens: number,
215
376
  completionTokens: number,
package/src/types.ts CHANGED
@@ -41,6 +41,44 @@ export interface GenerateOptions {
41
41
  extra?: Record<string, unknown>;
42
42
  }
43
43
 
44
+ /**
45
+ * Native (structured) tool-calling types. Providers that support a real tools API
46
+ * (OpenAI functions, Anthropic tool blocks) implement ILLMProvider.generateWithTools
47
+ * so the agent harness can use structured calls instead of the JSON-in-text protocol.
48
+ */
49
+ export interface NativeToolSpec {
50
+ name: string;
51
+ description: string;
52
+ /** JSON Schema for the tool's arguments. */
53
+ parameters: Record<string, unknown>;
54
+ }
55
+
56
+ /** A structured tool call the model emitted (arguments already parsed from JSON). */
57
+ export interface NativeToolCall {
58
+ id: string;
59
+ name: string;
60
+ arguments: unknown;
61
+ }
62
+
63
+ /** One message in a native tool-calling conversation. */
64
+ export interface NativeChatMessage {
65
+ role: 'user' | 'assistant' | 'tool';
66
+ content: string | null;
67
+ /** Set on assistant turns that called tools. */
68
+ toolCalls?: NativeToolCall[];
69
+ /** Set on tool-result turns: which call this answers. */
70
+ toolCallId?: string;
71
+ }
72
+
73
+ /** Result of one native tool-calling round-trip. */
74
+ export interface NativeToolResponse {
75
+ content: string | null;
76
+ toolCalls: NativeToolCall[];
77
+ usage?: TokenUsage;
78
+ model?: string;
79
+ finishReason?: string;
80
+ }
81
+
44
82
  /**
45
83
  * Provider configuration — everything needed to construct a provider instance.
46
84
  */