@sdods/agents 0.2.0

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.
Files changed (41) hide show
  1. package/LICENSE +17 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/adapter/claude-code.d.ts +29 -0
  4. package/dist/adapter/claude-code.js +195 -0
  5. package/dist/adapter/claude.d.ts +42 -0
  6. package/dist/adapter/claude.js +216 -0
  7. package/dist/adapter/cli-common.d.ts +46 -0
  8. package/dist/adapter/cli-common.js +145 -0
  9. package/dist/adapter/codex.d.ts +31 -0
  10. package/dist/adapter/codex.js +252 -0
  11. package/dist/adapter/factory.d.ts +41 -0
  12. package/dist/adapter/factory.js +102 -0
  13. package/dist/adapter/fake.d.ts +39 -0
  14. package/dist/adapter/fake.js +91 -0
  15. package/dist/adapter/index.d.ts +11 -0
  16. package/dist/adapter/index.js +11 -0
  17. package/dist/adapter/json-schema.d.ts +11 -0
  18. package/dist/adapter/json-schema.js +17 -0
  19. package/dist/adapter/mcp-bridge.d.ts +35 -0
  20. package/dist/adapter/mcp-bridge.js +122 -0
  21. package/dist/adapter/ollama.d.ts +62 -0
  22. package/dist/adapter/ollama.js +272 -0
  23. package/dist/adapter/openai-compat.d.ts +44 -0
  24. package/dist/adapter/openai-compat.js +200 -0
  25. package/dist/adapter/profile.d.ts +54 -0
  26. package/dist/adapter/profile.js +42 -0
  27. package/dist/adapter/tool-loop.d.ts +64 -0
  28. package/dist/adapter/tool-loop.js +192 -0
  29. package/dist/adapter/types.d.ts +109 -0
  30. package/dist/adapter/types.js +20 -0
  31. package/dist/claude-code/install.d.ts +36 -0
  32. package/dist/claude-code/install.js +183 -0
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.js +5 -0
  35. package/dist/jobs/runner.d.ts +80 -0
  36. package/dist/jobs/runner.js +197 -0
  37. package/dist/roles/index.d.ts +50 -0
  38. package/dist/roles/index.js +125 -0
  39. package/dist/roles/steps.d.ts +28 -0
  40. package/dist/roles/steps.js +75 -0
  41. package/package.json +50 -0
