LLM getNeighbours tool + contiguous region transfers (Stage 2 of #16 P2)
- llm.ts: LlmClient.chatWithTools — bounded OpenAI function-calling loop (default 6 tool rounds, then one forced no-tools request); shares the retry/backoff/reasoning-effort machinery with chat(); tools are only added to the request body when non-empty, so plain chat() bodies and their tests are unchanged; a tool-call response is a success (never a truncation); malformed tool arguments become an error tool-message. - engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (degrades unknown tools/args/keys without throwing); playerFrontier (sorted, deduped, other-nation-owned only); buildSystemPrompt gains a tool variant (contiguity rules, frontier hint) used only when the tool is actually sent; buildUserPrompt emits a frontier field whenever a resolver is given; runTurn takes getNeighbours and uses chatWithTools when both the resolver and the client's capability are present, falling back to plain chat() otherwise. - applyTurnResult: snapshot-first sole-ownership algorithm — a region key transfers only with exactly one claimant and exactly one previous owner (different from the gainer); double claims and pre-existing double ownership are left untouched; lone losses are no-ops; every key has 0 or 2+ -> 1 owner post-turn, never orphaned or duplicated. - stub.ts: deterministic one-region contiguous conquest per turn from the prompt's frontier field (bypasses the tool loop; empty frontier -> no transfer, all existing assertions stay stable). - regions.ts: optional region-adjacency.json load; hasAdjacency() and neighboursOf(keys) (union, deduped, queried excluded, sorted). - index.ts: jump route passes the adjacency resolver when loaded. - tests: chatWithTools protocol (echo, executor args, bounded rounds, malformed args, retry), tool-path runTurn wiring + fallback, playerFrontier, executor, applyTurnResult invariants, neighboursOf/ hasAdjacency (105 unit tests, 14 e2e — all green).
This commit is contained in:
@@ -208,3 +208,182 @@ describe('createLlmClient (injectable transport)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLlmClient.chatWithTools (function-calling loop, v0.21 #16 P2)', () => {
|
||||
const tool = {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'getNeighbours',
|
||||
description: 'list bordering region keys',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
function toolCallResponse(id: string, name: string, args: string) {
|
||||
return json({
|
||||
choices: [
|
||||
{
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
content: null,
|
||||
tool_calls: [{ id, type: 'function', function: { name, arguments: args } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function contentResponse(content: string) {
|
||||
return json({ choices: [{ finish_reason: 'stop', message: { content } }] });
|
||||
}
|
||||
|
||||
it('sends tools in the first request; echoes the assistant tool_calls + tool result; the executor gets parsed args', async () => {
|
||||
const sent: string[] = [];
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
||||
return contentResponse('final answer');
|
||||
};
|
||||
const executorCalls: Array<{ name: string; args: Record<string, unknown> }> = [];
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
(name, args) => {
|
||||
executorCalls.push({ name, args });
|
||||
return JSON.stringify({ neighbours: ['b'] });
|
||||
},
|
||||
);
|
||||
expect(out).toBe('final answer');
|
||||
expect(sent).toHaveLength(2);
|
||||
// request 1: the tools array is present, history is just the initial message
|
||||
const body1 = JSON.parse(sent[0]);
|
||||
expect(body1.tools).toEqual([tool]);
|
||||
expect(body1.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
||||
// request 2: the assistant tool-call and the tool result are echoed back
|
||||
const body2 = JSON.parse(sent[1]);
|
||||
expect(body2.tools).toEqual([tool]);
|
||||
expect(body2.messages).toEqual([
|
||||
{ role: 'user', content: 'hi' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'getNeighbours', arguments: '{"regionKeys":["a"]}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: JSON.stringify({ neighbours: ['b'] }) },
|
||||
]);
|
||||
// the executor received the parsed (object) arguments, not the raw string
|
||||
expect(executorCalls).toEqual([{ name: 'getNeighbours', args: { regionKeys: ['a'] } }]);
|
||||
});
|
||||
|
||||
it('a content-only response ends in one round (the executor is never called)', async () => {
|
||||
const sent: string[] = [];
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
return contentResponse('done');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => {
|
||||
throw new Error('the executor must not be called');
|
||||
},
|
||||
);
|
||||
expect(out).toBe('done');
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bounds the tool rounds: maxRounds tool-call rounds, then one final no-tools request', async () => {
|
||||
const sent: string[] = [];
|
||||
let n = 0;
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
n += 1;
|
||||
if (n <= 2) return toolCallResponse(`call_${n}`, 'getNeighbours', '{}');
|
||||
return contentResponse('committed');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => JSON.stringify({ neighbours: [] }),
|
||||
{ maxRounds: 2 },
|
||||
);
|
||||
expect(out).toBe('committed');
|
||||
expect(sent).toHaveLength(3); // 2 tool rounds + the forced final request
|
||||
expect(JSON.parse(sent[2]).tools).toBeUndefined(); // the final request has no tools
|
||||
});
|
||||
|
||||
it('malformed tool arguments produce an "invalid tool arguments" tool message (no throw)', async () => {
|
||||
const sent: string[] = [];
|
||||
let n = 0;
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
n += 1;
|
||||
if (n === 1) return toolCallResponse('call_1', 'getNeighbours', '{not valid json');
|
||||
return contentResponse('recovered');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => {
|
||||
throw new Error('the executor must not be called on malformed args');
|
||||
},
|
||||
);
|
||||
expect(out).toBe('recovered');
|
||||
const body2 = JSON.parse(sent[1]);
|
||||
const toolMsg = body2.messages.find((m: { role?: string }) => m.role === 'tool');
|
||||
expect(toolMsg).toBeDefined();
|
||||
expect(toolMsg.content).toMatch(/invalid tool arguments/);
|
||||
});
|
||||
|
||||
it('retries a tool-call round on transient failure and still resolves the tool (500 → tool_calls → content)', async () => {
|
||||
const sent: string[] = [];
|
||||
let n = 0;
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
n += 1;
|
||||
if (n === 1) return httpError(500);
|
||||
if (n === 2) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
||||
return contentResponse('ok');
|
||||
};
|
||||
const llm = createLlmClient({
|
||||
transport,
|
||||
apiBase: 'http://x/v1',
|
||||
model: 'm',
|
||||
retryBaseDelayMs: 1,
|
||||
});
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => JSON.stringify({ neighbours: [] }),
|
||||
);
|
||||
expect(out).toBe('ok');
|
||||
expect(sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('a tool-call response is never treated as a truncation failure (content null is fine)', async () => {
|
||||
const sent: string[] = [];
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{}');
|
||||
return contentResponse('ok');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => JSON.stringify({ neighbours: [] }),
|
||||
);
|
||||
expect(out).toBe('ok');
|
||||
expect(sent).toHaveLength(2); // no retry of the first (successful) round
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user