@tjamescouch/gro 1.3.5 → 1.3.7
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/drivers/anthropic.js +256 -0
- package/dist/drivers/index.js +2 -0
- package/dist/drivers/streaming-openai.js +262 -0
- package/dist/drivers/types.js +1 -0
- package/dist/errors.js +79 -0
- package/dist/logger.js +30 -0
- package/dist/main.js +867 -0
- package/dist/mcp/client.js +130 -0
- package/dist/mcp/index.js +1 -0
- package/dist/memory/advanced-memory.js +210 -0
- package/dist/memory/agent-memory.js +52 -0
- package/dist/memory/agenthnsw.js +86 -0
- package/{src/memory/index.ts → dist/memory/index.js} +0 -1
- package/dist/memory/simple-memory.js +34 -0
- package/dist/memory/vector-index.js +7 -0
- package/dist/package.json +22 -0
- package/dist/session.js +110 -0
- package/dist/tools/agentpatch.js +91 -0
- package/dist/tools/bash.js +61 -0
- package/dist/tools/version.js +76 -0
- package/dist/utils/rate-limiter.js +46 -0
- package/{src/utils/retry.ts → dist/utils/retry.js} +8 -12
- package/dist/utils/timed-fetch.js +25 -0
- package/gro +0 -0
- package/package.json +13 -2
- package/.github/workflows/ci.yml +0 -20
- package/src/drivers/anthropic.ts +0 -281
- package/src/drivers/index.ts +0 -5
- package/src/drivers/streaming-openai.ts +0 -258
- package/src/drivers/types.ts +0 -39
- package/src/errors.ts +0 -97
- package/src/logger.ts +0 -28
- package/src/main.ts +0 -905
- package/src/mcp/client.ts +0 -163
- package/src/mcp/index.ts +0 -2
- package/src/memory/advanced-memory.ts +0 -263
- package/src/memory/agent-memory.ts +0 -61
- package/src/memory/agenthnsw.ts +0 -122
- package/src/memory/simple-memory.ts +0 -41
- package/src/memory/vector-index.ts +0 -30
- package/src/session.ts +0 -150
- package/src/tools/agentpatch.ts +0 -89
- package/src/tools/bash.ts +0 -61
- package/src/tools/version.ts +0 -98
- package/src/utils/rate-limiter.ts +0 -60
- package/src/utils/timed-fetch.ts +0 -29
- package/tests/errors.test.ts +0 -246
- package/tests/memory.test.ts +0 -186
- package/tests/rate-limiter.test.ts +0 -76
- package/tests/retry.test.ts +0 -138
- package/tests/timed-fetch.test.ts +0 -104
- package/tsconfig.json +0 -13
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentpatch tool integration for gro.
|
|
3
|
+
*
|
|
4
|
+
* Exposes a first-class file editor: `apply_patch`.
|
|
5
|
+
*
|
|
6
|
+
* This wraps the agentpatch patch grammar and applies patches via the
|
|
7
|
+
* `agentpatch/bin/apply_patch` script.
|
|
8
|
+
*/
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { Logger } from "../logger.js";
|
|
13
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
14
|
+
const MAX_OUTPUT = 30_000;
|
|
15
|
+
export function agentpatchToolDefinition() {
|
|
16
|
+
return {
|
|
17
|
+
type: "function",
|
|
18
|
+
function: {
|
|
19
|
+
name: "apply_patch",
|
|
20
|
+
description: "Apply a unified agentpatch-style patch to the working tree (safe, idempotent).",
|
|
21
|
+
parameters: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
patch: { type: "string", description: "The patch text (agentpatch grammar)." },
|
|
25
|
+
dry_run: { type: "boolean", description: "If true, validate/preview without writing." },
|
|
26
|
+
verbose: { type: "boolean", description: "If true, emit debug logs from applier." },
|
|
27
|
+
allow_delete: { type: "boolean", description: "Allow *** Delete File ops." },
|
|
28
|
+
allow_rename: { type: "boolean", description: "Allow *** Rename File ops." },
|
|
29
|
+
timeout: { type: "number", description: "Timeout in ms (default 120000)." },
|
|
30
|
+
},
|
|
31
|
+
required: ["patch"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function truncate(s) {
|
|
37
|
+
if (s.length <= MAX_OUTPUT)
|
|
38
|
+
return s;
|
|
39
|
+
const half = Math.floor(MAX_OUTPUT / 2);
|
|
40
|
+
return s.slice(0, half) + `\n\n... (truncated ${s.length - MAX_OUTPUT} chars) ...\n\n` + s.slice(-half);
|
|
41
|
+
}
|
|
42
|
+
export function executeAgentpatch(args) {
|
|
43
|
+
const patch = args.patch || "";
|
|
44
|
+
if (!patch.trim())
|
|
45
|
+
return "Error: empty patch";
|
|
46
|
+
const timeout = args.timeout || DEFAULT_TIMEOUT;
|
|
47
|
+
const dryRun = args.dry_run === true;
|
|
48
|
+
const verbose = args.verbose === true;
|
|
49
|
+
const allowDelete = args.allow_delete === true;
|
|
50
|
+
const allowRename = args.allow_rename === true;
|
|
51
|
+
// Expected layout in this monorepo-ish runner: /home/agent/agentpatch
|
|
52
|
+
// If not present, instruct user to clone it.
|
|
53
|
+
const agentpatchPath = process.env.AGENTPATCH_PATH || join(process.env.HOME || "", "agentpatch");
|
|
54
|
+
const bin = join(agentpatchPath, "bin", "apply_patch");
|
|
55
|
+
if (!existsSync(bin)) {
|
|
56
|
+
return `Error: agentpatch not found at ${bin}. Set AGENTPATCH_PATH or clone agentpatch to ~/agentpatch.`;
|
|
57
|
+
}
|
|
58
|
+
const cmd = [bin];
|
|
59
|
+
if (dryRun)
|
|
60
|
+
cmd.push("--dry-run");
|
|
61
|
+
if (verbose)
|
|
62
|
+
cmd.push("--verbose");
|
|
63
|
+
if (allowDelete)
|
|
64
|
+
cmd.push("--allow-delete");
|
|
65
|
+
if (allowRename)
|
|
66
|
+
cmd.push("--allow-rename");
|
|
67
|
+
Logger.debug(`apply_patch: ${cmd.join(" ")}`);
|
|
68
|
+
try {
|
|
69
|
+
const out = execSync(cmd.join(" "), {
|
|
70
|
+
shell: "/bin/bash",
|
|
71
|
+
input: patch,
|
|
72
|
+
encoding: "utf-8",
|
|
73
|
+
timeout,
|
|
74
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
75
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
return truncate(out || "ok");
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
let result = "";
|
|
81
|
+
if (e.stdout)
|
|
82
|
+
result += e.stdout;
|
|
83
|
+
if (e.stderr)
|
|
84
|
+
result += (result ? "\n" : "") + e.stderr;
|
|
85
|
+
if (!result)
|
|
86
|
+
result = e.message || "Command failed";
|
|
87
|
+
if (e.status != null)
|
|
88
|
+
result += `\n[exit code: ${e.status}]`;
|
|
89
|
+
return truncate(result);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in bash tool for gro — executes shell commands and returns output.
|
|
3
|
+
* Gated behind --bash flag. Not enabled by default.
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { Logger } from "../logger.js";
|
|
7
|
+
const MAX_OUTPUT = 30_000;
|
|
8
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
9
|
+
export function bashToolDefinition() {
|
|
10
|
+
return {
|
|
11
|
+
type: "function",
|
|
12
|
+
function: {
|
|
13
|
+
name: "bash",
|
|
14
|
+
description: "Execute a bash command and return its output (stdout + stderr).",
|
|
15
|
+
parameters: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
command: { type: "string", description: "The bash command to execute" },
|
|
19
|
+
timeout: { type: "number", description: "Timeout in milliseconds (default: 120000)" },
|
|
20
|
+
},
|
|
21
|
+
required: ["command"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function executeBash(args) {
|
|
27
|
+
const command = args.command;
|
|
28
|
+
if (!command)
|
|
29
|
+
return "Error: no command provided";
|
|
30
|
+
const timeout = args.timeout || DEFAULT_TIMEOUT;
|
|
31
|
+
Logger.debug(`bash: ${command}`);
|
|
32
|
+
try {
|
|
33
|
+
const output = execSync(command, {
|
|
34
|
+
shell: "/bin/bash",
|
|
35
|
+
encoding: "utf-8",
|
|
36
|
+
timeout,
|
|
37
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
38
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
39
|
+
});
|
|
40
|
+
return truncate(output);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
// execSync throws on non-zero exit — capture stdout + stderr
|
|
44
|
+
let result = "";
|
|
45
|
+
if (e.stdout)
|
|
46
|
+
result += e.stdout;
|
|
47
|
+
if (e.stderr)
|
|
48
|
+
result += (result ? "\n" : "") + e.stderr;
|
|
49
|
+
if (!result)
|
|
50
|
+
result = e.message || "Command failed";
|
|
51
|
+
if (e.status != null)
|
|
52
|
+
result += `\n[exit code: ${e.status}]`;
|
|
53
|
+
return truncate(result);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function truncate(s) {
|
|
57
|
+
if (s.length <= MAX_OUTPUT)
|
|
58
|
+
return s;
|
|
59
|
+
const half = Math.floor(MAX_OUTPUT / 2);
|
|
60
|
+
return s.slice(0, half) + `\n\n... (truncated ${s.length - MAX_OUTPUT} chars) ...\n\n` + s.slice(-half);
|
|
61
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in version/identity tool for gro.
|
|
3
|
+
*
|
|
4
|
+
* Lets agents (and humans) introspect the gro runtime — version, provider,
|
|
5
|
+
* model, uptime, process info. This is the canonical way to confirm an
|
|
6
|
+
* agent is running on gro.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
const startTime = Date.now();
|
|
12
|
+
/** Read version from package.json — single source of truth. */
|
|
13
|
+
function readVersion() {
|
|
14
|
+
// In ESM, __dirname isn't available — derive from import.meta.url
|
|
15
|
+
let selfDir;
|
|
16
|
+
try {
|
|
17
|
+
selfDir = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
selfDir = process.cwd();
|
|
21
|
+
}
|
|
22
|
+
const candidates = [
|
|
23
|
+
join(selfDir, "..", "package.json"), // from dist/tools/ or src/tools/
|
|
24
|
+
join(selfDir, "..", "..", "package.json"), // from deeper nesting
|
|
25
|
+
join(process.cwd(), "package.json"),
|
|
26
|
+
];
|
|
27
|
+
for (const p of candidates) {
|
|
28
|
+
if (existsSync(p)) {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
31
|
+
if (pkg.name === "@tjamescouch/gro" && pkg.version) {
|
|
32
|
+
return pkg.version;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// try next candidate
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return "unknown";
|
|
41
|
+
}
|
|
42
|
+
// Cache version at module load
|
|
43
|
+
const GRO_VERSION = readVersion();
|
|
44
|
+
export function getGroVersion() {
|
|
45
|
+
return GRO_VERSION;
|
|
46
|
+
}
|
|
47
|
+
export function groVersionToolDefinition() {
|
|
48
|
+
return {
|
|
49
|
+
type: "function",
|
|
50
|
+
function: {
|
|
51
|
+
name: "gro_version",
|
|
52
|
+
description: "Report gro runtime identity and version. Returns runtime name, version, provider, model, uptime, and process info. Use this to confirm an agent is running on gro.",
|
|
53
|
+
parameters: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Execute the version tool. Requires runtime config to report provider/model.
|
|
62
|
+
*/
|
|
63
|
+
export function executeGroVersion(cfg) {
|
|
64
|
+
const info = {
|
|
65
|
+
runtime: "gro",
|
|
66
|
+
version: GRO_VERSION,
|
|
67
|
+
provider: cfg.provider,
|
|
68
|
+
model: cfg.model,
|
|
69
|
+
pid: process.pid,
|
|
70
|
+
uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
|
71
|
+
node_version: process.version,
|
|
72
|
+
platform: process.platform,
|
|
73
|
+
persistent: cfg.persistent,
|
|
74
|
+
};
|
|
75
|
+
return JSON.stringify(info, null, 2);
|
|
76
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function sleep(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
3
|
+
}
|
|
4
|
+
class RateLimiter {
|
|
5
|
+
constructor(now) {
|
|
6
|
+
this.states = new Map();
|
|
7
|
+
this.now =
|
|
8
|
+
now ??
|
|
9
|
+
(() => typeof performance !== "undefined" && typeof performance.now === "function"
|
|
10
|
+
? performance.now()
|
|
11
|
+
: Date.now());
|
|
12
|
+
}
|
|
13
|
+
async limit(name, throughputPerSecond) {
|
|
14
|
+
if (!Number.isFinite(throughputPerSecond) || throughputPerSecond <= 0) {
|
|
15
|
+
throw new RangeError(`throughputPerSecond must be a positive finite number; got ${throughputPerSecond}`);
|
|
16
|
+
}
|
|
17
|
+
const key = name || "default";
|
|
18
|
+
const state = this.getState(key);
|
|
19
|
+
const waitPromise = state.tail.then(async () => {
|
|
20
|
+
const intervalMs = 1000 / throughputPerSecond;
|
|
21
|
+
const now = this.now();
|
|
22
|
+
const scheduledAt = Math.max(now, state.nextAvailableMs);
|
|
23
|
+
state.nextAvailableMs = scheduledAt + intervalMs;
|
|
24
|
+
const delay = scheduledAt - now;
|
|
25
|
+
if (delay > 0)
|
|
26
|
+
await sleep(delay);
|
|
27
|
+
});
|
|
28
|
+
state.tail = waitPromise.catch(() => { });
|
|
29
|
+
return waitPromise;
|
|
30
|
+
}
|
|
31
|
+
reset(name) {
|
|
32
|
+
if (name)
|
|
33
|
+
this.states.delete(name);
|
|
34
|
+
else
|
|
35
|
+
this.states.clear();
|
|
36
|
+
}
|
|
37
|
+
getState(name) {
|
|
38
|
+
let s = this.states.get(name);
|
|
39
|
+
if (!s) {
|
|
40
|
+
s = { nextAvailableMs: this.now(), tail: Promise.resolve() };
|
|
41
|
+
this.states.set(name, s);
|
|
42
|
+
}
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const rateLimiter = new RateLimiter();
|
|
@@ -3,30 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Shared between Anthropic and OpenAI drivers to avoid duplication.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
6
|
export const MAX_RETRIES = 3;
|
|
8
7
|
export const RETRY_BASE_MS = 1000;
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* Check if an HTTP status code is retryable.
|
|
12
10
|
* 429 = rate limited, 502/503 = upstream error, 529 = overloaded.
|
|
13
11
|
*/
|
|
14
|
-
export function isRetryable(status
|
|
15
|
-
|
|
12
|
+
export function isRetryable(status) {
|
|
13
|
+
return status === 429 || status === 502 || status === 503 || status === 529;
|
|
16
14
|
}
|
|
17
|
-
|
|
18
15
|
/**
|
|
19
16
|
* Calculate retry delay with exponential backoff + jitter.
|
|
20
17
|
* attempt 0 → base 1s + 0-0.5s jitter
|
|
21
18
|
* attempt 1 → base 2s + 0-1s jitter
|
|
22
19
|
* attempt 2 → base 4s + 0-2s jitter
|
|
23
20
|
*/
|
|
24
|
-
export function retryDelay(attempt
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
export function retryDelay(attempt) {
|
|
22
|
+
const base = RETRY_BASE_MS * Math.pow(2, attempt);
|
|
23
|
+
const jitter = Math.random() * base * 0.5;
|
|
24
|
+
return base + jitter;
|
|
28
25
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
export function sleep(ms) {
|
|
27
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
28
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { asError } from "../errors.js";
|
|
2
|
+
/** Wrap fetch with timeout and location context for debugging. */
|
|
3
|
+
export async function timedFetch(url, init = {}) {
|
|
4
|
+
const { timeoutMs, where, ...rest } = init;
|
|
5
|
+
let controller = null;
|
|
6
|
+
let timer = null;
|
|
7
|
+
try {
|
|
8
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
9
|
+
controller = new AbortController();
|
|
10
|
+
rest.signal = controller.signal;
|
|
11
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
12
|
+
}
|
|
13
|
+
return await fetch(url, rest);
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
const wrapped = asError(e);
|
|
17
|
+
const err = new Error(`[fetch timeout] ${where ?? ""} ${url} -> ${wrapped.name}: ${wrapped.message}`);
|
|
18
|
+
err.cause = e;
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
if (timer)
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/gro
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tjamescouch/gro",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.7",
|
|
4
4
|
"description": "Provider-agnostic LLM runtime with context management",
|
|
5
|
+
"bin": {
|
|
6
|
+
"gro": "./dist/main.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/",
|
|
10
|
+
"gro",
|
|
11
|
+
"providers/",
|
|
12
|
+
"_base.md",
|
|
13
|
+
"owl"
|
|
14
|
+
],
|
|
5
15
|
"type": "module",
|
|
6
16
|
"scripts": {
|
|
7
17
|
"start": "npx tsx src/main.ts",
|
|
8
|
-
"build": "npx tsc &&
|
|
18
|
+
"build": "npx tsc && chmod +x dist/main.js",
|
|
19
|
+
"prepublishOnly": "npm run build",
|
|
9
20
|
"build:bun": "bun build src/main.ts --outdir dist --target bun",
|
|
10
21
|
"test": "npx tsx --test tests/*.test.ts",
|
|
11
22
|
"test:bun": "bun test"
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
pull_request:
|
|
5
|
-
branches: [main]
|
|
6
|
-
push:
|
|
7
|
-
branches: [main]
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
ci:
|
|
11
|
-
name: Build & Test
|
|
12
|
-
runs-on: ubuntu-latest
|
|
13
|
-
steps:
|
|
14
|
-
- uses: actions/checkout@v4
|
|
15
|
-
- uses: oven-sh/setup-bun@v2
|
|
16
|
-
with:
|
|
17
|
-
bun-version: latest
|
|
18
|
-
- run: bun install
|
|
19
|
-
- run: bun run build
|
|
20
|
-
- run: bun test
|
package/src/drivers/anthropic.ts
DELETED
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Anthropic Messages API driver.
|
|
3
|
-
* Direct HTTP — no SDK dependency.
|
|
4
|
-
*/
|
|
5
|
-
import { Logger } from "../logger.js";
|
|
6
|
-
import { rateLimiter } from "../utils/rate-limiter.js";
|
|
7
|
-
import { timedFetch } from "../utils/timed-fetch.js";
|
|
8
|
-
import { MAX_RETRIES, isRetryable, retryDelay, sleep } from "../utils/retry.js";
|
|
9
|
-
import { groError, asError, isGroError, errorLogFields } from "../errors.js";
|
|
10
|
-
import type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall, TokenUsage } from "./types.js";
|
|
11
|
-
|
|
12
|
-
export interface AnthropicDriverConfig {
|
|
13
|
-
apiKey: string;
|
|
14
|
-
baseUrl?: string;
|
|
15
|
-
model?: string;
|
|
16
|
-
maxTokens?: number;
|
|
17
|
-
timeoutMs?: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Convert tool definitions from OpenAI format to Anthropic format.
|
|
22
|
-
* OpenAI: { type: "function", function: { name, description, parameters } }
|
|
23
|
-
* Anthropic: { name, description, input_schema }
|
|
24
|
-
*/
|
|
25
|
-
function convertToolDefs(tools: any[]): any[] {
|
|
26
|
-
return tools.map(t => {
|
|
27
|
-
if (t.type === "function" && t.function) {
|
|
28
|
-
return {
|
|
29
|
-
type: "custom",
|
|
30
|
-
name: t.function.name,
|
|
31
|
-
description: t.function.description || "",
|
|
32
|
-
input_schema: t.function.parameters || { type: "object", properties: {} },
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
// Already in Anthropic format — ensure type is set
|
|
36
|
-
if (!t.type) return { type: "custom", ...t };
|
|
37
|
-
return t;
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Convert internal messages (OpenAI-style) to Anthropic Messages API format.
|
|
43
|
-
*
|
|
44
|
-
* Key differences:
|
|
45
|
-
* - Assistant tool calls become content blocks with type "tool_use"
|
|
46
|
-
* - Tool result messages become user messages with type "tool_result" content blocks
|
|
47
|
-
* - Anthropic requires strictly alternating user/assistant roles
|
|
48
|
-
*/
|
|
49
|
-
function convertMessages(messages: ChatMessage[]): { system: string | undefined; apiMessages: any[] } {
|
|
50
|
-
let systemPrompt: string | undefined;
|
|
51
|
-
const apiMessages: any[] = [];
|
|
52
|
-
|
|
53
|
-
for (const m of messages) {
|
|
54
|
-
if (m.role === "system") {
|
|
55
|
-
systemPrompt = systemPrompt ? systemPrompt + "\n" + m.content : m.content;
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (m.role === "assistant") {
|
|
60
|
-
const content: any[] = [];
|
|
61
|
-
if (m.content) content.push({ type: "text", text: m.content });
|
|
62
|
-
|
|
63
|
-
// Convert OpenAI-style tool_calls to Anthropic tool_use blocks
|
|
64
|
-
const toolCalls = (m as any).tool_calls;
|
|
65
|
-
if (Array.isArray(toolCalls)) {
|
|
66
|
-
for (const tc of toolCalls) {
|
|
67
|
-
let input: any;
|
|
68
|
-
try { input = JSON.parse(tc.function.arguments || "{}"); } catch { input = {}; }
|
|
69
|
-
content.push({
|
|
70
|
-
type: "tool_use",
|
|
71
|
-
id: tc.id,
|
|
72
|
-
name: tc.function.name,
|
|
73
|
-
input,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (content.length > 0) {
|
|
79
|
-
apiMessages.push({ role: "assistant", content });
|
|
80
|
-
}
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (m.role === "tool") {
|
|
85
|
-
// Tool results must be in a user message with tool_result content blocks
|
|
86
|
-
const block = {
|
|
87
|
-
type: "tool_result",
|
|
88
|
-
tool_use_id: m.tool_call_id,
|
|
89
|
-
content: m.content,
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
// Group consecutive tool results into a single user message
|
|
93
|
-
const last = apiMessages[apiMessages.length - 1];
|
|
94
|
-
if (last && last.role === "user" && Array.isArray(last.content) &&
|
|
95
|
-
last.content.length > 0 && last.content[0].type === "tool_result") {
|
|
96
|
-
last.content.push(block);
|
|
97
|
-
} else {
|
|
98
|
-
apiMessages.push({ role: "user", content: [block] });
|
|
99
|
-
}
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Regular user messages
|
|
104
|
-
apiMessages.push({ role: "user", content: m.content });
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return { system: systemPrompt, apiMessages };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Pattern matching transient network errors that should be retried */
|
|
111
|
-
const TRANSIENT_ERROR_RE = /fetch timeout|fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN|socket hang up/i;
|
|
112
|
-
|
|
113
|
-
/** Parse response content blocks into text + tool calls + token usage */
|
|
114
|
-
function parseResponseContent(data: any, onToken?: (t: string) => void): ChatOutput {
|
|
115
|
-
let text = "";
|
|
116
|
-
const toolCalls: ChatToolCall[] = [];
|
|
117
|
-
|
|
118
|
-
for (const block of data.content ?? []) {
|
|
119
|
-
if (block.type === "text") {
|
|
120
|
-
text += block.text;
|
|
121
|
-
if (onToken) {
|
|
122
|
-
try { onToken(block.text); } catch {}
|
|
123
|
-
}
|
|
124
|
-
} else if (block.type === "tool_use") {
|
|
125
|
-
toolCalls.push({
|
|
126
|
-
id: block.id,
|
|
127
|
-
type: "custom",
|
|
128
|
-
function: {
|
|
129
|
-
name: block.name,
|
|
130
|
-
arguments: JSON.stringify(block.input),
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const usage: TokenUsage | undefined = data.usage ? {
|
|
137
|
-
inputTokens: data.usage.input_tokens ?? 0,
|
|
138
|
-
outputTokens: data.usage.output_tokens ?? 0,
|
|
139
|
-
} : undefined;
|
|
140
|
-
|
|
141
|
-
return { text, toolCalls, usage };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function makeAnthropicDriver(cfg: AnthropicDriverConfig): ChatDriver {
|
|
145
|
-
const base = (cfg.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
|
|
146
|
-
const endpoint = `${base}/v1/messages`;
|
|
147
|
-
const model = cfg.model ?? "claude-sonnet-4-20250514";
|
|
148
|
-
const maxTokens = cfg.maxTokens ?? 4096;
|
|
149
|
-
const timeoutMs = cfg.timeoutMs ?? 2 * 60 * 60 * 1000;
|
|
150
|
-
|
|
151
|
-
async function chat(messages: ChatMessage[], opts?: any): Promise<ChatOutput> {
|
|
152
|
-
await rateLimiter.limit("llm-ask", 1);
|
|
153
|
-
|
|
154
|
-
const onToken: ((t: string) => void) | undefined = opts?.onToken;
|
|
155
|
-
const resolvedModel = opts?.model ?? model;
|
|
156
|
-
|
|
157
|
-
const { system: systemPrompt, apiMessages } = convertMessages(messages);
|
|
158
|
-
|
|
159
|
-
const body: any = {
|
|
160
|
-
model: resolvedModel,
|
|
161
|
-
thinking: {
|
|
162
|
-
type: "adaptive"
|
|
163
|
-
},
|
|
164
|
-
max_tokens: maxTokens,
|
|
165
|
-
messages: apiMessages,
|
|
166
|
-
};
|
|
167
|
-
if (systemPrompt) body.system = systemPrompt;
|
|
168
|
-
|
|
169
|
-
// Tools support — convert from OpenAI format to Anthropic format
|
|
170
|
-
if (Array.isArray(opts?.tools) && opts.tools.length) {
|
|
171
|
-
body.tools = convertToolDefs(opts.tools);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const headers: Record<string, string> = {
|
|
175
|
-
"Content-Type": "application/json",
|
|
176
|
-
"x-api-key": cfg.apiKey,
|
|
177
|
-
"anthropic-version": "2023-06-01",
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const RETRYABLE_STATUS = new Set([429, 503, 529]);
|
|
181
|
-
let requestId: string | undefined;
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
let res!: Response;
|
|
185
|
-
for (let attempt = 0; ; attempt++) {
|
|
186
|
-
res = await timedFetch(endpoint, {
|
|
187
|
-
method: "POST",
|
|
188
|
-
headers,
|
|
189
|
-
body: JSON.stringify(body),
|
|
190
|
-
where: "driver:anthropic",
|
|
191
|
-
timeoutMs,
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
if (res.ok) break;
|
|
195
|
-
|
|
196
|
-
if (isRetryable(res.status) && attempt < MAX_RETRIES) {
|
|
197
|
-
const delay = retryDelay(attempt);
|
|
198
|
-
Logger.warn(`Anthropic ${res.status}, retry ${attempt + 1}/${MAX_RETRIES} in ${Math.round(delay)}ms`);
|
|
199
|
-
await sleep(delay);
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const text = await res.text().catch(() => "");
|
|
204
|
-
const ge = groError("provider_error", `Anthropic API failed (${res.status}): ${text}`, {
|
|
205
|
-
provider: "anthropic",
|
|
206
|
-
model: resolvedModel,
|
|
207
|
-
request_id: requestId,
|
|
208
|
-
retryable: RETRYABLE_STATUS.has(res.status),
|
|
209
|
-
cause: new Error(text),
|
|
210
|
-
});
|
|
211
|
-
Logger.error("Anthropic driver error:", errorLogFields(ge));
|
|
212
|
-
throw ge;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const data = await res.json() as any;
|
|
216
|
-
return parseResponseContent(data, onToken);
|
|
217
|
-
} catch (e: unknown) {
|
|
218
|
-
if (isGroError(e)) throw e; // already wrapped above
|
|
219
|
-
|
|
220
|
-
// Classify the error: fetch timeouts and network errors are transient
|
|
221
|
-
const errMsg = asError(e).message;
|
|
222
|
-
const isTransient = TRANSIENT_ERROR_RE.test(errMsg);
|
|
223
|
-
|
|
224
|
-
if (isTransient) {
|
|
225
|
-
// Retry transient network errors (e.g. auth proxy down during container restart)
|
|
226
|
-
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
227
|
-
const delay = retryDelay(attempt);
|
|
228
|
-
Logger.warn(`Transient error: ${errMsg.substring(0, 120)}, retry ${attempt + 1}/${MAX_RETRIES} in ${Math.round(delay)}ms`);
|
|
229
|
-
await sleep(delay);
|
|
230
|
-
|
|
231
|
-
try {
|
|
232
|
-
const retryRes = await timedFetch(endpoint, {
|
|
233
|
-
method: "POST",
|
|
234
|
-
headers,
|
|
235
|
-
body: JSON.stringify(body),
|
|
236
|
-
where: "driver:anthropic",
|
|
237
|
-
timeoutMs,
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
if (!retryRes.ok) {
|
|
241
|
-
const text = await retryRes.text().catch(() => "");
|
|
242
|
-
if (isRetryable(retryRes.status) && attempt < MAX_RETRIES - 1) continue;
|
|
243
|
-
throw groError("provider_error", `Anthropic API failed (${retryRes.status}): ${text}`, {
|
|
244
|
-
provider: "anthropic", model: resolvedModel, retryable: false, cause: new Error(text),
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Success on retry — parse and return
|
|
249
|
-
const data = await retryRes.json() as any;
|
|
250
|
-
Logger.info(`Recovered from transient error after ${attempt + 1} retries`);
|
|
251
|
-
return parseResponseContent(data, onToken);
|
|
252
|
-
} catch (retryErr: unknown) {
|
|
253
|
-
if (isGroError(retryErr)) throw retryErr;
|
|
254
|
-
if (attempt === MAX_RETRIES - 1) {
|
|
255
|
-
// Exhausted retries — throw with context
|
|
256
|
-
const ge = groError("provider_error", `Anthropic driver error (after ${MAX_RETRIES} retries): ${errMsg}`, {
|
|
257
|
-
provider: "anthropic", model: resolvedModel, request_id: requestId,
|
|
258
|
-
retryable: false, cause: e,
|
|
259
|
-
});
|
|
260
|
-
Logger.error("Anthropic driver error (retries exhausted):", errorLogFields(ge));
|
|
261
|
-
throw ge;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Non-transient error — throw immediately
|
|
268
|
-
const ge = groError("provider_error", `Anthropic driver error: ${errMsg}`, {
|
|
269
|
-
provider: "anthropic",
|
|
270
|
-
model: resolvedModel,
|
|
271
|
-
request_id: requestId,
|
|
272
|
-
retryable: false,
|
|
273
|
-
cause: e,
|
|
274
|
-
});
|
|
275
|
-
Logger.error("Anthropic driver error:", errorLogFields(ge));
|
|
276
|
-
throw ge;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
return { chat };
|
|
281
|
-
}
|
package/src/drivers/index.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall, TokenUsage } from "./types.js";
|
|
2
|
-
export { makeStreamingOpenAiDriver } from "./streaming-openai.js";
|
|
3
|
-
export type { OpenAiDriverConfig } from "./streaming-openai.js";
|
|
4
|
-
export { makeAnthropicDriver } from "./anthropic.js";
|
|
5
|
-
export type { AnthropicDriverConfig } from "./anthropic.js";
|