@tjamescouch/gro 1.3.2

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 (44) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/README.md +218 -0
  3. package/_base.md +44 -0
  4. package/gro +198 -0
  5. package/owl/behaviors/agentic-turn.md +43 -0
  6. package/owl/components/cli.md +37 -0
  7. package/owl/components/drivers.md +29 -0
  8. package/owl/components/mcp.md +33 -0
  9. package/owl/components/memory.md +35 -0
  10. package/owl/components/session.md +35 -0
  11. package/owl/constraints.md +32 -0
  12. package/owl/product.md +28 -0
  13. package/owl/proposals/cooperative-scheduler.md +106 -0
  14. package/package.json +22 -0
  15. package/providers/claude.sh +50 -0
  16. package/providers/gemini.sh +36 -0
  17. package/providers/openai.py +85 -0
  18. package/src/drivers/anthropic.ts +215 -0
  19. package/src/drivers/index.ts +5 -0
  20. package/src/drivers/streaming-openai.ts +245 -0
  21. package/src/drivers/types.ts +33 -0
  22. package/src/errors.ts +97 -0
  23. package/src/logger.ts +28 -0
  24. package/src/main.ts +827 -0
  25. package/src/mcp/client.ts +147 -0
  26. package/src/mcp/index.ts +2 -0
  27. package/src/memory/advanced-memory.ts +263 -0
  28. package/src/memory/agent-memory.ts +61 -0
  29. package/src/memory/agenthnsw.ts +122 -0
  30. package/src/memory/index.ts +6 -0
  31. package/src/memory/simple-memory.ts +41 -0
  32. package/src/memory/vector-index.ts +30 -0
  33. package/src/session.ts +150 -0
  34. package/src/tools/agentpatch.ts +89 -0
  35. package/src/tools/bash.ts +61 -0
  36. package/src/utils/rate-limiter.ts +60 -0
  37. package/src/utils/retry.ts +32 -0
  38. package/src/utils/timed-fetch.ts +29 -0
  39. package/tests/errors.test.ts +246 -0
  40. package/tests/memory.test.ts +186 -0
  41. package/tests/rate-limiter.test.ts +76 -0
  42. package/tests/retry.test.ts +138 -0
  43. package/tests/timed-fetch.test.ts +104 -0
  44. package/tsconfig.json +13 -0