@@ -0,0 +1,200 @@
1
+ import { DEFAULT_MODELS, AgentsConfigError, } from './types.js';
2
+ import { toolParameters } from './json-schema.js';
3
+ import { bridgeMcpServers } from './mcp-bridge.js';
4
+ import { readToolArgs, runToolLoop, } from './tool-loop.js';
5
+ /** A hosted endpoint answers in seconds; a local one can take minutes on a first load. */
6
+ const DEFAULT_TIMEOUT_MS = 300_000;
7
+ /** Chat-completions tool loop for OpenAI-compatible endpoints (OpenAI, Azure, Ollama, vLLM …). */
8
+ export class OpenAiCompatibleAdapter {
9
+ opts;
10
+ provider = 'openai-compatible';
11
+ defaultModel;
12
+ baseUrl;
13
+ fetchImpl;
14
+ timeoutMs;
15
+ temperature;
16
+ constructor(opts = {}) {
17
+ this.opts = opts;
18
+ this.defaultModel =
19
+ opts.model ?? process.env.OPENAI_MODEL ?? DEFAULT_MODELS['openai-compatible'];
20
+ this.baseUrl = (opts.baseUrl ??
21
+ process.env.OPENAI_BASE_URL ??
22
+ 'https://api.openai.com/v1').replace(/\/$/, '');
23
+ this.fetchImpl = opts.fetchImpl ?? fetch;
24
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
25
+ this.temperature = opts.temperature ?? 0;
26
+ }
27
+ key() {
28
+ const k = this.opts.apiKey ?? process.env.OPENAI_API_KEY;
29
+ if (!k && !/localhost|127\.0\.0\.1/.test(this.baseUrl))
30
+ throw new AgentsConfigError('OPENAI_API_KEY is not set.', 'Export OPENAI_API_KEY (and OPENAI_BASE_URL for compatible servers) or use --adapter fake.');
31
+ return k ?? 'local';
32
+ }
33
+ /**
34
+ * One chat-completions call, with a deadline and a single retry.
35
+ *
36
+ * The deadline matters most for local servers, which can sit silent for minutes while a model
37
+ * loads; without it a hung server hangs the job forever. The retry covers the transient half of
38
+ * the failures (connection reset, 429, 5xx) and nothing else — a 400 or a missing model is the
39
+ * caller's problem and is reported as such.
40
+ */
41
+ async chat(body, signal) {
42
+ const url = `${this.baseUrl}/chat/completions`;
43
+ const send = async () => {
44
+ const deadline = AbortSignal.timeout(this.timeoutMs);
45
+ const composed = signal ? AbortSignal.any([signal, deadline]) : deadline;
46
+ let res;
47
+ try {
48
+ res = await this.fetchImpl(url, {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.key()}` },
51
+ body: JSON.stringify({ temperature: this.temperature, ...body }),
52
+ signal: composed,
53
+ });
54
+ }
55
+ catch (err) {
56
+ if (signal?.aborted)
57
+ throw err;
58
+ if (deadline.aborted)
59
+ throw new Error(`${url} did not answer within ${Math.round(this.timeoutMs / 1000)}s. Raise agents.requestTimeoutMs, or check that the server is running and the model is loaded.`);
60
+ throw err;
61
+ }
62
+ if (!res.ok) {
63
+ const text = await res.text().catch(() => '');
64
+ const error = new Error(`${url} → ${res.status} ${text}`);
65
+ error.status = res.status;
66
+ error.retryable = res.status === 429 || res.status >= 500;
67
+ throw error;
68
+ }
69
+ return res.json();
70
+ };
71
+ try {
72
+ return await send();
73
+ }
74
+ catch (err) {
75
+ const retryable = err.retryable ??
76
+ /ECONNRESET|ECONNREFUSED|EPIPE|fetch failed/i.test(String(err));
77
+ if (!retryable || signal?.aborted)
78
+ throw err;
79
+ await new Promise((r) => setTimeout(r, 1000));
80
+ return send();
81
+ }
82
+ }
83
+ async complete(req) {
84
+ const model = req.model ?? this.defaultModel;
85
+ const json = await this.chat({
86
+ model,
87
+ messages: [
88
+ ...(req.system ? [{ role: 'system', content: req.system }] : []),
89
+ { role: 'user', content: req.prompt },
90
+ ],
91
+ max_tokens: req.maxTokens,
92
+ }, req.signal);
93
+ const usage = usageOf(json.usage);
94
+ return {
95
+ text: json.choices?.[0]?.message?.content ?? '',
96
+ usage,
97
+ costUsd: this.cost(model, usage),
98
+ model,
99
+ };
100
+ }
101
+ async *stream(req) {
102
+ const r = await this.complete(req);
103
+ yield r.text;
104
+ }
105
+ async runAgent(o) {
106
+ const model = o.model ?? this.defaultModel;
107
+ // MCP servers become ordinary functions, so a role that needs a browser gets one here too.
108
+ const bridged = await bridgeMcpServers({
109
+ servers: o.mcpServers,
110
+ withPlaywright: o.needsBrowser,
111
+ maxToolsPerServer: o.maxBridgedTools,
112
+ cwd: o.cwd,
113
+ onEvent: o.onEvent,
114
+ });
115
+ try {
116
+ const withMcp = { ...o, tools: [...o.tools, ...bridged.tools] };
117
+ return await runToolLoop(this.transport(model, withMcp), withMcp);
118
+ }
119
+ finally {
120
+ await bridged.close();
121
+ }
122
+ }
123
+ /** Chat-completions shapes: tool calls carry an id, results are `{role:'tool', tool_call_id}`. */
124
+ transport(model, o) {
125
+ return {
126
+ model,
127
+ start: (system, prompt) => [
128
+ { role: 'system', content: system },
129
+ { role: 'user', content: prompt },
130
+ ],
131
+ send: async (history, tools, signal, turnOpts) => {
132
+ const json = await this.chat({
133
+ model,
134
+ messages: history,
135
+ tools: tools.length ? toolFunctions(tools) : undefined,
136
+ tool_choice: tools.length ? (turnOpts?.toolChoice ?? 'auto') : undefined,
137
+ // One decision per turn is the small profile's rule; the flag says so directly here.
138
+ parallel_tool_calls: o.maxToolCallsPerTurn === 1 ? false : undefined,
139
+ max_tokens: o.maxTokens,
140
+ }, signal);
141
+ const choice = json.choices?.[0];
142
+ const msg = choice?.message ?? {};
143
+ const calls = (msg.tool_calls ?? []).map((call, i) => {
144
+ const { args, error } = readToolArgs(call.function?.arguments);
145
+ return { id: call.id ?? `call_${i}`, name: call.function?.name ?? '', args, error };
146
+ });
147
+ return {
148
+ text: msg.content ?? '',
149
+ calls,
150
+ usage: usageOf(json.usage),
151
+ finishReason: choice?.finish_reason ?? 'stop',
152
+ };
153
+ },
154
+ pushAssistant: (history, turn) => {
155
+ history.push({
156
+ role: 'assistant',
157
+ content: turn.text || null,
158
+ tool_calls: turn.calls.length
159
+ ? turn.calls.map((c) => ({
160
+ id: c.id,
161
+ type: 'function',
162
+ function: { name: c.name, arguments: JSON.stringify(c.args) },
163
+ }))
164
+ : undefined,
165
+ });
166
+ },
167
+ pushToolResult: (history, call, output) => {
168
+ history.push({ role: 'tool', tool_call_id: call.id, content: output });
169
+ },
170
+ cost: (usage) => this.cost(model, usage),
171
+ };
172
+ }
173
+ cost(model, usage) {
174
+ const price = this.opts.prices?.[model];
175
+ if (!price || !usage)
176
+ return undefined;
177
+ return (((usage.inputTokens ?? 0) * price.input + (usage.outputTokens ?? 0) * price.output) /
178
+ 1_000_000);
179
+ }
180
+ }
181
+ /** Function definitions for the `tools` field of a chat-completions request. */
182
+ function toolFunctions(tools) {
183
+ return tools.map((t) => ({
184
+ type: 'function',
185
+ function: {
186
+ name: t.name,
187
+ description: t.description,
188
+ parameters: toolParameters(t.inputSchema, t.parametersJsonSchema),
189
+ },
190
+ }));
191
+ }
192
+ function usageOf(u) {
193
+ if (!u)
194
+ return undefined;
195
+ return {
196
+ inputTokens: Number(u.prompt_tokens ?? 0),
197
+ outputTokens: Number(u.completion_tokens ?? 0),
198
+ };
199
+ }
200
+ //# sourceMappingURL=openai-compat.js.map
@@ -0,0 +1,54 @@
1
+ import type { Provider } from './types.js';
2
+ /**
3
+ * How much of the platform to put in front of a model.
4
+ *
5
+ * A role exposes 33–35 tools, and a browser role adds Playwright's 24 on top. A frontier model
6
+ * copes with that; a 7–8B model does not — past roughly twenty functions it stops choosing one and
7
+ * starts *describing* the call it would make, in prose, forever. That is not a prompt problem, it
8
+ * is a menu problem.
9
+ *
10
+ * The small profile shrinks the menu: a handful of tools chosen for the role, one call per turn, a
11
+ * short turn budget, and the project's real step vocabulary in the system prompt so the model
12
+ * picks phrasing instead of inventing it. It is selected automatically for local models and can
13
+ * always be forced either way.
14
+ */
15
+ export type ProfileName = 'full' | 'small';
16
+ export type ProfileSetting = 'auto' | ProfileName;
17
+ export interface ProfileSettings {
18
+ profile: ProfileName;
19
+ /** Why this profile was chosen, for the status line and `--dry-run`. */
20
+ reason: string;
21
+ /** Cap on turns; the role's own default wins when it is lower. */
22
+ maxTurns?: number;
23
+ /** Small models handle one decision at a time. */
24
+ maxToolCallsPerTurn?: number;
25
+ /** Make the first turn act instead of narrate. */
26
+ firstTurnToolChoice?: 'auto' | 'required';
27
+ /** Cap on one assistant turn, so a rambling model does not eat the context. */
28
+ maxTokens?: number;
29
+ /** How many step patterns to inline in the system prompt. */
30
+ stepVocabularyLimit: number;
31
+ /** Ceiling on tools bridged from MCP servers (the browser server alone offers 24). */
32
+ maxBridgedTools?: number;
33
+ /**
34
+ * Whether a browser role gets the browser server attached.
35
+ *
36
+ * Measured on llama3.1:8b: with the five generator tools it calls `feature_write`; add a single
37
+ * `browser_*` tool and it stops calling anything and describes a plan instead. The browser is a
38
+ * different *kind* of work — explore, then decide — and a small model cannot hold both. So the
39
+ * small profile writes from the project's step vocabulary, and the browser is opt-in.
40
+ */
41
+ attachBrowser: boolean;
42
+ /** Read a tool call out of the prose when the model wrote one instead of making it. */
43
+ recoverTextToolCalls: boolean;
44
+ }
45
+ export declare function resolveProfile(o: {
46
+ configured?: ProfileSetting;
47
+ provider: Provider;
48
+ model: string;
49
+ baseUrl?: string;
50
+ contextTokens?: number;
51
+ /** From the server, when it reports them: a model without `tools` cannot drive the loop. */
52
+ capabilities?: string[];
53
+ }): ProfileSettings;
54
+ //# sourceMappingURL=profile.d.ts.map
@@ -0,0 +1,42 @@
1
+ const FULL = {
2
+ profile: 'full',
3
+ stepVocabularyLimit: 0,
4
+ attachBrowser: true,
5
+ recoverTextToolCalls: false,
6
+ };
7
+ const SMALL = {
8
+ profile: 'small',
9
+ maxTurns: 10,
10
+ maxToolCallsPerTurn: 1,
11
+ firstTurnToolChoice: 'required',
12
+ // A tool call is a hundred tokens; the cap exists to stop a model that has started rambling,
13
+ // and on a laptop at a few tokens a second a generous cap is a timeout waiting to happen.
14
+ maxTokens: 1024,
15
+ stepVocabularyLimit: 60,
16
+ maxBridgedTools: 6,
17
+ attachBrowser: false,
18
+ recoverTextToolCalls: true,
19
+ };
20
+ /** Model ids that name their own size: qwen2.5-coder:7b, llama3.1:8b, gemma:2b, phi3:3.8b … */
21
+ const SMALL_MODEL = /(^|[-:_.])(0\.5|1|1\.5|2|3|3\.8|4|6|7|8|9|12|13|14)\s*b\b/i;
22
+ /** A loopback or private address means someone is running the model themselves. */
23
+ const PRIVATE_HOST = /^(https?:\/\/)?(localhost|127\.|0\.0\.0\.0|\[?::1\]?|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|host\.docker\.internal|[^/]*\.local(:|\/|$))/i;
24
+ export function resolveProfile(o) {
25
+ const configured = o.configured ?? 'auto';
26
+ if (configured === 'full')
27
+ return { ...FULL, reason: 'configured' };
28
+ if (configured === 'small')
29
+ return { ...SMALL, reason: 'configured' };
30
+ if (o.capabilities?.length && !o.capabilities.includes('tools'))
31
+ return { ...SMALL, reason: `${o.model} does not advertise tool calling` };
32
+ if (o.provider === 'ollama')
33
+ return { ...SMALL, reason: 'a local model' };
34
+ if (o.provider === 'openai-compatible' && o.baseUrl && PRIVATE_HOST.test(o.baseUrl))
35
+ return { ...SMALL, reason: `a model served from ${o.baseUrl}` };
36
+ if (SMALL_MODEL.test(o.model))
37
+ return { ...SMALL, reason: `${o.model} is a small model` };
38
+ if (o.contextTokens && o.contextTokens < 16_384)
39
+ return { ...SMALL, reason: `a ${o.contextTokens}-token context` };
40
+ return { ...FULL, reason: 'a hosted model' };
41
+ }
42
+ //# sourceMappingURL=profile.js.map
@@ -0,0 +1,64 @@
1
+ import type { AgentSdkToolDef } from '@sdods/mcp';
2
+ import type { RunAgentOptions, RunAgentResult, TokenUsage } from './types.js';
3
+ /**
4
+ * The agent turn loop, shared by every adapter that drives a chat endpoint itself.
5
+ *
6
+ * Claude and the CLI adapters bring their own loop (the SDK or the CLI owns it). The
7
+ * chat-completions adapters do not, and they used to each own a copy — which is how the two
8
+ * drifted on argument validation and budget accounting. The loop lives here now; a transport
9
+ * supplies only the three things that genuinely differ between providers: how a request is sent,
10
+ * and how an assistant turn and a tool result are written back into the history.
11
+ */
12
+ /** One tool call, after the provider's own encoding has been normalised away. */
13
+ export interface NormalizedCall {
14
+ id: string;
15
+ name: string;
16
+ args: Record<string, unknown>;
17
+ /** Set when the model's arguments could not be read; the call must not be executed. */
18
+ error?: string;
19
+ }
20
+ export interface NormalizedTurn {
21
+ text: string;
22
+ calls: NormalizedCall[];
23
+ usage?: TokenUsage;
24
+ finishReason: string;
25
+ }
26
+ export interface TurnOptions {
27
+ /** 'required' makes a model act on the first turn instead of narrating what it would do. */
28
+ toolChoice?: 'auto' | 'required';
29
+ }
30
+ export interface ToolLoopTransport<Message> {
31
+ readonly model: string;
32
+ /** The opening history: whatever this provider expects for a system + user turn. */
33
+ start(system: string, prompt: string): Message[];
34
+ send(history: Message[], tools: AgentSdkToolDef[], signal?: AbortSignal, turnOpts?: TurnOptions): Promise<NormalizedTurn>;
35
+ pushAssistant(history: Message[], turn: NormalizedTurn): void;
36
+ pushToolResult(history: Message[], call: NormalizedCall, output: string, isError: boolean): void;
37
+ /** USD for one turn, or undefined when the provider does not price (local models: 0). */
38
+ cost(usage: TokenUsage | undefined): number | undefined;
39
+ }
40
+ export declare function runToolLoop<Message>(transport: ToolLoopTransport<Message>, o: RunAgentOptions): Promise<RunAgentResult>;
41
+ /**
42
+ * Recovers a tool call a model wrote out instead of making.
43
+ *
44
+ * Small models often produce exactly the right call in exactly the right shape — as prose:
45
+ * `step_find({"project":"shop"})`, or a fenced JSON object with `name` and `arguments`. The
46
+ * structured field stays empty and the job ends having done nothing. Since the arguments are
47
+ * validated against the tool's schema either way, reading the call out of the text costs nothing
48
+ * and turns a wasted run into a working one. It only ever runs when the turn made no real call.
49
+ */
50
+ export declare function recoverTextToolCall(text: string, known: Set<string>): {
51
+ name: string;
52
+ args: Record<string, unknown>;
53
+ } | undefined;
54
+ /**
55
+ * Reads the `arguments` a model sent for a tool call. OpenAI encodes them as a JSON string; some
56
+ * local servers send an object. Anything else is reported rather than silently turned into `{}` —
57
+ * calling a write tool with no arguments is how an empty feature file gets written and nobody
58
+ * finds out why.
59
+ */
60
+ export declare function readToolArgs(raw: unknown): {
61
+ args: Record<string, unknown>;
62
+ error?: string;
63
+ };
64
+ //# sourceMappingURL=tool-loop.d.ts.map
@@ -0,0 +1,192 @@
1
+ /**
2
+ * How many malformed tool calls to answer with a correction before giving up. One repair turn is
3
+ * enough for a model that can recover; a model that cannot will not on the tenth either.
4
+ */
5
+ const MAX_BAD_TOOL_CALLS = 2;
6
+ export async function runToolLoop(transport, o) {
7
+ const byName = new Map(o.tools.map((t) => [t.name, t]));
8
+ const history = transport.start(o.system, o.prompt);
9
+ const usageTotal = { inputTokens: 0, outputTokens: 0 };
10
+ const maxTurns = o.maxTurns ?? 30;
11
+ let turns = 0;
12
+ let toolCalls = 0;
13
+ let badToolCalls = 0;
14
+ /** Recovered calls are deduplicated: a model describing what it already did must not repeat it. */
15
+ const executed = new Set();
16
+ let text = '';
17
+ let cost = 0;
18
+ const finish = (stopReason, isError) => {
19
+ o.onEvent?.({ type: 'result', costUsd: cost, usage: usageTotal, turns, stopReason, isError });
20
+ return { text, costUsd: cost, usage: usageTotal, turns, stopReason, isError, toolCalls };
21
+ };
22
+ while (turns < maxTurns) {
23
+ if (o.signal?.aborted)
24
+ return { text, turns, stopReason: 'aborted', toolCalls, costUsd: cost, usage: usageTotal };
25
+ turns++;
26
+ const turn = await transport.send(history, o.tools, o.signal, {
27
+ toolChoice: turns === 1 ? (o.firstTurnToolChoice ?? 'auto') : 'auto',
28
+ });
29
+ // One decision per turn: a small model given two calls at once usually gets the second wrong,
30
+ // and the dropped call is cheaper to explain than to undo.
31
+ if (o.maxToolCallsPerTurn && turn.calls.length > o.maxToolCallsPerTurn) {
32
+ const dropped = turn.calls.slice(o.maxToolCallsPerTurn);
33
+ turn.calls = turn.calls.slice(0, o.maxToolCallsPerTurn);
34
+ o.onEvent?.({
35
+ type: 'status',
36
+ message: `dropped ${dropped.length} extra tool call(s) this turn: ${dropped.map((c) => c.name).join(', ')}`,
37
+ });
38
+ }
39
+ usageTotal.inputTokens += turn.usage?.inputTokens ?? 0;
40
+ usageTotal.outputTokens += turn.usage?.outputTokens ?? 0;
41
+ cost += transport.cost(turn.usage) ?? 0;
42
+ if (o.maxBudgetUsd && cost > o.maxBudgetUsd)
43
+ return finish('error_max_budget_usd', true);
44
+ if (turn.text) {
45
+ text += turn.text;
46
+ o.onEvent?.({ type: 'text', text: turn.text });
47
+ }
48
+ if (!turn.calls.length && o.recoverTextToolCalls && turn.text) {
49
+ const recovered = recoverTextToolCall(turn.text, new Set(byName.keys()));
50
+ const signature = recovered && `${recovered.name}:${JSON.stringify(recovered.args)}`;
51
+ if (recovered && signature && !executed.has(signature)) {
52
+ turn.calls = [{ id: `text_${turns}`, name: recovered.name, args: recovered.args }];
53
+ o.onEvent?.({
54
+ type: 'status',
55
+ message: `recovered a ${recovered.name} call the model wrote as text`,
56
+ });
57
+ }
58
+ }
59
+ transport.pushAssistant(history, turn);
60
+ if (!turn.calls.length)
61
+ return finish(turn.finishReason, false);
62
+ for (const call of turn.calls) {
63
+ toolCalls++;
64
+ executed.add(`${call.name}:${JSON.stringify(call.args)}`);
65
+ o.onEvent?.({ type: 'tool_call', id: call.id, name: call.name, input: call.args });
66
+ const tool = byName.get(call.name);
67
+ const result = call.error
68
+ ? {
69
+ content: [
70
+ {
71
+ type: 'text',
72
+ text: `${call.error}. Call ${call.name} again with a single JSON object matching its parameters.`,
73
+ },
74
+ ],
75
+ isError: true,
76
+ }
77
+ : tool
78
+ ? await tool.handler(call.args)
79
+ : {
80
+ content: [
81
+ {
82
+ type: 'text',
83
+ text: `Unknown tool ${call.name}. Available: ${[...byName.keys()].join(', ')}`,
84
+ },
85
+ ],
86
+ isError: true,
87
+ };
88
+ const output = result.content
89
+ .map((c) => (c.type === 'text' ? c.text : `[image ${c.mimeType}]`))
90
+ .join('\n');
91
+ o.onEvent?.({ type: 'tool_result', id: call.id, output, isError: result.isError });
92
+ transport.pushToolResult(history, call, output, Boolean(result.isError));
93
+ if (call.error) {
94
+ badToolCalls++;
95
+ if (badToolCalls > MAX_BAD_TOOL_CALLS)
96
+ return finish('error_tool_arguments', true);
97
+ }
98
+ }
99
+ }
100
+ return finish('error_max_turns', true);
101
+ }
102
+ /**
103
+ * Recovers a tool call a model wrote out instead of making.
104
+ *
105
+ * Small models often produce exactly the right call in exactly the right shape — as prose:
106
+ * `step_find({"project":"shop"})`, or a fenced JSON object with `name` and `arguments`. The
107
+ * structured field stays empty and the job ends having done nothing. Since the arguments are
108
+ * validated against the tool's schema either way, reading the call out of the text costs nothing
109
+ * and turns a wasted run into a working one. It only ever runs when the turn made no real call.
110
+ */
111
+ export function recoverTextToolCall(text, known) {
112
+ if (!text)
113
+ return undefined;
114
+ // `tool_name({ ... })`
115
+ const callSyntax = /\b([a-z][a-z0-9_]{2,})\s*\(\s*(\{[\s\S]*?\})\s*\)/gi;
116
+ for (const m of text.matchAll(callSyntax)) {
117
+ const name = m[1];
118
+ if (!known.has(name))
119
+ continue;
120
+ const args = parseObject(m[2]);
121
+ if (args)
122
+ return { name, args };
123
+ }
124
+ // `{"name": "tool_name", "arguments"|"parameters": { ... }}`, fenced or not. The object is
125
+ // found by matching braces rather than by regex: the payload contains nested objects, and a
126
+ // non-greedy pattern stops at the first inner brace.
127
+ for (const candidate of balancedObjects(text)) {
128
+ const obj = parseObject(candidate);
129
+ if (!obj)
130
+ continue;
131
+ const name = typeof obj.name === 'string' ? obj.name : undefined;
132
+ if (!name || !known.has(name))
133
+ continue;
134
+ const args = obj.arguments ?? obj.parameters ?? obj.input;
135
+ if (args && typeof args === 'object')
136
+ return { name, args: args };
137
+ }
138
+ return undefined;
139
+ }
140
+ /** Every balanced `{...}` substring, outermost first, so nested payloads survive. */
141
+ function balancedObjects(text) {
142
+ const found = [];
143
+ for (let i = 0; i < text.length; i++) {
144
+ if (text[i] !== '{')
145
+ continue;
146
+ let depth = 0;
147
+ for (let j = i; j < text.length; j++) {
148
+ if (text[j] === '{')
149
+ depth++;
150
+ else if (text[j] === '}') {
151
+ depth--;
152
+ if (depth === 0) {
153
+ found.push(text.slice(i, j + 1));
154
+ i = j;
155
+ break;
156
+ }
157
+ }
158
+ }
159
+ }
160
+ return found;
161
+ }
162
+ function parseObject(raw) {
163
+ try {
164
+ const parsed = JSON.parse(raw);
165
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
166
+ }
167
+ catch {
168
+ return undefined;
169
+ }
170
+ }
171
+ /**
172
+ * Reads the `arguments` a model sent for a tool call. OpenAI encodes them as a JSON string; some
173
+ * local servers send an object. Anything else is reported rather than silently turned into `{}` —
174
+ * calling a write tool with no arguments is how an empty feature file gets written and nobody
175
+ * finds out why.
176
+ */
177
+ export function readToolArgs(raw) {
178
+ if (raw && typeof raw === 'object')
179
+ return { args: raw };
180
+ if (typeof raw !== 'string' || !raw.trim())
181
+ return { args: {} };
182
+ try {
183
+ const parsed = JSON.parse(raw);
184
+ if (parsed && typeof parsed === 'object')
185
+ return { args: parsed };
186
+ return { args: {}, error: `arguments must be a JSON object, got ${typeof parsed}` };
187
+ }
188
+ catch (err) {
189
+ return { args: {}, error: `arguments were not valid JSON (${err.message})` };
190
+ }
191
+ }
192
+ //# sourceMappingURL=tool-loop.js.map
@@ -0,0 +1,109 @@
1
+ import type { AgentSdkToolDef } from '@sdods/mcp';
2
+ /**
3
+ * claude / openai-compatible: API keys. claude-code / codex: shell out to the user's logged-in
4
+ * CLI (no key needed). ollama: a model running on this machine, no key and no cost.
5
+ * fake: scripted, for tests and --dry-run.
6
+ */
7
+ export type Provider = 'claude' | 'claude-code' | 'codex' | 'openai-compatible' | 'ollama' | 'fake';
8
+ export interface TokenUsage {
9
+ inputTokens?: number;
10
+ outputTokens?: number;
11
+ cacheReadTokens?: number;
12
+ cacheWriteTokens?: number;
13
+ }
14
+ export type AgentEvent = {
15
+ type: 'text';
16
+ text: string;
17
+ } | {
18
+ type: 'tool_call';
19
+ id: string;
20
+ name: string;
21
+ input: unknown;
22
+ } | {
23
+ type: 'tool_result';
24
+ id: string;
25
+ output: unknown;
26
+ isError?: boolean;
27
+ } | {
28
+ type: 'status';
29
+ message: string;
30
+ } | {
31
+ type: 'result';
32
+ costUsd?: number;
33
+ usage?: TokenUsage;
34
+ turns: number;
35
+ stopReason: string;
36
+ isError?: boolean;
37
+ };
38
+ export interface ExternalMcpServerConfig {
39
+ transport?: 'stdio' | 'http';
40
+ command?: string;
41
+ args?: string[];
42
+ env?: Record<string, string>;
43
+ url?: string;
44
+ headers?: Record<string, string>;
45
+ allowedTools?: string[];
46
+ }
47
+ export interface CompleteRequest {
48
+ system?: string;
49
+ prompt: string;
50
+ model?: string;
51
+ maxTokens?: number;
52
+ effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max';
53
+ /** JSON schema the answer must satisfy, where the provider can enforce one. */
54
+ responseSchema?: Record<string, unknown>;
55
+ signal?: AbortSignal;
56
+ }
57
+ export interface CompleteResult {
58
+ text: string;
59
+ usage?: TokenUsage;
60
+ costUsd?: number;
61
+ model?: string;
62
+ }
63
+ export interface RunAgentOptions {
64
+ system: string;
65
+ prompt: string;
66
+ tools: AgentSdkToolDef[];
67
+ mcpServers?: Record<string, ExternalMcpServerConfig>;
68
+ /** The role wants to look at the application, so a browser server should be attached. */
69
+ needsBrowser?: boolean;
70
+ /** Small-model profile: one tool call per turn, and a first turn that must act. */
71
+ maxToolCallsPerTurn?: number;
72
+ firstTurnToolChoice?: 'auto' | 'required';
73
+ /** Ceiling on tools taken from each bridged MCP server. */
74
+ maxBridgedTools?: number;
75
+ /** Read a tool call out of the answer when the model wrote one instead of making it. */
76
+ recoverTextToolCalls?: boolean;
77
+ model?: string;
78
+ maxTurns?: number;
79
+ /** Cap on one assistant turn; local models otherwise ramble until the context ends. */
80
+ maxTokens?: number;
81
+ maxBudgetUsd?: number;
82
+ cwd?: string;
83
+ signal?: AbortSignal;
84
+ onEvent?: (e: AgentEvent) => void;
85
+ }
86
+ export interface RunAgentResult {
87
+ text: string;
88
+ costUsd?: number;
89
+ usage?: TokenUsage;
90
+ turns: number;
91
+ stopReason: string;
92
+ isError?: boolean;
93
+ toolCalls: number;
94
+ }
95
+ export interface LlmAdapter {
96
+ readonly provider: Provider;
97
+ readonly defaultModel: string;
98
+ complete(req: CompleteRequest): Promise<CompleteResult>;
99
+ stream(req: CompleteRequest): AsyncIterable<string>;
100
+ runAgent(opts: RunAgentOptions): Promise<RunAgentResult>;
101
+ }
102
+ export declare class AgentsConfigError extends Error {
103
+ readonly hint?: string | undefined;
104
+ readonly code = "AGENTS_CONFIG";
105
+ constructor(message: string, hint?: string | undefined);
106
+ }
107
+ /** `default` means "let the CLI pick its configured model" (claude-code, codex). */
108
+ export declare const DEFAULT_MODELS: Record<Provider, string>;
109
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,20 @@
1
+ export class AgentsConfigError extends Error {
2
+ hint;
3
+ code = 'AGENTS_CONFIG';
4
+ constructor(message, hint) {
5
+ super(message);
6
+ this.hint = hint;
7
+ this.name = 'AgentsConfigError';
8
+ }
9
+ }
10
+ /** `default` means "let the CLI pick its configured model" (claude-code, codex). */
11
+ export const DEFAULT_MODELS = {
12
+ claude: 'claude-opus-5',
13
+ 'claude-code': 'default',
14
+ codex: 'default',
15
+ 'openai-compatible': 'gpt-4.1',
16
+ // Small enough to run on a laptop, and the best of that size at tool calls and structured output.
17
+ ollama: 'qwen2.5-coder:7b',
18
+ fake: 'fake-model',
19
+ };
20
+ //# sourceMappingURL=types.js.map