featherslop 0.0.0-stage → 0.1.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.
- package/dist/bin/featherslop.d.ts +2 -0
- package/dist/bin/featherslop.js +91 -0
- package/dist/src/compact.d.ts +14 -0
- package/dist/src/compact.js +13 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +14 -0
- package/dist/src/loop.d.ts +92 -0
- package/dist/src/loop.js +184 -0
- package/dist/src/provider.d.ts +73 -0
- package/dist/src/provider.js +15 -0
- package/dist/src/providers/anthropic.d.ts +37 -0
- package/dist/src/providers/anthropic.js +203 -0
- package/dist/src/providers/index.d.ts +8 -0
- package/dist/src/providers/index.js +15 -0
- package/dist/src/providers/openai.d.ts +10 -0
- package/dist/src/providers/openai.js +109 -0
- package/dist/src/simple-ui.d.ts +41 -0
- package/dist/src/simple-ui.js +318 -0
- package/dist/src/tool.d.ts +67 -0
- package/dist/src/tool.js +100 -0
- package/dist/src/tools/file-state.d.ts +32 -0
- package/dist/src/tools/file-state.js +76 -0
- package/dist/src/tools/fs.d.ts +4 -0
- package/dist/src/tools/fs.js +22 -0
- package/dist/src/tools/html.d.ts +6 -0
- package/dist/src/tools/html.js +56 -0
- package/dist/src/tools/parallel-search.d.ts +21 -0
- package/dist/src/tools/parallel-search.js +63 -0
- package/dist/src/tools/read.d.ts +12 -0
- package/dist/src/tools/read.js +57 -0
- package/dist/src/tools/shell.d.ts +14 -0
- package/dist/src/tools/shell.js +107 -0
- package/dist/src/tools/update.d.ts +9 -0
- package/dist/src/tools/update.js +68 -0
- package/dist/src/tools/webfetch.d.ts +18 -0
- package/dist/src/tools/webfetch.js +141 -0
- package/dist/src/tools/write.d.ts +7 -0
- package/dist/src/tools/write.js +36 -0
- package/package.json +58 -4
- package/README.md +0 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import OpenAI from 'openai';
|
|
6
|
+
import yargs from 'yargs';
|
|
7
|
+
import { hideBin } from 'yargs/helpers';
|
|
8
|
+
import { AnthropicProvider, Loop, ManagedFileTools, ParallelWebSearchTool, ShellTool, SimpleUI, WebFetchTool, } from '../src/index.js';
|
|
9
|
+
const argv = await yargs(hideBin(process.argv))
|
|
10
|
+
.scriptName('featherslop')
|
|
11
|
+
.usage('$0 [options] [prompt]\n\nChat with an agent in the terminal, or run one prompt and exit.')
|
|
12
|
+
.option('model', {
|
|
13
|
+
alias: 'm',
|
|
14
|
+
type: 'string',
|
|
15
|
+
default: process.env.MODEL ?? 'qwen-3.5-9b',
|
|
16
|
+
describe: 'Model (env MODEL)',
|
|
17
|
+
})
|
|
18
|
+
.option('provider', {
|
|
19
|
+
choices: ['openai', 'anthropic'],
|
|
20
|
+
describe: 'API to use (default: anthropic for claude-* models, otherwise openai)',
|
|
21
|
+
})
|
|
22
|
+
.option('base-url', {
|
|
23
|
+
type: 'string',
|
|
24
|
+
default: process.env.OPENAI_BASE_URL ?? 'http://127.0.0.1:9931/v1',
|
|
25
|
+
describe: 'OpenAI-compatible endpoint (env OPENAI_BASE_URL)',
|
|
26
|
+
})
|
|
27
|
+
.option('shell', { type: 'boolean', default: false, describe: 'Add an unsandboxed shell tool' })
|
|
28
|
+
.epilogue('Tools: read, write, update, webfetch; websearch when PARALLEL_API_KEY is set.\n' +
|
|
29
|
+
'Keys: OPENAI_API_KEY, ANTHROPIC_API_KEY, PARALLEL_API_KEY.')
|
|
30
|
+
.version(version())
|
|
31
|
+
.alias('h', 'help')
|
|
32
|
+
.alias('v', 'version')
|
|
33
|
+
.strictOptions()
|
|
34
|
+
.parseAsync();
|
|
35
|
+
const { model, shell } = argv;
|
|
36
|
+
const provider = argv.provider ?? (model.startsWith('claude-') ? 'anthropic' : 'openai');
|
|
37
|
+
const api = provider === 'anthropic' ? await anthropic() : openai();
|
|
38
|
+
// A factory, so `/c` starts over with fresh tool state (e.g. which files were read).
|
|
39
|
+
const createAgent = () => {
|
|
40
|
+
const tools = {
|
|
41
|
+
// read, write and update; updates and overwrites only after a read.
|
|
42
|
+
...ManagedFileTools(),
|
|
43
|
+
webfetch: WebFetchTool(provider === 'openai'
|
|
44
|
+
? // llama.cpp: skip thinking for compaction; other servers ignore unknown fields.
|
|
45
|
+
{ params: { compactOptions: { value: { chat_template_kwargs: { enable_thinking: false } } } } }
|
|
46
|
+
: {}),
|
|
47
|
+
};
|
|
48
|
+
if (process.env.PARALLEL_API_KEY)
|
|
49
|
+
tools.websearch = ParallelWebSearchTool();
|
|
50
|
+
if (shell)
|
|
51
|
+
tools.shell = ShellTool();
|
|
52
|
+
return Loop(api, tools);
|
|
53
|
+
};
|
|
54
|
+
const ui = new SimpleUI(createAgent, {
|
|
55
|
+
model,
|
|
56
|
+
system: `Concise assistant. Today is ${new Date().toISOString().slice(0, 10)}. cwd: ${process.cwd()}`,
|
|
57
|
+
});
|
|
58
|
+
const prompt = argv._.join(' ');
|
|
59
|
+
if (prompt)
|
|
60
|
+
await ui.ask(prompt);
|
|
61
|
+
else
|
|
62
|
+
await ui.start();
|
|
63
|
+
function openai() {
|
|
64
|
+
return new OpenAI({
|
|
65
|
+
baseURL: argv.baseUrl,
|
|
66
|
+
apiKey: process.env.OPENAI_API_KEY ?? 'none',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** `@anthropic-ai/sdk` is an optional peer dependency; load it only when asked for. */
|
|
70
|
+
async function anthropic() {
|
|
71
|
+
try {
|
|
72
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
73
|
+
// Agentic work does better at high effort than Claude Opus 5.5's medium default.
|
|
74
|
+
return new AnthropicProvider(new Anthropic(), { effort: 'high' });
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err.code !== 'ERR_MODULE_NOT_FOUND')
|
|
78
|
+
throw err;
|
|
79
|
+
console.error('The anthropic provider needs @anthropic-ai/sdk: npm install @anthropic-ai/sdk');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Finds our package.json from either bin/ (source) or dist/bin/ (built). */
|
|
84
|
+
function version() {
|
|
85
|
+
for (let dir = dirname(fileURLToPath(import.meta.url)); dir !== dirname(dir); dir = dirname(dir)) {
|
|
86
|
+
const file = join(dir, 'package.json');
|
|
87
|
+
if (existsSync(file))
|
|
88
|
+
return JSON.parse(readFileSync(file, 'utf8')).version;
|
|
89
|
+
}
|
|
90
|
+
return 'unknown';
|
|
91
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ToolContext } from './tool.ts';
|
|
2
|
+
export interface CompactOptions {
|
|
3
|
+
/** Instruction for the compacting model. */
|
|
4
|
+
prompt: string;
|
|
5
|
+
content: string;
|
|
6
|
+
/** What the calling agent cares about, if it said. */
|
|
7
|
+
focus?: string | undefined;
|
|
8
|
+
/** Defaults to the model the loop is running with. */
|
|
9
|
+
model?: string | undefined;
|
|
10
|
+
/** Extra request fields, e.g. `{ chat_template_kwargs: { enable_thinking: false } }` for llama.cpp. */
|
|
11
|
+
request?: Record<string, unknown> | undefined;
|
|
12
|
+
}
|
|
13
|
+
/** Shrinks a tool result with a single inference call before it reaches the agent's context. */
|
|
14
|
+
export declare function compact(ctx: ToolContext, { prompt, content, focus, model, request }: CompactOptions): Promise<string>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Shrinks a tool result with a single inference call before it reaches the agent's context. */
|
|
2
|
+
export async function compact(ctx, { prompt, content, focus, model, request }) {
|
|
3
|
+
const result = await ctx.api.complete({
|
|
4
|
+
model: model ?? ctx.model,
|
|
5
|
+
system: prompt,
|
|
6
|
+
prompt: focus ? `${content}\n\n---\nFocus: ${focus}` : content,
|
|
7
|
+
signal: ctx.signal,
|
|
8
|
+
request,
|
|
9
|
+
});
|
|
10
|
+
if (!result)
|
|
11
|
+
throw new Error('Compaction returned no content');
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './loop.ts';
|
|
2
|
+
export * from './tool.ts';
|
|
3
|
+
export * from './tools/read.ts';
|
|
4
|
+
export * from './tools/write.ts';
|
|
5
|
+
export * from './tools/update.ts';
|
|
6
|
+
export * from './tools/shell.ts';
|
|
7
|
+
export * from './tools/fs.ts';
|
|
8
|
+
export * from './tools/file-state.ts';
|
|
9
|
+
export * from './compact.ts';
|
|
10
|
+
export * from './tools/webfetch.ts';
|
|
11
|
+
export * from './tools/parallel-search.ts';
|
|
12
|
+
export * from './simple-ui.ts';
|
|
13
|
+
export * from './provider.ts';
|
|
14
|
+
export * from './providers/index.ts';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './loop.js';
|
|
2
|
+
export * from './tool.js';
|
|
3
|
+
export * from './tools/read.js';
|
|
4
|
+
export * from './tools/write.js';
|
|
5
|
+
export * from './tools/update.js';
|
|
6
|
+
export * from './tools/shell.js';
|
|
7
|
+
export * from './tools/fs.js';
|
|
8
|
+
export * from './tools/file-state.js';
|
|
9
|
+
export * from './compact.js';
|
|
10
|
+
export * from './tools/webfetch.js';
|
|
11
|
+
export * from './tools/parallel-search.js';
|
|
12
|
+
export * from './simple-ui.js';
|
|
13
|
+
export * from './provider.js';
|
|
14
|
+
export * from './providers/index.js';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { type AssistantMessage, type Message, type Provider, type Usage } from './provider.ts';
|
|
3
|
+
import { type ApiClient } from './providers/index.ts';
|
|
4
|
+
import { type Toolset } from './tool.ts';
|
|
5
|
+
export type { AssistantMessage, Message, Usage } from './provider.ts';
|
|
6
|
+
export interface EndState {
|
|
7
|
+
/** Assistant message produced by the latest turn. */
|
|
8
|
+
message: AssistantMessage;
|
|
9
|
+
/** Full conversation so far, including `message`. */
|
|
10
|
+
messages: readonly Message[];
|
|
11
|
+
/** 1-based number of the turn that just finished. */
|
|
12
|
+
turn: number;
|
|
13
|
+
}
|
|
14
|
+
/** Return true to stop the loop. Checked after each assistant turn. */
|
|
15
|
+
export type EndCriteria = (state: EndState) => boolean | Promise<boolean>;
|
|
16
|
+
export declare const noToolCalls: EndCriteria;
|
|
17
|
+
export interface LoopOptions {
|
|
18
|
+
endCriteria?: EndCriteria;
|
|
19
|
+
/** Run every tool call in the model's order, one at a time. Off by default. */
|
|
20
|
+
sequentialTools?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface RunOptions {
|
|
23
|
+
model: string;
|
|
24
|
+
input: Message[];
|
|
25
|
+
/** Overrides the toolset given to the constructor for this run. */
|
|
26
|
+
toolset?: Toolset;
|
|
27
|
+
endCriteria?: EndCriteria;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
/** Extra provider-specific request fields (temperature, llama.cpp sampling params, ...). */
|
|
30
|
+
request?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
export interface RunResult {
|
|
33
|
+
/** Final assistant message. */
|
|
34
|
+
message: AssistantMessage;
|
|
35
|
+
/** Full conversation, starting with the input. */
|
|
36
|
+
messages: Message[];
|
|
37
|
+
/** Tokens used by the run, including inference done by tools. */
|
|
38
|
+
usage: Usage;
|
|
39
|
+
}
|
|
40
|
+
export interface ToolCallEvent {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
arguments: unknown;
|
|
44
|
+
}
|
|
45
|
+
export interface ToolResultEvent extends ToolCallEvent {
|
|
46
|
+
result: string;
|
|
47
|
+
isError: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface UsageEvent extends Usage {
|
|
50
|
+
model: string;
|
|
51
|
+
/** 1-based turn the call happened in. */
|
|
52
|
+
turn: number;
|
|
53
|
+
/** A model turn, or inference a tool ran itself (e.g. compaction). */
|
|
54
|
+
source: 'turn' | 'tool';
|
|
55
|
+
/** Name of the tool, for `source: 'tool'`. */
|
|
56
|
+
tool?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface LoopEvents {
|
|
59
|
+
/** A request to the model is about to be made. */
|
|
60
|
+
turn: [turn: number];
|
|
61
|
+
/** Streamed reasoning/thinking delta. */
|
|
62
|
+
reasoning: [delta: string];
|
|
63
|
+
/** Streamed content delta. */
|
|
64
|
+
content: [delta: string];
|
|
65
|
+
/** A tool is about to be invoked. */
|
|
66
|
+
tool_call: [call: ToolCallEvent];
|
|
67
|
+
tool_result: [result: ToolResultEvent];
|
|
68
|
+
/** Tokens used by one model call. */
|
|
69
|
+
usage: [usage: UsageEvent];
|
|
70
|
+
/** A message was appended to the conversation (assistant, tool, or queued). */
|
|
71
|
+
message: [message: Message];
|
|
72
|
+
/** End criteria were met, or the model refused. */
|
|
73
|
+
end: [result: RunResult];
|
|
74
|
+
}
|
|
75
|
+
export declare class AgentLoop extends EventEmitter<LoopEvents> {
|
|
76
|
+
#private;
|
|
77
|
+
readonly api: Provider;
|
|
78
|
+
readonly toolset: Toolset;
|
|
79
|
+
readonly endCriteria: EndCriteria;
|
|
80
|
+
readonly sequentialTools: boolean;
|
|
81
|
+
constructor(api: ApiClient, toolset?: Toolset, options?: LoopOptions);
|
|
82
|
+
get running(): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Queues a message to be sent with the next request. While a run is in
|
|
85
|
+
* progress, a queued message also keeps the loop from ending. Messages queued
|
|
86
|
+
* between runs are sent after the next run's input.
|
|
87
|
+
*/
|
|
88
|
+
queue(message: Message | string): void;
|
|
89
|
+
run(options: RunOptions): Promise<RunResult>;
|
|
90
|
+
}
|
|
91
|
+
/** `Loop(api, toolset)` from the spec; equivalent to `new AgentLoop(...)`. */
|
|
92
|
+
export declare function Loop(api: ApiClient, toolset?: Toolset, options?: LoopOptions): AgentLoop;
|
package/dist/src/loop.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { addUsage, emptyUsage, } from './provider.js';
|
|
3
|
+
import { toProvider } from './providers/index.js';
|
|
4
|
+
import { ToolInputError } from './tool.js';
|
|
5
|
+
export const noToolCalls = ({ message }) => !message.tool_calls?.length;
|
|
6
|
+
export class AgentLoop extends EventEmitter {
|
|
7
|
+
api;
|
|
8
|
+
toolset;
|
|
9
|
+
endCriteria;
|
|
10
|
+
sequentialTools;
|
|
11
|
+
#pending = [];
|
|
12
|
+
#running = false;
|
|
13
|
+
constructor(api, toolset = {}, options = {}) {
|
|
14
|
+
super();
|
|
15
|
+
this.api = toProvider(api);
|
|
16
|
+
this.toolset = toolset;
|
|
17
|
+
this.endCriteria = options.endCriteria ?? noToolCalls;
|
|
18
|
+
this.sequentialTools = options.sequentialTools ?? false;
|
|
19
|
+
}
|
|
20
|
+
get running() {
|
|
21
|
+
return this.#running;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Queues a message to be sent with the next request. While a run is in
|
|
25
|
+
* progress, a queued message also keeps the loop from ending. Messages queued
|
|
26
|
+
* between runs are sent after the next run's input.
|
|
27
|
+
*/
|
|
28
|
+
queue(message) {
|
|
29
|
+
this.#pending.push(typeof message === 'string' ? { role: 'user', content: message } : message);
|
|
30
|
+
}
|
|
31
|
+
async run(options) {
|
|
32
|
+
if (this.#running)
|
|
33
|
+
throw new Error('AgentLoop is already running');
|
|
34
|
+
this.#running = true;
|
|
35
|
+
try {
|
|
36
|
+
return await this.#run(options);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
this.#running = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async #run({ model, input, toolset = this.toolset, endCriteria = this.endCriteria, signal, request }) {
|
|
43
|
+
const messages = [...input];
|
|
44
|
+
const tools = toolSpecs(toolset);
|
|
45
|
+
let usage = emptyUsage();
|
|
46
|
+
let turn = 0;
|
|
47
|
+
const track = (used, info) => {
|
|
48
|
+
usage = addUsage(usage, used);
|
|
49
|
+
this.emit('usage', { ...used, ...info, turn });
|
|
50
|
+
};
|
|
51
|
+
for (turn = 1;; turn++) {
|
|
52
|
+
this.#drainQueue(messages);
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
|
+
this.emit('turn', turn);
|
|
55
|
+
const message = await this.api.turn({ model, messages, tools, signal, request }, {
|
|
56
|
+
reasoning: (delta) => this.emit('reasoning', delta),
|
|
57
|
+
content: (delta) => this.emit('content', delta),
|
|
58
|
+
usage: (used) => track(used, { model, source: 'turn' }),
|
|
59
|
+
});
|
|
60
|
+
// SDKs may end an aborted stream quietly; don't act on a cut-off message.
|
|
61
|
+
signal?.throwIfAborted();
|
|
62
|
+
messages.push(message);
|
|
63
|
+
this.emit('message', message);
|
|
64
|
+
if (message.tool_calls?.length) {
|
|
65
|
+
// A refused or truncated turn's tool calls may be incomplete; answer them without running.
|
|
66
|
+
const skip = message.stop === 'refusal' || message.stop === 'max_tokens' ? message.stop : undefined;
|
|
67
|
+
const results = skip
|
|
68
|
+
? message.tool_calls.map((call) => this.#skip(call, skip))
|
|
69
|
+
: await this.#invokeAll(message.tool_calls, toolset, { model, signal, track });
|
|
70
|
+
for (const result of results) {
|
|
71
|
+
messages.push(result);
|
|
72
|
+
this.emit('message', result);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const done = message.stop === 'refusal' || (await endCriteria({ message, messages, turn }));
|
|
76
|
+
if (done && (this.#pending.length === 0 || message.stop === 'refusal')) {
|
|
77
|
+
const result = { message, messages, usage };
|
|
78
|
+
this.emit('end', result);
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
#drainQueue(messages) {
|
|
84
|
+
for (const message of this.#pending.splice(0)) {
|
|
85
|
+
messages.push(message);
|
|
86
|
+
this.emit('message', message);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Runs a turn's calls concurrently, except sequential ones, which wait for
|
|
91
|
+
* everything before them and run alone. Results keep the model's order.
|
|
92
|
+
*/
|
|
93
|
+
async #invokeAll(calls, toolset, context) {
|
|
94
|
+
const results = [];
|
|
95
|
+
let batch = [];
|
|
96
|
+
for (const call of calls) {
|
|
97
|
+
if (this.sequentialTools || toolset[call.function.name]?.sequential) {
|
|
98
|
+
results.push(...(await Promise.all(batch)));
|
|
99
|
+
batch = [];
|
|
100
|
+
results.push(await this.#invoke(call, toolset, context));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
batch.push(this.#invoke(call, toolset, context));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
results.push(...(await Promise.all(batch)));
|
|
107
|
+
return results;
|
|
108
|
+
}
|
|
109
|
+
async #invoke(call, toolset, { model, signal, track }) {
|
|
110
|
+
const { id } = call;
|
|
111
|
+
const { name, arguments: raw } = call.function;
|
|
112
|
+
const args = parseArguments(raw);
|
|
113
|
+
this.emit('tool_call', { id, name, arguments: args ?? raw });
|
|
114
|
+
let result;
|
|
115
|
+
let isError = false;
|
|
116
|
+
try {
|
|
117
|
+
const tool = toolset[name];
|
|
118
|
+
if (!tool)
|
|
119
|
+
throw new ToolInputError(`Unknown tool "${name}"`);
|
|
120
|
+
if (!args)
|
|
121
|
+
throw new ToolInputError('Arguments must be a JSON object');
|
|
122
|
+
result = await tool.invoke(args, { api: attributed(this.api, name, track), model, signal });
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
if (signal?.aborted)
|
|
126
|
+
throw err;
|
|
127
|
+
// Any failure goes back to the model as the tool result; it may be able to recover.
|
|
128
|
+
isError = true;
|
|
129
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
130
|
+
}
|
|
131
|
+
this.emit('tool_result', { id, name, arguments: args, result, isError });
|
|
132
|
+
return { role: 'tool', tool_call_id: id, content: result, ...(isError ? { is_error: true } : {}) };
|
|
133
|
+
}
|
|
134
|
+
#skip(call, reason) {
|
|
135
|
+
const { id } = call;
|
|
136
|
+
const { name, arguments: raw } = call.function;
|
|
137
|
+
const result = reason === 'max_tokens'
|
|
138
|
+
? 'Error: not run; the response hit the output token limit, so the arguments may be incomplete'
|
|
139
|
+
: 'Error: not run; the response was declined';
|
|
140
|
+
this.emit('tool_call', { id, name, arguments: parseArguments(raw) ?? raw });
|
|
141
|
+
this.emit('tool_result', { id, name, arguments: parseArguments(raw), result, isError: true });
|
|
142
|
+
return { role: 'tool', tool_call_id: id, content: result, is_error: true };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** `Loop(api, toolset)` from the spec; equivalent to `new AgentLoop(...)`. */
|
|
146
|
+
export function Loop(api, toolset = {}, options = {}) {
|
|
147
|
+
return new AgentLoop(api, toolset, options);
|
|
148
|
+
}
|
|
149
|
+
/** The provider as a tool sees it: its own inference is reported as the tool's usage. */
|
|
150
|
+
function attributed(api, tool, track) {
|
|
151
|
+
return {
|
|
152
|
+
name: api.name,
|
|
153
|
+
turn: (request, on) => api.turn(request, {
|
|
154
|
+
...on,
|
|
155
|
+
usage: (used) => {
|
|
156
|
+
on.usage?.(used);
|
|
157
|
+
track(used, { model: request.model, source: 'tool', tool });
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
complete: (request) => api.complete({
|
|
161
|
+
...request,
|
|
162
|
+
onUsage: (used) => {
|
|
163
|
+
request.onUsage?.(used);
|
|
164
|
+
track(used, { model: request.model, source: 'tool', tool });
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function parseArguments(raw) {
|
|
170
|
+
if (!raw.trim())
|
|
171
|
+
return {};
|
|
172
|
+
try {
|
|
173
|
+
const args = JSON.parse(raw);
|
|
174
|
+
return typeof args === 'object' && args !== null && !Array.isArray(args)
|
|
175
|
+
? args
|
|
176
|
+
: undefined;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function toolSpecs(toolset) {
|
|
183
|
+
return Object.entries(toolset).map(([name, tool]) => ({ name, ...tool.schema() }));
|
|
184
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ChatCompletionAssistantMessageParam, ChatCompletionMessageFunctionToolCall, ChatCompletionMessageParam, ChatCompletionToolMessageParam } from 'openai/resources/chat/completions';
|
|
2
|
+
import type { JSONSchema } from './tool.ts';
|
|
3
|
+
/** Why a model stopped, normalized across providers. */
|
|
4
|
+
export type StopReason = 'end' | 'tool_use' | 'max_tokens' | 'refusal' | 'other';
|
|
5
|
+
/**
|
|
6
|
+
* Assistant message as kept in history. Conversations use the OpenAI chat
|
|
7
|
+
* shape as the common format, with a few extensions:
|
|
8
|
+
* - `reasoning_content`: thinking text (llama.cpp's field; filled by every provider).
|
|
9
|
+
* - `stop`: why the model stopped.
|
|
10
|
+
* - `native`: the provider's raw content, replayed verbatim to the same provider
|
|
11
|
+
* (e.g. Anthropic thinking blocks must be passed back unchanged).
|
|
12
|
+
*/
|
|
13
|
+
export type AssistantMessage = Omit<ChatCompletionAssistantMessageParam, 'tool_calls'> & {
|
|
14
|
+
tool_calls?: ChatCompletionMessageFunctionToolCall[];
|
|
15
|
+
reasoning_content?: string;
|
|
16
|
+
stop?: StopReason;
|
|
17
|
+
native?: {
|
|
18
|
+
provider: string;
|
|
19
|
+
content: unknown;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/** Tool result; `is_error` lets providers that support it flag failures. */
|
|
23
|
+
export type ToolMessage = ChatCompletionToolMessageParam & {
|
|
24
|
+
is_error?: boolean;
|
|
25
|
+
};
|
|
26
|
+
export type Message = ChatCompletionMessageParam | AssistantMessage | ToolMessage;
|
|
27
|
+
/** Token counts for one model call. `input` excludes cache reads and writes. */
|
|
28
|
+
export interface Usage {
|
|
29
|
+
input: number;
|
|
30
|
+
output: number;
|
|
31
|
+
cacheRead: number;
|
|
32
|
+
cacheWrite: number;
|
|
33
|
+
/** Part of `output` spent on reasoning, when the provider reports it. */
|
|
34
|
+
reasoning?: number;
|
|
35
|
+
}
|
|
36
|
+
export declare function addUsage(a: Usage, b: Usage): Usage;
|
|
37
|
+
export declare const emptyUsage: () => Usage;
|
|
38
|
+
export interface ToolSpec {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
parameters: JSONSchema;
|
|
42
|
+
}
|
|
43
|
+
export interface TurnRequest {
|
|
44
|
+
model: string;
|
|
45
|
+
messages: readonly Message[];
|
|
46
|
+
tools: readonly ToolSpec[];
|
|
47
|
+
signal?: AbortSignal | undefined;
|
|
48
|
+
/** Extra provider-specific request fields. */
|
|
49
|
+
request?: Record<string, unknown> | undefined;
|
|
50
|
+
}
|
|
51
|
+
export interface TurnHandlers {
|
|
52
|
+
reasoning(delta: string): void;
|
|
53
|
+
content(delta: string): void;
|
|
54
|
+
usage?(usage: Usage): void;
|
|
55
|
+
}
|
|
56
|
+
export interface CompleteRequest {
|
|
57
|
+
model: string;
|
|
58
|
+
system?: string | undefined;
|
|
59
|
+
prompt: string;
|
|
60
|
+
signal?: AbortSignal | undefined;
|
|
61
|
+
onUsage?: ((usage: Usage) => void) | undefined;
|
|
62
|
+
/** Extra provider-specific request fields. */
|
|
63
|
+
request?: Record<string, unknown> | undefined;
|
|
64
|
+
}
|
|
65
|
+
/** What the loop and tools need from a model API. */
|
|
66
|
+
export interface Provider {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
/** Streams one assistant turn, reporting deltas as they arrive. */
|
|
69
|
+
turn(request: TurnRequest, on: TurnHandlers): Promise<AssistantMessage>;
|
|
70
|
+
/** Single non-streaming completion without tools, e.g. for compaction. */
|
|
71
|
+
complete(request: CompleteRequest): Promise<string>;
|
|
72
|
+
}
|
|
73
|
+
export declare function isProvider(value: unknown): value is Provider;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function addUsage(a, b) {
|
|
2
|
+
const sum = {
|
|
3
|
+
input: a.input + b.input,
|
|
4
|
+
output: a.output + b.output,
|
|
5
|
+
cacheRead: a.cacheRead + b.cacheRead,
|
|
6
|
+
cacheWrite: a.cacheWrite + b.cacheWrite,
|
|
7
|
+
};
|
|
8
|
+
if (a.reasoning !== undefined || b.reasoning !== undefined)
|
|
9
|
+
sum.reasoning = (a.reasoning ?? 0) + (b.reasoning ?? 0);
|
|
10
|
+
return sum;
|
|
11
|
+
}
|
|
12
|
+
export const emptyUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
13
|
+
export function isProvider(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null && typeof value.turn === 'function';
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta/messages/messages';
|
|
3
|
+
import type { AssistantMessage, CompleteRequest, Message, Provider, TurnHandlers, TurnRequest } from '../provider.ts';
|
|
4
|
+
export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
5
|
+
export interface AnthropicProviderOptions {
|
|
6
|
+
/** Defaults to 64000; turns are streamed, so large values are fine. */
|
|
7
|
+
maxTokens?: number;
|
|
8
|
+
/** `output_config.effort`; unset uses the model's default (`medium` on Claude Opus 5.5). */
|
|
9
|
+
effort?: Effort;
|
|
10
|
+
/**
|
|
11
|
+
* Adaptive thinking display. `summarized` (default) returns readable thinking
|
|
12
|
+
* summaries; `false` omits the `thinking` parameter (e.g. for Claude Haiku 4.5).
|
|
13
|
+
*/
|
|
14
|
+
thinking?: 'summarized' | 'omitted' | false;
|
|
15
|
+
/** Server-side fallback when a request is declined for policy reasons. On by default. */
|
|
16
|
+
fallbacks?: 'default' | false;
|
|
17
|
+
/** Automatic prompt caching of the conversation prefix. On by default. */
|
|
18
|
+
cache?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Claude through the Messages API (`@anthropic-ai/sdk`). */
|
|
21
|
+
export declare class AnthropicProvider implements Provider {
|
|
22
|
+
readonly name = "anthropic";
|
|
23
|
+
readonly client: Anthropic;
|
|
24
|
+
readonly options: AnthropicProviderOptions;
|
|
25
|
+
constructor(client: Anthropic, options?: AnthropicProviderOptions);
|
|
26
|
+
turn({ model, messages, tools, signal, request }: TurnRequest, on: TurnHandlers): Promise<AssistantMessage>;
|
|
27
|
+
complete({ model, system, prompt, signal, request, onUsage }: CompleteRequest): Promise<string>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Converts the common history format. Leading system messages become the
|
|
31
|
+
* `system` prompt; tool results and any user text that follows are merged
|
|
32
|
+
* into one user message, since parallel results belong together.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toAnthropic(messages: readonly Message[]): {
|
|
35
|
+
system: string | undefined;
|
|
36
|
+
messages: BetaMessageParam[];
|
|
37
|
+
};
|