File size: 4,012 Bytes
f0634fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import { describe, expect, it, vi } from 'vitest';

import type {
  IAgentToolExecutorService,
  ToolExecutionResult,
} from '#/agent/toolExecutor/toolExecutor';
import { createMachineTools } from '#/agent/loop/machine/tools';
import type { ToolCall } from '#human/llm/message';
import type { ToolExecuteInput } from '#human/tool/executor';
import type { ToolInfo } from '#/tool/toolContract';

function call(id: string, name: string): ToolCall {
  return { type: 'function', id, name, arguments: '{}' };
}

function input(toolCall: ToolCall): ToolExecuteInput {
  return { toolCall, signal: new AbortController().signal };
}

function createRecordingExecutor(): {
  toolExecutor: IAgentToolExecutorService;
  batches: string[][];
} {
  const batches: string[][] = [];
  const toolExecutor = {
    execute: async function* (calls: ToolCall[]) {
      batches.push(calls.map((toolCall) => toolCall.id));
      for (const toolCall of calls) {
        yield {
          toolCallId: toolCall.id,
          toolName: toolCall.name,
          result: { output: `ok:${toolCall.id}` },
        } satisfies ToolExecutionResult;
      }
    },
  } as unknown as IAgentToolExecutorService;
  return { toolExecutor, batches };
}

const toolInfos: ToolInfo[] = [
  { name: 'Bash', description: 'run a command', source: 'builtin' },
  { name: 'Read', description: 'read a file', source: 'builtin' },
];

describe('createMachineTools duplicate tool call ids', () => {
  it('runs one batch per unique id and settles the superseded pending entry', async () => {
    const { toolExecutor, batches } = createRecordingExecutor();
    const onBatchError = vi.fn();
    const tools = createMachineTools({
      toolExecutor,
      toolInfos: () => toolInfos,
      turnId: () => 1,
      onBatchError,
    });
    tools.sync();
    const bash = tools.tools.find((tool) => tool.name === 'Bash');
    const read = tools.tools.find((tool) => tool.name === 'Read');
    if (bash === undefined || read === undefined) throw new Error('missing tool definitions');

    tools.beginBatch([call('t1', 'Bash'), call('t1', 'Bash'), call('t2', 'Read')]);
    const first = bash.execute(input(call('t1', 'Bash')));
    const second = bash.execute(input(call('t1', 'Bash')));
    const third = read.execute(input(call('t2', 'Read')));

    const [firstResult, secondResult, thirdResult] = await Promise.all([first, second, third]);

    expect(batches).toEqual([['t1', 't2']]);
    expect(onBatchError).not.toHaveBeenCalled();
    expect(firstResult.isError).toBe(true);
    expect(secondResult.content).toEqual([{ type: 'text', text: 'ok:t1' }]);
    expect(secondResult.isError).toBeUndefined();
    expect(thirdResult.content).toEqual([{ type: 'text', text: 'ok:t2' }]);
    expect(tools.extras.has('t1')).toBe(true);
    expect(tools.extras.has('t2')).toBe(true);
  });

  it('reports executor failures through onBatchError and settles every pending call', async () => {
    const toolExecutor = {
      execute: (): AsyncIterable<ToolExecutionResult> => ({
        [Symbol.asyncIterator]() {
          return { next: () => Promise.reject(new Error('executor exploded')) };
        },
      }),
    } as unknown as IAgentToolExecutorService;
    const onBatchError = vi.fn();
    const tools = createMachineTools({
      toolExecutor,
      toolInfos: () => toolInfos,
      turnId: () => 1,
      onBatchError,
    });
    tools.sync();
    const bash = tools.tools.find((tool) => tool.name === 'Bash');
    const read = tools.tools.find((tool) => tool.name === 'Read');
    if (bash === undefined || read === undefined) throw new Error('missing tool definitions');

    tools.beginBatch([call('t1', 'Bash'), call('t2', 'Read')]);
    const [firstResult, secondResult] = await Promise.all([
      bash.execute(input(call('t1', 'Bash'))),
      read.execute(input(call('t2', 'Read'))),
    ]);

    expect(onBatchError).toHaveBeenCalledTimes(1);
    expect(firstResult.isError).toBe(true);
    expect(secondResult.isError).toBe(true);
  });
});