@lowire/loop 0.0.8 → 0.0.9

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,33 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import type * as types from './types';
17
- export type LoopOptions = types.CompletionOptions & {
17
+ export type LoopEvents = {
18
+ onBeforeTurn?: (params: {
19
+ conversation: types.Conversation;
20
+ totalUsage: types.Usage;
21
+ budgetTokens: number;
22
+ }) => Promise<'break' | 'continue' | void>;
23
+ onAfterTurn?: (params: {
24
+ assistantMessage: types.AssistantMessage;
25
+ totalUsage: types.Usage;
26
+ budgetTokens: number;
27
+ }) => Promise<'break' | 'continue' | void>;
28
+ onBeforeToolCall?: (params: {
29
+ assistantMessage: types.AssistantMessage;
30
+ toolCall: types.ToolCallContentPart;
31
+ }) => Promise<'allowed' | 'disallowed' | void>;
32
+ onAfterToolCall?: (params: {
33
+ assistantMessage: types.AssistantMessage;
34
+ toolCall: types.ToolCallContentPart;
35
+ result: types.ToolResult;
36
+ }) => Promise<'allowed' | 'disallowed' | void>;
37
+ onToolCallError?: (params: {
38
+ assistantMessage: types.AssistantMessage;
39
+ toolCall: types.ToolCallContentPart;
40
+ error: Error;
41
+ }) => Promise<void>;
42
+ };
43
+ export type LoopOptions = types.CompletionOptions & LoopEvents & {
18
44
  tools?: types.Tool[];
19
45
  callTool?: types.ToolCallback;
20
46
  maxTurns?: number;
@@ -23,12 +49,6 @@ export type LoopOptions = types.CompletionOptions & {
23
49
  messages: types.ReplayCache;
24
50
  secrets: Record<string, string>;
25
51
  };
26
- beforeTurn?: (params: {
27
- turn: number;
28
- conversation: types.Conversation;
29
- summarizedConversation?: types.Conversation;
30
- usage: types.Usage;
31
- }) => 'break' | 'continue' | void;
32
52
  summarize?: boolean;
33
53
  };
34
54
  export declare class Loop {
@@ -42,6 +62,7 @@ export declare class Loop {
42
62
  result?: T;
43
63
  status: 'ok' | 'break';
44
64
  usage: types.Usage;
65
+ turns: number;
45
66
  }>;
46
67
  private _summarizeConversation;
47
68
  cache(): types.ReplayCache;
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,15 @@ 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 === 'disallowed') {
90
+ toolCall.result = {
91
+ content: [{ type: 'text', text: 'Tool call is disallowed.' }],
92
+ isError: true,
93
+ };
94
+ continue;
95
+ }
78
96
  try {
79
97
  const result = await options.callTool({
80
98
  name,
@@ -89,23 +107,23 @@ class Loop {
89
107
  });
90
108
  const text = result.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
91
109
  debug?.('lowire:loop')('Tool result', text, JSON.stringify(result, null, 2));
110
+ const status = await options.onAfterToolCall?.({ assistantMessage, toolCall, result });
111
+ if (status === 'disallowed') {
112
+ toolCall.result = {
113
+ content: [{ type: 'text', text: 'Tool result is disallowed to be reported.' }],
114
+ isError: true,
115
+ };
116
+ continue;
117
+ }
92
118
  toolCall.result = result;
93
119
  }
94
120
  catch (error) {
95
121
  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));
122
+ await options.onToolCallError?.({ assistantMessage, toolCall, error });
97
123
  toolCall.result = {
98
124
  content: [{ type: 'text', text: errorMessage }],
99
125
  isError: true,
100
126
  };
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
127
  }
110
128
  }
111
129
  }
@@ -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.9",
4
4
  "description": "Small agentic loop",
5
5
  "repository": {
6
6
  "type": "git",