@@ -0,0 +1,32 @@
1
+ # constraints
2
+
3
+ ## technology
4
+
5
+ - TypeScript, targeting ES2021
6
+ - Bun as primary runtime, Node.js 18+ as fallback
7
+ - NodeNext module resolution with explicit `.js` import extensions
8
+ - No frontend, no terminal UI, no raw/cooked mode — stdout/stderr only
9
+
10
+ ## dependencies
11
+
12
+ - `@modelcontextprotocol/sdk` for MCP client transport (stdio)
13
+ - `@types/node` for type definitions
14
+ - `typescript` for compilation
15
+ - No other runtime dependencies — HTTP calls use native fetch
16
+
17
+ ## architecture
18
+
19
+ - Single-agent only. Multi-agent coordination happens via agentchat sockets, not inside gro
20
+ - Drivers are pure functions: `(messages, opts) => ChatOutput`. No state, no side effects beyond the HTTP call
21
+ - Memory is a class hierarchy: `AgentMemory` (abstract) -> `SimpleMemory` | `AdvancedMemory`
22
+ - Session state lives in `.gro/context/<uuid>/` with `messages.json` and `meta.json`
23
+ - MCP servers are discovered from Claude Code's `settings.json` or explicit `--mcp-config` paths
24
+ - Config is resolved from CLI flags only (no config file yet). Environment variables for API keys
25
+
26
+ ## style
27
+
28
+ - Prefer `async/await` over raw promises
29
+ - Prefer explicit types over inference for function signatures
30
+ - No classes where a plain function suffices (drivers are factory functions, not classes)
31
+ - Error messages go to stderr via Logger. Completions go to stdout
32
+ - Graceful degradation: unknown flags warn, never crash
package/owl/product.md ADDED
@@ -0,0 +1,28 @@
1
+ # gro
2
+
3
+ Provider-agnostic LLM runtime with context management. Single-agent, headless CLI that supersets the `claude` command-line interface.
4
+
5
+ ## purpose
6
+
7
+ - Execute LLM completions against any provider (Anthropic, OpenAI, local) through a unified CLI
8
+ - Manage conversation context with swim-lane summarization so long sessions don't overflow the context window
9
+ - Connect to MCP servers for tool use, maintaining full compatibility with Claude Code's MCP ecosystem
10
+ - Persist sessions to disk so conversations can be resumed across process restarts
11
+ - Accept all `claude` CLI flags as a drop-in replacement, with graceful degradation for unimplemented features
12
+
13
+ ## components
14
+
15
+ - **drivers**: Provider-specific chat completion backends (Anthropic native, OpenAI streaming, local via OpenAI-compat)
16
+ - **memory**: Conversation state management with optional swim-lane summarization and token budgeting
17
+ - **mcp**: MCP client manager that discovers servers, enumerates tools, and routes tool calls
18
+ - **session**: Persistence layer for saving/loading conversation state to `.gro/context/<id>/`
19
+ - **cli**: Flag parsing, config resolution, mode dispatch (interactive, print, pipe)
20
+
21
+ ## success criteria
22
+
23
+ - `gro -p "hello"` produces a completion on stdout and exits
24
+ - `gro -i` enters interactive mode with context management and session auto-save
25
+ - `gro -c` resumes the most recent session with full message history
26
+ - `gro --allowedTools Bash "hello"` warns about unsupported flag and still works
27
+ - Summarization keeps token usage within budget during long interactive sessions
28
+ - MCP tools discovered from `~/.claude/settings.json` are callable during agentic turns
@@ -0,0 +1,106 @@
1
+ # Proposal: Cooperative scheduler for persistent tool loops
2
+
3
+ ## problem
4
+
5
+ In `--persistent` mode, gro nudges the model when it returns a text-only response (no tool calls) by injecting:
6
+
7
+ > "[SYSTEM] You stopped calling tools... Call agentchat_listen now ..."
8
+
9
+ This is a reasonable *guardrail*, but it has an unintended second-order effect in agentchat-style workflows:
10
+
11
+ - Models interpret the nudge as a hard requirement to **only** call `agentchat_listen` repeatedly.
12
+ - That starves actual work (`bash`, repo edits, commits), because the model prioritizes satisfying the persistence nudge.
13
+ - Humans then (correctly) complain: agents are "present" but not shipping.
14
+
15
+ We need a runtime-level way to preserve responsiveness (check chat periodically) **without** forcing the model into a single-tool monoculture.
16
+
17
+ ## goals
18
+
19
+ - Keep agents responsive to chat/interrupts.
20
+ - Allow real work (bash/tooling) to progress.
21
+ - Avoid instruction conflicts: "listen forever" vs "ship code".
22
+ - Avoid daemons/multi-process requirements.
23
+ - Preserve provider/tool compatibility (OpenAI/Anthropic + MCP).
24
+
25
+ ## non-goals
26
+
27
+ - Multi-agent orchestration inside gro.
28
+ - A full job scheduler with priorities, retries, persistence, etc. (keep it small).
29
+
30
+ ## proposal (runtime behavior)
31
+
32
+ ### 1) Add an explicit *work-first* persistent policy
33
+
34
+ Add a `--persistent-policy` flag:
35
+
36
+ - `listen-only` (current emergent behavior; not recommended)
37
+ - `work-first` (default for persistent)
38
+
39
+ `work-first` changes the injected nudge message and adds a runtime contract:
40
+
41
+ - The model should alternate between (a) short checks for new messages and (b) work slices.
42
+ - The runtime should help by making it easy to do the right thing.
43
+
44
+ ### 2) Replace the current persistence nudge with a cooperative contract
45
+
46
+ Current nudge text hardcodes `agentchat_listen`. Instead, use:
47
+
48
+ - **If tools exist**: request a tool call (any tool) OR a short `agentchat_listen`, but do not prescribe one tool.
49
+ - Explicitly allow a work slice.
50
+
51
+ Suggested nudge:
52
+
53
+ ```
54
+ [SYSTEM] Persistent mode: you must keep making forward progress.
55
+ Loop:
56
+ 1) Check messages quickly (agentchat_listen with short timeout)
57
+ 2) Do one work slice (bash/tool)
58
+ 3) Repeat.
59
+ Do not get stuck calling listen repeatedly.
60
+ ```
61
+
62
+ ### 3) Runtime supports a short-timeout listen hint
63
+
64
+ In agentchat MCP tool definition (or in docs), support `agentchat_listen({..., max_wait_ms})`.
65
+
66
+ If tool does not support it, gro can still encourage a short cadence by setting expectations in the system nudge.
67
+
68
+ ### 4) Add a first-class “yield” tool (optional)
69
+
70
+ Provide an internal tool `yield({ms})` (or `sleep`) that:
71
+
72
+ - blocks for `ms`
73
+ - returns a small structured result
74
+
75
+ This gives the model a safe way to wait without spamming chat tools, and keeps the tool loop alive.
76
+
77
+ ### 5) Heartbeat + fairness guardrail
78
+
79
+ Add a runtime counter:
80
+
81
+ - If the model calls the same tool N times consecutively (e.g. `agentchat_listen`), inject a corrective system message:
82
+
83
+ ```
84
+ [SYSTEM] You have called agentchat_listen N times without doing any work.
85
+ Do one work slice (bash/tool) now before listening again.
86
+ ```
87
+
88
+ This is crude, but it fixes the failure mode without needing deep semantic understanding.
89
+
90
+ ## minimal implementation plan
91
+
92
+ 1. Implement `--persistent-policy work-first` (default when `--persistent` is set).
93
+ 2. Change the nudge message in `src/main.ts` to the cooperative contract.
94
+ 3. Implement consecutive-tool guardrail (same-tool repetition).
95
+ 4. (Optional) add `yield` tool.
96
+
97
+ ## acceptance criteria
98
+
99
+ - In a chat-driven prompt, the agent alternates: listen → bash work → listen.
100
+ - Agent no longer gets stuck in an infinite `agentchat_listen` loop after a restart.
101
+ - Existing non-chat uses of `--persistent` still behave correctly.
102
+
103
+ ## notes
104
+
105
+ - This proposal intentionally does not require changes to the agentchat server.
106
+ - If we later add structured “work queue” tools, this policy becomes the default scheduling model.
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@tjamescouch/gro",
3
+ "version": "1.3.2",
4
+ "description": "Provider-agnostic LLM runtime with context management",
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "npx tsx src/main.ts",
8
+ "build": "npx tsc && cp package.json dist/",
9
+ "build:bun": "bun build src/main.ts --outdir dist --target bun",
10
+ "test": "npx tsx --test tests/*.test.ts",
11
+ "test:bun": "bun test"
12
+ },
13
+ "devDependencies": {
14
+ "@types/node": "^25.2.3",
15
+ "tsx": "^4.21.0",
16
+ "typescript": "^5.9.3"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.26.0",
20
+ "isexe": "^4.0.0"
21
+ }
22
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # gro adapter: claude
5
+ # reads prompt from stdin, outputs completion to stdout
6
+ # env: GRO_MODEL, GRO_SYSTEM_PROMPT
7
+
8
+ prompt=$(cat)
9
+
10
+ # try CLI first
11
+ if command -v claude &>/dev/null; then
12
+ args=(-p)
13
+ [[ -n "${GRO_MODEL:-}" ]] && args+=(--model "$GRO_MODEL")
14
+ [[ -n "${GRO_SYSTEM_PROMPT:-}" ]] && args+=(--system-prompt "$GRO_SYSTEM_PROMPT")
15
+ echo "$prompt" | claude "${args[@]}"
16
+ exit
17
+ fi
18
+
19
+ # fallback to HTTP API
20
+ api_key="${ANTHROPIC_API_KEY:-}"
21
+ if [[ -z "$api_key" && -f "${GRO_CONFIG_FILE:-}" ]]; then
22
+ api_key=$(grep "^anthropic.api-key=" "$GRO_CONFIG_FILE" 2>/dev/null | cut -d= -f2- || true)
23
+ fi
24
+
25
+ if [[ -z "$api_key" ]]; then
26
+ echo "gro/claude: neither \`claude\` CLI nor ANTHROPIC_API_KEY available" >&2
27
+ exit 1
28
+ fi
29
+
30
+ model="${GRO_MODEL:-claude-sonnet-4-20250514}"
31
+
32
+ if [[ -n "${GRO_SYSTEM_PROMPT:-}" ]]; then
33
+ body=$(jq -nc \
34
+ --arg model "$model" \
35
+ --arg prompt "$prompt" \
36
+ --arg sys "$GRO_SYSTEM_PROMPT" \
37
+ '{model: $model, max_tokens: 4096, system: $sys, messages: [{role: "user", content: $prompt}]}')
38
+ else
39
+ body=$(jq -nc \
40
+ --arg model "$model" \
41
+ --arg prompt "$prompt" \
42
+ '{model: $model, max_tokens: 4096, messages: [{role: "user", content: $prompt}]}')
43
+ fi
44
+
45
+ curl -sS https://api.anthropic.com/v1/messages \
46
+ -H "Content-Type: application/json" \
47
+ -H "x-api-key: ${api_key}" \
48
+ -H "anthropic-version: 2023-06-01" \
49
+ -d "$body" \
50
+ | jq -r '.content[0].text // error(.error.message // "empty response")'
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # gro adapter: gemini
5
+ # reads prompt from stdin, outputs completion to stdout
6
+ # env: GRO_MODEL, GRO_SYSTEM_PROMPT, GEMINI_API_KEY
7
+
8
+ prompt=$(cat)
9
+
10
+ api_key="${GEMINI_API_KEY:-}"
11
+ if [[ -z "$api_key" && -f "${GRO_CONFIG_FILE:-}" ]]; then
12
+ api_key=$(grep "^gemini.api-key=" "$GRO_CONFIG_FILE" 2>/dev/null | cut -d= -f2- || true)
13
+ fi
14
+
15
+ if [[ -z "$api_key" ]]; then
16
+ echo "gro/gemini: GEMINI_API_KEY not set" >&2
17
+ exit 1
18
+ fi
19
+
20
+ model="${GRO_MODEL:-gemini-2.0-flash}"
21
+
22
+ if [[ -n "${GRO_SYSTEM_PROMPT:-}" ]]; then
23
+ body=$(jq -nc \
24
+ --arg prompt "$prompt" \
25
+ --arg sys "$GRO_SYSTEM_PROMPT" \
26
+ '{systemInstruction: {parts: [{text: $sys}]}, contents: [{role: "user", parts: [{text: $prompt}]}]}')
27
+ else
28
+ body=$(jq -nc \
29
+ --arg prompt "$prompt" \
30
+ '{contents: [{role: "user", parts: [{text: $prompt}]}]}')
31
+ fi
32
+
33
+ curl -sS "https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${api_key}" \
34
+ -H "Content-Type: application/json" \
35
+ -d "$body" \
36
+ | jq -r '.candidates[0].content.parts[0].text // error(.error.message // "empty response")'
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """gro adapter: openai
3
+ Reads prompt from stdin, outputs completion to stdout.
4
+ Env: GRO_MODEL, GRO_SYSTEM_PROMPT, OPENAI_API_KEY, GRO_CONFIG_FILE
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ import json
10
+ import urllib.request
11
+ import urllib.error
12
+
13
+
14
+ TIMEOUT = 60
15
+
16
+
17
+ def load_config_value(key):
18
+ config_file = os.environ.get("GRO_CONFIG_FILE", "")
19
+ if not config_file or not os.path.exists(config_file):
20
+ return ""
21
+ with open(config_file) as f:
22
+ for line in f:
23
+ line = line.strip()
24
+ if not line or line.startswith("#"):
25
+ continue
26
+ if line.startswith(f"{key}="):
27
+ return line.split("=", 1)[1]
28
+ return ""
29
+
30
+
31
+ def main():
32
+ prompt = sys.stdin.read().strip()
33
+ if not prompt:
34
+ print("gro/openai: empty prompt", file=sys.stderr)
35
+ sys.exit(1)
36
+
37
+ api_key = os.environ.get("OPENAI_API_KEY") or load_config_value("openai.api-key")
38
+ if not api_key:
39
+ print("gro/openai: OPENAI_API_KEY not set", file=sys.stderr)
40
+ sys.exit(1)
41
+
42
+ model = os.environ.get("GRO_MODEL") or "gpt-4o"
43
+ system_prompt = os.environ.get("GRO_SYSTEM_PROMPT", "")
44
+
45
+ messages = []
46
+ if system_prompt:
47
+ messages.append({"role": "system", "content": system_prompt})
48
+ messages.append({"role": "user", "content": prompt})
49
+
50
+ body = json.dumps({"model": model, "messages": messages}).encode()
51
+
52
+ req = urllib.request.Request(
53
+ "https://api.openai.com/v1/chat/completions",
54
+ data=body,
55
+ headers={
56
+ "Content-Type": "application/json",
57
+ "Authorization": f"Bearer {api_key}",
58
+ },
59
+ )
60
+
61
+ try:
62
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
63
+ data = json.loads(resp.read())
64
+ except urllib.error.HTTPError as e:
65
+ error_body = e.read().decode()
66
+ try:
67
+ err = json.loads(error_body)
68
+ print(f"gro/openai: {err.get('error', {}).get('message', error_body)}", file=sys.stderr)
69
+ except json.JSONDecodeError:
70
+ print(f"gro/openai: HTTP {e.code}: {error_body[:200]}", file=sys.stderr)
71
+ sys.exit(1)
72
+ except urllib.error.URLError as e:
73
+ print(f"gro/openai: network error: {e.reason}", file=sys.stderr)
74
+ sys.exit(1)
75
+
76
+ content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
77
+ if not content:
78
+ print("gro/openai: empty response", file=sys.stderr)
79
+ sys.exit(1)
80
+
81
+ print(content.strip())
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
@@ -0,0 +1,215 @@
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 } 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
+ name: t.function.name,
30
+ description: t.function.description || "",
31
+ input_schema: t.function.parameters || { type: "object", properties: {} },
32
+ };
33
+ }
34
+ // Already in Anthropic format — pass through
35
+ return t;
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Convert internal messages (OpenAI-style) to Anthropic Messages API format.
41
+ *
42
+ * Key differences:
43
+ * - Assistant tool calls become content blocks with type "tool_use"
44
+ * - Tool result messages become user messages with type "tool_result" content blocks
45
+ * - Anthropic requires strictly alternating user/assistant roles
46
+ */
47
+ function convertMessages(messages: ChatMessage[]): { system: string | undefined; apiMessages: any[] } {
48
+ let systemPrompt: string | undefined;
49
+ const apiMessages: any[] = [];
50
+
51
+ for (const m of messages) {
52
+ if (m.role === "system") {
53
+ systemPrompt = systemPrompt ? systemPrompt + "\n" + m.content : m.content;
54
+ continue;
55
+ }
56
+
57
+ if (m.role === "assistant") {
58
+ const content: any[] = [];
59
+ if (m.content) content.push({ type: "text", text: m.content });
60
+
61
+ // Convert OpenAI-style tool_calls to Anthropic tool_use blocks
62
+ const toolCalls = (m as any).tool_calls;
63
+ if (Array.isArray(toolCalls)) {
64
+ for (const tc of toolCalls) {
65
+ let input: any;
66
+ try { input = JSON.parse(tc.function.arguments || "{}"); } catch { input = {}; }
67
+ content.push({
68
+ type: "tool_use",
69
+ id: tc.id,
70
+ name: tc.function.name,
71
+ input,
72
+ });
73
+ }
74
+ }
75
+
76
+ if (content.length > 0) {
77
+ apiMessages.push({ role: "assistant", content });
78
+ }
79
+ continue;
80
+ }
81
+
82
+ if (m.role === "tool") {
83
+ // Tool results must be in a user message with tool_result content blocks
84
+ const block = {
85
+ type: "tool_result",
86
+ tool_use_id: m.tool_call_id,
87
+ content: m.content,
88
+ };
89
+
90
+ // Group consecutive tool results into a single user message
91
+ const last = apiMessages[apiMessages.length - 1];
92
+ if (last && last.role === "user" && Array.isArray(last.content) &&
93
+ last.content.length > 0 && last.content[0].type === "tool_result") {
94
+ last.content.push(block);
95
+ } else {
96
+ apiMessages.push({ role: "user", content: [block] });
97
+ }
98
+ continue;
99
+ }
100
+
101
+ // Regular user messages
102
+ apiMessages.push({ role: "user", content: m.content });
103
+ }
104
+
105
+ return { system: systemPrompt, apiMessages };
106
+ }
107
+
108
+ export function makeAnthropicDriver(cfg: AnthropicDriverConfig): ChatDriver {
109
+ const base = (cfg.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
110
+ const endpoint = `${base}/v1/messages`;
111
+ const model = cfg.model ?? "claude-sonnet-4-20250514";
112
+ const maxTokens = cfg.maxTokens ?? 4096;
113
+ const timeoutMs = cfg.timeoutMs ?? 2 * 60 * 60 * 1000;
114
+
115
+ async function chat(messages: ChatMessage[], opts?: any): Promise<ChatOutput> {
116
+ await rateLimiter.limit("llm-ask", 1);
117
+
118
+ const onToken: ((t: string) => void) | undefined = opts?.onToken;
119
+ const resolvedModel = opts?.model ?? model;
120
+
121
+ const { system: systemPrompt, apiMessages } = convertMessages(messages);
122
+
123
+ const body: any = {
124
+ model: resolvedModel,
125
+ max_tokens: maxTokens,
126
+ messages: apiMessages,
127
+ };
128
+ if (systemPrompt) body.system = systemPrompt;
129
+
130
+ // Tools support — convert from OpenAI format to Anthropic format
131
+ if (Array.isArray(opts?.tools) && opts.tools.length) {
132
+ body.tools = convertToolDefs(opts.tools);
133
+ }
134
+
135
+ const headers: Record<string, string> = {
136
+ "Content-Type": "application/json",
137
+ "x-api-key": cfg.apiKey,
138
+ "anthropic-version": "2023-06-01",
139
+ };
140
+
141
+ const RETRYABLE_STATUS = new Set([429, 503, 529]);
142
+ let requestId: string | undefined;
143
+
144
+ try {
145
+ let res!: Response;
146
+ for (let attempt = 0; ; attempt++) {
147
+ res = await timedFetch(endpoint, {
148
+ method: "POST",
149
+ headers,
150
+ body: JSON.stringify(body),
151
+ where: "driver:anthropic",
152
+ timeoutMs,
153
+ });
154
+
155
+ if (res.ok) break;
156
+
157
+ if (isRetryable(res.status) && attempt < MAX_RETRIES) {
158
+ const delay = retryDelay(attempt);
159
+ Logger.warn(`Anthropic ${res.status}, retry ${attempt + 1}/${MAX_RETRIES} in ${Math.round(delay)}ms`);
160
+ await sleep(delay);
161
+ continue;
162
+ }
163
+
164
+ const text = await res.text().catch(() => "");
165
+ const ge = groError("provider_error", `Anthropic API failed (${res.status}): ${text}`, {
166
+ provider: "anthropic",
167
+ model: resolvedModel,
168
+ request_id: requestId,
169
+ retryable: RETRYABLE_STATUS.has(res.status),
170
+ cause: new Error(text),
171
+ });
172
+ Logger.error("Anthropic driver error:", errorLogFields(ge));
173
+ throw ge;
174
+ }
175
+
176
+ const data = await res.json() as any;
177
+
178
+ let text = "";
179
+ const toolCalls: ChatToolCall[] = [];
180
+
181
+ for (const block of data.content ?? []) {
182
+ if (block.type === "text") {
183
+ text += block.text;
184
+ if (onToken) {
185
+ try { onToken(block.text); } catch {}
186
+ }
187
+ } else if (block.type === "tool_use") {
188
+ toolCalls.push({
189
+ id: block.id,
190
+ type: "custom",
191
+ function: {
192
+ name: block.name,
193
+ arguments: JSON.stringify(block.input),
194
+ },
195
+ });
196
+ }
197
+ }
198
+
199
+ return { text, toolCalls };
200
+ } catch (e: unknown) {
201
+ if (isGroError(e)) throw e; // already wrapped above
202
+ const ge = groError("provider_error", `Anthropic driver error: ${asError(e).message}`, {
203
+ provider: "anthropic",
204
+ model: resolvedModel,
205
+ request_id: requestId,
206
+ retryable: false,
207
+ cause: e,
208
+ });
209
+ Logger.error("Anthropic driver error:", errorLogFields(ge));
210
+ throw ge;
211
+ }
212
+ }
213
+
214
+ return { chat };
215
+ }
@@ -0,0 +1,5 @@
1
+ export type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall } 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";