@lowire/loop 0.0.8 → 0.0.10

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/lib/loop.d.ts CHANGED
@@ -14,7 +14,34 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import type * as types from './types';
17
- export type LoopOptions = types.CompletionOptions & {
17
+ type PromiseOrValue<T> = T | Promise<T>;
18
+ export type LoopEvents = {
19
+ onBeforeTurn?: (params: {
20
+ conversation: types.Conversation;
21
+ totalUsage: types.Usage;
22
+ budgetTokens: number;
23
+ }) => PromiseOrValue<'continue' | 'break' | void>;
24
+ onAfterTurn?: (params: {
25
+ assistantMessage: types.AssistantMessage;
26
+ totalUsage: types.Usage;
27
+ budgetTokens: number;
28
+ }) => PromiseOrValue<'continue' | 'break' | void>;
29
+ onBeforeToolCall?: (params: {
30
+ assistantMessage: types.AssistantMessage;
31
+ toolCall: types.ToolCallContentPart;
32
+ }) => PromiseOrValue<'continue' | 'break' | 'disallow' | void>;
33
+ onAfterToolCall?: (params: {
34
+ assistantMessage: types.AssistantMessage;
35
+ toolCall: types.ToolCallContentPart;
36
+ result: types.ToolResult;
37
+ }) => PromiseOrValue<'continue' | 'break' | 'disallow' | void>;
38
+ onToolCallError?: (params: {
39
+ assistantMessage: types.AssistantMessage;
40
+ toolCall: types.ToolCallContentPart;
41
+ error: Error;
42
+ }) => PromiseOrValue<'continue' | 'break' | void>;
43
+ };
44
+ export type LoopOptions = types.CompletionOptions & LoopEvents & {
18
45
  tools?: types.Tool[];
19
46
  callTool?: types.ToolCallback;
20
47
  maxTurns?: number;
@@ -23,12 +50,6 @@ export type LoopOptions = types.CompletionOptions & {
23
50
  messages: types.ReplayCache;
24
51
  secrets: Record<string, string>;
25
52
  };
26
- beforeTurn?: (params: {
27
- turn: number;
28
- conversation: types.Conversation;
29
- summarizedConversation?: types.Conversation;
30
- usage: types.Usage;
31
- }) => 'break' | 'continue' | void;
32
53
  summarize?: boolean;
33
54
  };
34
55
  export declare class Loop {
@@ -42,7 +63,9 @@ export declare class Loop {
42
63
  result?: T;
43
64
  status: 'ok' | 'break';
44
65
  usage: types.Usage;
66
+ turns: number;
45
67
  }>;
46
68
  private _summarizeConversation;
47
69
  cache(): types.ReplayCache;
48
70
  }
71
+ export {};
package/lib/loop.js CHANGED
@@ -43,27 +43,37 @@ class Loop {
43
43
  tools: allTools,
44
44
  };
45
45
  const debug = options.debug;
46
+ let budgetTokens = options.maxTokens ?? 100_000;
46
47
  const totalUsage = { input: 0, output: 0 };
47
48
  debug?.('lowire:loop')(`Starting ${this._provider.name} loop`, task);
48
49
  const maxTurns = options.maxTurns || 100;
49
- for (let turn = 0; turn < maxTurns; ++turn) {
50
- debug?.('lowire:loop')(`Turn ${turn + 1} of (max ${maxTurns})`);
50
+ for (let turns = 0; turns < maxTurns; ++turns) {
51
+ if (budgetTokens <= 0)
52
+ throw new Error(`Budget tokens ${options.maxTokens} exhausted`);
53
+ debug?.('lowire:loop')(`Turn ${turns + 1} of (max ${maxTurns})`);
51
54
  const caches = options.cache ? {
52
55
  input: options.cache.messages,
53
56
  output: this._cacheOutput,
54
57
  secrets: options.cache.secrets
55
58
  } : undefined;
56
59
  const summarizedConversation = options.summarize ? this._summarizeConversation(task, conversation, options) : conversation;
57
- const status = options.beforeTurn?.({ turn, conversation, summarizedConversation, usage: totalUsage });
58
- if (status === 'break')
59
- return { status: 'break', usage: totalUsage };
60
+ const beforeStatus = await options.onBeforeTurn?.({ conversation: summarizedConversation, totalUsage, budgetTokens });
61
+ if (beforeStatus === 'break')
62
+ return { status: 'break', usage: totalUsage, turns };
60
63
  debug?.('lowire:loop')(`Request`, JSON.stringify({ ...summarizedConversation, tools: `${summarizedConversation.tools.length} tools` }, null, 2));
61
- const { result: assistantMessage, usage } = await (0, cache_1.cachedComplete)(this._provider, summarizedConversation, caches, options);
64
+ const { result: assistantMessage, usage } = await (0, cache_1.cachedComplete)(this._provider, summarizedConversation, caches, {
65
+ ...options,
66
+ maxTokens: budgetTokens,
67
+ });
62
68
  const intent = assistantMessage.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
63
- debug?.('lowire:loop')('Usage', `input: ${usage.input}, output: ${usage.output}`);
64
- debug?.('lowire:loop')('Assistant', intent, JSON.stringify(assistantMessage.content, null, 2));
65
69
  totalUsage.input += usage.input;
66
70
  totalUsage.output += usage.output;
71
+ budgetTokens -= usage.input + usage.output;
72
+ debug?.('lowire:loop')('Usage', `input: ${usage.input}, output: ${usage.output}`);
73
+ debug?.('lowire:loop')('Assistant', intent, JSON.stringify(assistantMessage.content, null, 2));
74
+ const afterStatus = await options.onAfterTurn?.({ assistantMessage, totalUsage, budgetTokens });
75
+ if (afterStatus === 'break')
76
+ return { status: 'break', usage: totalUsage, turns };
67
77
  conversation.messages.push(assistantMessage);
68
78
  const toolCalls = assistantMessage.content.filter(part => part.type === 'tool_call');
69
79
  if (toolCalls.length === 0) {
@@ -74,7 +84,17 @@ class Loop {
74
84
  const { name, arguments: args } = toolCall;
75
85
  debug?.('lowire:loop')('Call tool', name, JSON.stringify(args, null, 2));
76
86
  if (name === 'report_result')
77
- return { result: args, status: 'ok', usage: totalUsage };
87
+ return { result: args, status: 'ok', usage: totalUsage, turns };
88
+ const status = await options.onBeforeToolCall?.({ assistantMessage, toolCall });
89
+ if (status === 'break')
90
+ return { status: 'break', usage: totalUsage, turns };
91
+ if (status === 'disallow') {
92
+ toolCall.result = {
93
+ content: [{ type: 'text', text: 'Tool call is disallowed.' }],
94
+ isError: true,
95
+ };
96
+ continue;
97
+ }
78
98
  try {
79
99
  const result = await options.callTool({
80
100
  name,
@@ -89,23 +109,27 @@ class Loop {
89
109
  });
90
110
  const text = result.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
91
111
  debug?.('lowire:loop')('Tool result', text, JSON.stringify(result, null, 2));
112
+ const status = await options.onAfterToolCall?.({ assistantMessage, toolCall, result });
113
+ if (status === 'break')
114
+ return { status: 'break', usage: totalUsage, turns };
115
+ if (status === 'disallow') {
116
+ toolCall.result = {
117
+ content: [{ type: 'text', text: 'Tool result is disallowed to be reported.' }],
118
+ isError: true,
119
+ };
120
+ continue;
121
+ }
92
122
  toolCall.result = result;
93
123
  }
94
124
  catch (error) {
95
125
  const errorMessage = `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`;
96
- debug?.('lowire:loop')('Tool error', errorMessage, String(error));
126
+ const status = await options.onToolCallError?.({ assistantMessage, toolCall, error });
127
+ if (status === 'break')
128
+ return { status: 'break', usage: totalUsage, turns };
97
129
  toolCall.result = {
98
130
  content: [{ type: 'text', text: errorMessage }],
99
131
  isError: true,
100
132
  };
101
- // Skip remaining tool calls for this iteration
102
- for (const remainingToolCall of toolCalls.slice(toolCalls.indexOf(toolCall) + 1)) {
103
- remainingToolCall.result = {
104
- content: [{ type: 'text', text: `This tool call is skipped due to previous error.` }],
105
- isError: true,
106
- };
107
- }
108
- break;
109
133
  }
110
134
  }
111
135
  }
@@ -19,16 +19,17 @@ exports.Anthropic = void 0;
19
19
  class Anthropic {
20
20
  name = 'anthropic';
21
21
  async complete(conversation, options) {
22
+ const maxTokens = Math.min(options.maxTokens ?? 32768, 32768);
22
23
  const response = await create({
23
24
  model: options.model,
24
- max_tokens: options.maxTokens ?? 32768,
25
+ max_tokens: maxTokens,
25
26
  temperature: options.temperature,
26
27
  system: systemPrompt(conversation.systemPrompt),
27
28
  messages: conversation.messages.map(toAnthropicMessageParts).flat(),
28
29
  tools: conversation.tools.map(toAnthropicTool),
29
30
  thinking: options.reasoning ? {
30
31
  type: 'enabled',
31
- budget_tokens: options.maxTokens ? Math.round(options.maxTokens / 10) : 1024,
32
+ budget_tokens: options.maxTokens ? Math.round(maxTokens / 10) : 1024,
32
33
  } : undefined,
33
34
  }, options);
34
35
  const result = toAssistantMessage(response);
@@ -52,16 +52,18 @@ class Github {
52
52
  if (!response || !response.choices.length)
53
53
  throw new Error('Failed to get response from GitHub Copilot');
54
54
  const result = { role: 'assistant', content: [] };
55
- const message = response.choices[0].message;
56
- if (message.content)
57
- result.content.push({ type: 'text', text: message.content });
58
- for (const entry of message.tool_calls || []) {
59
- if (entry.type !== 'function')
60
- continue;
61
- const { toolCall, intent } = toToolCall(entry);
62
- if (intent)
63
- result.content.push({ type: 'text', text: intent, copilotToolCallId: toolCall.id });
64
- result.content.push(toolCall);
55
+ for (const choice of response.choices) {
56
+ const message = choice.message;
57
+ if (message.content)
58
+ result.content.push({ type: 'text', text: message.content });
59
+ for (const entry of message.tool_calls || []) {
60
+ if (entry.type !== 'function')
61
+ continue;
62
+ const { toolCall, intent } = toToolCall(entry);
63
+ if (intent)
64
+ result.content.push({ type: 'text', text: intent, copilotToolCallId: toolCall.id });
65
+ result.content.push(toolCall);
66
+ }
65
67
  }
66
68
  const usage = {
67
69
  input: response.usage?.prompt_tokens ?? 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowire/loop",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Small agentic loop",
5
5
  "repository": {
6
6
  "type": "git",