Skip to content

Commit 8985b9b

Browse files
authored
refactor(agent): unify tool specs and trim registry (#25)
* refactor(agent): unify tool specs and trim registry ## Added - Core tools defined once in `toolSpecs` (LLM schema + handler bind); tests guard drift vs `buildToolDefinitionList` and empty-allowlist `listTools`. - `agentLoop` for the LLM tool-call loop (split from `runtime`). - `fieldCandidates` module; `fieldCaps` keeps resolution only. ## Changed - Registry registers core tools from a single loop; kb/mcp defs unchanged in behavior. - `resolveField` takes `readonly string[]`; fieldCaps passes a spread copy into the OpenSearch client for correct typing. ## Removed - Parallel copies of tool metadata and `handlers.set` boilerplate. * chore(agent): bind tool handlers once; restore bytes candidate note - Registry: call spec.bind(ctxBundle) once per tool at build time - fieldCandidates: document bytes list ordering for field caps - toolRegistry test: note baseEnv coupling for listTools length assert * style: tighten registry and field-candidate comments * refactor(agent): type agentLoop log as pino Logger Removes value import of getLogger used only for ReturnType.
1 parent 84b49d4 commit 8985b9b

9 files changed

Lines changed: 828 additions & 818 deletions

File tree

src/agent/agentLoop.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import type { Logger } from 'pino';
2+
import { logErr, withDurationMs } from '../logging/logger.js';
3+
import { isRecord } from '../util/guards.js';
4+
import { parseLenientTopLevelJson } from '../util/json.js';
5+
import type { ChatMessage, LlmClient } from '../llm/types.js';
6+
import type { ConversationTurn } from '../storage/conversationStore.js';
7+
import type { ToolRegistry, ToolResult } from './tools/types.js';
8+
import { buildAgentPrompt } from './prompts/agentPrompt.js';
9+
import { AGENT_JSON_TOOL_CALLS_SINGLE_OBJECT } from './prompts/intentMetadata.js';
10+
import { classifyAgentIntent } from './prompts/intent.js';
11+
12+
export async function runAgentLoop(opts: {
13+
llm: LlmClient;
14+
tools: ToolRegistry;
15+
turns: ConversationTurn[];
16+
log: Logger;
17+
summary?: string;
18+
}): Promise<string> {
19+
const lastUser = [...opts.turns].reverse().find((t) => t.role === 'user')?.content ?? '';
20+
const intent = await withDurationMs(opts.log, 'agent.intent_classify', () =>
21+
classifyAgentIntent({ llm: opts.llm, userText: lastUser, log: opts.log }),
22+
);
23+
24+
const step = async (
25+
toolResults: ToolResult[],
26+
remaining: number,
27+
repairAttempted: boolean,
28+
): Promise<string> => {
29+
if (remaining <= 0) return 'I ran out of steps while investigating. Try narrowing the question.';
30+
31+
const messages = buildAgentPrompt({
32+
tools: opts.tools.listTools(),
33+
turns: opts.turns,
34+
toolResults: toolResults,
35+
intent,
36+
...(opts.summary ? { summary: opts.summary } : {}),
37+
});
38+
39+
const { content } = await withDurationMs(opts.log, 'agent.chat_completions', () =>
40+
opts.llm.chatCompletions({ messages, temperature: 0.2 }),
41+
);
42+
const parsed = safeJsonParse({ raw: content, log: opts.log, context: 'agent.response_parse' });
43+
if (!isRecord(parsed)) {
44+
if (looksLikeToolCalls(content) && !repairAttempted) {
45+
const repaired = await repairInvalidToolCalls({ llm: opts.llm, messages, badText: content });
46+
const repairedParsed = safeJsonParse({ raw: repaired, log: opts.log, context: 'agent.response_repair_parse' });
47+
if (isRecord(repairedParsed)) {
48+
const toolCalls2 = normalizeToolCalls(repairedParsed['tool_calls'], opts.log, 'agent.tool_calls_repair_parse');
49+
if (Array.isArray(toolCalls2) && toolCalls2.length > 0) {
50+
const next = await runToolCalls({ tools: opts.tools, toolCalls: toolCalls2, log: opts.log });
51+
return await step(next, remaining - 1, true);
52+
}
53+
}
54+
}
55+
if (looksLikeToolCalls(content)) return invalidToolCallsReply();
56+
return content;
57+
}
58+
59+
const reply = parsed['reply'];
60+
const toolCalls = normalizeToolCalls(parsed['tool_calls'], opts.log, 'agent.tool_calls_parse');
61+
62+
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
63+
const next = await runToolCalls({ tools: opts.tools, toolCalls, log: opts.log });
64+
return await step(next, remaining - 1, repairAttempted);
65+
}
66+
67+
if (typeof reply === 'string') {
68+
const embedded = extractToolCallsFromText(reply, opts.log, 'agent.reply_parse');
69+
if (embedded && embedded.length > 0) {
70+
const next = await runToolCalls({ tools: opts.tools, toolCalls: embedded, log: opts.log });
71+
return await step(next, remaining - 1, repairAttempted);
72+
}
73+
if (looksLikeToolCalls(reply) && !repairAttempted) {
74+
const repaired = await repairInvalidToolCalls({ llm: opts.llm, messages, badText: reply });
75+
const repairedParsed = safeJsonParse({ raw: repaired, log: opts.log, context: 'agent.reply_repair_parse' });
76+
if (isRecord(repairedParsed)) {
77+
const toolCalls2 = normalizeToolCalls(
78+
repairedParsed['tool_calls'],
79+
opts.log,
80+
'agent.tool_calls_reply_repair_parse',
81+
);
82+
if (Array.isArray(toolCalls2) && toolCalls2.length > 0) {
83+
const next = await runToolCalls({ tools: opts.tools, toolCalls: toolCalls2, log: opts.log });
84+
return await step(next, remaining - 1, true);
85+
}
86+
}
87+
}
88+
if (looksLikeToolCalls(reply)) return invalidToolCallsReply();
89+
return toolResults.length === 0 ? compressToMaxLines(reply, 10) : reply;
90+
}
91+
return content;
92+
};
93+
94+
return await step([], 6, false);
95+
}
96+
97+
function looksLikeToolCalls(s: string): boolean {
98+
return /"tool_calls"\s*:/.test(s);
99+
}
100+
101+
function invalidToolCallsReply(): string {
102+
return 'Tool-call JSON was invalid; please re-ask with fewer constraints.';
103+
}
104+
105+
async function repairInvalidToolCalls(opts: { llm: LlmClient; messages: ChatMessage[]; badText: string }): Promise<string> {
106+
const { content } = await opts.llm.chatCompletions({
107+
messages: [
108+
...opts.messages,
109+
{
110+
role: 'user',
111+
content:
112+
'Your last message contained invalid tool-call JSON. Output ONLY one valid top-level JSON object. ' +
113+
`If you need tools, output ${AGENT_JSON_TOOL_CALLS_SINGLE_OBJECT}. ` +
114+
'No prose, no code fences, no nested/quoted JSON.\n\nInvalid content:\n' +
115+
opts.badText.slice(0, 1200),
116+
},
117+
],
118+
temperature: 0.0,
119+
});
120+
return content.trim();
121+
}
122+
123+
function normalizeToolCalls(v: unknown, log: Logger, context: string): unknown[] | null {
124+
if (Array.isArray(v)) return v;
125+
if (typeof v !== 'string') return null;
126+
const parsed = safeJsonParse({ raw: v, log, context });
127+
if (Array.isArray(parsed)) return parsed as unknown[];
128+
if (isRecord(parsed) && Array.isArray(parsed['tool_calls'])) return parsed['tool_calls'] as unknown[];
129+
return null;
130+
}
131+
132+
function extractToolCallsFromText(raw: string, log: Logger, context: string): unknown[] | null {
133+
const parsed = safeJsonParse({ raw, log, context });
134+
if (isRecord(parsed) && Array.isArray(parsed['tool_calls'])) return parsed['tool_calls'] as unknown[];
135+
return null;
136+
}
137+
138+
function compressToMaxLines(text: string, maxLines: number): string {
139+
const lines = text
140+
.replace(/\r\n/g, '\n')
141+
.split('\n')
142+
.map((l) => l.trim())
143+
.filter((l) => l.length > 0)
144+
.map((l) => l.replace(/\s+/g, ' '));
145+
146+
if (maxLines <= 0) return '';
147+
if (lines.length <= maxLines) return lines.join('\n');
148+
149+
const head = lines.slice(0, Math.max(1, maxLines - 1));
150+
return [...head, '... (truncated; share vendor/platform + timestamps for deeper triage)'].join('\n');
151+
}
152+
153+
const jsonParseWarnAt: { nextAtMs: number } = { nextAtMs: 0 };
154+
function warnJsonParseDegraded(opts: { log: Logger; context: string; raw: string; err: unknown }): void {
155+
const now = Date.now();
156+
if (now < jsonParseWarnAt.nextAtMs) return;
157+
jsonParseWarnAt.nextAtMs = now + 10 * 60_000;
158+
const snippet = opts.raw.length > 800 ? `${opts.raw.slice(0, 800)}...` : opts.raw;
159+
opts.log.warn(
160+
{
161+
degradedContext: opts.context,
162+
degradedSnippet: snippet,
163+
...logErr(opts.err),
164+
},
165+
'agent JSON parse degraded (falling back)',
166+
);
167+
}
168+
169+
function safeJsonParse(opts: { raw: string; log: Logger; context: string }): unknown {
170+
const v = parseLenientTopLevelJson(opts.raw);
171+
if (v !== null) return v;
172+
warnJsonParseDegraded({ log: opts.log, context: opts.context, raw: opts.raw, err: new Error('unparseable JSON') });
173+
return null;
174+
}
175+
176+
async function runToolCalls(opts: {
177+
tools: ToolRegistry;
178+
toolCalls: unknown[];
179+
log: Logger;
180+
}): Promise<ToolResult[]> {
181+
const picked = opts.toolCalls
182+
.map((c): { name: string; args: Record<string, unknown> } | null => {
183+
if (!isRecord(c)) return null;
184+
const name = c['name'];
185+
const args = c['args'];
186+
if (typeof name !== 'string') return null;
187+
if (!isRecord(args)) return null;
188+
return { name, args };
189+
})
190+
.filter((v): v is { name: string; args: Record<string, unknown> } => v !== null)
191+
.slice(0, 3);
192+
193+
return await Promise.all(
194+
picked.map(async (t) => {
195+
const t0 = Date.now();
196+
const res = await opts.tools.call(t);
197+
opts.log.info({ tool: t.name, ok: res.ok, durationMs: Date.now() - t0 }, 'agent tool finished');
198+
return res;
199+
}),
200+
);
201+
}

0 commit comments

Comments
 (0)