@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.
- package/.github/workflows/ci.yml +20 -0
- package/README.md +218 -0
- package/_base.md +44 -0
- package/gro +198 -0
- package/owl/behaviors/agentic-turn.md +43 -0
- package/owl/components/cli.md +37 -0
- package/owl/components/drivers.md +29 -0
- package/owl/components/mcp.md +33 -0
- package/owl/components/memory.md +35 -0
- package/owl/components/session.md +35 -0
- package/owl/constraints.md +32 -0
- package/owl/product.md +28 -0
- package/owl/proposals/cooperative-scheduler.md +106 -0
- package/package.json +22 -0
- package/providers/claude.sh +50 -0
- package/providers/gemini.sh +36 -0
- package/providers/openai.py +85 -0
- package/src/drivers/anthropic.ts +215 -0
- package/src/drivers/index.ts +5 -0
- package/src/drivers/streaming-openai.ts +245 -0
- package/src/drivers/types.ts +33 -0
- package/src/errors.ts +97 -0
- package/src/logger.ts +28 -0
- package/src/main.ts +827 -0
- package/src/mcp/client.ts +147 -0
- package/src/mcp/index.ts +2 -0
- package/src/memory/advanced-memory.ts +263 -0
- package/src/memory/agent-memory.ts +61 -0
- package/src/memory/agenthnsw.ts +122 -0
- package/src/memory/index.ts +6 -0
- package/src/memory/simple-memory.ts +41 -0
- package/src/memory/vector-index.ts +30 -0
- package/src/session.ts +150 -0
- package/src/tools/agentpatch.ts +89 -0
- package/src/tools/bash.ts +61 -0
- package/src/utils/rate-limiter.ts +60 -0
- package/src/utils/retry.ts +32 -0
- package/src/utils/timed-fetch.ts +29 -0
- package/tests/errors.test.ts +246 -0
- package/tests/memory.test.ts +186 -0
- package/tests/rate-limiter.test.ts +76 -0
- package/tests/retry.test.ts +138 -0
- package/tests/timed-fetch.test.ts +104 -0
- 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";
|