loopengine 0.2.0 → 0.2.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.
@@ -273,16 +273,18 @@ export type AgentModelConfig = {
273
273
  model?: string;
274
274
  apiKey?: string;
275
275
  maxTokens?: number;
276
+ /** How much Claude thinks before answering; omitted, the model's own
277
+ * default. See AnthropicModelCallOptions.reasoningEffort
278
+ * (core/model-calls/anthropic-model-call.ts). */
279
+ reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
276
280
  } | {
277
281
  provider: 'openai';
278
282
  model: string;
279
283
  apiKey?: string;
280
284
  maxTokens?: number;
281
- /** Omit unless the model actually needs it — see
282
- * OpenAIModelCallOptions.reasoningEffort's own doc comment
283
- * (core/model-calls/openai-model-call.ts) for why a reasoning-
284
- * capable model (gpt-5.x) can require this be `'none'` just to use
285
- * function tools at all. */
285
+ /** How much the model reasons before answering; omitted, the
286
+ * model's own default. See OpenAIModelCallOptions.reasoningEffort
287
+ * (core/model-calls/openai-model-call.ts). */
286
288
  reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
287
289
  } | {
288
290
  provider: 'deepseek';
@@ -4,7 +4,14 @@ export interface AnthropicModelCallOptions {
4
4
  /** Defaults to the ANTHROPIC_API_KEY env var, same as the SDK itself. */
5
5
  apiKey?: string;
6
6
  model?: string;
7
+ /** Defaults to 4096, or 16000 with a reasoning effort set: thinking
8
+ * counts toward it. */
7
9
  maxTokens?: number;
10
+ /** How much Claude thinks before answering. 'none' turns thinking off;
11
+ * 'minimal' and 'low' to 'max' turn on adaptive thinking at that effort
12
+ * (Claude has no 'minimal', so it means 'low'). Omitted, the model's
13
+ * own default. */
14
+ reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
8
15
  /** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
9
16
  client?: Anthropic;
10
17
  }
@@ -13,7 +13,24 @@
13
13
  // Anthropic's own API expects a tool_use to be answered by a matching
14
14
  // tool_result. This file's only job is translating between that shape and
15
15
  // the SDK's own param/response types — see message-to-param below.
16
+ //
17
+ // With a reasoning effort set, Claude thinks before answering (adaptive
18
+ // thinking at that effort), and its thinking blocks come back signed. The
19
+ // API needs them sent back unchanged with the tool results they led to,
20
+ // so each rides in the conversation as an 'anthropic_thinking' block (the
21
+ // whole block as JSON in `data`), sent back only to Anthropic.
16
22
  import Anthropic from '@anthropic-ai/sdk';
23
+ /** The thinking (or redacted thinking) block an 'anthropic_thinking'
24
+ * block carries, or undefined if its data isn't one. */
25
+ function thinkingBlockParam(block) {
26
+ try {
27
+ const parsed = JSON.parse(block.data ?? '');
28
+ return parsed && (parsed.type === 'thinking' || parsed.type === 'redacted_thinking') ? parsed : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
17
34
  function toContentBlockParam(block) {
18
35
  if (block.type === 'tool_use') {
19
36
  return { type: 'tool_use', id: block.id, name: block.name, input: block.input ?? {} };
@@ -29,13 +46,27 @@ function toContentBlockParam(block) {
29
46
  function toMessageParam(message) {
30
47
  return {
31
48
  role: message.role === 'assistant' ? 'assistant' : 'user',
32
- content: typeof message.content === 'string' ? message.content : message.content.map(toContentBlockParam),
49
+ // Claude's own signed thinking goes back as it came. Blocks only
50
+ // another provider can use (DeepSeek's 'thinking', 'openai_reasoning')
51
+ // are left out; as text they'd be empty text blocks, which the API
52
+ // rejects.
53
+ content: typeof message.content === 'string'
54
+ ? message.content
55
+ : message.content.flatMap((block) => {
56
+ if (block.type === 'anthropic_thinking') {
57
+ const thinking = thinkingBlockParam(block);
58
+ return thinking ? [thinking] : [];
59
+ }
60
+ return ['text', 'tool_use', 'tool_result'].includes(block.type) ? [toContentBlockParam(block)] : [];
61
+ }),
33
62
  };
34
63
  }
35
64
  export function createAnthropicModelCall(options = {}) {
36
65
  const client = options.client ?? new Anthropic({ apiKey: options.apiKey });
37
66
  const model = options.model ?? 'claude-sonnet-5';
38
- const maxTokens = options.maxTokens ?? 4096;
67
+ const maxTokens = options.maxTokens ?? (options.reasoningEffort ? 16000 : 4096);
68
+ const effort = options.reasoningEffort === 'minimal' ? 'low' : options.reasoningEffort;
69
+ const reasoning = effort === undefined ? {} : effort === 'none' ? { thinking: { type: 'disabled' } } : { thinking: { type: 'adaptive' }, output_config: { effort } };
39
70
  return async (messages, system, tools) => {
40
71
  const response = await client.messages.create({
41
72
  model,
@@ -49,10 +80,13 @@ export function createAnthropicModelCall(options = {}) {
49
80
  input_schema: t.input_schema,
50
81
  }))
51
82
  : undefined,
83
+ ...reasoning,
52
84
  });
53
85
  const content = response.content.map((block) => {
54
86
  if (block.type === 'text')
55
87
  return { type: 'text', text: block.text };
88
+ if (block.type === 'thinking' || block.type === 'redacted_thinking')
89
+ return { type: 'anthropic_thinking', data: JSON.stringify(block) };
56
90
  if (block.type === 'tool_use') {
57
91
  return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
58
92
  }
@@ -9,16 +9,10 @@ export interface OpenAIModelCallOptions {
9
9
  * one you actually want. */
10
10
  model: string;
11
11
  maxTokens?: number;
12
- /** Omitted by default — a reasoning-capable model (the gpt-5.x family)
13
- * otherwise gets whatever effort level OpenAI defaults it to, which
14
- * some of these models refuse to combine with function tools on
15
- * /v1/chat/completions at all ("Function tools with reasoning_effort
16
- * are not supported ... set reasoning_effort to 'none'" — confirmed
17
- * live against gpt-5.6-luna). Set this to `'none'` for a model that
18
- * hits that error; every real loopengine call passes `tools` (even an
19
- * empty array is still the `tools` field being present), so there's no
20
- * tools-vs-no-tools branch here to get this wrong on. */
21
- reasoningEffort?: OpenAI.Chat.Completions.ChatCompletionReasoningEffort;
12
+ /** How much the model reasons before answering ('none' to 'max');
13
+ * omitted, the model's own default. Works with tools, since this calls
14
+ * /v1/responses. */
15
+ reasoningEffort?: OpenAI.ReasoningEffort;
22
16
  /** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
23
17
  client?: OpenAI;
24
18
  }
@@ -30,5 +24,15 @@ export declare function parseToolArguments(raw: string): Record<string, unknown>
30
24
  export declare function toMessageParams(message: Message): ChatMessage[];
31
25
  export declare function toStopReason(finishReason: string): string;
32
26
  export declare function toModelResponse(choice: OpenAI.Chat.Completions.ChatCompletion.Choice): ModelResponse;
27
+ type ResponseInputItem = OpenAI.Responses.ResponseInputItem;
28
+ /** One loopengine Message as Responses input items: an assistant turn's
29
+ * reasoning, text and tool calls in the order the model produced them,
30
+ * and a user turn's tool results (or text). Another provider's 'thinking'
31
+ * blocks are dropped: OpenAI can't use them. */
32
+ export declare function toResponsesInput(message: Message): ResponseInputItem[];
33
+ /** A Response as a ModelResponse: reasoning items as 'openai_reasoning'
34
+ * blocks, message text, and function calls as tool_use blocks (their
35
+ * call_id is the id a tool result answers). */
36
+ export declare function fromResponse(response: OpenAI.Responses.Response): ModelResponse;
33
37
  export declare function createOpenAIModelCall(options: OpenAIModelCallOptions): ModelCall;
34
38
  export {};
@@ -3,6 +3,20 @@
3
3
  // the canned fake in any agent and nothing in run-agent.ts, the adapters,
4
4
  // or any AgentConfig changes.
5
5
  //
6
+ // createOpenAIModelCall calls OpenAI's Responses API (/v1/responses):
7
+ // OpenAI's reasoning models refuse function tools together with any
8
+ // reasoning effort on /v1/chat/completions (every gpt-5.4 to gpt-6.1 model
9
+ // checked live, Nov 2026), so an agent with tools could only run them with
10
+ // reasoning off there. The Chat Completions translation below
11
+ // (toMessageParams/toStopReason/toModelResponse) stays for the
12
+ // OpenAI-compatible providers that reuse it.
13
+ //
14
+ // Responses is called statelessly (store: false): loopengine keeps the
15
+ // conversation itself, so each reasoning item comes back encrypted, rides
16
+ // in the conversation as an 'openai_reasoning' block, and is sent back
17
+ // with the tool results it led to, which the API needs to carry the
18
+ // model's reasoning across tool calls.
19
+ //
6
20
  // Structural mismatch worth knowing: loopengine's Message bundles every
7
21
  // tool_result from one turn into a single user-role message's content
8
22
  // array (mirroring Anthropic's own shape, which this framework's Message
@@ -152,23 +166,89 @@ export function toModelResponse(choice) {
152
166
  }
153
167
  return { stop_reason: toStopReason(choice.finish_reason), content };
154
168
  }
169
+ /** The reasoning item an 'openai_reasoning' block carries, or undefined if
170
+ * its data isn't one (it is sent back only as OpenAI returned it). */
171
+ function reasoningItem(block) {
172
+ try {
173
+ const item = JSON.parse(block.data ?? '');
174
+ return item && item.type === 'reasoning' ? item : undefined;
175
+ }
176
+ catch {
177
+ return undefined;
178
+ }
179
+ }
180
+ /** One loopengine Message as Responses input items: an assistant turn's
181
+ * reasoning, text and tool calls in the order the model produced them,
182
+ * and a user turn's tool results (or text). Another provider's 'thinking'
183
+ * blocks are dropped: OpenAI can't use them. */
184
+ export function toResponsesInput(message) {
185
+ const role = message.role === 'assistant' ? 'assistant' : 'user';
186
+ if (typeof message.content === 'string')
187
+ return [{ role, content: message.content }];
188
+ const items = [];
189
+ for (const block of message.content) {
190
+ if (block.type === 'openai_reasoning') {
191
+ const item = reasoningItem(block);
192
+ if (item)
193
+ items.push(item);
194
+ }
195
+ else if (block.type === 'tool_use') {
196
+ items.push({ type: 'function_call', call_id: block.id, name: block.name, arguments: JSON.stringify(block.input ?? {}) });
197
+ }
198
+ else if (block.type === 'tool_result') {
199
+ items.push({ type: 'function_call_output', call_id: block.tool_use_id, output: block.content ?? '' });
200
+ }
201
+ else if (block.type === 'text' && block.text) {
202
+ items.push({ role, content: block.text });
203
+ }
204
+ }
205
+ return items;
206
+ }
207
+ /** A Response as a ModelResponse: reasoning items as 'openai_reasoning'
208
+ * blocks, message text, and function calls as tool_use blocks (their
209
+ * call_id is the id a tool result answers). */
210
+ export function fromResponse(response) {
211
+ const content = [];
212
+ let calledTools = false;
213
+ for (const item of response.output) {
214
+ if (item.type === 'reasoning') {
215
+ content.push({ type: 'openai_reasoning', data: JSON.stringify(item) });
216
+ }
217
+ else if (item.type === 'message') {
218
+ const text = item.content.map((part) => (part.type === 'output_text' ? part.text : part.refusal)).join('');
219
+ if (text)
220
+ content.push({ type: 'text', text });
221
+ }
222
+ else if (item.type === 'function_call') {
223
+ calledTools = true;
224
+ content.push({ type: 'tool_use', id: item.call_id, name: item.name, input: parseToolArguments(item.arguments) });
225
+ }
226
+ }
227
+ const stop_reason = calledTools
228
+ ? 'tool_use'
229
+ : response.status === 'incomplete' && response.incomplete_details?.reason === 'max_output_tokens'
230
+ ? 'max_tokens'
231
+ : 'end_turn';
232
+ const usage = response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined;
233
+ return { stop_reason, content, ...(usage ? { usage } : {}) };
234
+ }
155
235
  export function createOpenAIModelCall(options) {
156
236
  const client = options.client ?? new OpenAI({ apiKey: options.apiKey });
157
- const maxTokens = options.maxTokens ?? 4096;
237
+ // Reasoning counts toward max_output_tokens.
238
+ const maxTokens = options.maxTokens ?? (options.reasoningEffort && options.reasoningEffort !== 'none' ? 16000 : 4096);
158
239
  return async (messages, system, tools) => {
159
- const response = await client.chat.completions.create({
240
+ const response = await client.responses.create({
160
241
  model: options.model,
161
- max_completion_tokens: maxTokens,
162
- messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
242
+ instructions: system,
243
+ input: messages.flatMap(toResponsesInput),
244
+ max_output_tokens: maxTokens,
163
245
  tools: tools.length
164
- ? tools.map((t) => ({
165
- type: 'function',
166
- function: { name: t.name, description: t.description, parameters: t.input_schema },
167
- }))
246
+ ? tools.map((t) => ({ type: 'function', name: t.name, description: t.description, parameters: t.input_schema, strict: false }))
168
247
  : undefined,
169
- reasoning_effort: options.reasoningEffort,
248
+ ...(options.reasoningEffort ? { reasoning: { effort: options.reasoningEffort } } : {}),
249
+ store: false,
250
+ include: ['reasoning.encrypted_content'],
170
251
  });
171
- const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
172
- return { ...toModelResponse(response.choices[0]), usage };
252
+ return fromResponse(response);
173
253
  };
174
254
  }
@@ -33,6 +33,10 @@ export interface ModelContentBlock {
33
33
  content?: string;
34
34
  /** tool_result blocks: true if the tool call failed or was denied. */
35
35
  is_error?: boolean;
36
+ /** Provider data carried opaquely and sent back to the same provider,
37
+ * e.g. an 'openai_reasoning' block's encrypted reasoning item (see
38
+ * model-calls/openai-model-call.ts). Never shown to a model as text. */
39
+ data?: string;
36
40
  /** tool_result blocks: the actauth decision's own reason this call was
37
41
  * allowed (e.g. "matched rule '...'"). Extra metadata riding alongside
38
42
  * `content`, not folded into it — every model-calls/* adapter
@@ -3,6 +3,8 @@ export declare class AgentEditNotSupportedError extends Error {
3
3
  export declare class AgentFileNotFoundError extends Error {
4
4
  }
5
5
  type Provider = 'anthropic' | 'openai' | 'deepseek' | 'kimi' | 'glm' | 'gemini' | 'xai' | 'meta';
6
+ /** The providers whose model calls take a reasoning effort. */
7
+ export declare const REASONING_PROVIDERS: Provider[];
6
8
  export interface AgentEditableFields {
7
9
  systemPrompt?: string;
8
10
  model?: {
@@ -48,6 +48,8 @@ export class AgentEditNotSupportedError extends Error {
48
48
  }
49
49
  export class AgentFileNotFoundError extends Error {
50
50
  }
51
+ /** The providers whose model calls take a reasoning effort. */
52
+ export const REASONING_PROVIDERS = ['openai', 'anthropic'];
51
53
  const MODEL_ENV_VAR = {
52
54
  anthropic: 'ANTHROPIC_API_KEY',
53
55
  openai: 'OPENAI_API_KEY',
@@ -178,12 +180,12 @@ export function editAgentFile(agentName, fields) {
178
180
  throw new AgentEditNotSupportedError('model.maxTokens must be a positive integer.');
179
181
  }
180
182
  const reasoningEffort = fields.model.reasoningEffort;
181
- // Only AgentModelConfig's 'openai' variant carries this (see its own
182
- // doc comment) — refusing rather than silently dropping it for
183
- // another provider, same "don't guess" posture the model-name check
184
- // right above already follows.
185
- if (reasoningEffort !== undefined && provider !== 'openai') {
186
- throw new AgentModelError(`model.reasoningEffort only applies to provider 'openai' — '${provider}' doesn't support it.`);
183
+ // Only AgentModelConfig's 'openai' and 'anthropic' variants carry this
184
+ // (see their doc comments) — refusing rather than silently dropping it
185
+ // for another provider, same "don't guess" posture the model-name
186
+ // check right above already follows.
187
+ if (reasoningEffort !== undefined && !REASONING_PROVIDERS.includes(provider)) {
188
+ throw new AgentModelError(`model.reasoningEffort only applies to providers ${REASONING_PROVIDERS.join(' and ')} — '${provider}' doesn't support it.`);
187
189
  }
188
190
  // Every field the form actually round-trips (not just provider/model)
189
191
  // — omitting maxTokens/reasoningEffort here, the way this used to
@@ -28,6 +28,7 @@
28
28
  import { devUiCss } from './dev-ui-styles.js';
29
29
  import { MESSAGING_ICONS } from './messaging-icons.js';
30
30
  import { consoleShellScript, consoleSidebarHtml } from './console-shell.js';
31
+ import { REASONING_PROVIDERS } from './agent-file-admin.js';
31
32
  export const agentsConfigPageHtml = `<!doctype html>
32
33
  <html lang="en">
33
34
  <head>
@@ -358,6 +359,8 @@ ${consoleSidebarHtml}
358
359
  ${consoleShellScript}
359
360
  <script>
360
361
  (function () {
362
+ // The providers whose models take a reasoning effort (web/agent-file-admin.ts).
363
+ var REASONING_PROVIDERS = ${JSON.stringify(REASONING_PROVIDERS)};
361
364
  var empty = document.getElementById('empty');
362
365
  var detail = document.getElementById('detail');
363
366
  var currentName = null;
@@ -792,7 +795,7 @@ ${consoleShellScript}
792
795
  '<dt>Provider</dt><dd>' + escapeHtml(cfg.model.provider) + '</dd>' +
793
796
  '<dt>Model</dt><dd>' + escapeHtml(cfg.model.model || '(provider default)') + '</dd>' +
794
797
  '<dt>Max tokens</dt><dd>' + escapeHtml(cfg.model.maxTokens != null ? cfg.model.maxTokens : '(default)') + '</dd>' +
795
- '<dt>Reasoning effort</dt><dd>' + escapeHtml(cfg.model.reasoningEffort || (cfg.model.provider === 'openai' ? '(provider default)' : 'n/a')) + '</dd>' +
798
+ '<dt>Reasoning effort</dt><dd>' + escapeHtml(cfg.model.reasoningEffort || (REASONING_PROVIDERS.indexOf(cfg.model.provider) !== -1 ? '(model default)' : 'n/a')) + '</dd>' +
796
799
  '</dl>' +
797
800
  '<form class="add-source" id="modelForm" style="display:none">' +
798
801
  '<label>Provider' +
@@ -808,7 +811,7 @@ ${consoleShellScript}
808
811
  '<label>Max tokens <span class="hint">(blank for provider default)</span>' +
809
812
  '<input type="number" name="maxTokens" min="1" step="1" value="' + (cfg.model.maxTokens != null ? escapeHtml(cfg.model.maxTokens) : '') + '">' +
810
813
  '</label>' +
811
- '<label>Reasoning effort <span class="hint">(openai only — some reasoning models reject function tools unless this is \\\'none\\\'; leave unset otherwise)</span>' +
814
+ '<label id="reasoningEffortField">Reasoning effort <span class="hint">(how much the model thinks before answering: none turns it off, unset uses the model\\\'s default)</span>' +
812
815
  '<select name="reasoningEffort">' +
813
816
  ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].map(function (v) {
814
817
  return '<option value="' + v + '"' + (v === (cfg.model.reasoningEffort || '') ? ' selected' : '') + '>' + (v || '(unset)') + '</option>';
@@ -918,7 +921,19 @@ ${consoleShellScript}
918
921
  var modelForm = overviewPanel.querySelector('#modelForm');
919
922
  var modelError = overviewPanel.querySelector('#modelError');
920
923
 
924
+ // Only OpenAI and Anthropic models take a reasoning effort: the
925
+ // field shows for those, and is cleared for any other provider so
926
+ // it's never saved with one.
927
+ var providerSelect = modelForm.querySelector('select[name="provider"]');
928
+ var reasoningField = modelForm.querySelector('#reasoningEffortField');
929
+ function syncReasoningField() {
930
+ var supported = REASONING_PROVIDERS.indexOf(providerSelect.value) !== -1;
931
+ reasoningField.style.display = supported ? '' : 'none';
932
+ if (!supported) reasoningField.querySelector('select').value = '';
933
+ }
934
+ providerSelect.addEventListener('change', syncReasoningField);
921
935
  editModelBtn.addEventListener('click', function () {
936
+ syncReasoningField();
922
937
  modelDisplay.style.display = 'none';
923
938
  editModelBtn.style.display = 'none';
924
939
  modelForm.style.display = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",